mirror of
https://github.com/Memo-2023/mana-monorepo.git
synced 2026-05-14 19:01:08 +02:00
✨ feat(figgos): add fusion endpoint — merge two figures into one
POST /api/v1/figures/fuse takes two figure IDs, merges their profiles via Gemini LLM, generates a combined card image using both source images as references, and produces a new fusion figure with dedicated purple/gold card style. Rarity based on higher parent with upgrade chance. Co-Authored-By: Claude Opus 4.6 <noreply@anthropic.com>
This commit is contained in:
parent
ed1dfd7472
commit
3caf731afd
8 changed files with 409 additions and 2 deletions
|
|
@ -31,6 +31,8 @@ export const figures = pgTable(
|
|||
errorMessage: text('error_message'),
|
||||
isPublic: boolean('is_public').default(false).notNull(),
|
||||
isArchived: boolean('is_archived').default(false).notNull(),
|
||||
isFusion: boolean('is_fusion').default(false).notNull(),
|
||||
parentFigureIds: text('parent_figure_ids').array(),
|
||||
createdAt: timestamp('created_at', { withTimezone: true }).defaultNow().notNull(),
|
||||
updatedAt: timestamp('updated_at', { withTimezone: true }).defaultNow().notNull(),
|
||||
},
|
||||
|
|
|
|||
|
|
@ -0,0 +1,9 @@
|
|||
import { IsUUID } from 'class-validator';
|
||||
|
||||
export class FuseFiguresDto {
|
||||
@IsUUID()
|
||||
figureIdA!: string;
|
||||
|
||||
@IsUUID()
|
||||
figureIdB!: string;
|
||||
}
|
||||
|
|
@ -11,6 +11,7 @@ import {
|
|||
import { JwtAuthGuard, CurrentUser, CurrentUserData } from '@manacore/shared-nestjs-auth';
|
||||
import { FiguresService } from './figures.service';
|
||||
import { CreateFigureDto } from './dto/create-figure.dto';
|
||||
import { FuseFiguresDto } from './dto/fuse-figures.dto';
|
||||
|
||||
@Controller('figures')
|
||||
@UseGuards(JwtAuthGuard)
|
||||
|
|
@ -29,6 +30,12 @@ export class FiguresController {
|
|||
return { figure };
|
||||
}
|
||||
|
||||
@Post('fuse')
|
||||
async fuse(@CurrentUser() user: CurrentUserData, @Body() dto: FuseFiguresDto) {
|
||||
const figure = await this.figuresService.fuse(user.userId, dto.figureIdA, dto.figureIdB);
|
||||
return { figure };
|
||||
}
|
||||
|
||||
@Get()
|
||||
async findAll(@CurrentUser() user: CurrentUserData) {
|
||||
const figures = await this.figuresService.findByUserId(user.userId);
|
||||
|
|
|
|||
|
|
@ -6,7 +6,9 @@ import { figures } from '../db/schema';
|
|||
import type { Figure } from '../db/schema';
|
||||
import {
|
||||
RARITY_WEIGHTS,
|
||||
STAT_RANGES,
|
||||
getCardStyle,
|
||||
rollFusionRarity,
|
||||
type FigureRarity,
|
||||
type FigureLanguage,
|
||||
} from '@figgos/shared';
|
||||
|
|
@ -97,4 +99,53 @@ export class FiguresService {
|
|||
|
||||
await this.db.delete(figures).where(eq(figures.id, id));
|
||||
}
|
||||
|
||||
async fuse(userId: string, figureIdA: string, figureIdB: string): Promise<Figure> {
|
||||
// Fetch both figures — must exist and be completed
|
||||
const [figA] = await this.db
|
||||
.select()
|
||||
.from(figures)
|
||||
.where(and(eq(figures.id, figureIdA), eq(figures.status, 'completed')));
|
||||
if (!figA) throw new NotFoundException(`Figure ${figureIdA} not found or not completed`);
|
||||
|
||||
const [figB] = await this.db
|
||||
.select()
|
||||
.from(figures)
|
||||
.where(and(eq(figures.id, figureIdB), eq(figures.status, 'completed')));
|
||||
if (!figB) throw new NotFoundException(`Figure ${figureIdB} not found or not completed`);
|
||||
|
||||
// TODO: verify ownership (figA.userId === userId && figB.userId === userId)
|
||||
|
||||
const rarity = rollFusionRarity(figA.rarity, figB.rarity);
|
||||
|
||||
// Insert fusion figure row
|
||||
const [figure] = await this.db
|
||||
.insert(figures)
|
||||
.values({
|
||||
userId,
|
||||
name: `${figA.name} + ${figB.name}`, // placeholder, will be overwritten by LLM
|
||||
rarity,
|
||||
language: figA.language,
|
||||
userInput: { description: `Fusion of ${figA.name} and ${figB.name}`, language: figA.language },
|
||||
status: 'pending',
|
||||
isFusion: true,
|
||||
parentFigureIds: [figureIdA, figureIdB],
|
||||
})
|
||||
.returning();
|
||||
|
||||
const completed = await this.generationService.generateFusion(figure.id, userId, {
|
||||
nameA: figA.name,
|
||||
profileA: figA.generatedProfile!,
|
||||
rarityA: figA.rarity,
|
||||
imageUrlA: figA.imageUrl!,
|
||||
nameB: figB.name,
|
||||
profileB: figB.generatedProfile!,
|
||||
rarityB: figB.rarity,
|
||||
imageUrlB: figB.imageUrl!,
|
||||
rarity,
|
||||
cardStyle: 'fusion',
|
||||
});
|
||||
|
||||
return completed;
|
||||
}
|
||||
}
|
||||
|
|
|
|||
|
|
@ -6,8 +6,12 @@ import { ConfigService } from '@nestjs/config';
|
|||
import {
|
||||
PROFILE_JSON_SCHEMA,
|
||||
PROFILE_SYSTEM_PROMPT,
|
||||
FUSION_PROFILE_SYSTEM_PROMPT,
|
||||
FUSION_PROFILE_JSON_SCHEMA,
|
||||
buildImagePrompt,
|
||||
buildProfileUserPrompt,
|
||||
buildFusionProfilePrompt,
|
||||
buildFusionImagePrompt,
|
||||
} from './prompts';
|
||||
|
||||
const TEXT_MODEL = 'gemini-3-flash-preview';
|
||||
|
|
@ -155,4 +159,106 @@ export class GeminiService {
|
|||
|
||||
throw new Error('Gemini returned no image data');
|
||||
}
|
||||
|
||||
async mergeProfiles(
|
||||
nameA: string,
|
||||
profileA: GeneratedProfile,
|
||||
rarityA: FigureRarity,
|
||||
nameB: string,
|
||||
profileB: GeneratedProfile,
|
||||
rarityB: FigureRarity,
|
||||
fusedRarity: FigureRarity
|
||||
): Promise<{ name: string } & GeneratedProfile> {
|
||||
const statRange = STAT_RANGES[fusedRarity];
|
||||
const userPrompt = buildFusionProfilePrompt(nameA, profileA, rarityA, nameB, profileB, rarityB, statRange);
|
||||
|
||||
this.logger.log(`Merging profiles: "${nameA}" + "${nameB}" → ${fusedRarity}`);
|
||||
|
||||
const response = await this.client.models.generateContent({
|
||||
model: TEXT_MODEL,
|
||||
contents: userPrompt,
|
||||
config: {
|
||||
systemInstruction: FUSION_PROFILE_SYSTEM_PROMPT,
|
||||
responseMimeType: 'application/json',
|
||||
responseSchema: FUSION_PROFILE_JSON_SCHEMA,
|
||||
temperature: 1.0,
|
||||
thinkingConfig: { thinkingBudget: 0 },
|
||||
safetySettings: this.safetySettings,
|
||||
},
|
||||
});
|
||||
|
||||
const text = response.text;
|
||||
if (!text) {
|
||||
throw new Error('Gemini returned empty text response for fusion profile');
|
||||
}
|
||||
|
||||
const parsed = JSON.parse(text);
|
||||
|
||||
if (!parsed.name || !parsed.subtitle || !parsed.backstory || !parsed.visualDescription) {
|
||||
throw new Error('Fusion profile missing required fields');
|
||||
}
|
||||
if (!Array.isArray(parsed.items) || parsed.items.length !== 3) {
|
||||
throw new Error(`Expected 3 items, got ${parsed.items?.length}`);
|
||||
}
|
||||
if (!parsed.stats || typeof parsed.stats.attack !== 'number') {
|
||||
throw new Error('Fusion profile has invalid stats');
|
||||
}
|
||||
if (!parsed.specialAttack?.name || !parsed.specialAttack?.description) {
|
||||
throw new Error('Fusion profile missing specialAttack');
|
||||
}
|
||||
|
||||
const clamp = (v: number) => Math.max(statRange.min, Math.min(statRange.max, v));
|
||||
parsed.stats.attack = clamp(parsed.stats.attack);
|
||||
parsed.stats.defense = clamp(parsed.stats.defense);
|
||||
parsed.stats.special = clamp(parsed.stats.special);
|
||||
|
||||
this.logger.log(`Fusion profile: "${parsed.name}" — "${parsed.subtitle}"`);
|
||||
return parsed;
|
||||
}
|
||||
|
||||
async generateFusionImage(
|
||||
name: string,
|
||||
subtitle: string,
|
||||
visualDescription: string,
|
||||
items: string[],
|
||||
cardStyle: CardStyle,
|
||||
sourceImageA: Buffer,
|
||||
sourceImageB: Buffer
|
||||
): Promise<Buffer> {
|
||||
const prompt = buildFusionImagePrompt(name, subtitle, visualDescription, items, cardStyle);
|
||||
|
||||
this.logger.log(`Generating fusion image for "${name}"...`);
|
||||
|
||||
const contents: Array<string | { inlineData: { mimeType: string; data: string } }> = [
|
||||
{ inlineData: { mimeType: 'image/jpeg', data: sourceImageA.toString('base64') } },
|
||||
{ inlineData: { mimeType: 'image/jpeg', data: sourceImageB.toString('base64') } },
|
||||
prompt,
|
||||
];
|
||||
|
||||
const response = await this.client.models.generateContent({
|
||||
model: IMAGE_MODEL,
|
||||
contents,
|
||||
config: {
|
||||
responseModalities: ['IMAGE', 'TEXT'],
|
||||
safetySettings: this.safetySettings,
|
||||
},
|
||||
});
|
||||
|
||||
const parts = response.candidates?.[0]?.content?.parts;
|
||||
if (!parts) {
|
||||
throw new Error(
|
||||
'The AI could not generate this fusion — try different figures'
|
||||
);
|
||||
}
|
||||
|
||||
for (const part of parts) {
|
||||
if (part.inlineData?.data) {
|
||||
const buffer = Buffer.from(part.inlineData.data, 'base64');
|
||||
this.logger.log(`Fusion image generated: ${(buffer.length / 1024).toFixed(0)} KB`);
|
||||
return buffer;
|
||||
}
|
||||
}
|
||||
|
||||
throw new Error('Gemini returned no image data for fusion');
|
||||
}
|
||||
}
|
||||
|
|
|
|||
|
|
@ -11,6 +11,7 @@ import type {
|
|||
CardStyle,
|
||||
GeneratedProfile,
|
||||
} from '@figgos/shared';
|
||||
import sharp from 'sharp';
|
||||
import { GeminiService } from './gemini.service';
|
||||
import { ImageProcessingService } from './image-processing.service';
|
||||
import { StorageService } from '../storage/storage.service';
|
||||
|
|
@ -107,6 +108,112 @@ export class GenerationService {
|
|||
}
|
||||
}
|
||||
|
||||
async generateFusion(
|
||||
figureId: string,
|
||||
userId: string,
|
||||
input: {
|
||||
nameA: string;
|
||||
profileA: GeneratedProfile;
|
||||
rarityA: FigureRarity;
|
||||
imageUrlA: string;
|
||||
nameB: string;
|
||||
profileB: GeneratedProfile;
|
||||
rarityB: FigureRarity;
|
||||
imageUrlB: string;
|
||||
rarity: FigureRarity;
|
||||
cardStyle: CardStyle;
|
||||
}
|
||||
): Promise<Figure> {
|
||||
try {
|
||||
// Phase 1: Merge profiles via LLM
|
||||
await this.updateStatus(figureId, 'generating_profile');
|
||||
const fusedProfile = await this.geminiService.mergeProfiles(
|
||||
input.nameA,
|
||||
input.profileA,
|
||||
input.rarityA,
|
||||
input.nameB,
|
||||
input.profileB,
|
||||
input.rarityB,
|
||||
input.rarity
|
||||
);
|
||||
|
||||
// Save profile + fused name
|
||||
const { name: fusedName, ...profile } = fusedProfile;
|
||||
await this.db
|
||||
.update(figures)
|
||||
.set({ name: fusedName, generatedProfile: profile, updatedAt: new Date() })
|
||||
.where(eq(figures.id, figureId));
|
||||
|
||||
// Phase 2: Download parent images + convert webp→jpeg
|
||||
await this.updateStatus(figureId, 'generating_image');
|
||||
this.logger.log('Downloading parent images for fusion...');
|
||||
const [imgA, imgB] = await Promise.all([
|
||||
this.downloadAndConvertImage(input.imageUrlA),
|
||||
this.downloadAndConvertImage(input.imageUrlB),
|
||||
]);
|
||||
|
||||
// Phase 3: Generate fused image
|
||||
const itemLabels = profile.items.map((item) => `${item.name} — ${item.description}`);
|
||||
const pngBuffer = await this.geminiService.generateFusionImage(
|
||||
fusedName,
|
||||
profile.subtitle,
|
||||
profile.visualDescription,
|
||||
itemLabels,
|
||||
input.cardStyle,
|
||||
imgA,
|
||||
imgB
|
||||
);
|
||||
|
||||
// Phase 4: BG removal
|
||||
await this.updateStatus(figureId, 'processing');
|
||||
const webpBuffer = await this.imageProcessingService.removeBackground(pngBuffer);
|
||||
|
||||
// Phase 5: Upload
|
||||
const imageUrl = await this.storageService.uploadFigureImage(userId, figureId, webpBuffer);
|
||||
|
||||
// Phase 6: Finalize
|
||||
const [completed] = await this.db
|
||||
.update(figures)
|
||||
.set({
|
||||
imageUrl,
|
||||
status: 'completed',
|
||||
updatedAt: new Date(),
|
||||
})
|
||||
.where(eq(figures.id, figureId))
|
||||
.returning();
|
||||
|
||||
this.logger.log(`Fusion "${fusedName}" generation completed`);
|
||||
return completed;
|
||||
} catch (error) {
|
||||
const message = error instanceof Error ? error.message : 'Unknown error';
|
||||
this.logger.error(`Fusion generation failed: ${message}`);
|
||||
|
||||
try {
|
||||
const [failed] = await this.db
|
||||
.update(figures)
|
||||
.set({
|
||||
status: 'failed',
|
||||
errorMessage: message,
|
||||
updatedAt: new Date(),
|
||||
})
|
||||
.where(eq(figures.id, figureId))
|
||||
.returning();
|
||||
|
||||
return failed;
|
||||
} catch (dbError) {
|
||||
this.logger.error(`Failed to update error status for fusion ${figureId}`, dbError);
|
||||
throw error;
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
private async downloadAndConvertImage(imageUrl: string): Promise<Buffer> {
|
||||
const res = await fetch(imageUrl);
|
||||
if (!res.ok) throw new Error(`Failed to download image: ${res.status}`);
|
||||
const webpBuffer = Buffer.from(await res.arrayBuffer());
|
||||
return sharp(webpBuffer).jpeg({ quality: 85 }).toBuffer();
|
||||
}
|
||||
|
||||
private async updateStatus(figureId: string, status: FigureStatus): Promise<void> {
|
||||
await this.db
|
||||
.update(figures)
|
||||
|
|
|
|||
|
|
@ -1,4 +1,4 @@
|
|||
import type { CardStyle, FigureLanguage } from '@figgos/shared';
|
||||
import type { CardStyle, FigureLanguage, GeneratedProfile } from '@figgos/shared';
|
||||
|
||||
// ══════════════════════════════════════════════════════════════
|
||||
// Profile Generation — System Prompt
|
||||
|
|
@ -176,6 +176,14 @@ export const RARITY_STYLES: Record<CardStyle, RarityStyle> = {
|
|||
vibe: 'This packaging is LOUD and electric. It demands attention. The holographic surface throws rainbow light everywhere. The neon accents make it feel like it belongs in an arcade or a cyberpunk display case. Nothing about this is subtle.',
|
||||
},
|
||||
|
||||
// -- Fusion: Purple & gold hybrid --
|
||||
fusion: {
|
||||
card: 'Dark matte charcoal cardstock backing card with a subtle purple metallic sheen and fine gold filigree border.',
|
||||
textStyle: 'gold metallic uppercase with a purple glow outline',
|
||||
tag: 'A metallic purple tag with gold border in the top-right corner reading "FUSION" in gold text. The tag has a subtle shine.',
|
||||
vibe: 'This packaging signals something unique — a fusion of two characters into one. The purple-and-gold accents distinguish it from standard rarity packaging.',
|
||||
},
|
||||
|
||||
// -- Legendary: Ultra-luxury black & gold, museum piece --
|
||||
legendary: {
|
||||
card: 'Ultra-premium heavyweight matte black cardstock with a wide ornate gold foil border featuring intricate filigree scrollwork patterns embossed into the card. Gold foil decorative corner pieces with Art Deco geometric designs. A subtle gold foil crest or emblem is centered above the figure name. The card itself has a soft-touch velvet-like texture.',
|
||||
|
|
@ -231,3 +239,100 @@ IMPORTANT: The figure and accessories must NOT contain pure white (#FFFFFF) area
|
|||
|
||||
Pure white background, soft even studio lighting, product catalog quality. 85mm lens, sharp focus.`;
|
||||
}
|
||||
|
||||
// ══════════════════════════════════════════════════════════════
|
||||
// Fusion — Profile Merge Prompt
|
||||
// ══════════════════════════════════════════════════════════════
|
||||
|
||||
export const FUSION_PROFILE_SYSTEM_PROMPT = `You are the creative engine behind FIGGOS — a collectible action figure game. You are performing a FUSION: merging two existing figures into one new hybrid character.
|
||||
|
||||
The fused figure should:
|
||||
- Combine elements from BOTH characters (appearance, backstory, abilities)
|
||||
- Have a new unique name that blends or references both originals
|
||||
- Have a new subtitle that reflects the fusion
|
||||
- Have a backstory that explains how/why these two merged
|
||||
- Have a visualDescription that combines visual elements from both figures
|
||||
- Pick or merge items from both sets (exactly 3 items total)
|
||||
- Have a new special attack that combines elements of both
|
||||
- The visualDescription must describe ONLY the figure (not packaging)
|
||||
- Never use pure white for clothing — use off-white, cream, ivory instead`;
|
||||
|
||||
export function buildFusionProfilePrompt(
|
||||
nameA: string,
|
||||
profileA: GeneratedProfile,
|
||||
rarityA: string,
|
||||
nameB: string,
|
||||
profileB: GeneratedProfile,
|
||||
rarityB: string,
|
||||
statRange: { min: number; max: number }
|
||||
): string {
|
||||
const formatProfile = (name: string, profile: GeneratedProfile, rarity: string) =>
|
||||
`=== "${name}" (${rarity}) ===
|
||||
Subtitle: ${profile.subtitle}
|
||||
Backstory: ${profile.backstory}
|
||||
Visual: ${profile.visualDescription}
|
||||
Items: ${profile.items.map((i) => `${i.name} — ${i.description}`).join('; ')}
|
||||
Stats: ATK ${profile.stats.attack}, DEF ${profile.stats.defense}, SPL ${profile.stats.special}
|
||||
Special: ${profile.specialAttack.name} — ${profile.specialAttack.description}`;
|
||||
|
||||
return `Fuse these two figures into one new character:
|
||||
|
||||
${formatProfile(nameA, profileA, rarityA)}
|
||||
|
||||
${formatProfile(nameB, profileB, rarityB)}
|
||||
|
||||
Generate the fused character. Stats must be between ${statRange.min} and ${statRange.max}.`;
|
||||
}
|
||||
|
||||
export const FUSION_PROFILE_JSON_SCHEMA = {
|
||||
type: 'object' as const,
|
||||
properties: {
|
||||
name: {
|
||||
type: 'string' as const,
|
||||
description: 'New fused character name that blends or references both originals.',
|
||||
},
|
||||
...PROFILE_JSON_SCHEMA.properties,
|
||||
},
|
||||
required: ['name', ...PROFILE_JSON_SCHEMA.required] as const,
|
||||
};
|
||||
|
||||
// ══════════════════════════════════════════════════════════════
|
||||
// Fusion — Image Generation Prompt
|
||||
// ══════════════════════════════════════════════════════════════
|
||||
|
||||
export function buildFusionImagePrompt(
|
||||
name: string,
|
||||
subtitle: string,
|
||||
visualDescription: string,
|
||||
items: string[],
|
||||
cardStyle: CardStyle
|
||||
): string {
|
||||
const style = RARITY_STYLES[cardStyle];
|
||||
const itemsText = items
|
||||
.slice(0, 3)
|
||||
.map((item) => ` - ${item}`)
|
||||
.join('\n');
|
||||
|
||||
return `Product photograph of a premium collectible figure in sealed blister packaging on a pure white background. Package fills 95% of frame.
|
||||
|
||||
FUSION FIGURE: This figure is the result of merging two existing characters. The two attached images show the original parent figures. The fused figure should visually combine elements from BOTH parents into a cohesive new design.
|
||||
|
||||
${style.card} Hanging hole at top center. Clear plastic blister with molded compartments.
|
||||
|
||||
${style.tag}
|
||||
|
||||
Name in ${style.textStyle}: "${name.toUpperCase()}" large at the top. "${subtitle.toUpperCase()}" in smaller text below.
|
||||
|
||||
In the left compartment stands the figure: ${visualDescription}
|
||||
|
||||
${REALISM_BLOCK}
|
||||
${style.vibe}
|
||||
|
||||
Three accessories in separate molded blister compartments on the right side, stacked vertically:
|
||||
${itemsText}
|
||||
Each accessory is detailed, clearly visible, and generously sized.
|
||||
|
||||
IMPORTANT: The figure and accessories must NOT contain pure white (#FFFFFF) areas. Use off-white, cream, ivory, or light gray instead of white for any clothing, skin highlights, or materials. Pure white is reserved for the background only.
|
||||
|
||||
Pure white background, soft even studio lighting, product catalog quality. 85mm lens, sharp focus.`;
|
||||
}
|
||||
|
|
|
|||
|
|
@ -25,7 +25,8 @@ export type CardStyle =
|
|||
| 'common_warm'
|
||||
| 'rare'
|
||||
| 'epic'
|
||||
| 'legendary';
|
||||
| 'legendary'
|
||||
| 'fusion';
|
||||
|
||||
const COMMON_STYLES: CardStyle[] = ['common_kraft', 'common_white', 'common_mint', 'common_warm'];
|
||||
|
||||
|
|
@ -37,6 +38,23 @@ export function getCardStyle(rarity: FigureRarity): CardStyle {
|
|||
return rarity as CardStyle;
|
||||
}
|
||||
|
||||
// ── Fusion Rarity ──
|
||||
|
||||
const RARITY_TIERS: FigureRarity[] = ['common', 'rare', 'epic', 'legendary'];
|
||||
|
||||
/** Roll rarity for a fused figure. Base = higher parent, 30% chance +1 tier, 10% chance +2 tiers. */
|
||||
export function rollFusionRarity(rarityA: FigureRarity, rarityB: FigureRarity): FigureRarity {
|
||||
const idxA = RARITY_TIERS.indexOf(rarityA);
|
||||
const idxB = RARITY_TIERS.indexOf(rarityB);
|
||||
let base = Math.max(idxA, idxB);
|
||||
|
||||
const roll = Math.random();
|
||||
if (roll < 0.10) base += 2;
|
||||
else if (roll < 0.40) base += 1;
|
||||
|
||||
return RARITY_TIERS[Math.min(base, RARITY_TIERS.length - 1)];
|
||||
}
|
||||
|
||||
// ── Language ──
|
||||
|
||||
export type FigureLanguage = 'en' | 'de';
|
||||
|
|
@ -101,6 +119,8 @@ export interface FigureResponse {
|
|||
isPublic: boolean;
|
||||
errorMessage: string | null;
|
||||
isArchived: boolean;
|
||||
isFusion: boolean;
|
||||
parentFigureIds: string[] | null;
|
||||
createdAt: string;
|
||||
updatedAt: string;
|
||||
}
|
||||
|
|
|
|||
Loading…
Add table
Add a link
Reference in a new issue