From 46a36222cbb79cf443a79db0ac45c42af78e4fdc Mon Sep 17 00:00:00 2001 From: kdletters Date: Thu, 4 Jun 2026 03:34:06 +0800 Subject: [PATCH] =?UTF-8?q?fix:=20=E6=94=B6=E7=B4=A7=E6=8B=BC=E5=9B=BE?= =?UTF-8?q?=E8=8D=89=E7=A8=BF=E6=81=A2=E5=A4=8D=E5=88=A4=E5=AE=9A?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit --- .hermes/shared-memory/decision-log.md | 8 + .hermes/shared-memory/pitfalls.md | 8 +- docs/README.md | 2 + ...rmPuzzleDraftRecoveryModel收口计划-2026-06-04.md | 44 ++++ ...玩法创作】平台入口与玩法链路-2026-05-15.md | 2 + .../PlatformEntryFlowShellImpl.tsx | 75 +------ .../platformPuzzleDraftRecoveryModel.test.ts | 195 ++++++++++++++++++ .../platformPuzzleDraftRecoveryModel.ts | 154 ++++++++++++++ 8 files changed, 413 insertions(+), 75 deletions(-) create mode 100644 docs/technical/【前端架构】PlatformPuzzleDraftRecoveryModel收口计划-2026-06-04.md create mode 100644 src/components/platform-entry/platformPuzzleDraftRecoveryModel.test.ts create mode 100644 src/components/platform-entry/platformPuzzleDraftRecoveryModel.ts diff --git a/.hermes/shared-memory/decision-log.md b/.hermes/shared-memory/decision-log.md index b437fecd..b245459b 100644 --- a/.hermes/shared-memory/decision-log.md +++ b/.hermes/shared-memory/decision-log.md @@ -1411,6 +1411,14 @@ - 验证方式:`npm run test -- src/components/platform-entry/platformMiniGameDraftPayloadModel.test.ts`、针对新 Module 和 `PlatformEntryFlowShellImpl.tsx` 执行 ESLint、`npm run typecheck`、`npm run check:encoding`。 - 关联文档:`docs/technical/【前端架构】PlatformMiniGameDraftPayloadModel收口计划-2026-06-04.md`、`docs/【玩法创作】平台入口与玩法链路-2026-05-15.md`。 +## 2026-06-04 Platform Puzzle Draft Recovery Model 收口 + +- 背景:`PlatformEntryFlowShellImpl.tsx` 的拼图恢复链路只要 cover 或候选图存在就会把恢复 session 抬为 ready,可能让缺关卡画面、UI spritesheet 或关卡背景的半成品直接进入结果页完成态。 +- 决策:新增 `src/components/platform-entry/platformPuzzleDraftRecoveryModel.ts`,收口 `normalizeRecoveredPuzzleDraftSession` 与 `hasRecoverableGeneratedPuzzleDraft`。恢复完成态必须同时具备首图、`levelSceneImage*`、`uiSpritesheetImage*` 与 `levelBackgroundImage*`;只有完整资产包成立时才把 draft 与首关 `generationStatus` 抬为 `ready`。 +- 影响范围:拼图生成完成后刷新恢复、拼图 background compile task 完成态写入和结果页自动打开。 +- 验证方式:`npm run test -- src/components/platform-entry/platformPuzzleDraftRecoveryModel.test.ts`、针对新 Module 和 `PlatformEntryFlowShellImpl.tsx` 执行 ESLint、`npm run test -- src/components/rpg-entry/RpgEntryFlowShell.agent.interaction.test.tsx -t "persisted generating puzzle draft"`、`npm run typecheck`、`npm run check:encoding`。 +- 关联文档:`docs/technical/【前端架构】PlatformPuzzleDraftRecoveryModel收口计划-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/.hermes/shared-memory/pitfalls.md b/.hermes/shared-memory/pitfalls.md index 4aff3fc0..f9043f96 100644 --- a/.hermes/shared-memory/pitfalls.md +++ b/.hermes/shared-memory/pitfalls.md @@ -1244,10 +1244,10 @@ ## 拼图会过早进入待发布态,结果页可能空图但仍显示可发布 - 现象:拼图创作有时刚结束就跳到“待发布”结果页,但结果页里的正式图还是空的,发布检查随后又会拦住,用户会感觉“已经完成了却又不能发布”。 -- 原因:拼图的待发布判定太弱,`build_result_preview` / `validate_publish_requirements` 和 `is_puzzle_session_snapshot_publish_ready` 只检查了作品名、简介、标签、关卡名和 cover 图,没有要求 `level_scene_image_src`、`ui_spritesheet_image_src`、`level_background_image_src` 等完整资产都齐;前端恢复链路里的 `hasRecoverableGeneratedPuzzleDraft` / `normalizeRecoveredPuzzleDraftSession` 也只要有 cover 或候选图就会把草稿当成已完成。 -- 处理:待修复时要把“待发布”门槛收紧到整套拼图资产包完整,再让恢复逻辑只在完整草稿下抬高为完成态,避免半成品直接进入结果页。 -- 验证:当某个拼图草稿只补齐首图、但关卡背景或 UI spritesheet 仍缺失时,不应再进入 `ready_to_publish`;结果页也不应把这类草稿误判为已完成。 -- 关联:`server-rs/crates/module-puzzle/src/application.rs`、`server-rs/crates/api-server/src/puzzle/tags.rs`、`server-rs/crates/api-server/src/puzzle/draft.rs`、`src/components/platform-entry/PlatformEntryFlowShellImpl.tsx`、`src/components/puzzle-result/PuzzleResultView.tsx`。 +- 原因:拼图的待发布判定太弱,`build_result_preview` / `validate_publish_requirements` 和 `is_puzzle_session_snapshot_publish_ready` 只检查了作品名、简介、标签、关卡名和 cover 图,没有要求 `level_scene_image_src`、`ui_spritesheet_image_src`、`level_background_image_src` 等完整资产都齐;历史前端恢复链路里的 `hasRecoverableGeneratedPuzzleDraft` / `normalizeRecoveredPuzzleDraftSession` 也只要有 cover 或候选图就会把草稿当成已完成。 +- 处理:前端恢复链路已收口到 `platformPuzzleDraftRecoveryModel.ts`,只有首图、关卡画面、UI spritesheet 与关卡背景资产包完整时才把恢复草稿抬为完成态;后端 `build_result_preview` / `validate_publish_requirements` / `is_puzzle_session_snapshot_publish_ready` 的待发布门槛仍需后续收紧到整套拼图资产包完整。 +- 验证:当某个拼图草稿只补齐首图、但关卡背景或 UI spritesheet 仍缺失时,前端恢复链路不应把它误判为已完成;后端后续修复后也不应进入 `ready_to_publish`。 +- 关联:`server-rs/crates/module-puzzle/src/application.rs`、`server-rs/crates/api-server/src/puzzle/tags.rs`、`server-rs/crates/api-server/src/puzzle/draft.rs`、`src/components/platform-entry/platformPuzzleDraftRecoveryModel.ts`、`src/components/puzzle-result/PuzzleResultView.tsx`。 ## WebGL 画布在高 DPR 移动端放大溢出 diff --git a/docs/README.md b/docs/README.md index 008afbd3..6923e065 100644 --- a/docs/README.md +++ b/docs/README.md @@ -61,6 +61,8 @@ AI 文字游戏模板接入以 [AI_NATIVE_TEXT_GAME_TEMPLATE_MOKU_REFERENCE_PRD_ 平台小游戏草稿恢复和提交所需的拼图 / 抓大鹅表单 payload、拼图编译 action 与 pending metadata 收口到 `src/components/platform-entry/platformMiniGameDraftPayloadModel.ts`,壳层只保留 API、Action 执行、background task 与状态副作用,规则见 [【前端架构】PlatformMiniGameDraftPayloadModel收口计划-2026-06-04.md](./technical/【前端架构】PlatformMiniGameDraftPayloadModel收口计划-2026-06-04.md)。 +平台拼图生成完成后刷新恢复的草稿归一化与可恢复完成态判定收口到 `src/components/platform-entry/platformPuzzleDraftRecoveryModel.ts`,恢复链路只有在首图、关卡画面、UI spritesheet 与关卡背景资产包完整时才抬为 ready,规则见 [【前端架构】PlatformPuzzleDraftRecoveryModel收口计划-2026-06-04.md](./technical/【前端架构】PlatformPuzzleDraftRecoveryModel收口计划-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/【前端架构】PlatformPuzzleDraftRecoveryModel收口计划-2026-06-04.md b/docs/technical/【前端架构】PlatformPuzzleDraftRecoveryModel收口计划-2026-06-04.md new file mode 100644 index 00000000..6301b5b1 --- /dev/null +++ b/docs/technical/【前端架构】PlatformPuzzleDraftRecoveryModel收口计划-2026-06-04.md @@ -0,0 +1,44 @@ +# 【前端架构】Platform Puzzle Draft Recovery Model 收口计划 + +## 背景 + +`PlatformEntryFlowShellImpl.tsx` 曾内联维护拼图生成完成后刷新恢复的两个纯函数:`normalizeRecoveredPuzzleDraftSession` 与 `hasRecoverableGeneratedPuzzleDraft`。旧逻辑只要草稿有 `coverImageSrc`、首关 cover 或候选图,就会把恢复会话的 draft 和首关 `generationStatus` 抬成 `ready`,再进入结果页。 + +`.hermes/shared-memory/pitfalls.md` 已记录:拼图待发布判定偏弱时,只有首图但缺关卡画面、UI spritesheet 或关卡背景的半成品会被误当完成,用户进入结果页后仍可能空图或无法发布。 + +本切片先修前端恢复链路:只有完整首关资产包存在时,恢复流程才视为可完成。后端 `build_result_preview` / `validate_publish_requirements` / `is_puzzle_session_snapshot_publish_ready` 的发布门槛收紧另作后续切片,不混入本次前端模型收口。 + +## 决策 + +新增 `src/components/platform-entry/platformPuzzleDraftRecoveryModel.ts` 作为 Platform Puzzle Draft Recovery **Module**。其公开 **Interface** 为: + +- `normalizeRecoveredPuzzleDraftSession(session)`:从恢复会话里补齐首图 cover、assetId 和 selectedCandidateId;只有完整资产包满足时,才把 draft 与首关 `generationStatus` 改为 `ready`。 +- `hasRecoverableGeneratedPuzzleDraft(session)`:判断恢复会话是否拥有完整首关资产包。 + +`PlatformEntryFlowShellImpl.tsx` 仍作为 **Adapter**:它继续负责拉取 session、写 background task、写 React state、打开结果页和切换 stage。 + +## Interface 约束 + +- 无 draft 时保持原 session,并判定不可恢复完成态。 +- 首图可来自 `draft.coverImageSrc`、首关 `coverImageSrc` 或选中 / 首个候选图。 +- 完整首关资产包必须同时具备: + - 首图 cover; + - `levelSceneImageSrc` 或 `levelSceneImageObjectKey`; + - `uiSpritesheetImageSrc` 或 `uiSpritesheetImageObjectKey`; + - `levelBackgroundImageSrc` 或 `levelBackgroundImageObjectKey`。 +- cover / assetId / selectedCandidateId 可按旧优先级从 draft、首关、候选图回填;但若完整资产包不满足,不得把 `generationStatus` 抬为 `ready`。 +- 只修复前端恢复判定,不改变拼图发布接口、后端 session stage 或后端 preview compiler。 + +## Depth / Leverage / Locality + +- **Depth**:壳层以两个函数表达“恢复会话归一化”和“是否可作为生成完成态恢复”;完整资产门槛和候选图 fallback 藏入 Module Implementation。 +- **Leverage**:后续后端补齐发布门槛时,可用同一资产语言对齐前端恢复模型,避免壳层再散落条件判断。 +- **Locality**:拼图恢复判定集中到纯测试面,避免在异步恢复 callback 中把半成品 ready 规则继续隐身。 + +## 验收 + +- `npm run test -- src/components/platform-entry/platformPuzzleDraftRecoveryModel.test.ts` +- `npx eslint src/components/platform-entry/platformPuzzleDraftRecoveryModel.ts src/components/platform-entry/platformPuzzleDraftRecoveryModel.test.ts src/components/platform-entry/PlatformEntryFlowShellImpl.tsx --quiet` +- `npm run test -- src/components/rpg-entry/RpgEntryFlowShell.agent.interaction.test.tsx -t "persisted generating puzzle draft"` +- `npm run typecheck` +- `npm run check:encoding` diff --git a/docs/【玩法创作】平台入口与玩法链路-2026-05-15.md b/docs/【玩法创作】平台入口与玩法链路-2026-05-15.md index 2f5f5cdf..15efac60 100644 --- a/docs/【玩法创作】平台入口与玩法链路-2026-05-15.md +++ b/docs/【玩法创作】平台入口与玩法链路-2026-05-15.md @@ -18,6 +18,8 @@ 拼图 / 抓大鹅草稿恢复和提交所需的表单 payload、拼图编译 action 与 pending metadata 统一由 `platformMiniGameDraftPayloadModel.ts` 构造。平台壳不得重新手写拼图描述字段优先级、formDraft 回退、Match3D config / draft / anchorPack 优先级、数字解析或 pending 标题摘要派生规则。 +拼图生成完成后刷新恢复的草稿归一化与可恢复完成态判定统一由 `platformPuzzleDraftRecoveryModel.ts` 处理。恢复链路只有在首图、关卡画面、UI spritesheet 与关卡背景资产包完整时才可把 draft 和首关状态抬为 `ready`;只有 cover 或候选图的半成品不得直接进入结果页完成态。 + 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 19788c5d..467d46ae 100644 --- a/src/components/platform-entry/PlatformEntryFlowShellImpl.tsx +++ b/src/components/platform-entry/PlatformEntryFlowShellImpl.tsx @@ -571,6 +571,10 @@ import { resolvePlatformPublicWorkStartIntent, resolveVisiblePuzzleDetailCoverCount, } from './platformPublicWorkDetailFlow'; +import { + hasRecoverableGeneratedPuzzleDraft, + normalizeRecoveredPuzzleDraftSession, +} from './platformPuzzleDraftRecoveryModel'; import { buildPuzzleResultProfileId, buildPuzzleResultWorkId, @@ -1045,77 +1049,6 @@ function openPuzzleRuntimeStage( writePuzzleRuntimeUrlState(state); } -function normalizeRecoveredPuzzleDraftSession( - session: PuzzleAgentSessionSnapshot, -): PuzzleAgentSessionSnapshot { - const draft = session.draft; - if (!draft) { - return session; - } - - const primaryLevel = draft.levels?.[0]; - const selectedCandidate = - primaryLevel?.candidates.find((candidate) => candidate.selected) ?? - primaryLevel?.candidates[0] ?? - draft.candidates.find((candidate) => candidate.selected) ?? - draft.candidates[0] ?? - null; - const coverImageSrc = - draft.coverImageSrc?.trim() || - primaryLevel?.coverImageSrc?.trim() || - selectedCandidate?.imageSrc.trim() || - null; - const coverAssetId = - draft.coverAssetId?.trim() || - primaryLevel?.coverAssetId?.trim() || - selectedCandidate?.assetId.trim() || - null; - const selectedCandidateId = - draft.selectedCandidateId ?? - primaryLevel?.selectedCandidateId ?? - selectedCandidate?.candidateId ?? - null; - - return { - ...session, - draft: { - ...draft, - coverImageSrc, - coverAssetId, - selectedCandidateId, - generationStatus: 'ready', - levels: draft.levels?.map((level, index) => - index === 0 - ? { - ...level, - coverImageSrc: level.coverImageSrc ?? coverImageSrc, - coverAssetId: level.coverAssetId ?? coverAssetId, - selectedCandidateId: - level.selectedCandidateId ?? selectedCandidateId, - generationStatus: 'ready', - } - : level, - ), - }, - }; -} - -function hasRecoverableGeneratedPuzzleDraft( - session: PuzzleAgentSessionSnapshot, -) { - const draft = session.draft; - if (!draft) { - return false; - } - - const firstLevel = draft.levels?.[0]; - return Boolean( - draft.coverImageSrc?.trim() || - firstLevel?.coverImageSrc?.trim() || - firstLevel?.candidates.some((candidate) => candidate.imageSrc.trim()), - ); -} - function resolveProfileWalletBalance( dashboard: { walletBalance?: number | null } | null | undefined, ) { diff --git a/src/components/platform-entry/platformPuzzleDraftRecoveryModel.test.ts b/src/components/platform-entry/platformPuzzleDraftRecoveryModel.test.ts new file mode 100644 index 00000000..f2adbd05 --- /dev/null +++ b/src/components/platform-entry/platformPuzzleDraftRecoveryModel.test.ts @@ -0,0 +1,195 @@ +import { describe, expect, test } from 'vitest'; + +import type { + PuzzleAnchorPack, + PuzzleDraftLevel, + PuzzleGeneratedImageCandidate, + PuzzleResultDraft, +} from '../../../packages/shared/src/contracts/puzzleAgentDraft'; +import type { PuzzleAgentSessionSnapshot } from '../../../packages/shared/src/contracts/puzzleAgentSession'; +import { + hasRecoverableGeneratedPuzzleDraft, + normalizeRecoveredPuzzleDraftSession, +} from './platformPuzzleDraftRecoveryModel'; + +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 buildCandidate( + overrides: Partial = {}, +): PuzzleGeneratedImageCandidate { + return { + candidateId: 'candidate-1', + imageSrc: '/candidate-cover.png', + assetId: 'asset-candidate-cover', + prompt: '星桥机关', + sourceType: 'generated', + selected: true, + ...overrides, + }; +} + +function buildLevel(overrides: Partial = {}): PuzzleDraftLevel { + return { + levelId: 'level-1', + levelName: '星桥机关', + pictureDescription: '星桥机关画面', + candidates: [buildCandidate()], + selectedCandidateId: null, + coverImageSrc: null, + coverAssetId: null, + generationStatus: 'generating', + ...overrides, + }; +} + +function buildDraft(overrides: Partial = {}): PuzzleResultDraft { + const anchorPack = buildAnchorPack(); + return { + workTitle: '星桥拼图', + workDescription: '修复星桥机关。', + levelName: '星桥机关', + summary: '把碎片拼回原位。', + themeTags: ['星桥', '机关', '修复'], + forbiddenDirectives: [], + creatorIntent: null, + anchorPack, + candidates: [], + selectedCandidateId: null, + coverImageSrc: null, + coverAssetId: null, + generationStatus: 'generating', + levels: [buildLevel()], + ...overrides, + }; +} + +function buildSession( + overrides: Partial = {}, +): PuzzleAgentSessionSnapshot { + const anchorPack = buildAnchorPack(); + return { + sessionId: 'puzzle-session-1', + seedText: '星桥', + currentTurn: 1, + progressPercent: 100, + stage: 'draft_ready', + anchorPack, + draft: buildDraft(), + messages: [], + lastAssistantReply: null, + publishedProfileId: null, + suggestedActions: [], + resultPreview: null, + updatedAt: '2026-06-01T10:00:00.000Z', + ...overrides, + }; +} + +function withCompleteLevelAssets( + overrides: Partial = {}, +): PuzzleDraftLevel { + return buildLevel({ + levelSceneImageSrc: '/level-scene.png', + uiSpritesheetImageSrc: '/ui-spritesheet.png', + levelBackgroundImageSrc: '/level-background.png', + ...overrides, + }); +} + +describe('platformPuzzleDraftRecoveryModel', () => { + test('normalizes and marks recovered puzzle draft ready when asset pack is complete', () => { + const normalized = normalizeRecoveredPuzzleDraftSession( + buildSession({ + draft: buildDraft({ + levels: [withCompleteLevelAssets()], + }), + }), + ); + + expect(hasRecoverableGeneratedPuzzleDraft(normalized)).toBe(true); + expect(normalized.draft).toMatchObject({ + coverImageSrc: '/candidate-cover.png', + coverAssetId: 'asset-candidate-cover', + selectedCandidateId: 'candidate-1', + generationStatus: 'ready', + }); + expect(normalized.draft?.levels?.[0]).toMatchObject({ + coverImageSrc: '/candidate-cover.png', + coverAssetId: 'asset-candidate-cover', + selectedCandidateId: 'candidate-1', + generationStatus: 'ready', + }); + }); + + test('keeps half-finished draft generating when only cover candidate exists', () => { + const normalized = normalizeRecoveredPuzzleDraftSession(buildSession()); + + expect(hasRecoverableGeneratedPuzzleDraft(normalized)).toBe(false); + expect(normalized.draft).toMatchObject({ + coverImageSrc: '/candidate-cover.png', + generationStatus: 'generating', + }); + expect(normalized.draft?.levels?.[0]).toMatchObject({ + coverImageSrc: '/candidate-cover.png', + generationStatus: 'generating', + }); + }); + + test('requires level scene, ui spritesheet and level background assets together', () => { + expect( + hasRecoverableGeneratedPuzzleDraft( + buildSession({ + draft: buildDraft({ + coverImageSrc: '/draft-cover.png', + levels: [ + withCompleteLevelAssets({ + uiSpritesheetImageSrc: null, + uiSpritesheetImageObjectKey: null, + }), + ], + }), + }), + ), + ).toBe(false); + }); + + test('accepts object keys as recovered asset references', () => { + expect( + hasRecoverableGeneratedPuzzleDraft( + buildSession({ + draft: buildDraft({ + coverImageSrc: '/draft-cover.png', + levels: [ + buildLevel({ + levelSceneImageObjectKey: 'level-scene.png', + uiSpritesheetImageObjectKey: 'ui-spritesheet.png', + levelBackgroundImageObjectKey: 'level-background.png', + }), + ], + }), + }), + ), + ).toBe(true); + }); + + test('leaves sessions without draft unchanged and unrecoverable', () => { + const session = buildSession({ draft: null }); + + expect(normalizeRecoveredPuzzleDraftSession(session)).toBe(session); + expect(hasRecoverableGeneratedPuzzleDraft(session)).toBe(false); + }); +}); diff --git a/src/components/platform-entry/platformPuzzleDraftRecoveryModel.ts b/src/components/platform-entry/platformPuzzleDraftRecoveryModel.ts new file mode 100644 index 00000000..ccec512f --- /dev/null +++ b/src/components/platform-entry/platformPuzzleDraftRecoveryModel.ts @@ -0,0 +1,154 @@ +import type { PuzzleDraftLevel } from '../../../packages/shared/src/contracts/puzzleAgentDraft'; +import type { PuzzleAgentSessionSnapshot } from '../../../packages/shared/src/contracts/puzzleAgentSession'; + +function normalizeRecoveryText(value: string | null | undefined) { + return value?.trim() || null; +} + +function hasPuzzleAssetReference( + imageSrc: string | null | undefined, + objectKey: string | null | undefined, +) { + return Boolean(normalizeRecoveryText(imageSrc) || normalizeRecoveryText(objectKey)); +} + +function resolvePrimaryPuzzleLevel(session: PuzzleAgentSessionSnapshot) { + return session.draft?.levels?.[0] ?? null; +} + +function resolvePuzzleRecoveryCandidate( + session: PuzzleAgentSessionSnapshot, + primaryLevel: PuzzleDraftLevel | null, +) { + const draft = session.draft; + if (!draft) { + return null; + } + + return ( + primaryLevel?.candidates.find((candidate) => candidate.selected) ?? + primaryLevel?.candidates[0] ?? + draft.candidates.find((candidate) => candidate.selected) ?? + draft.candidates[0] ?? + null + ); +} + +function resolvePuzzleRecoveryCoverFields( + session: PuzzleAgentSessionSnapshot, +) { + const draft = session.draft; + const primaryLevel = resolvePrimaryPuzzleLevel(session); + const selectedCandidate = resolvePuzzleRecoveryCandidate( + session, + primaryLevel, + ); + + return { + coverImageSrc: + normalizeRecoveryText(draft?.coverImageSrc) ?? + normalizeRecoveryText(primaryLevel?.coverImageSrc) ?? + normalizeRecoveryText(selectedCandidate?.imageSrc), + coverAssetId: + normalizeRecoveryText(draft?.coverAssetId) ?? + normalizeRecoveryText(primaryLevel?.coverAssetId) ?? + normalizeRecoveryText(selectedCandidate?.assetId), + selectedCandidateId: + draft?.selectedCandidateId ?? + primaryLevel?.selectedCandidateId ?? + selectedCandidate?.candidateId ?? + null, + }; +} + +function hasCompleteGeneratedPuzzleLevelAssets( + level: PuzzleDraftLevel | null, + coverImageSrc: string | null, +) { + return Boolean( + normalizeRecoveryText(coverImageSrc) && + hasPuzzleAssetReference( + level?.levelSceneImageSrc, + level?.levelSceneImageObjectKey, + ) && + hasPuzzleAssetReference( + level?.uiSpritesheetImageSrc, + level?.uiSpritesheetImageObjectKey, + ) && + hasPuzzleAssetReference( + level?.levelBackgroundImageSrc, + level?.levelBackgroundImageObjectKey, + ), + ); +} + +export function hasRecoverableGeneratedPuzzleDraft( + session: PuzzleAgentSessionSnapshot, +) { + const draft = session.draft; + if (!draft) { + return false; + } + + const primaryLevel = resolvePrimaryPuzzleLevel(session); + const { coverImageSrc } = resolvePuzzleRecoveryCoverFields(session); + return hasCompleteGeneratedPuzzleLevelAssets(primaryLevel, coverImageSrc); +} + +export function normalizeRecoveredPuzzleDraftSession( + session: PuzzleAgentSessionSnapshot, +): PuzzleAgentSessionSnapshot { + const draft = session.draft; + if (!draft) { + return session; + } + + const { coverImageSrc, coverAssetId, selectedCandidateId } = + resolvePuzzleRecoveryCoverFields(session); + const nextLevels = draft.levels?.map((level, index) => + index === 0 + ? { + ...level, + coverImageSrc: normalizeRecoveryText(level.coverImageSrc) + ? level.coverImageSrc + : coverImageSrc, + coverAssetId: normalizeRecoveryText(level.coverAssetId) + ? level.coverAssetId + : coverAssetId, + selectedCandidateId: + level.selectedCandidateId ?? selectedCandidateId, + } + : level, + ); + const nextSession = { + ...session, + draft: { + ...draft, + coverImageSrc, + coverAssetId, + selectedCandidateId, + levels: nextLevels, + }, + } satisfies PuzzleAgentSessionSnapshot; + const isRecoverable = hasRecoverableGeneratedPuzzleDraft(nextSession); + + if (!isRecoverable) { + return nextSession; + } + + return { + ...nextSession, + draft: { + ...nextSession.draft, + generationStatus: 'ready', + levels: nextSession.draft.levels?.map((level, index) => + index === 0 + ? { + ...level, + generationStatus: 'ready', + } + : level, + ), + }, + }; +}