import type { BigFishSessionSnapshotResponse } from '../../packages/shared/src/contracts/bigFish'; import type { CreatePuzzleAgentSessionRequest, PuzzleAgentSessionSnapshot, } from '../../packages/shared/src/contracts/puzzleAgentSession'; import type { CreateMatch3DSessionRequest, Match3DAgentSessionSnapshot, } from '../../packages/shared/src/contracts/match3dAgent'; 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'; export type MiniGameDraftGenerationPhase = | 'idle' | 'compile' | 'big-fish-draft' | 'big-fish-levels' | 'big-fish-runtime' | 'square-hole-draft' | 'square-hole-cover' | 'square-hole-shapes' | 'square-hole-ready' | 'match3d-item-names' | 'match3d-material-sheet' | 'match3d-slice-images' | 'match3d-upload-images' | 'match3d-ready' | 'puzzle-images' | 'puzzle-select-image' | 'ready' | 'failed'; export type MiniGameDraftGenerationState = { kind: MiniGameDraftGenerationKind; phase: MiniGameDraftGenerationPhase; startedAtMs: 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: 20, }, { id: 'puzzle-images', label: '生成首关画面', detail: '调用图片模型生成适合切块的正方形首图。', weight: 70, }, { id: 'puzzle-select-image', label: '写入正式草稿', detail: '确认首图并同步关卡数据,准备进入结果页。', weight: 10, }, ] as const satisfies ReadonlyArray; const PUZZLE_ESTIMATED_WAIT_MS = 60_000; const PUZZLE_NON_READY_MAX_PROGRESS = 98; const PUZZLE_PHASE_TIMELINE: Array<{ phase: Extract< MiniGameDraftGenerationPhase, 'compile' | 'puzzle-images' | 'puzzle-select-image' >; durationMs: number; }> = [ { phase: 'compile', durationMs: 12_000 }, { phase: 'puzzle-images', durationMs: 42_000 }, { phase: 'puzzle-select-image', durationMs: 6_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-item-names', label: '生成物品名称', detail: '根据题材生成本局的 3 个物品名称。', weight: 16, }, { id: 'match3d-material-sheet', label: '生成素材图', detail: '生成一张 1:1 的网格素材图。', weight: 30, }, { id: 'match3d-slice-images', label: '切割独立图片', detail: '把素材图切成独立物品参考图。', weight: 14, }, { id: 'match3d-upload-images', label: '上传图片资产', detail: '写入切割图片并准备进入草稿页。', weight: 40, }, ] 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; } 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-item-names' : '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, ): MiniGameDraftGenerationPhase { if (elapsedMs >= 72_000) { return 'match3d-upload-images'; } if (elapsedMs >= 58_000) { return 'match3d-slice-images'; } if (elapsedMs >= 16_000) { return 'match3d-material-sheet'; } return 'match3d-item-names'; } 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 elapsedMs = Math.max(0, nowMs - 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; 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 : 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' ? '抓大鹅素材与草稿已准备完成,可进入结果页继续编辑。' : '首关草稿与正式图已准备完成,可进入结果页补作品信息。' : 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, 120_000 - 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 itemCount = 3; 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()); } 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()); }