import type { BigFishSessionSnapshotResponse } from '../../packages/shared/src/contracts/bigFish'; import type { BabyObjectMatchDraft, CreateBabyObjectMatchDraftRequest, } from '../../packages/shared/src/contracts/edutainmentBabyObject'; import type { CreateMatch3DSessionRequest, Match3DAgentSessionSnapshot, } from '../../packages/shared/src/contracts/match3dAgent'; import type { CreatePuzzleAgentSessionRequest, PuzzleAgentSessionSnapshot, } from '../../packages/shared/src/contracts/puzzleAgentSession'; import type { CustomWorldGenerationProgress, CustomWorldGenerationStep, } from '../../packages/shared/src/contracts/runtime'; import type { SquareHoleSessionSnapshot } from '../../packages/shared/src/contracts/squareHoleAgent'; import type { CustomWorldStructuredAnchorEntry } from './customWorldAgentGenerationProgress'; export type MiniGameDraftGenerationKind = | 'puzzle' | 'big-fish' | 'square-hole' | 'match3d' | 'baby-object-match'; export type MiniGameDraftGenerationPhase = | 'idle' | 'compile' | 'puzzle-level-name' | 'big-fish-draft' | 'big-fish-levels' | 'big-fish-runtime' | 'square-hole-draft' | 'square-hole-cover' | 'square-hole-shapes' | 'square-hole-ready' | 'match3d-work-title' | 'match3d-item-names' | 'match3d-background-prompt' | 'match3d-material-sheet' | 'match3d-slice-images' | 'match3d-upload-images' | 'match3d-generate-views' | 'match3d-background-image' | 'match3d-write-draft' | 'match3d-ready' | 'baby-object-draft' | 'baby-object-images' | 'baby-object-ready' | 'puzzle-images' | 'puzzle-ui-background' | 'puzzle-select-image' | 'ready' | 'failed'; export type MiniGameDraftGenerationState = { kind: MiniGameDraftGenerationKind; phase: MiniGameDraftGenerationPhase; startedAtMs: number; finishedAtMs?: number; completedAssetCount: number; totalAssetCount: number; error: string | null; }; type MiniGameStepDefinition = { id: MiniGameDraftGenerationPhase; label: string; detail: string; weight: number; }; type MiniGameAnchorSource = { key: string; label: string; value: string; }; const PUZZLE_STEPS = [ { id: 'compile', label: '编译首关草稿', detail: '读取画面描述,建立可编辑草稿与首关结构。', weight: 10, }, { id: 'puzzle-level-name', label: '生成关卡名称', detail: '根据画面描述和图像语义整理首关题目。', weight: 8, }, { id: 'puzzle-images', label: '生成首关画面', detail: '调用图片模型生成适合切块的正方形首图。', weight: 42, }, { id: 'puzzle-ui-background', label: '生成UI背景', detail: '生成不含槽位和控件的 9:16 纯背景。', weight: 32, }, { id: 'puzzle-select-image', label: '写入正式草稿', detail: '写入首图、UI背景和首关数据。', weight: 8, }, ] as const satisfies ReadonlyArray; const PUZZLE_ESTIMATED_WAIT_MS = 132_000; const PUZZLE_NON_READY_MAX_PROGRESS = 98; const BABY_OBJECT_MATCH_ESTIMATED_WAIT_MS = 6 * 60_000; const PUZZLE_PHASE_TIMELINE: Array<{ phase: Extract< MiniGameDraftGenerationPhase, | 'compile' | 'puzzle-level-name' | 'puzzle-images' | 'puzzle-ui-background' | 'puzzle-select-image' >; durationMs: number; }> = [ { phase: 'compile', durationMs: 12_000 }, { phase: 'puzzle-level-name', durationMs: 8_000 }, { phase: 'puzzle-images', durationMs: 70_000 }, { phase: 'puzzle-ui-background', durationMs: 32_000 }, { phase: 'puzzle-select-image', durationMs: 10_000 }, ]; const BIG_FISH_STEPS = [ { id: 'big-fish-draft', label: '整理玩法骨架', detail: '收拢玩法承诺、成长阶梯与风险节奏。', weight: 30, }, { id: 'big-fish-levels', label: '编译等级蓝图', detail: '生成每级角色描述、形象描述与动作描述。', weight: 45, }, { id: 'big-fish-runtime', label: '校准场地与参数', detail: '整理背景蓝图与运行参数,准备结果页。', weight: 25, }, ] as const satisfies ReadonlyArray; const SQUARE_HOLE_STEPS = [ { id: 'square-hole-draft', label: '整理玩法草稿', detail: '收拢题材、展示选项与洞口选项。', weight: 28, }, { id: 'square-hole-cover', label: '生成封面与背景', detail: '生成作品封面和运行背景。', weight: 32, }, { id: 'square-hole-shapes', label: '生成选项贴图', detail: '为展示选项与洞口选项生成贴图。', weight: 40, }, ] as const satisfies ReadonlyArray; const MATCH3D_STEPS = [ { id: 'match3d-work-title', label: '建立草稿存档', detail: '创建可恢复作品草稿,锁定本次题材和难度。', weight: 8, }, { id: 'match3d-item-names', label: '生成作品计划', detail: '生成游戏名称、物品名称与标签。', weight: 10, }, { id: 'match3d-background-prompt', label: '生成背景提示词', detail: '整理纯背景图与容器 UI 图提示词。', weight: 6, }, { id: 'match3d-material-sheet', label: '分批生成素材图', detail: '按 1K 参数分批生成 5x5 多视角素材图。', weight: 24, }, { id: 'match3d-slice-images', label: '切割独立图片', detail: '把素材图切成每个物品的五个视角。', weight: 12, }, { id: 'match3d-upload-images', label: '上传图片资产', detail: '上传每个物品的 2D 五视角素材。', weight: 14, }, { id: 'match3d-generate-views', label: '校验素材结构', detail: '确认物品顺序和五视角图片。', weight: 8, }, { id: 'match3d-background-image', label: '生成UI背景', detail: '生成无 UI 元素纯背景,并生成题材容器 UI 图。', weight: 16, }, { id: 'match3d-write-draft', label: '写入草稿页', detail: '保存素材、背景、容器和作品草稿。', weight: 2, }, ] as const satisfies ReadonlyArray; const MATCH3D_ESTIMATED_WAIT_MS = 510_000; const MATCH3D_PHASE_ORDER: Partial< Record > = { 'match3d-work-title': 0, 'match3d-item-names': 1, 'match3d-background-prompt': 2, 'match3d-material-sheet': 3, 'match3d-slice-images': 4, 'match3d-upload-images': 5, 'match3d-generate-views': 6, 'match3d-background-image': 7, 'match3d-write-draft': 8, }; const BABY_OBJECT_MATCH_STEPS = [ { id: 'baby-object-draft', label: '整理识物草稿', detail: '写入两个物品名称与寓教于乐标签。', weight: 22, }, { id: 'baby-object-images', label: '生成游戏素材', detail: '生成物品图、背景、礼物盒、篮子和界面包装。', weight: 68, }, { id: 'baby-object-ready', label: '准备结果页', detail: '校验草稿字段并进入结果页。', weight: 10, }, ] as const satisfies ReadonlyArray; function clampProgress(value: number) { return Math.max(0, Math.min(100, Math.round(value))); } function getStepDefinitions(kind: MiniGameDraftGenerationKind) { if (kind === 'puzzle') { return PUZZLE_STEPS; } if (kind === 'square-hole') { return SQUARE_HOLE_STEPS; } if (kind === 'match3d') { return MATCH3D_STEPS; } if (kind === 'baby-object-match') { return BABY_OBJECT_MATCH_STEPS; } return BIG_FISH_STEPS; } function getActiveStepIndex( steps: ReadonlyArray, phase: MiniGameDraftGenerationPhase, ) { if (phase === 'ready') { return steps.length - 1; } const index = steps.findIndex((step) => step.id === phase); return index >= 0 ? index : 0; } function buildMiniGameProgressSteps( steps: ReadonlyArray, activeStepIndex: number, state: MiniGameDraftGenerationState, activeStepProgressRatio: number, ) { return steps.map((step, index) => { const isCompleted = state.phase === 'ready' || index < activeStepIndex; const isActive = state.phase !== 'failed' && !isCompleted && index === activeStepIndex; const isAssetStep = step.id === state.phase && state.totalAssetCount > 0; return { id: step.id, label: step.label, detail: step.detail, completed: isCompleted ? 1 : isAssetStep ? state.completedAssetCount : isActive ? activeStepProgressRatio : 0, total: isAssetStep ? state.totalAssetCount : 1, status: isCompleted ? 'completed' : isActive ? 'active' : 'pending', } satisfies CustomWorldGenerationStep; }); } export function createMiniGameDraftGenerationState( kind: MiniGameDraftGenerationKind, ): MiniGameDraftGenerationState { return { kind, phase: kind === 'big-fish' ? 'big-fish-draft' : kind === 'square-hole' ? 'square-hole-draft' : kind === 'match3d' ? 'match3d-work-title' : kind === 'baby-object-match' ? 'baby-object-draft' : 'compile', startedAtMs: Date.now(), completedAssetCount: 0, totalAssetCount: 0, error: null, }; } function resolveBigFishPhaseByElapsedMs( elapsedMs: number, ): MiniGameDraftGenerationPhase { if (elapsedMs >= 4_500) { return 'big-fish-runtime'; } if (elapsedMs >= 1_800) { return 'big-fish-levels'; } return 'big-fish-draft'; } function resolveSquareHolePhaseByElapsedMs( elapsedMs: number, ): MiniGameDraftGenerationPhase { if (elapsedMs >= 6_500) { return 'square-hole-shapes'; } if (elapsedMs >= 2_400) { return 'square-hole-cover'; } return 'square-hole-draft'; } function resolveMatch3DPhaseByElapsedMs( elapsedMs: number, currentPhase: MiniGameDraftGenerationPhase, ): MiniGameDraftGenerationPhase { const elapsedPhase = elapsedMs >= 492_000 ? 'match3d-write-draft' : elapsedMs >= 370_000 ? 'match3d-background-image' : elapsedMs >= 340_000 ? 'match3d-generate-views' : elapsedMs >= 260_000 ? 'match3d-upload-images' : elapsedMs >= 210_000 ? 'match3d-slice-images' : elapsedMs >= 28_000 ? 'match3d-material-sheet' : elapsedMs >= 12_000 ? 'match3d-background-prompt' : elapsedMs >= 4_000 ? 'match3d-item-names' : 'match3d-work-title'; const elapsedOrder = MATCH3D_PHASE_ORDER[elapsedPhase] ?? 0; const currentOrder = MATCH3D_PHASE_ORDER[currentPhase] ?? -1; return currentOrder > elapsedOrder ? currentPhase : elapsedPhase; } function resolveBabyObjectMatchPhaseByElapsedMs( elapsedMs: number, ): MiniGameDraftGenerationPhase { if (elapsedMs >= 330_000) { return 'baby-object-ready'; } if (elapsedMs >= 8_000) { return 'baby-object-images'; } return 'baby-object-draft'; } function resolvePuzzleTimelineByElapsedMs(elapsedMs: number) { let elapsedBeforePhase = 0; for (const item of PUZZLE_PHASE_TIMELINE) { const elapsedInPhase = elapsedMs - elapsedBeforePhase; if (elapsedInPhase < item.durationMs) { return { phase: item.phase, activeStepProgressRatio: Math.max( 0, Math.min(1, elapsedInPhase / item.durationMs), ), }; } elapsedBeforePhase += item.durationMs; } return { phase: 'puzzle-select-image' as const, activeStepProgressRatio: 1, }; } export function buildMiniGameDraftGenerationProgress( state: MiniGameDraftGenerationState | null, nowMs = Date.now(), ): CustomWorldGenerationProgress | null { if (!state) { return null; } const effectiveNowMs = typeof state.finishedAtMs === 'number' && Number.isFinite(state.finishedAtMs) ? state.finishedAtMs : nowMs; const elapsedMs = Math.max(0, effectiveNowMs - state.startedAtMs); const puzzleTimeline = state.kind === 'puzzle' && state.phase !== 'failed' && state.phase !== 'ready' ? resolvePuzzleTimelineByElapsedMs(elapsedMs) : null; const normalizedState = puzzleTimeline != null ? { ...state, phase: puzzleTimeline.phase, } : state.kind === 'big-fish' && state.phase !== 'failed' && state.phase !== 'ready' ? { ...state, phase: resolveBigFishPhaseByElapsedMs(elapsedMs), } : state.kind === 'square-hole' && state.phase !== 'failed' && state.phase !== 'ready' ? { ...state, phase: resolveSquareHolePhaseByElapsedMs(elapsedMs), } : state.kind === 'match3d' && state.phase !== 'failed' && state.phase !== 'ready' ? { ...state, phase: resolveMatch3DPhaseByElapsedMs(elapsedMs, state.phase), } : state.kind === 'baby-object-match' && state.phase !== 'failed' && state.phase !== 'ready' ? { ...state, phase: resolveBabyObjectMatchPhaseByElapsedMs(elapsedMs), } : state; const steps = getStepDefinitions(normalizedState.kind); const activeStepIndex = getActiveStepIndex(steps, normalizedState.phase); const completedWeight = steps .slice( 0, normalizedState.phase === 'ready' ? steps.length : activeStepIndex, ) .reduce((sum, step) => sum + step.weight, 0); const activeStep = steps[activeStepIndex] ?? steps[0]; const assetRatio = normalizedState.totalAssetCount > 0 ? Math.min( 1, normalizedState.completedAssetCount / normalizedState.totalAssetCount, ) : normalizedState.phase === 'ready' ? 1 : normalizedState.kind === 'puzzle' ? (puzzleTimeline?.activeStepProgressRatio ?? 0) : normalizedState.kind === 'big-fish' ? 0.55 : normalizedState.kind === 'square-hole' ? 0.42 : normalizedState.kind === 'match3d' ? 0.5 : normalizedState.kind === 'baby-object-match' ? 0.52 : 0; const overallProgress = normalizedState.phase === 'failed' ? Math.max(1, completedWeight) : normalizedState.phase === 'ready' ? 100 : completedWeight + activeStep.weight * assetRatio; const cappedOverallProgress = normalizedState.phase === 'ready' || normalizedState.phase === 'failed' ? overallProgress : normalizedState.kind === 'puzzle' ? Math.min(PUZZLE_NON_READY_MAX_PROGRESS, overallProgress) : overallProgress; return { phaseId: normalizedState.phase, phaseLabel: normalizedState.phase === 'failed' ? '生成失败' : normalizedState.phase === 'ready' ? '生成完成' : activeStep.label, phaseDetail: normalizedState.error ?? (normalizedState.phase === 'ready' ? normalizedState.kind === 'big-fish' ? '玩法草稿已准备完成,可进入结果页继续生成主图、动作和背景。' : normalizedState.kind === 'match3d' ? '抓大鹅素材与草稿已准备完成,可进入结果页继续编辑。' : normalizedState.kind === 'baby-object-match' ? '宝贝识物草稿已准备完成,可进入结果页继续发布。' : '首关草稿与正式图已准备完成,可进入结果页补作品信息。' : activeStep.detail), batchLabel: activeStep.label, overallProgress: clampProgress(cappedOverallProgress), completedWeight: clampProgress(cappedOverallProgress), totalWeight: 100, elapsedMs, estimatedRemainingMs: normalizedState.phase === 'ready' ? 0 : normalizedState.kind === 'puzzle' ? Math.max(0, PUZZLE_ESTIMATED_WAIT_MS - elapsedMs) : normalizedState.kind === 'big-fish' ? Math.max(0, 7_000 - elapsedMs) : normalizedState.kind === 'square-hole' ? Math.max(0, 12_000 - elapsedMs) : normalizedState.kind === 'match3d' ? Math.max(0, MATCH3D_ESTIMATED_WAIT_MS - elapsedMs) : normalizedState.kind === 'baby-object-match' ? Math.max( 0, BABY_OBJECT_MATCH_ESTIMATED_WAIT_MS - elapsedMs, ) : null, activeStepIndex, steps: buildMiniGameProgressSteps( steps, activeStepIndex, normalizedState, assetRatio, ), }; } export function buildPuzzleGenerationAnchorEntries( session: PuzzleAgentSessionSnapshot | null | undefined, formPayload: CreatePuzzleAgentSessionRequest | null | undefined = null, ): CustomWorldStructuredAnchorEntry[] { if (!session) { return []; } const entries: Array = [ { key: 'picture-description', label: '画面描述', value: formPayload?.pictureDescription?.trim() || formPayload?.seedText?.trim() || session.draft?.levels?.[0]?.pictureDescription || session.anchorPack.visualSubject.value, }, ]; return entries .filter((entry): entry is MiniGameAnchorSource => Boolean(entry)) .map((entry) => ({ id: entry.key, label: entry.label, value: entry.value, })) .filter((entry) => entry.value.trim()); } export function buildBigFishGenerationAnchorEntries( session: BigFishSessionSnapshotResponse | null | undefined, ): CustomWorldStructuredAnchorEntry[] { if (!session) { return []; } const draft = session.draft; const assetReadyCount = session.assetSlots.filter( (slot) => slot.status === 'ready', ).length; const entries: Array = [ session.anchorPack.gameplayPromise, session.anchorPack.ecologyVisualTheme, session.anchorPack.growthLadder, session.anchorPack.riskTempo, draft ? { key: 'level-characters', label: '角色描述', value: draft.levels .map( (level) => `Lv.${level.level} ${level.name}:${level.oneLineFantasy}`, ) .join('\n'), } : null, draft ? { key: 'asset-coverage', label: '图片与动作', value: `已生成 ${assetReadyCount}/${session.assetSlots.length} 个资产`, } : null, ]; return entries .filter((entry): entry is MiniGameAnchorSource => Boolean(entry)) .map((entry) => ({ id: entry.key, label: entry.label, value: entry.value, })) .filter((entry) => entry.value.trim()); } export function buildMatch3DGenerationAnchorEntries( session: Match3DAgentSessionSnapshot | null | undefined, formPayload: CreateMatch3DSessionRequest | null | undefined = null, ): CustomWorldStructuredAnchorEntry[] { if (!session && !formPayload) { return []; } const config = session?.config; const clearCount = formPayload?.clearCount ?? config?.clearCount ?? null; const difficulty = formPayload?.difficulty ?? config?.difficulty ?? null; const itemCount = resolveMatch3DGeneratedItemCount(clearCount, difficulty); const entries: Array = [ { key: 'match3d-theme', label: '题材', value: formPayload?.themeText?.trim() || config?.themeText?.trim() || session?.anchorPack.theme.value || '', }, { key: 'match3d-items', label: '物品数量', value: `${itemCount} 件`, }, ]; return entries .filter((entry): entry is MiniGameAnchorSource => Boolean(entry)) .map((entry) => ({ id: entry.key, label: entry.label, value: entry.value, })) .filter((entry) => entry.value.trim()); } function resolveMatch3DGeneratedItemCount( clearCount: number | null | undefined, difficulty: number | null | undefined, ) { const roundToSheet = (count: number) => Math.ceil(count / 5) * 5; if (clearCount === 8) return roundToSheet(3); if (clearCount === 12) return roundToSheet(9); if (clearCount === 16) return roundToSheet(15); if (clearCount === 20 || clearCount === 21) return roundToSheet(21); const normalizedDifficulty = typeof difficulty === 'number' && Number.isFinite(difficulty) ? Math.max(1, Math.min(10, Math.round(difficulty))) : 4; if (normalizedDifficulty <= 2) return roundToSheet(3); if (normalizedDifficulty <= 4) return roundToSheet(9); if (normalizedDifficulty <= 6) return roundToSheet(15); return roundToSheet(21); } export function buildBabyObjectMatchGenerationAnchorEntries( formPayload: CreateBabyObjectMatchDraftRequest | null | undefined, draft: BabyObjectMatchDraft | null | undefined = null, ): CustomWorldStructuredAnchorEntry[] { const itemNames = formPayload?.itemAName?.trim() || formPayload?.itemBName?.trim() ? [formPayload.itemAName.trim(), formPayload.itemBName.trim()] : (draft?.itemNames ?? []); return itemNames.filter(Boolean).map((value, index) => ({ id: `baby-object-item-${index + 1}`, label: `物品 ${index + 1}`, value, })); } export function buildSquareHoleGenerationAnchorEntries( session: SquareHoleSessionSnapshot | null | undefined, ): CustomWorldStructuredAnchorEntry[] { if (!session) { return []; } const draft = session.draft; const shapeCount = draft?.shapeOptions.filter((option) => option.imageSrc?.trim()).length ?? session.config.shapeOptions.filter((option) => option.imageSrc?.trim()) .length; const totalShapeCount = draft?.shapeOptions.length || session.config.shapeOptions.length; const entries: Array = [ { key: 'square-hole-title', label: '作品名称', value: draft?.gameName || `${session.config.themeText}方洞挑战`, }, { key: 'square-hole-theme', label: '题材与规则', value: `${session.config.themeText}|${session.config.twistRule}`, }, { key: 'square-hole-options', label: '选项资产', value: totalShapeCount > 0 ? `形状贴图 ${shapeCount}/${totalShapeCount}` : '', }, ]; return entries .filter((entry): entry is MiniGameAnchorSource => Boolean(entry)) .map((entry) => ({ id: entry.key, label: entry.label, value: entry.value, })) .filter((entry) => entry.value.trim()); }