From 3caf731afd51f1186bfec1ae617d9161d50cee03 Mon Sep 17 00:00:00 2001 From: Chr1st1anG <73988455+Chr1st1anG@users.noreply.github.com> Date: Thu, 12 Feb 2026 22:35:19 +0100 Subject: [PATCH] =?UTF-8?q?=E2=9C=A8=20feat(figgos):=20add=20fusion=20endp?= =?UTF-8?q?oint=20=E2=80=94=20merge=20two=20figures=20into=20one?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit 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 --- .../backend/src/db/schema/figures.schema.ts | 2 + .../src/figures/dto/fuse-figures.dto.ts | 9 ++ .../backend/src/figures/figures.controller.ts | 7 ++ .../backend/src/figures/figures.service.ts | 51 +++++++++ .../backend/src/generation/gemini.service.ts | 106 +++++++++++++++++ .../src/generation/generation.service.ts | 107 ++++++++++++++++++ .../apps/backend/src/generation/prompts.ts | 107 +++++++++++++++++- apps/figgos/packages/shared/src/index.ts | 22 +++- 8 files changed, 409 insertions(+), 2 deletions(-) create mode 100644 apps/figgos/apps/backend/src/figures/dto/fuse-figures.dto.ts diff --git a/apps/figgos/apps/backend/src/db/schema/figures.schema.ts b/apps/figgos/apps/backend/src/db/schema/figures.schema.ts index ad16676ac..9c8add03c 100644 --- a/apps/figgos/apps/backend/src/db/schema/figures.schema.ts +++ b/apps/figgos/apps/backend/src/db/schema/figures.schema.ts @@ -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(), }, diff --git a/apps/figgos/apps/backend/src/figures/dto/fuse-figures.dto.ts b/apps/figgos/apps/backend/src/figures/dto/fuse-figures.dto.ts new file mode 100644 index 000000000..366c684e4 --- /dev/null +++ b/apps/figgos/apps/backend/src/figures/dto/fuse-figures.dto.ts @@ -0,0 +1,9 @@ +import { IsUUID } from 'class-validator'; + +export class FuseFiguresDto { + @IsUUID() + figureIdA!: string; + + @IsUUID() + figureIdB!: string; +} diff --git a/apps/figgos/apps/backend/src/figures/figures.controller.ts b/apps/figgos/apps/backend/src/figures/figures.controller.ts index 0c36aa7d3..d56f90bb9 100644 --- a/apps/figgos/apps/backend/src/figures/figures.controller.ts +++ b/apps/figgos/apps/backend/src/figures/figures.controller.ts @@ -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); diff --git a/apps/figgos/apps/backend/src/figures/figures.service.ts b/apps/figgos/apps/backend/src/figures/figures.service.ts index 5f17830a6..78d151b1e 100644 --- a/apps/figgos/apps/backend/src/figures/figures.service.ts +++ b/apps/figgos/apps/backend/src/figures/figures.service.ts @@ -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
{ + // 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; + } } diff --git a/apps/figgos/apps/backend/src/generation/gemini.service.ts b/apps/figgos/apps/backend/src/generation/gemini.service.ts index fa8baa3c3..90afcecf0 100644 --- a/apps/figgos/apps/backend/src/generation/gemini.service.ts +++ b/apps/figgos/apps/backend/src/generation/gemini.service.ts @@ -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 { + const prompt = buildFusionImagePrompt(name, subtitle, visualDescription, items, cardStyle); + + this.logger.log(`Generating fusion image for "${name}"...`); + + const contents: Array = [ + { 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'); + } } diff --git a/apps/figgos/apps/backend/src/generation/generation.service.ts b/apps/figgos/apps/backend/src/generation/generation.service.ts index 75171328b..e78b2481f 100644 --- a/apps/figgos/apps/backend/src/generation/generation.service.ts +++ b/apps/figgos/apps/backend/src/generation/generation.service.ts @@ -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
{ + 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 { + 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 { await this.db .update(figures) diff --git a/apps/figgos/apps/backend/src/generation/prompts.ts b/apps/figgos/apps/backend/src/generation/prompts.ts index fb546e1a6..f5cb6ce35 100644 --- a/apps/figgos/apps/backend/src/generation/prompts.ts +++ b/apps/figgos/apps/backend/src/generation/prompts.ts @@ -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 = { 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.`; +} diff --git a/apps/figgos/packages/shared/src/index.ts b/apps/figgos/packages/shared/src/index.ts index b606d3736..3ce3c0b54 100644 --- a/apps/figgos/packages/shared/src/index.ts +++ b/apps/figgos/packages/shared/src/index.ts @@ -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; }