diff --git a/.hermes/shared-memory/decision-log.md b/.hermes/shared-memory/decision-log.md index 0f10d5d3..87e5fb67 100644 --- a/.hermes/shared-memory/decision-log.md +++ b/.hermes/shared-memory/decision-log.md @@ -1371,6 +1371,14 @@ - 验证方式:`npm run test -- src/components/platform-entry/platformPlayedWorkOpenModel.test.ts`、针对新 Module 和 `PlatformEntryFlowShellImpl.tsx` 执行 ESLint、相关 profile 面板交互片段、`npm run typecheck`、`npm run check:encoding`。 - 关联文档:`docs/technical/【前端架构】PlatformPlayedWorkOpenModel收口计划-2026-06-04.md`、`docs/【玩法创作】平台入口与玩法链路-2026-05-15.md`。 +## 2026-06-04 Platform Generation Progress Tick Model 收口 + +- 背景:`PlatformEntryFlowShellImpl.tsx` 的生成页进度 tick effect 内联维护 stage 到小游戏生成状态的三元链,并额外手写视觉小说 `startedAtMs` / `phase` 特例,壳层同时承担纯判定与 interval 副作用。 +- 决策:新增 `src/components/platform-entry/platformGenerationProgressTickModel.ts`,以 `resolvePlatformGenerationProgressTickDecision(input)` 返回 `{ activeKind, shouldTick }`。Module 负责 stage 到 kind 映射、小游戏状态缺失 / 终态判定、视觉小说轻量生成判定;壳层继续负责 `Date.now()`、`window.setInterval`、progress now state 写入和 cleanup。 +- 影响范围:拼图、抓大鹅、大鱼吃小鱼、方洞挑战、跳一跳、敲木鱼、宝贝识物和视觉小说生成页进度 tick。 +- 验证方式:`npm run test -- src/components/platform-entry/platformGenerationProgressTickModel.test.ts`、针对新 Module 和 `PlatformEntryFlowShellImpl.tsx` 执行 ESLint、`npm run typecheck`、`npm run check:encoding`。 +- 关联文档:`docs/technical/【前端架构】PlatformGenerationProgressTickModel收口计划-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 23f617d5..22d1010e 100644 --- a/docs/README.md +++ b/docs/README.md @@ -53,6 +53,8 @@ AI 文字游戏模板接入以 [AI_NATIVE_TEXT_GAME_TEMPLATE_MOKU_REFERENCE_PRD_ 个人“玩过作品”面板的玩法别名、`worldKey` 前缀兜底、RPG 公开详情 payload 和大鱼吃小鱼 gallery miss fallback 收口到 `src/components/platform-entry/platformPlayedWorkOpenModel.ts`,壳层只执行面板关闭、gallery 读取、详情打开和错误提示副作用,规则见 [【前端架构】PlatformPlayedWorkOpenModel收口计划-2026-06-04.md](./technical/【前端架构】PlatformPlayedWorkOpenModel收口计划-2026-06-04.md)。 +平台入口生成页进度 tick 的 stage 到生成状态映射、终态判定和视觉小说轻量生成特例收口到 `src/components/platform-entry/platformGenerationProgressTickModel.ts`,壳层只保留 `Date.now()`、`setInterval` 和 cleanup 副作用,规则见 [【前端架构】PlatformGenerationProgressTickModel收口计划-2026-06-04.md](./technical/【前端架构】PlatformGenerationProgressTickModel收口计划-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)。 平台入口创作恢复 URL 私有 query、初始恢复判定、创作直达恢复目标解析、恢复目标身份匹配、跳一跳 / 敲木鱼恢复阶段落点、拼图 runtime query 与拼图稳定身份互推收口到 `src/components/platform-entry/platformCreationUrlStateModel.ts` 和 `src/components/platform-entry/platformPuzzleIdentityModel.ts`,规则见 [【前端架构】CreationUrlStateModel收口计划-2026-06-03.md](./technical/【前端架构】CreationUrlStateModel收口计划-2026-06-03.md)。 diff --git a/docs/technical/【前端架构】PlatformGenerationProgressTickModel收口计划-2026-06-04.md b/docs/technical/【前端架构】PlatformGenerationProgressTickModel收口计划-2026-06-04.md new file mode 100644 index 00000000..f9c5abe3 --- /dev/null +++ b/docs/technical/【前端架构】PlatformGenerationProgressTickModel收口计划-2026-06-04.md @@ -0,0 +1,37 @@ +# 【前端架构】Platform Generation Progress Tick Model 收口计划 + +## 背景 + +`PlatformEntryFlowShellImpl.tsx` 的生成页进度 tick effect 曾以内联三元链按 `selectionStage` 选择拼图、抓大鹅、大鱼吃小鱼、方洞挑战、跳一跳、敲木鱼和宝贝识物的生成状态,并额外手写视觉小说的 `startedAtMs` / `phase` 判定。壳层因此既要维护 `setInterval` 副作用,又要记住每个生成页 stage 对应哪份进度状态。 + +生成进度是否需要 tick 是纯判定;`Date.now()`、`window.setInterval` 和进度时间 state 写入仍属于 React 壳层副作用。 + +## 决策 + +新增 `src/components/platform-entry/platformGenerationProgressTickModel.ts` 作为 Platform Generation Progress Tick **Module**。其公开 **Interface** 为: + +- `resolvePlatformGenerationProgressTickDecision(input)`:输入当前 `selectionStage`、各小游戏 `MiniGameDraftGenerationState` 和视觉小说轻量生成状态,输出 `{ activeKind, shouldTick }`。 +- `PlatformGenerationProgressTickKind`:枚举可 tick 的生成类型,包含已有小游戏生成 kind 与 `visual-novel`。 + +`PlatformEntryFlowShellImpl.tsx` 仍作为 **Adapter**:它把当前 state 组装给 Module,若 `shouldTick=false` 则不启动 interval;若为真,仍按旧逻辑立即写一次 `Date.now()`,再每 `500ms` 更新并在 effect cleanup 中清理 timer。 + +## Interface 约束 + +- 小游戏生成 stage 只读取匹配 kind 的 `MiniGameDraftGenerationState`;stage 与 state 不匹配时不 tick。 +- 小游戏状态缺失、`phase='ready'` 或 `phase='failed'` 时不 tick;其它 phase 按进行中处理。 +- `visual-novel-generating` 不强行转成 `MiniGameDraftGenerationState`,只在 `startedAtMs != null` 且 phase 非 `ready` / `failed` 时 tick。 +- 非生成 stage 即使传入可运行 state 也不 tick。 +- 本 Module 不计算进度、不重建 view state、不处理拼图 / 抓大鹅 background task 覆盖;这些仍按既有生成页和作品架模型处理。 + +## Depth / Leverage / Locality + +- **Depth**:壳层只消费 `shouldTick`,stage 到 state 的映射和终态判定藏入 Module Implementation。 +- **Leverage**:新增生成页玩法时,先扩展 stage-to-kind 映射和单测,再让壳层 Adapter 传入对应 state。 +- **Locality**:生成进度 tick 规则集中到一个纯测试面,interval 副作用继续局部留在 React effect,避免把 timer 控制做成浅 Interface。 + +## 验收 + +- `npm run test -- src/components/platform-entry/platformGenerationProgressTickModel.test.ts` +- `npx eslint src/components/platform-entry/platformGenerationProgressTickModel.ts src/components/platform-entry/platformGenerationProgressTickModel.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 f73a589c..7e618026 100644 --- a/docs/【玩法创作】平台入口与玩法链路-2026-05-15.md +++ b/docs/【玩法创作】平台入口与玩法链路-2026-05-15.md @@ -10,6 +10,8 @@ 创作恢复参数只保留 `sessionId`、`profileId`、`draftId`、`workId` 这四个私有 query。它们只允许在同一条创作链路的结果页、生成页、工作台之间保留;切到首页、公开作品详情、runtime 或另一条玩法链路时必须清掉。平台入口刷新直达时,路径到玩法恢复目标、四个 query 归一化、生成页标记、大鱼吃小鱼 workId 兜底、作品 / 草稿身份匹配和跳一跳 / 敲木鱼恢复阶段落点统一由 `platformCreationUrlStateModel.ts` 解析,壳层只执行读取作品、恢复草稿和切换阶段等副作用。生成页等待时间统一以生成状态里的 `startedAtMs` 为准;创建该状态时优先使用后端 session 下发的时间戳,作品摘要里的 `updatedAt` 仍只用于排序与摘要展示,不作为前端自行推导业务状态的真相。 +生成页进度 tick 是否启动统一由 `platformGenerationProgressTickModel.ts` 判定:各小游戏生成页只在当前 stage 与对应生成状态匹配、状态存在且 phase 非 `ready` / `failed` 时 tick;视觉小说继续使用 `startedAtMs` 与轻量 phase 判定,不强行转成小游戏生成状态。平台壳只保留 `Date.now()`、`setInterval` 和 cleanup 副作用,不在壳层重复维护 stage 到 state 的三元链。 + 统一创作入口覆盖当前可进入创作链路的已有模板:`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 bdc8ea92..a84d8fa8 100644 --- a/src/components/platform-entry/PlatformEntryFlowShellImpl.tsx +++ b/src/components/platform-entry/PlatformEntryFlowShellImpl.tsx @@ -498,6 +498,7 @@ import type { import { PlatformEntryWorldDetailView } from './PlatformEntryWorldDetailView'; import { PlatformErrorDialog } from './PlatformErrorDialog'; import { PlatformFeedbackView } from './PlatformFeedbackView'; +import { resolvePlatformGenerationProgressTickDecision } from './platformGenerationProgressTickModel'; import { buildMatch3DProfileFromSession, hasMatch3DRuntimeAsset, @@ -3807,32 +3808,25 @@ export function PlatformEntryFlowShellImpl({ ]); useEffect(() => { - const activeGenerationState = - selectionStage === 'puzzle-generating' - ? puzzleGenerationState - : selectionStage === 'match3d-generating' - ? match3dGenerationState - : selectionStage === 'big-fish-generating' - ? bigFishGenerationState - : selectionStage === 'square-hole-generating' - ? squareHoleGenerationState - : selectionStage === 'jump-hop-generating' - ? jumpHopGenerationState - : selectionStage === 'wooden-fish-generating' - ? woodenFishGenerationState - : selectionStage === 'baby-object-match-generating' - ? babyObjectMatchGenerationState - : null; - const shouldTickProgress = - selectionStage === 'visual-novel-generating' - ? visualNovelGenerationStartedAtMs != null && - visualNovelGenerationPhase !== 'ready' && - visualNovelGenerationPhase !== 'failed' - : activeGenerationState != null && - activeGenerationState.phase !== 'ready' && - activeGenerationState.phase !== 'failed'; + const progressTickDecision = + resolvePlatformGenerationProgressTickDecision({ + selectionStage, + miniGameStates: { + puzzle: puzzleGenerationState, + match3d: match3dGenerationState, + 'big-fish': bigFishGenerationState, + 'square-hole': squareHoleGenerationState, + 'jump-hop': jumpHopGenerationState, + 'wooden-fish': woodenFishGenerationState, + 'baby-object-match': babyObjectMatchGenerationState, + }, + visualNovel: { + startedAtMs: visualNovelGenerationStartedAtMs, + phase: visualNovelGenerationPhase, + }, + }); - if (!shouldTickProgress) { + if (!progressTickDecision.shouldTick) { return undefined; } diff --git a/src/components/platform-entry/platformGenerationProgressTickModel.test.ts b/src/components/platform-entry/platformGenerationProgressTickModel.test.ts new file mode 100644 index 00000000..9e0d8a48 --- /dev/null +++ b/src/components/platform-entry/platformGenerationProgressTickModel.test.ts @@ -0,0 +1,173 @@ +import { describe, expect, test } from 'vitest'; + +import type { + MiniGameDraftGenerationKind, + MiniGameDraftGenerationPhase, + MiniGameDraftGenerationState, +} from '../../services/miniGameDraftGenerationProgress'; +import type { SelectionStage } from './platformEntryTypes'; +import { resolvePlatformGenerationProgressTickDecision } from './platformGenerationProgressTickModel'; + +function buildGenerationState( + kind: MiniGameDraftGenerationKind, + phase: MiniGameDraftGenerationPhase = 'compile', +): MiniGameDraftGenerationState { + return { + kind, + phase, + startedAtMs: 1000, + completedAssetCount: 0, + totalAssetCount: 1, + error: null, + }; +} + +describe('platformGenerationProgressTickModel', () => { + test('ticks while a mini-game generation stage has a running state', () => { + const cases: Array< + [stage: SelectionStage, kind: MiniGameDraftGenerationKind] + > = [ + ['puzzle-generating', 'puzzle'], + ['match3d-generating', 'match3d'], + ['big-fish-generating', 'big-fish'], + ['square-hole-generating', 'square-hole'], + ['jump-hop-generating', 'jump-hop'], + ['wooden-fish-generating', 'wooden-fish'], + ['baby-object-match-generating', 'baby-object-match'], + ]; + + for (const [selectionStage, kind] of cases) { + expect( + resolvePlatformGenerationProgressTickDecision({ + selectionStage, + miniGameStates: { + [kind]: buildGenerationState(kind), + }, + visualNovel: { + startedAtMs: null, + phase: 'generating', + }, + }), + ).toEqual({ + activeKind: kind, + shouldTick: true, + }); + } + }); + + test('does not tick mini-game generation when state is missing or terminal', () => { + expect( + resolvePlatformGenerationProgressTickDecision({ + selectionStage: 'puzzle-generating', + miniGameStates: {}, + visualNovel: { + startedAtMs: null, + phase: 'generating', + }, + }), + ).toEqual({ + activeKind: 'puzzle', + shouldTick: false, + }); + + for (const phase of ['ready', 'failed'] as const) { + expect( + resolvePlatformGenerationProgressTickDecision({ + selectionStage: 'puzzle-generating', + miniGameStates: { + puzzle: buildGenerationState('puzzle', phase), + }, + visualNovel: { + startedAtMs: null, + phase: 'generating', + }, + }), + ).toEqual({ + activeKind: 'puzzle', + shouldTick: false, + }); + } + }); + + test('does not tick when stage and mini-game state do not match', () => { + expect( + resolvePlatformGenerationProgressTickDecision({ + selectionStage: 'puzzle-generating', + miniGameStates: { + match3d: buildGenerationState('match3d'), + }, + visualNovel: { + startedAtMs: null, + phase: 'generating', + }, + }), + ).toEqual({ + activeKind: 'puzzle', + shouldTick: false, + }); + }); + + test('ticks visual novel generation only after it has started and before terminal phases', () => { + expect( + resolvePlatformGenerationProgressTickDecision({ + selectionStage: 'visual-novel-generating', + miniGameStates: {}, + visualNovel: { + startedAtMs: 1000, + phase: 'generating', + }, + }), + ).toEqual({ + activeKind: 'visual-novel', + shouldTick: true, + }); + + expect( + resolvePlatformGenerationProgressTickDecision({ + selectionStage: 'visual-novel-generating', + miniGameStates: {}, + visualNovel: { + startedAtMs: null, + phase: 'generating', + }, + }), + ).toEqual({ + activeKind: 'visual-novel', + shouldTick: false, + }); + + for (const phase of ['ready', 'failed'] as const) { + expect( + resolvePlatformGenerationProgressTickDecision({ + selectionStage: 'visual-novel-generating', + miniGameStates: {}, + visualNovel: { + startedAtMs: 1000, + phase, + }, + }), + ).toEqual({ + activeKind: 'visual-novel', + shouldTick: false, + }); + } + }); + + test('does not tick non-generation stages even when states are present', () => { + expect( + resolvePlatformGenerationProgressTickDecision({ + selectionStage: 'platform', + miniGameStates: { + puzzle: buildGenerationState('puzzle'), + }, + visualNovel: { + startedAtMs: 1000, + phase: 'generating', + }, + }), + ).toEqual({ + activeKind: null, + shouldTick: false, + }); + }); +}); diff --git a/src/components/platform-entry/platformGenerationProgressTickModel.ts b/src/components/platform-entry/platformGenerationProgressTickModel.ts new file mode 100644 index 00000000..67a29bdd --- /dev/null +++ b/src/components/platform-entry/platformGenerationProgressTickModel.ts @@ -0,0 +1,79 @@ +import type { + MiniGameDraftGenerationKind, + MiniGameDraftGenerationState, +} from '../../services/miniGameDraftGenerationProgress'; +import type { SelectionStage } from './platformEntryTypes'; + +export type PlatformVisualNovelGenerationPhase = + | 'generating' + | 'ready' + | 'failed'; + +export type PlatformGenerationProgressTickKind = + | MiniGameDraftGenerationKind + | 'visual-novel'; + +export type PlatformGenerationProgressTickInput = { + selectionStage: SelectionStage; + miniGameStates: Partial< + Record + >; + visualNovel: { + startedAtMs: number | null; + phase: PlatformVisualNovelGenerationPhase; + }; +}; + +export type PlatformGenerationProgressTickDecision = { + activeKind: PlatformGenerationProgressTickKind | null; + shouldTick: boolean; +}; + +const MINI_GAME_GENERATION_STAGE_TO_KIND: Partial< + Record +> = { + 'puzzle-generating': 'puzzle', + 'match3d-generating': 'match3d', + 'big-fish-generating': 'big-fish', + 'square-hole-generating': 'square-hole', + 'jump-hop-generating': 'jump-hop', + 'wooden-fish-generating': 'wooden-fish', + 'baby-object-match-generating': 'baby-object-match', +}; + +function shouldTickMiniGameGenerationState( + state: MiniGameDraftGenerationState | null | undefined, +) { + return state != null && state.phase !== 'ready' && state.phase !== 'failed'; +} + +/** 收口生成页进度 tick 判定,壳层只保留 interval 副作用。 */ +export function resolvePlatformGenerationProgressTickDecision( + input: PlatformGenerationProgressTickInput, +): PlatformGenerationProgressTickDecision { + if (input.selectionStage === 'visual-novel-generating') { + return { + activeKind: 'visual-novel', + shouldTick: + input.visualNovel.startedAtMs != null && + input.visualNovel.phase !== 'ready' && + input.visualNovel.phase !== 'failed', + }; + } + + const activeKind = + MINI_GAME_GENERATION_STAGE_TO_KIND[input.selectionStage] ?? null; + if (!activeKind) { + return { + activeKind: null, + shouldTick: false, + }; + } + + return { + activeKind, + shouldTick: shouldTickMiniGameGenerationState( + input.miniGameStates[activeKind], + ), + }; +}