diff --git a/.hermes/shared-memory/decision-log.md b/.hermes/shared-memory/decision-log.md index b833b173..fa2c5a69 100644 --- a/.hermes/shared-memory/decision-log.md +++ b/.hermes/shared-memory/decision-log.md @@ -1395,6 +1395,14 @@ - 验证方式:`npm run test -- src/components/platform-entry/platformRpgAgentResultPreviewModel.test.ts`、针对新 Module 和 `PlatformEntryFlowShellImpl.tsx` 执行 ESLint、`npm run typecheck`、`npm run check:encoding`。 - 关联文档:`docs/technical/【前端架构】PlatformRpgAgentResultPreviewModel收口计划-2026-06-04.md`、`docs/【玩法创作】平台入口与玩法链路-2026-05-15.md`。 +## 2026-06-04 Platform Mini Game Draft Generation State Model 收口 + +- 背景:`PlatformEntryFlowShellImpl.tsx` 内联维护小游戏生成状态恢复、失败 / 完成收尾、展示 rebase、拼图后端进度合并和 ready / generating 判定,壳层同时承担 API / background task 副作用和 `MiniGameDraftGenerationState` 生命周期细节。 +- 决策:新增 `src/components/platform-entry/platformMiniGameDraftGenerationStateModel.ts`,收口恢复态、失败态、完成态、展示 rebase、拼图 progress phase 阈值和进度 metadata 合并。壳层继续负责 API、后台任务、React state 写入、作品架刷新、URL 和 stage 切换。 +- 影响范围:拼图 / 抓大鹅 / 大鱼吃小鱼 / 方洞 / 跳一跳 / 敲木鱼 / 宝贝识物生成状态恢复、完成失败收尾、生成页返回展示和拼图轮询进度合并。 +- 验证方式:`npm run test -- src/components/platform-entry/platformMiniGameDraftGenerationStateModel.test.ts`、针对新 Module 和 `PlatformEntryFlowShellImpl.tsx` 执行 ESLint、`npm run typecheck`、`npm run check:encoding`。 +- 关联文档:`docs/technical/【前端架构】PlatformMiniGameDraftGenerationStateModel收口计划-2026-06-04.md`、`docs/【玩法创作】平台入口与玩法链路-2026-05-15.md`。 + ## 2026-06-03 Public Work Presentation 收口 - 背景:作品卡、推荐 runtime meta、排行项、分类项、搜索结果和桌面 hero 共用玩法类型 label 与紧凑计数格式,但规则仍在 `RpgEntryHomeView.tsx` 页面 Implementation 内。 diff --git a/docs/README.md b/docs/README.md index 6a656a7c..82fbcb7f 100644 --- a/docs/README.md +++ b/docs/README.md @@ -57,6 +57,8 @@ AI 文字游戏模板接入以 [AI_NATIVE_TEXT_GAME_TEMPLATE_MOKU_REFERENCE_PRD_ 平台壳的拼图 runtime 恢复 work、跳一跳 pending session、敲木鱼 detail 恢复 session 和敲木鱼 pending session DTO 映射收口到 `src/components/platform-entry/platformMiniGameSessionMappingModel.ts`,壳层只保留网络、状态、URL 与 stage 副作用,规则见 [【前端架构】PlatformMiniGameSessionMappingModel收口计划-2026-06-04.md](./technical/【前端架构】PlatformMiniGameSessionMappingModel收口计划-2026-06-04.md)。 +平台小游戏生成状态的恢复、失败 / 完成收尾、展示 rebase、拼图后端进度合并和 ready / generating 判定收口到 `src/components/platform-entry/platformMiniGameDraftGenerationStateModel.ts`,壳层只保留 API、后台任务、React state、URL 与 stage 副作用,规则见 [【前端架构】PlatformMiniGameDraftGenerationStateModel收口计划-2026-06-04.md](./technical/【前端架构】PlatformMiniGameDraftGenerationStateModel收口计划-2026-06-04.md)。 + RPG Agent 结果页发布门禁展示和预览来源 label 收口到 `src/components/platform-entry/platformRpgAgentResultPreviewModel.ts`,壳层只保留 session/profile 编排和结果页 props 传递,规则见 [【前端架构】PlatformRpgAgentResultPreviewModel收口计划-2026-06-04.md](./technical/【前端架构】PlatformRpgAgentResultPreviewModel收口计划-2026-06-04.md)。 平台入口创作生成通知、pending 作品架占位、失败覆盖、拼图稳定 ID 和草稿 Tab 未读点收口到 `src/components/platform-entry/platformDraftGenerationShelfModel.ts`,规则见 [【前端架构】DraftGenerationShelfModel收口计划-2026-06-03.md](./technical/%E3%80%90%E5%89%8D%E7%AB%AF%E6%9E%B6%E6%9E%84%E3%80%91DraftGenerationShelfModel%E6%94%B6%E5%8F%A3%E8%AE%A1%E5%88%92-2026-06-03.md)。 diff --git a/docs/technical/【前端架构】PlatformMiniGameDraftGenerationStateModel收口计划-2026-06-04.md b/docs/technical/【前端架构】PlatformMiniGameDraftGenerationStateModel收口计划-2026-06-04.md new file mode 100644 index 00000000..bbfb5ed8 --- /dev/null +++ b/docs/technical/【前端架构】PlatformMiniGameDraftGenerationStateModel收口计划-2026-06-04.md @@ -0,0 +1,43 @@ +# 【前端架构】Platform Mini Game Draft Generation State Model 收口计划 + +## 背景 + +`PlatformEntryFlowShellImpl.tsx` 曾内联维护小游戏生成状态的恢复、失败/完成收尾、展示 rebase、拼图后端进度合并和生成中 / ready 判定。壳层因此既要处理 API 回包、React state、后台任务、URL 和 stage,又要记住 `MiniGameDraftGenerationState` 的生命周期细节。 + +这些状态变换不读取 DOM,不请求网络,也不写 React state;它们属于平台层小游戏草稿生成状态 **Module**。壳层只应决定何时调用、把返回值写入对应 state。 + +## 决策 + +新增 `src/components/platform-entry/platformMiniGameDraftGenerationStateModel.ts` 作为 Platform Mini Game Draft Generation State **Module**。其公开 **Interface** 为: + +- `createMiniGameDraftGenerationStateForRestoredDraft(kind, metadata?, startedAtMs?)`:为恢复的草稿重建生成态,并保留后端开始时间作为进度事实源。 +- `createFailedMiniGameDraftGenerationStateForRestoredDraft(kind, updatedAt, error, metadata?)`:恢复失败草稿时按后端 `updatedAt` 建立失败态。 +- `rebaseMiniGameDraftGenerationStateForDisplay(state)` 与 `rebaseMiniGameDraftBackgroundCompileTaskForDisplay(task)`:清理展示用 `finishedAtMs`,避免返回生成页后沿用结束态计时。 +- `createPuzzleDraftGenerationStateFromPayload(payload, session?)`、`resolvePuzzlePhaseFromSessionProgress(state, session)`、`mergePuzzleSessionProgressIntoGenerationState(state, session)`:集中处理拼图生成的 aiRedraw、后端进度百分比和 phase 推进。 +- `resolveFinishedMiniGameDraftGenerationState(state, phase, options?)`:统一完成 / 失败收尾的 `finishedAtMs`、错误与资产计数合并。 +- `isMiniGameDraftReady(state)` 与 `isMiniGameDraftGenerating(state)`:统一生成态轻量判定。 + +`PlatformEntryFlowShellImpl.tsx` 仍作为 **Adapter**:它继续负责 API、background task、React state 写入、作品架刷新、URL 与 stage 切换。 + +## Interface 约束 + +- 恢复草稿状态必须允许调用方传入 `startedAtMs`;未传时使用当前时间,与旧逻辑一致。 +- 恢复失败状态必须通过 `resolveMiniGameDraftGenerationStartedAtMs(updatedAt)` 解析后端时间,并保留传入 metadata。 +- `resolveFinishedMiniGameDraftGenerationState` 只覆盖显式传入的 `error`、`completedAssetCount`、`totalAssetCount`;未传时沿用原 state。 +- 拼图 session 只有在 `draft` 存在且不是 `formDraft` 时才视为后端编译生成中 session,才写入 `puzzleProgressPercent` 并推进 phase。 +- 拼图进度阈值保持旧值:`>=96` 到 `puzzle-select-image`,`>=94` 到 `puzzle-ui-assets`,`>=88` 时按 `puzzleAiRedraw=false` 进入 `puzzle-level-scene`,否则进入 `puzzle-cover-image`。 +- phase 变化时 `puzzleActiveStepStartedAtMs` 使用 session `updatedAt` 解析值;phase 不变时保留旧值。 +- 展示 rebase 只清理 `finishedAtMs`,不得修改 phase、error、资产计数或 metadata。 + +## Depth / Leverage / Locality + +- **Depth**:壳层以状态变换函数表达意图;生成态字段、拼图阈值、时间解析与计数合并藏入 Module Implementation。 +- **Leverage**:后续新增小游戏生成恢复或调整拼图后端进度阈值时,先改 Module 与单测,再让壳层 Adapter 保持调用点不变。 +- **Locality**:小游戏生成状态规则集中到一个纯测试面,避免在大型壳层的 API callback、background task 和恢复流程中重复推理 `MiniGameDraftGenerationState`。 + +## 验收 + +- `npm run test -- src/components/platform-entry/platformMiniGameDraftGenerationStateModel.test.ts` +- `npx eslint src/components/platform-entry/platformMiniGameDraftGenerationStateModel.ts src/components/platform-entry/platformMiniGameDraftGenerationStateModel.test.ts src/components/platform-entry/PlatformEntryFlowShellImpl.tsx --quiet` +- `npm run typecheck` +- `npm run check:encoding` diff --git a/docs/【玩法创作】平台入口与玩法链路-2026-05-15.md b/docs/【玩法创作】平台入口与玩法链路-2026-05-15.md index a2a78a17..964d9423 100644 --- a/docs/【玩法创作】平台入口与玩法链路-2026-05-15.md +++ b/docs/【玩法创作】平台入口与玩法链路-2026-05-15.md @@ -14,6 +14,8 @@ 拼图 runtime 刷新恢复、跳一跳生成中草稿打开和敲木鱼生成中 / detail 草稿恢复所需的 session / work DTO 映射统一由 `platformMiniGameSessionMappingModel.ts` 构造。平台壳只负责读取后端、写入本地 state、写 URL 和切换 stage;不得在壳层重新手写 sessionId 优先级、pending draft 空素材默认值或拼图稳定 ID 映射。 +平台小游戏生成状态的恢复、失败 / 完成收尾、展示 rebase、拼图后端进度合并和 ready / generating 判定统一由 `platformMiniGameDraftGenerationStateModel.ts` 处理。平台壳只决定何时调用并写入对应 React state,不得在壳层重新维护 `MiniGameDraftGenerationState` 的 phase 阈值、`finishedAtMs` 清理或拼图进度 metadata 合并规则。 + RPG Agent 结果页发布门禁展示由 `platformRpgAgentResultPreviewModel.ts` 判定:平台壳不得重新手写 `CustomWorldProfile` 顶层、`creatorIntent`、`anchorContent`、章节蓝图与首幕 acts 的结构探测,也不得在壳层内联 result preview source label 映射;壳层只负责 session/profile 编排和结果页 props 传递。 统一创作入口覆盖当前可进入创作链路的已有模板:`rpg`、`big-fish`、`puzzle`、`match3d`、`jump-hop`、`wooden-fish`、`square-hole`、`bark-battle`、`visual-novel`、`baby-object-match` 和 `creative-agent`;`airp` 仍是未开放占位,不作为当前统一创作链路目标。拼图、抓大鹅、跳一跳和敲木鱼在前端继续经过 `UnifiedCreationWorkspace` 和 `UnifiedGenerationPage`:`UnifiedCreationWorkspace` 作为平台壳依赖的统一创作编排层,再内部调用 `src/components/unified-creation/workspaces/` 下的 `PuzzleCreationWorkspace`、`Match3DCreationWorkspace`、`JumpHopCreationWorkspace` 和 `WoodenFishCreationWorkspace`。其它已有模板由平台壳用 `UnifiedCreationPage` 包住既有工作台,复用统一标题栏、返回入口、页面级纵向滚动和隐藏字段契约,同时保留各玩法自己的表单、草稿恢复和后续编排。创作页字段清单由后端在 `GET /api/creation-entry/config` 的 `creationTypes[].unifiedCreationSpec` 下发,前端仅在该扩展位缺失时回退到本地默认 spec;字段类型只保留 `text`、`select`、`image`、`audio`。`UnifiedCreationPage` 不在 UI 中额外展示字段说明 chip,也不在右上角显示内部 `playId`、模板 ID 或工作台阶段名;竖屏移动端必须能从标题、表单一路滑到提交按钮。各玩法工作台负责渲染真实输入控件、上传、历史素材、校验和提交,但返回按钮只保留在统一页头,工作台内部不再重复渲染。暗色创作进度卡片位于 `platform-remap-surface` 内时,必须用组件专属 class 覆盖浅色主题 remap,确保白字、浅色边框和进度条底色不会被全局规则改成深色;不要只依赖通用 `text-white*` 类。敲木鱼的音效和功德词条面板不得放进独立内部滚动容器,移动端应跟随页面自然滚动展开。生成页统一展示阶段、当前步骤、总进度、错误和重试动作。 diff --git a/src/components/platform-entry/PlatformEntryFlowShellImpl.tsx b/src/components/platform-entry/PlatformEntryFlowShellImpl.tsx index 63495893..ace14e6e 100644 --- a/src/components/platform-entry/PlatformEntryFlowShellImpl.tsx +++ b/src/components/platform-entry/PlatformEntryFlowShellImpl.tsx @@ -213,8 +213,6 @@ import { buildSquareHoleGenerationAnchorEntries, buildWoodenFishGenerationAnchorEntries, createMiniGameDraftGenerationState, - type MiniGameDraftGenerationKind, - type MiniGameDraftGenerationPhase, type MiniGameDraftGenerationState, resolveMiniGameDraftGenerationStartedAtMs, } from '../../services/miniGameDraftGenerationProgress'; @@ -513,6 +511,17 @@ import { resolveMatch3DRuntimeGeneratedBackgroundAsset, resolveMatch3DRuntimeGeneratedItemAssets, } from './platformMatch3DRuntimeProfile'; +import { + createFailedMiniGameDraftGenerationStateForRestoredDraft, + createMiniGameDraftGenerationStateForRestoredDraft, + createPuzzleDraftGenerationStateFromPayload, + isMiniGameDraftGenerating, + isMiniGameDraftReady, + mergePuzzleSessionProgressIntoGenerationState, + rebaseMiniGameDraftBackgroundCompileTaskForDisplay, + rebaseMiniGameDraftGenerationStateForDisplay, + resolveFinishedMiniGameDraftGenerationState, +} from './platformMiniGameDraftGenerationStateModel'; import { buildJumpHopPendingSession, buildPuzzleRuntimeWorkFromSession, @@ -1026,35 +1035,6 @@ function openPuzzleRuntimeStage( writePuzzleRuntimeUrlState(state); } -/** 为恢复的小游戏草稿重建生成态,保留后端开始时间作为进度事实源。 */ -function createMiniGameDraftGenerationStateForRestoredDraft( - kind: MiniGameDraftGenerationKind, - metadata?: MiniGameDraftGenerationState['metadata'], - startedAtMs = Date.now(), -): MiniGameDraftGenerationState { - return { - ...createMiniGameDraftGenerationState(kind, startedAtMs), - ...(metadata ? { metadata } : {}), - }; -} - -function createFailedMiniGameDraftGenerationStateForRestoredDraft( - kind: MiniGameDraftGenerationKind, - updatedAt: string | null | undefined, - error: string, - metadata?: MiniGameDraftGenerationState['metadata'], -): MiniGameDraftGenerationState { - return resolveFinishedMiniGameDraftGenerationState( - createMiniGameDraftGenerationStateForRestoredDraft( - kind, - metadata, - resolveMiniGameDraftGenerationStartedAtMs(updatedAt), - ), - 'failed', - { error }, - ); -} - function buildPuzzleFormPayloadFromWork( item: PuzzleWorkSummary, ): CreatePuzzleAgentSessionRequest { @@ -1138,122 +1118,6 @@ function buildMatch3DFormPayloadFromWork( }; } -/** 清理生成态完成时间,避免返回生成页后继续沿用结束态计时。 */ -function rebaseMiniGameDraftGenerationStateForDisplay( - state: MiniGameDraftGenerationState, -): MiniGameDraftGenerationState { - return { - ...state, - finishedAtMs: undefined, - }; -} - -function rebaseMiniGameDraftBackgroundCompileTaskForDisplay< - T extends PuzzleBackgroundCompileTask | Match3DBackgroundCompileTask, ->(task: T): T { - return { - ...task, - generationState: rebaseMiniGameDraftGenerationStateForDisplay( - task.generationState, - ), - }; -} - -function createPuzzleDraftGenerationStateFromPayload( - payload: CreatePuzzleAgentSessionRequest | null | undefined, - session: PuzzleAgentSessionSnapshot | null | undefined = null, -): MiniGameDraftGenerationState { - const puzzleProgressPercent = - session?.draft && !session.draft.formDraft - ? session.progressPercent - : undefined; - - return { - ...createMiniGameDraftGenerationState( - 'puzzle', - resolveMiniGameDraftGenerationStartedAtMs(session?.updatedAt), - ), - metadata: { - puzzleAiRedraw: payload?.aiRedraw ?? true, - puzzleActivePhaseId: - typeof puzzleProgressPercent === 'number' ? 'compile' : undefined, - puzzleActiveStepStartedAtMs: - typeof puzzleProgressPercent === 'number' ? Date.now() : undefined, - puzzleProgressPercent, - }, - }; -} - -function resolvePuzzlePhaseFromSessionProgress( - state: MiniGameDraftGenerationState, - session: PuzzleAgentSessionSnapshot, -): MiniGameDraftGenerationPhase { - if (session.progressPercent >= 96) { - return 'puzzle-select-image'; - } - if (session.progressPercent >= 94) { - return 'puzzle-ui-assets'; - } - if (session.progressPercent >= 88) { - return state.metadata?.puzzleAiRedraw === false - ? 'puzzle-level-scene' - : 'puzzle-cover-image'; - } - - return 'compile'; -} - -function mergePuzzleSessionProgressIntoGenerationState( - state: MiniGameDraftGenerationState, - session: PuzzleAgentSessionSnapshot, -): MiniGameDraftGenerationState { - const isCompiledGenerationSession = Boolean( - session.draft && !session.draft.formDraft, - ); - - const nextPhaseId = isCompiledGenerationSession - ? resolvePuzzlePhaseFromSessionProgress(state, session) - : state.metadata?.puzzleActivePhaseId; - const shouldResetActiveStepStart = - isCompiledGenerationSession && - nextPhaseId != null && - nextPhaseId !== state.metadata?.puzzleActivePhaseId; - - return { - ...state, - metadata: { - ...state.metadata, - puzzleActivePhaseId: nextPhaseId, - puzzleActiveStepStartedAtMs: shouldResetActiveStepStart - ? resolveMiniGameDraftGenerationStartedAtMs(session.updatedAt) - : state.metadata?.puzzleActiveStepStartedAtMs, - puzzleProgressPercent: isCompiledGenerationSession - ? session.progressPercent - : state.metadata?.puzzleProgressPercent, - }, - }; -} - -function resolveFinishedMiniGameDraftGenerationState( - state: MiniGameDraftGenerationState, - phase: 'ready' | 'failed', - options: { - error?: string | null; - completedAssetCount?: number; - totalAssetCount?: number; - } = {}, -): MiniGameDraftGenerationState { - return { - ...state, - phase, - finishedAtMs: Date.now(), - error: options.error ?? state.error, - completedAssetCount: - options.completedAssetCount ?? state.completedAssetCount, - totalAssetCount: options.totalAssetCount ?? state.totalAssetCount, - }; -} - function normalizeRecoveredPuzzleDraftSession( session: PuzzleAgentSessionSnapshot, ): PuzzleAgentSessionSnapshot { @@ -1325,14 +1189,6 @@ function hasRecoverableGeneratedPuzzleDraft( ); } -function isMiniGameDraftReady(state: MiniGameDraftGenerationState | null) { - return state?.phase === 'ready'; -} - -function isMiniGameDraftGenerating(state: MiniGameDraftGenerationState | null) { - return Boolean(state && state.phase !== 'ready' && state.phase !== 'failed'); -} - function resolveProfileWalletBalance( dashboard: { walletBalance?: number | null } | null | undefined, ) { diff --git a/src/components/platform-entry/platformMiniGameDraftGenerationStateModel.test.ts b/src/components/platform-entry/platformMiniGameDraftGenerationStateModel.test.ts new file mode 100644 index 00000000..df06fd1c --- /dev/null +++ b/src/components/platform-entry/platformMiniGameDraftGenerationStateModel.test.ts @@ -0,0 +1,303 @@ +import { afterEach, beforeEach, describe, expect, test, vi } from 'vitest'; + +import type { PuzzleAnchorPack } from '../../../packages/shared/src/contracts/puzzleAgentDraft'; +import type { + CreatePuzzleAgentSessionRequest, + PuzzleAgentSessionSnapshot, +} from '../../../packages/shared/src/contracts/puzzleAgentSession'; +import type { MiniGameDraftGenerationState } from '../../services/miniGameDraftGenerationProgress'; +import { + createFailedMiniGameDraftGenerationStateForRestoredDraft, + createMiniGameDraftGenerationStateForRestoredDraft, + createPuzzleDraftGenerationStateFromPayload, + isMiniGameDraftGenerating, + isMiniGameDraftReady, + mergePuzzleSessionProgressIntoGenerationState, + rebaseMiniGameDraftBackgroundCompileTaskForDisplay, + rebaseMiniGameDraftGenerationStateForDisplay, + resolveFinishedMiniGameDraftGenerationState, + resolvePuzzlePhaseFromSessionProgress, +} from './platformMiniGameDraftGenerationStateModel'; + +const NOW = Date.parse('2026-06-04T03:00:00.000Z'); +const SESSION_UPDATED_AT = '2026-06-01T10:00:00.000Z'; +const SESSION_UPDATED_AT_MS = Date.parse(SESSION_UPDATED_AT); + +function buildAnchorPack(): PuzzleAnchorPack { + const item = { + key: 'theme', + label: '主题', + value: '星桥机关', + status: 'confirmed' as const, + }; + return { + themePromise: item, + visualSubject: item, + visualMood: item, + compositionHooks: item, + tagsAndForbidden: item, + }; +} + +function buildPuzzleSession( + overrides: Partial = {}, +): PuzzleAgentSessionSnapshot { + const anchorPack = buildAnchorPack(); + return { + sessionId: 'puzzle-session-1', + seedText: '星桥', + currentTurn: 1, + progressPercent: 90, + stage: 'draft_ready', + anchorPack, + draft: { + workTitle: '星桥拼图', + workDescription: '修复星桥机关。', + levelName: '星桥机关', + summary: '把星桥碎片拼回原位。', + themeTags: ['星桥'], + forbiddenDirectives: [], + creatorIntent: null, + anchorPack, + candidates: [], + selectedCandidateId: null, + coverImageSrc: null, + coverAssetId: null, + generationStatus: 'generating', + levels: [], + }, + messages: [], + lastAssistantReply: null, + publishedProfileId: null, + suggestedActions: [], + resultPreview: null, + updatedAt: SESSION_UPDATED_AT, + ...overrides, + }; +} + +function buildState( + overrides: Partial = {}, +): MiniGameDraftGenerationState { + return { + kind: 'puzzle', + phase: 'compile', + startedAtMs: 100, + completedAssetCount: 0, + totalAssetCount: 0, + error: null, + metadata: { + puzzleAiRedraw: true, + puzzleActivePhaseId: 'compile', + puzzleActiveStepStartedAtMs: 200, + puzzleProgressPercent: 20, + }, + ...overrides, + }; +} + +beforeEach(() => { + vi.useFakeTimers(); + vi.setSystemTime(NOW); +}); + +afterEach(() => { + vi.useRealTimers(); +}); + +describe('platformMiniGameDraftGenerationStateModel', () => { + test('creates restored generation state with metadata and explicit start time', () => { + expect( + createMiniGameDraftGenerationStateForRestoredDraft( + 'match3d', + { puzzleAiRedraw: false }, + 123, + ), + ).toMatchObject({ + kind: 'match3d', + phase: 'match3d-work-title', + startedAtMs: 123, + metadata: { + puzzleAiRedraw: false, + }, + }); + }); + + test('creates failed restored state from backend updated time', () => { + expect( + createFailedMiniGameDraftGenerationStateForRestoredDraft( + 'puzzle', + SESSION_UPDATED_AT, + '生成失败', + { puzzleAiRedraw: true }, + ), + ).toMatchObject({ + kind: 'puzzle', + phase: 'failed', + startedAtMs: SESSION_UPDATED_AT_MS, + finishedAtMs: NOW, + error: '生成失败', + metadata: { + puzzleAiRedraw: true, + }, + }); + }); + + test('rebases finished state for display without changing other fields', () => { + const state = buildState({ + phase: 'ready', + finishedAtMs: 300, + completedAssetCount: 2, + totalAssetCount: 3, + }); + + expect(rebaseMiniGameDraftGenerationStateForDisplay(state)).toEqual({ + ...state, + finishedAtMs: undefined, + }); + expect( + rebaseMiniGameDraftBackgroundCompileTaskForDisplay({ + sessionId: 'task-1', + generationState: state, + }), + ).toEqual({ + sessionId: 'task-1', + generationState: { + ...state, + finishedAtMs: undefined, + }, + }); + }); + + test('creates puzzle generation state from payload and compiled session', () => { + const payload: CreatePuzzleAgentSessionRequest = { + seedText: '星桥', + aiRedraw: false, + }; + + expect(createPuzzleDraftGenerationStateFromPayload(payload)).toMatchObject({ + kind: 'puzzle', + phase: 'compile', + startedAtMs: NOW, + metadata: { + puzzleAiRedraw: false, + puzzleActivePhaseId: undefined, + puzzleActiveStepStartedAtMs: undefined, + puzzleProgressPercent: undefined, + }, + }); + + expect( + createPuzzleDraftGenerationStateFromPayload(payload, buildPuzzleSession()), + ).toMatchObject({ + kind: 'puzzle', + phase: 'compile', + startedAtMs: SESSION_UPDATED_AT_MS, + metadata: { + puzzleAiRedraw: false, + puzzleActivePhaseId: 'compile', + puzzleActiveStepStartedAtMs: NOW, + puzzleProgressPercent: 90, + }, + }); + }); + + test('resolves puzzle phase from backend progress thresholds', () => { + const state = buildState(); + expect( + resolvePuzzlePhaseFromSessionProgress( + state, + buildPuzzleSession({ progressPercent: 96 }), + ), + ).toBe('puzzle-select-image'); + expect( + resolvePuzzlePhaseFromSessionProgress( + state, + buildPuzzleSession({ progressPercent: 94 }), + ), + ).toBe('puzzle-ui-assets'); + expect( + resolvePuzzlePhaseFromSessionProgress( + buildState({ metadata: { puzzleAiRedraw: false } }), + buildPuzzleSession({ progressPercent: 88 }), + ), + ).toBe('puzzle-level-scene'); + expect( + resolvePuzzlePhaseFromSessionProgress( + state, + buildPuzzleSession({ progressPercent: 88 }), + ), + ).toBe('puzzle-cover-image'); + expect( + resolvePuzzlePhaseFromSessionProgress( + state, + buildPuzzleSession({ progressPercent: 20 }), + ), + ).toBe('compile'); + }); + + test('merges compiled puzzle session progress into generation state', () => { + expect( + mergePuzzleSessionProgressIntoGenerationState( + buildState({ + metadata: { + puzzleAiRedraw: false, + puzzleActivePhaseId: 'compile', + puzzleActiveStepStartedAtMs: 200, + puzzleProgressPercent: 20, + }, + }), + buildPuzzleSession({ progressPercent: 90 }), + ), + ).toMatchObject({ + metadata: { + puzzleAiRedraw: false, + puzzleActivePhaseId: 'puzzle-level-scene', + puzzleActiveStepStartedAtMs: SESSION_UPDATED_AT_MS, + puzzleProgressPercent: 90, + }, + }); + + expect( + mergePuzzleSessionProgressIntoGenerationState( + buildState(), + buildPuzzleSession({ + draft: { + ...buildPuzzleSession().draft!, + formDraft: { + pictureDescription: '星桥', + }, + }, + }), + ).metadata, + ).toMatchObject({ + puzzleActivePhaseId: 'compile', + puzzleActiveStepStartedAtMs: 200, + puzzleProgressPercent: 20, + }); + }); + + test('finishes generation state and resolves ready/generating flags', () => { + const failedState = resolveFinishedMiniGameDraftGenerationState( + buildState({ error: '旧错误' }), + 'failed', + { + completedAssetCount: 1, + totalAssetCount: 2, + }, + ); + + expect(failedState).toMatchObject({ + phase: 'failed', + finishedAtMs: NOW, + error: '旧错误', + completedAssetCount: 1, + totalAssetCount: 2, + }); + expect(isMiniGameDraftReady(failedState)).toBe(false); + expect(isMiniGameDraftGenerating(failedState)).toBe(false); + expect(isMiniGameDraftReady({ ...failedState, phase: 'ready' })).toBe(true); + expect(isMiniGameDraftGenerating(buildState())).toBe(true); + expect(isMiniGameDraftGenerating(null)).toBe(false); + }); +}); diff --git a/src/components/platform-entry/platformMiniGameDraftGenerationStateModel.ts b/src/components/platform-entry/platformMiniGameDraftGenerationStateModel.ts new file mode 100644 index 00000000..00ee2e39 --- /dev/null +++ b/src/components/platform-entry/platformMiniGameDraftGenerationStateModel.ts @@ -0,0 +1,167 @@ +import type { + CreatePuzzleAgentSessionRequest, + PuzzleAgentSessionSnapshot, +} from '../../../packages/shared/src/contracts/puzzleAgentSession'; +import { + createMiniGameDraftGenerationState, + type MiniGameDraftGenerationKind, + type MiniGameDraftGenerationPhase, + type MiniGameDraftGenerationState, + resolveMiniGameDraftGenerationStartedAtMs, +} from '../../services/miniGameDraftGenerationProgress'; + +export function createMiniGameDraftGenerationStateForRestoredDraft( + kind: MiniGameDraftGenerationKind, + metadata?: MiniGameDraftGenerationState['metadata'], + startedAtMs = Date.now(), +): MiniGameDraftGenerationState { + return { + ...createMiniGameDraftGenerationState(kind, startedAtMs), + ...(metadata ? { metadata } : {}), + }; +} + +export function createFailedMiniGameDraftGenerationStateForRestoredDraft( + kind: MiniGameDraftGenerationKind, + updatedAt: string | null | undefined, + error: string, + metadata?: MiniGameDraftGenerationState['metadata'], +): MiniGameDraftGenerationState { + return resolveFinishedMiniGameDraftGenerationState( + createMiniGameDraftGenerationStateForRestoredDraft( + kind, + metadata, + resolveMiniGameDraftGenerationStartedAtMs(updatedAt), + ), + 'failed', + { error }, + ); +} + +/** 清理生成态完成时间,避免返回生成页后继续沿用结束态计时。 */ +export function rebaseMiniGameDraftGenerationStateForDisplay( + state: MiniGameDraftGenerationState, +): MiniGameDraftGenerationState { + return { + ...state, + finishedAtMs: undefined, + }; +} + +export function rebaseMiniGameDraftBackgroundCompileTaskForDisplay< + T extends { generationState: MiniGameDraftGenerationState }, +>(task: T): T { + return { + ...task, + generationState: rebaseMiniGameDraftGenerationStateForDisplay( + task.generationState, + ), + }; +} + +export function createPuzzleDraftGenerationStateFromPayload( + payload: CreatePuzzleAgentSessionRequest | null | undefined, + session: PuzzleAgentSessionSnapshot | null | undefined = null, +): MiniGameDraftGenerationState { + const puzzleProgressPercent = + session?.draft && !session.draft.formDraft + ? session.progressPercent + : undefined; + + return { + ...createMiniGameDraftGenerationState( + 'puzzle', + resolveMiniGameDraftGenerationStartedAtMs(session?.updatedAt), + ), + metadata: { + puzzleAiRedraw: payload?.aiRedraw ?? true, + puzzleActivePhaseId: + typeof puzzleProgressPercent === 'number' ? 'compile' : undefined, + puzzleActiveStepStartedAtMs: + typeof puzzleProgressPercent === 'number' ? Date.now() : undefined, + puzzleProgressPercent, + }, + }; +} + +export function resolvePuzzlePhaseFromSessionProgress( + state: MiniGameDraftGenerationState, + session: PuzzleAgentSessionSnapshot, +): MiniGameDraftGenerationPhase { + if (session.progressPercent >= 96) { + return 'puzzle-select-image'; + } + if (session.progressPercent >= 94) { + return 'puzzle-ui-assets'; + } + if (session.progressPercent >= 88) { + return state.metadata?.puzzleAiRedraw === false + ? 'puzzle-level-scene' + : 'puzzle-cover-image'; + } + + return 'compile'; +} + +export function mergePuzzleSessionProgressIntoGenerationState( + state: MiniGameDraftGenerationState, + session: PuzzleAgentSessionSnapshot, +): MiniGameDraftGenerationState { + const isCompiledGenerationSession = Boolean( + session.draft && !session.draft.formDraft, + ); + + const nextPhaseId = isCompiledGenerationSession + ? resolvePuzzlePhaseFromSessionProgress(state, session) + : state.metadata?.puzzleActivePhaseId; + const shouldResetActiveStepStart = + isCompiledGenerationSession && + nextPhaseId != null && + nextPhaseId !== state.metadata?.puzzleActivePhaseId; + + return { + ...state, + metadata: { + ...state.metadata, + puzzleActivePhaseId: nextPhaseId, + puzzleActiveStepStartedAtMs: shouldResetActiveStepStart + ? resolveMiniGameDraftGenerationStartedAtMs(session.updatedAt) + : state.metadata?.puzzleActiveStepStartedAtMs, + puzzleProgressPercent: isCompiledGenerationSession + ? session.progressPercent + : state.metadata?.puzzleProgressPercent, + }, + }; +} + +export function resolveFinishedMiniGameDraftGenerationState( + state: MiniGameDraftGenerationState, + phase: 'ready' | 'failed', + options: { + error?: string | null; + completedAssetCount?: number; + totalAssetCount?: number; + } = {}, +): MiniGameDraftGenerationState { + return { + ...state, + phase, + finishedAtMs: Date.now(), + error: options.error ?? state.error, + completedAssetCount: + options.completedAssetCount ?? state.completedAssetCount, + totalAssetCount: options.totalAssetCount ?? state.totalAssetCount, + }; +} + +export function isMiniGameDraftReady( + state: MiniGameDraftGenerationState | null, +) { + return state?.phase === 'ready'; +} + +export function isMiniGameDraftGenerating( + state: MiniGameDraftGenerationState | null, +) { + return Boolean(state && state.phase !== 'ready' && state.phase !== 'failed'); +}