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 { PuzzleClearSessionSnapshotResponse, PuzzleClearWorkspaceCreateRequest, } from '../../packages/shared/src/contracts/puzzleClear'; import type { CustomWorldGenerationProgress, CustomWorldGenerationStep, } from '../../packages/shared/src/contracts/runtime'; import type { SquareHoleSessionSnapshot } from '../../packages/shared/src/contracts/squareHoleAgent'; import type { WoodenFishSessionSnapshotResponse, WoodenFishWorkspaceCreateRequest, } from '../../packages/shared/src/contracts/woodenFish'; import type { CustomWorldStructuredAnchorEntry } from './customWorldAgentGenerationProgress'; import type { CreateJumpHopSessionRequest, JumpHopSessionSnapshot, } from './jump-hop/jumpHopClient'; export type MiniGameDraftGenerationKind = | 'puzzle' | 'big-fish' | 'square-hole' | 'match3d' | 'baby-object-match' | 'jump-hop' | 'puzzle-clear' | 'wooden-fish'; 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-level-scene' | 'match3d-derived-assets' | 'match3d-parse-spritesheet' | 'match3d-write-draft' | 'match3d-ready' | 'baby-object-draft' | 'baby-object-images' | 'baby-object-ready' | 'jump-hop-draft' | 'jump-hop-tile-atlas' | 'jump-hop-slice-tiles' | 'jump-hop-write-draft' | 'puzzle-clear-draft' | 'puzzle-clear-background' | 'puzzle-clear-atlas' | 'puzzle-clear-slices' | 'puzzle-clear-write-draft' | 'wooden-fish-draft' | 'wooden-fish-hit-object' | 'wooden-fish-background' | 'wooden-fish-back-button' | 'wooden-fish-write-draft' | 'puzzle-cover-image' | 'puzzle-level-scene' | 'puzzle-ui-assets' | '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; metadata?: { puzzleAiRedraw?: boolean; puzzleActivePhaseId?: MiniGameDraftGenerationPhase; puzzleActiveStepStartedAtMs?: number; puzzleProgressPercent?: number; }; }; type MiniGameStepDefinition = { id: MiniGameDraftGenerationPhase; label: string; detail: string; weight: number; }; type TimedMiniGameStepDefinition = Omit & { durationMs: number; }; type MiniGameAnchorSource = { key: string; label: string; value: string; }; const PUZZLE_COVER_IMAGE_GENERATION_EXPECTED_MS = 240_000; const PUZZLE_IMAGE_GENERATION_EXPECTED_MS = 90_000; const PUZZLE_COMPILE_EXPECTED_MS = 8_000; const PUZZLE_LEVEL_NAME_EXPECTED_MS = 10_000; const PUZZLE_WRITE_DRAFT_EXPECTED_MS = 10_000; const PUZZLE_COMPILE_MILESTONE_PROGRESS = 88; const PUZZLE_IMAGE_MILESTONE_PROGRESS = 94; const PUZZLE_UI_MILESTONE_PROGRESS = 96; function shouldSkipPuzzleCoverGeneration(state: MiniGameDraftGenerationState) { return state.metadata?.puzzleAiRedraw === false; } function buildWeightedPuzzleSteps( steps: ReadonlyArray, ) { const totalDuration = steps.reduce((sum, step) => sum + step.durationMs, 0); let usedWeight = 0; return steps.map((step, index) => { const weight = index === steps.length - 1 ? Math.max(1, 100 - usedWeight) : Math.max(1, Math.round((step.durationMs / totalDuration) * 100)); usedWeight += weight; return { id: step.id, label: step.label, detail: step.detail, weight, } satisfies MiniGameStepDefinition; }); } function buildPuzzleTimedSteps(state: MiniGameDraftGenerationState) { const steps: TimedMiniGameStepDefinition[] = [ { id: 'compile', label: '编译首关草稿', detail: '建立可恢复草稿,整理首关描述与关卡结构,约 8 秒。', durationMs: PUZZLE_COMPILE_EXPECTED_MS, }, { id: 'puzzle-level-name', label: '生成关卡名称', detail: '根据描述生成关卡名、作品描述和标签,约 10 秒。', durationMs: PUZZLE_LEVEL_NAME_EXPECTED_MS, }, ]; if (!shouldSkipPuzzleCoverGeneration(state)) { steps.push({ id: 'puzzle-cover-image', label: '生成拼图首图', detail: '生成 1:1 拼图首图,预计 4 分钟。', durationMs: PUZZLE_COVER_IMAGE_GENERATION_EXPECTED_MS, }); } steps.push( { id: 'puzzle-level-scene', label: '生成关卡画面', detail: shouldSkipPuzzleCoverGeneration(state) ? '直接使用上传图作为参考,生成 9:16 完整关卡画面,预计 90 秒。' : '使用拼图首图作为参考,生成 9:16 完整关卡画面,预计 90 秒。', durationMs: PUZZLE_IMAGE_GENERATION_EXPECTED_MS, }, { id: 'puzzle-ui-assets', label: '生成UI与背景', detail: '用关卡画面作参考,并发生成 UI spritesheet 与 9:16 纯背景,预计 90 秒。', durationMs: PUZZLE_IMAGE_GENERATION_EXPECTED_MS, }, { id: 'puzzle-select-image', label: '写入正式草稿', detail: '校验资产并写入正式首关、作品摘要和草稿投影,约 10 秒。', durationMs: PUZZLE_WRITE_DRAFT_EXPECTED_MS, }, ); return steps; } function buildPuzzleSteps(state: MiniGameDraftGenerationState) { return buildWeightedPuzzleSteps(buildPuzzleTimedSteps(state)); } function resolvePuzzleEstimatedWaitMs(state: MiniGameDraftGenerationState) { return buildPuzzleTimedSteps(state).reduce( (sum, step) => sum + step.durationMs, 0, ); } const PUZZLE_NON_READY_MAX_PROGRESS = 98; const BABY_OBJECT_MATCH_ESTIMATED_WAIT_MS = 6 * 60_000; function resolvePuzzleBackendProgressPercent( state: MiniGameDraftGenerationState, ) { const progressPercent = state.metadata?.puzzleProgressPercent; if ( typeof progressPercent !== 'number' || !Number.isFinite(progressPercent) ) { return null; } return Math.max(0, Math.min(100, Math.round(progressPercent))); } function resolvePuzzlePhaseByBackendProgress( state: MiniGameDraftGenerationState, ): MiniGameDraftGenerationPhase | null { const progressPercent = resolvePuzzleBackendProgressPercent(state); if (progressPercent == null) { return null; } // 中文注释:拼图生成页的跨步骤只跟随后端会话真实里程碑; // 每步内部的等待反馈仍由本地假进度补足。 if (progressPercent >= 96) { return 'puzzle-select-image'; } if (progressPercent >= 94) { return 'puzzle-ui-assets'; } if (progressPercent >= 88) { return shouldSkipPuzzleCoverGeneration(state) ? 'puzzle-level-scene' : 'puzzle-cover-image'; } return null; } 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-level-scene', label: '生成关卡整图', detail: '生成 9:16 完整抓大鹅关卡画面。', weight: 28, }, { id: 'match3d-derived-assets', label: '生成三张派生图', detail: '以关卡整图为参考,并发生成 UI、背景和 10x10 物品 Sprite。', weight: 34, }, { id: 'match3d-parse-spritesheet', label: '解析物品Sprite', detail: '解析 20 个物品和每个物品的 5 个形态,并上传透明 PNG。', weight: 18, }, { id: 'match3d-write-draft', label: '写入草稿页', detail: '保存关卡整图、派生图集、20 种物品素材和作品草稿。', weight: 2, }, ] as const satisfies ReadonlyArray; const MATCH3D_ESTIMATED_WAIT_MS = 460_000; const MATCH3D_PHASE_ORDER: Partial< Record > = { 'match3d-work-title': 0, 'match3d-item-names': 1, 'match3d-level-scene': 2, 'match3d-derived-assets': 3, 'match3d-parse-spritesheet': 4, 'match3d-write-draft': 5, // 中文注释:旧生成页阶段在恢复生成中草稿时归并到新流程对应阶段。 'match3d-background-prompt': 1, 'match3d-material-sheet': 3, 'match3d-slice-images': 4, 'match3d-upload-images': 4, 'match3d-generate-views': 4, 'match3d-background-image': 3, }; function normalizeMatch3DGenerationPhase( phase: MiniGameDraftGenerationPhase, ): MiniGameDraftGenerationPhase { switch (phase) { case 'match3d-background-prompt': return 'match3d-item-names'; case 'match3d-material-sheet': case 'match3d-background-image': return 'match3d-derived-assets'; case 'match3d-slice-images': case 'match3d-upload-images': case 'match3d-generate-views': return 'match3d-parse-spritesheet'; default: return phase; } } 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; const JUMP_HOP_STEPS = [ { id: 'jump-hop-draft', label: '整理玩法草稿', detail: '保存主题并派生作品信息和默认角色配置。', weight: 12, }, { id: 'jump-hop-tile-atlas', label: '生成 5x5 地块图集', detail: '调用 image2 生成 25 个主题地块素材。', weight: 54, }, { id: 'jump-hop-slice-tiles', label: '切分 25 个地块', detail: '按 5 行 5 列切分透明地块 PNG。', weight: 24, }, { id: 'jump-hop-write-draft', label: '写入正式草稿', detail: '保存地块池、无限路径缓冲和运行态配置。', weight: 10, }, ] as const satisfies ReadonlyArray; const JUMP_HOP_ESTIMATED_WAIT_MS = 5 * 60_000; const PUZZLE_CLEAR_STEPS = [ { id: 'puzzle-clear-draft', label: '整理玩法草稿', detail: '保存作品信息、主题词与底图策略。', weight: 8, }, { id: 'puzzle-clear-background', label: '准备场地底图', detail: '处理上传底图或生成中央场地底图。', weight: 22, }, { id: 'puzzle-clear-atlas', label: '生成复合图集', detail: '生成 135 组复合图案 atlas。', weight: 42, }, { id: 'puzzle-clear-slices', label: '切分卡牌碎片', detail: '按预排坐标切成 1x1 卡牌碎片并校验。', weight: 20, }, { id: 'puzzle-clear-write-draft', label: '写入正式草稿', detail: '保存底图、图集、碎片和作品摘要。', weight: 8, }, ] as const satisfies ReadonlyArray; const PUZZLE_CLEAR_ESTIMATED_WAIT_MS = 620_000; const WOODEN_FISH_STEPS = [ { id: 'wooden-fish-draft', label: '整理玩法草稿', detail: '保存作品信息、敲击物、音效和飘字配置。', weight: 8, }, { id: 'wooden-fish-hit-object', label: '生成敲击物图案', detail: '调用 image2 生成绿幕敲击物并去绿透明化,预计约 3 分钟。', weight: 32, }, { id: 'wooden-fish-background', label: '生成背景环境图', detail: '使用透明敲击物作参考生成 9:16 背景环境图,预计约 3 分钟。', weight: 32, }, { id: 'wooden-fish-back-button', label: '生成返回按钮图', detail: '使用敲击物和背景作参考生成主题圆形返回按钮,预计约 3 分钟。', weight: 20, }, { id: 'wooden-fish-write-draft', label: '写入正式草稿', detail: '保存图案、背景、返回按钮、音效、飘字和封面摘要。', weight: 8, }, ] as const satisfies ReadonlyArray; const WOODEN_FISH_COMPILE_EXPECTED_MS = 8_000; const WOODEN_FISH_IMAGE_GENERATION_EXPECTED_MS = 180_000; const WOODEN_FISH_WRITE_DRAFT_EXPECTED_MS = 10_000; const WOODEN_FISH_ESTIMATED_WAIT_MS = WOODEN_FISH_COMPILE_EXPECTED_MS + WOODEN_FISH_IMAGE_GENERATION_EXPECTED_MS * 3 + WOODEN_FISH_WRITE_DRAFT_EXPECTED_MS; function clampProgress(value: number) { return Math.max(0, Math.min(100, Math.round(value))); } export function resolveMiniGameDraftGenerationStartedAtMs( startedAt: string | number | null | undefined, fallbackMs = Date.now(), ) { if (typeof startedAt === 'number' && Number.isFinite(startedAt)) { return startedAt; } if (typeof startedAt === 'string') { const parsed = Date.parse(startedAt); if (Number.isFinite(parsed)) { return parsed; } } return fallbackMs; } function getStepDefinitions(kind: MiniGameDraftGenerationKind) { if (kind === 'puzzle') { return buildPuzzleSteps(createMiniGameDraftGenerationState('puzzle')); } if (kind === 'square-hole') { return SQUARE_HOLE_STEPS; } if (kind === 'match3d') { return MATCH3D_STEPS; } if (kind === 'baby-object-match') { return BABY_OBJECT_MATCH_STEPS; } if (kind === 'jump-hop') { return JUMP_HOP_STEPS; } if (kind === 'puzzle-clear') { return PUZZLE_CLEAR_STEPS; } if (kind === 'wooden-fish') { return WOODEN_FISH_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) => { // 中文注释:拼图草稿编译的 action 回包才代表可进入结果页; // 但预计写入时长已耗尽时,最后一步自身应呈现已完成,避免出现“进行中 100%”。 const isTimedWriteStepCompleted = (state.kind === 'puzzle' || state.kind === 'wooden-fish') && state.phase !== 'failed' && (step.id === 'puzzle-select-image' || step.id === 'wooden-fish-write-draft') && clampProgress(activeStepProgressRatio * 100) >= 100; const isCompleted = state.phase === 'ready' || index < activeStepIndex || isTimedWriteStepCompleted; 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, startedAtMs = Date.now(), ): 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' : kind === 'jump-hop' ? 'jump-hop-draft' : kind === 'puzzle-clear' ? 'puzzle-clear-draft' : kind === 'wooden-fish' ? 'wooden-fish-draft' : 'compile', startedAtMs, 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 >= 450_000 ? 'match3d-write-draft' : elapsedMs >= 360_000 ? 'match3d-parse-spritesheet' : elapsedMs >= 118_000 ? 'match3d-derived-assets' : elapsedMs >= 28_000 ? 'match3d-level-scene' : elapsedMs >= 8_000 ? 'match3d-item-names' : 'match3d-work-title'; const elapsedOrder = MATCH3D_PHASE_ORDER[elapsedPhase] ?? 0; const normalizedCurrentPhase = normalizeMatch3DGenerationPhase(currentPhase); const currentOrder = MATCH3D_PHASE_ORDER[normalizedCurrentPhase] ?? -1; return currentOrder > elapsedOrder ? normalizedCurrentPhase : 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 resolveJumpHopPhaseByElapsedMs( elapsedMs: number, ): MiniGameDraftGenerationPhase { if (elapsedMs >= 270_000) { return 'jump-hop-write-draft'; } if (elapsedMs >= 220_000) { return 'jump-hop-slice-tiles'; } if (elapsedMs >= 115_000) { return 'jump-hop-tile-atlas'; } if (elapsedMs >= 12_000) { return 'jump-hop-tile-atlas'; } return 'jump-hop-draft'; } function resolvePuzzleClearPhaseByElapsedMs( elapsedMs: number, ): MiniGameDraftGenerationPhase { if (elapsedMs >= 590_000) { return 'puzzle-clear-write-draft'; } if (elapsedMs >= 470_000) { return 'puzzle-clear-slices'; } if (elapsedMs >= 120_000) { return 'puzzle-clear-atlas'; } if (elapsedMs >= 8_000) { return 'puzzle-clear-background'; } return 'puzzle-clear-draft'; } function buildWoodenFishPhaseTimeline(): Array<{ phase: Extract< MiniGameDraftGenerationPhase, | 'wooden-fish-draft' | 'wooden-fish-hit-object' | 'wooden-fish-background' | 'wooden-fish-back-button' | 'wooden-fish-write-draft' >; durationMs: number; }> { return [ { phase: 'wooden-fish-draft', durationMs: WOODEN_FISH_COMPILE_EXPECTED_MS, }, { phase: 'wooden-fish-hit-object', durationMs: WOODEN_FISH_IMAGE_GENERATION_EXPECTED_MS, }, { phase: 'wooden-fish-background', durationMs: WOODEN_FISH_IMAGE_GENERATION_EXPECTED_MS, }, { phase: 'wooden-fish-back-button', durationMs: WOODEN_FISH_IMAGE_GENERATION_EXPECTED_MS, }, { phase: 'wooden-fish-write-draft', durationMs: WOODEN_FISH_WRITE_DRAFT_EXPECTED_MS, }, ]; } function resolveWoodenFishTimelineByElapsedMs(elapsedMs: number) { let elapsedBeforePhase = 0; for (const item of buildWoodenFishPhaseTimeline()) { 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: 'wooden-fish-write-draft' as const, activeStepProgressRatio: 1, }; } function resolvePuzzleActiveStepProgressRatio( steps: ReadonlyArray, activeStepIndex: number, elapsedMs: number, ) { const activeStep = steps[activeStepIndex]; if (!activeStep) { return 0; } const elapsedBeforeActiveStep = steps .slice(0, activeStepIndex) .reduce((sum, step) => sum + step.durationMs, 0); const elapsedInActiveStep = Math.max(0, elapsedMs - elapsedBeforeActiveStep); return Math.max( 0, Math.min(0.98, elapsedInActiveStep / Math.max(1, activeStep.durationMs)), ); } function resolvePuzzleActiveStepElapsedProgressRatio( state: MiniGameDraftGenerationState, steps: ReadonlyArray, activeStepIndex: number, elapsedMs: number, effectiveNowMs: number, ) { if (resolvePuzzleBackendProgressPercent(state) != null) { const stepStartedAtMs = state.metadata?.puzzleActiveStepStartedAtMs; if ( state.metadata?.puzzleActivePhaseId === state.phase && typeof stepStartedAtMs === 'number' && Number.isFinite(stepStartedAtMs) ) { const activeStep = steps[activeStepIndex]; if (!activeStep) { return 0; } return Math.max( 0, Math.min( 0.98, (effectiveNowMs - stepStartedAtMs) / Math.max(1, activeStep.durationMs), ), ); } return resolvePuzzleActiveStepProgressRatio( steps, activeStepIndex, elapsedMs, ); } const activeStep = steps[activeStepIndex]; if (!activeStep) { return 0; } // 中文注释:未收到后端真实里程碑时,跨步骤必须卡住; // 但当前步骤内的假进度要按整段等待时间继续向前走,避免短步骤几秒后停死。 const fallbackDurationMs = Math.max(1, resolvePuzzleEstimatedWaitMs(state)); return Math.max(0, Math.min(0.98, elapsedMs / fallbackDurationMs)); } function resolveElapsedActiveStepProgressRatio( kind: MiniGameDraftGenerationKind, elapsedMs: number, ) { const estimatedWaitMs = kind === 'big-fish' ? 7_000 : kind === 'square-hole' ? 12_000 : kind === 'match3d' ? MATCH3D_ESTIMATED_WAIT_MS : kind === 'baby-object-match' ? BABY_OBJECT_MATCH_ESTIMATED_WAIT_MS : kind === 'jump-hop' ? JUMP_HOP_ESTIMATED_WAIT_MS : kind === 'puzzle-clear' ? PUZZLE_CLEAR_ESTIMATED_WAIT_MS : kind === 'wooden-fish' ? WOODEN_FISH_ESTIMATED_WAIT_MS : 1; return Math.max( 0, Math.min(0.98, elapsedMs / Math.max(1, estimatedWaitMs)), ); } /** 计算拼图生成总进度,后端里程碑决定跨步骤,当前步骤内使用平滑假进度。 */ function resolvePuzzleOverallProgress( state: MiniGameDraftGenerationState, activeStepProgressRatio: number, ) { const backendProgressPercent = resolvePuzzleBackendProgressPercent(state); // 中文注释:88 以下的后端进度只保留为会话事实,不参与首帧总进度抬升。 // 生成页恢复时必须先从 0% 起步,再由当前步骤内的假进度平滑推进。 const backendProgressFloor = backendProgressPercent != null && backendProgressPercent >= PUZZLE_COMPILE_MILESTONE_PROGRESS ? backendProgressPercent : 0; const range = state.phase === 'puzzle-select-image' ? { start: PUZZLE_UI_MILESTONE_PROGRESS, end: PUZZLE_NON_READY_MAX_PROGRESS, } : state.phase === 'puzzle-ui-assets' ? { start: PUZZLE_IMAGE_MILESTONE_PROGRESS, end: PUZZLE_UI_MILESTONE_PROGRESS, } : state.phase === 'puzzle-cover-image' || state.phase === 'puzzle-level-scene' ? { start: PUZZLE_COMPILE_MILESTONE_PROGRESS, end: PUZZLE_IMAGE_MILESTONE_PROGRESS, } : { start: 0, end: PUZZLE_COMPILE_MILESTONE_PROGRESS, }; const fakeProgress = range.start + (range.end - range.start) * activeStepProgressRatio; const nextProgress = Math.min( PUZZLE_NON_READY_MAX_PROGRESS, Math.max(range.start, backendProgressFloor, fakeProgress), ); return nextProgress; } 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 puzzleBackendPhase = state.kind === 'puzzle' && state.phase !== 'failed' && state.phase !== 'ready' ? resolvePuzzlePhaseByBackendProgress(state) : null; const woodenFishTimeline = state.kind === 'wooden-fish' && state.phase !== 'failed' && state.phase !== 'ready' ? resolveWoodenFishTimelineByElapsedMs(elapsedMs) : null; const normalizedState = puzzleBackendPhase != null ? { ...state, phase: puzzleBackendPhase, } : woodenFishTimeline != null ? { ...state, phase: woodenFishTimeline.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.kind === 'jump-hop' && state.phase !== 'failed' && state.phase !== 'ready' ? { ...state, phase: resolveJumpHopPhaseByElapsedMs(elapsedMs), } : state.kind === 'puzzle-clear' && state.phase !== 'failed' && state.phase !== 'ready' ? { ...state, phase: resolvePuzzleClearPhaseByElapsedMs(elapsedMs), } : state; const puzzleTimedSteps = normalizedState.kind === 'puzzle' ? buildPuzzleTimedSteps(normalizedState) : null; const steps = normalizedState.kind === 'puzzle' ? buildWeightedPuzzleSteps( puzzleTimedSteps ?? buildPuzzleTimedSteps(normalizedState), ) : getStepDefinitions(normalizedState.kind); const activeStepIndex = getActiveStepIndex(steps, normalizedState.phase); const activeStep = steps[activeStepIndex] ?? steps[0]; const activeStepProgressRatio = normalizedState.phase === 'failed' ? 0 : normalizedState.kind === 'puzzle' ? normalizedState.phase === 'ready' ? 1 : resolvePuzzleActiveStepElapsedProgressRatio( normalizedState, puzzleTimedSteps ?? buildPuzzleTimedSteps(normalizedState), activeStepIndex, elapsedMs, effectiveNowMs, ) : normalizedState.totalAssetCount > 0 ? Math.min( 1, normalizedState.completedAssetCount / normalizedState.totalAssetCount, ) : normalizedState.phase === 'ready' ? 1 : normalizedState.kind === 'big-fish' ? resolveElapsedActiveStepProgressRatio( normalizedState.kind, elapsedMs, ) : normalizedState.kind === 'square-hole' ? resolveElapsedActiveStepProgressRatio( normalizedState.kind, elapsedMs, ) : normalizedState.kind === 'match3d' ? resolveElapsedActiveStepProgressRatio( normalizedState.kind, elapsedMs, ) : normalizedState.kind === 'baby-object-match' ? resolveElapsedActiveStepProgressRatio( normalizedState.kind, elapsedMs, ) : normalizedState.kind === 'jump-hop' ? resolveElapsedActiveStepProgressRatio( normalizedState.kind, elapsedMs, ) : normalizedState.kind === 'puzzle-clear' ? resolveElapsedActiveStepProgressRatio( normalizedState.kind, elapsedMs, ) : normalizedState.kind === 'wooden-fish' ? (woodenFishTimeline?.activeStepProgressRatio ?? 0) : 0; const completedWeight = steps .slice( 0, normalizedState.phase === 'ready' ? steps.length : activeStepIndex, ) .reduce((sum, step) => sum + step.weight, 0); const overallProgress = normalizedState.phase === 'failed' ? Math.max(1, completedWeight) : normalizedState.phase === 'ready' ? 100 : completedWeight + activeStep.weight * activeStepProgressRatio; const cappedOverallProgress = normalizedState.phase === 'ready' || normalizedState.phase === 'failed' ? overallProgress : Math.min(PUZZLE_NON_READY_MAX_PROGRESS, 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' ? '宝贝识物草稿已准备完成,可进入结果页继续发布。' : normalizedState.kind === 'jump-hop' ? '跳一跳草稿已准备完成,可进入结果页试玩或发布。' : normalizedState.kind === 'puzzle-clear' ? '拼消消草稿已准备完成,可进入结果页试玩或发布。' : normalizedState.kind === 'wooden-fish' ? '敲木鱼草稿已准备完成,可进入结果页试玩或发布。' : '首关草稿与正式图已准备完成,可进入结果页补作品信息。' : activeStep.detail), batchLabel: activeStep.label, overallProgress: clampProgress(cappedOverallProgress), completedWeight: clampProgress(cappedOverallProgress), totalWeight: 100, elapsedMs, estimatedRemainingMs: normalizedState.phase === 'ready' || normalizedState.phase === 'failed' ? 0 : normalizedState.kind === 'puzzle' ? Math.max( 0, resolvePuzzleEstimatedWaitMs(normalizedState) - 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) : normalizedState.kind === 'jump-hop' ? Math.max(0, JUMP_HOP_ESTIMATED_WAIT_MS - elapsedMs) : normalizedState.kind === 'puzzle-clear' ? Math.max( 0, PUZZLE_CLEAR_ESTIMATED_WAIT_MS - elapsedMs, ) : normalizedState.kind === 'wooden-fish' ? Math.max(0, WOODEN_FISH_ESTIMATED_WAIT_MS - elapsedMs) : null, activeStepIndex, steps: buildMiniGameProgressSteps( steps, activeStepIndex, normalizedState, activeStepProgressRatio, ), }; } export function buildJumpHopGenerationAnchorEntries( session: JumpHopSessionSnapshot | null | undefined, formPayload: CreateJumpHopSessionRequest | null | undefined = null, ): CustomWorldStructuredAnchorEntry[] { const sessionRecord = session as | { config?: Partial; draft?: { workTitle?: string; themeText?: string; characterPrompt?: string; tilePrompt?: string; } | null; } | null | undefined; const config = sessionRecord?.config; const draft = sessionRecord?.draft; const entries: Array = [ { key: 'jump-hop-theme', label: '主题', value: formPayload?.themeText?.trim() || config?.themeText?.trim() || draft?.themeText?.trim() || draft?.workTitle?.trim() || '', }, { key: 'jump-hop-tile-style', label: '地块图集', value: formPayload?.tilePrompt?.trim() || config?.tilePrompt?.trim() || draft?.tilePrompt?.trim() || '', }, ]; 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 buildWoodenFishGenerationAnchorEntries( session: WoodenFishSessionSnapshotResponse | null | undefined, formPayload: WoodenFishWorkspaceCreateRequest | null | undefined = null, ): CustomWorldStructuredAnchorEntry[] { const draft = session?.draft; const entries: Array = [ { key: 'wooden-fish-hit-object', label: '敲击物', value: formPayload?.hitObjectPrompt?.trim() || draft?.hitObjectPrompt?.trim() || '', }, { key: 'wooden-fish-hit-sound', label: '音效', value: formPayload?.hitSoundAsset?.prompt?.trim() || draft?.hitSoundAsset?.prompt?.trim() || '', }, { key: 'wooden-fish-words', label: '飘字', value: formPayload?.floatingWords ?.map((word) => word.trim()) .filter(Boolean) .slice(0, 8) .join('、') || draft?.floatingWords ?.map((word) => word.trim()) .filter(Boolean) .slice(0, 8) .join('、') || '', }, ]; 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 buildPuzzleClearGenerationAnchorEntries( session: PuzzleClearSessionSnapshotResponse | null | undefined, formPayload: PuzzleClearWorkspaceCreateRequest | null | undefined = null, ): CustomWorldStructuredAnchorEntry[] { const draft = session?.draft; const entries: Array = [ { key: 'puzzle-clear-title', label: '作品', value: formPayload?.workTitle?.trim() || draft?.workTitle?.trim() || '拼消消', }, { key: 'puzzle-clear-theme', label: '主题', value: formPayload?.themePrompt?.trim() || draft?.themePrompt?.trim() || '', }, { key: 'puzzle-clear-background', label: '底图', value: formPayload?.boardBackgroundPrompt?.trim() || draft?.boardBackgroundPrompt?.trim() || (formPayload?.boardBackgroundAsset ?? draft?.boardBackgroundAsset) ?.prompt?.trim() || (formPayload?.generateBoardBackground ?? draft?.generateBoardBackground ? 'AI生成' : '上传底图'), }, ]; 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 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 entries: Array = [ { key: 'match3d-theme', label: '题材', value: formPayload?.themeText?.trim() || config?.themeText?.trim() || session?.anchorPack.theme.value || '', }, { key: 'match3d-items', label: '素材数量', value: `${resolveMatch3DGeneratedItemCount()} 种素材`, }, ]; 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 = null, _difficulty: number | null | undefined = null, ) { return 20; } 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()); }