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:
Chr1st1anG 2026-02-12 22:35:19 +01:00
parent ed1dfd7472
commit 3caf731afd
8 changed files with 409 additions and 2 deletions

View file

@ -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(),
},

View file

@ -0,0 +1,9 @@
import { IsUUID } from 'class-validator';
export class FuseFiguresDto {
@IsUUID()
figureIdA!: string;
@IsUUID()
figureIdB!: string;
}

View file

@ -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);

View file

@ -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;
}
}

View file

@ -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');
}
}

View file

@ -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)

View file

@ -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.`;
}

View file

@ -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;
}