Merge codex/sse-stream-architecture into architecture adjustment

This commit is contained in:
2026-06-07 00:23:42 +08:00
136 changed files with 22344 additions and 7543 deletions

View File

@@ -81,6 +81,153 @@
- 验证方式:关闭任一创作入口后,新建创作请求返回 `creation_entry_disabled`;公开作品列表 / 详情 / 启动 / 运行态动作不返回该错误进入平台首页不弹“平台首页creation_entry_disabled”关闭态入口卡显示锁定状态且不显示 `10-20泥点数`
- 关联文档:`docs/【玩法创作】平台入口与玩法链路-2026-05-15.md`
## 2026-06-04 Draft Generation Shelf 剩余草稿打开 intent 收口
- 背景:拼图 / 抓大鹅草稿打开 intent 已归入 `platformDraftGenerationShelfModel.ts`,但方洞挑战、大鱼吃小鱼和视觉小说仍在平台壳层内联判断已发布详情、缺 session、active generating、当前结果页和普通草稿恢复。
- 决策:继续扩展 `src/components/platform-entry/platformDraftGenerationShelfModel.ts`,新增 `resolveSquareHoleDraftOpenIntent(...)``resolveBigFishDraftOpenIntent(...)``resolveVisualNovelDraftOpenIntent(...)`;平台壳只按 intent 执行 notice seen、详情打开、恢复 session、读取 work detail、清生成态和切 stage 副作用。
- 追加决策:跳一跳与敲木鱼草稿打开也归入同一 Draft Generation Shelf Model新增 `resolveJumpHopDraftOpenIntent(...)``resolveWoodenFishDraftOpenIntent(...)`;壳层只按 intent 执行已发布详情、失败生成页恢复、持久化 generating 恢复、读取 detail 和敲木鱼失败 fallback stage 副作用。
- 影响范围:创作中心作品架打开方洞挑战 / 大鱼吃小鱼 / 视觉小说 / 跳一跳 / 敲木鱼草稿、创作 URL 恢复时强制打开草稿、生成中回到生成页和视觉小说结果页恢复。
- 验证方式:`npm run test -- src/components/platform-entry/platformDraftGenerationShelfModel.test.ts`、针对 Draft Shelf Module 与平台壳执行 ESLint、`npm run typecheck``npm run check:encoding`
- 关联文档:`docs/technical/【前端架构】DraftGenerationShelfModel收口计划-2026-06-03.md`
## 2026-06-04 Platform Public Code Search matcher / DTO 收口
- 背景:`resolvePlatformPublicCodeSearchPlan(...)` 已收口公开搜索顺序,但 `PlatformEntryFlowShellImpl.tsx` 仍内联 RPG by-code DTO 构造,以及拼图、大鱼吃小鱼、跳一跳、敲木鱼、宝贝识物、抓大鹅、方洞挑战、视觉小说和汪汪声浪的 `isSame*PublicWorkCode` 匹配、公开可见性过滤与详情卡映射。
- 决策:扩展 `src/components/platform-entry/platformPublicCodeSearchModel.ts`,以 `mapRpgPublicCodeSearchDetailToGalleryCard(...)` 和各 `resolve*PublicCodeSearchMatch(...)` 收口 per-play 公开码匹配与 DTO 映射;壳层只保留 gallery 刷新、详情打开、Bark Battle runtime 特例、用户查询和错误归航副作用。`M3D-*` 旧抓大鹅前缀在 `isSameMatch3DPublicWorkCode(...)` 中继续匹配。
- 影响范围:平台首页搜索框、初始 `publicWorkCode` 恢复、各玩法公开作品号命中、RPG 公开作品 by-code 详情映射、Bark Battle runtime 内搜索启动。
- 验证方式:`npm run test -- src/components/platform-entry/platformPublicCodeSearchModel.test.ts src/services/publicWorkCode.test.ts`、针对搜索 Module / 壳层 / publicWorkCode 执行 ESLint、`npm run typecheck``npm run check:encoding`
- 关联文档:`docs/technical/【前端架构】PlatformPublicCodeSearchModel收口计划-2026-06-04.md``docs/【玩法创作】平台入口与玩法链路-2026-05-15.md`
## 2026-06-04 Draft Generation Shelf 草稿打开 intent 收口
- 背景:`openPuzzleDraft` / `openMatch3DDraft` 在平台壳内重复判断已发布作品、缺 session、ready 未读、失败 notice、active / background 生成中、持久化 generating 和普通草稿恢复,导致壳层继续理解拼图稳定 ID、抓大鹅 notice key 与生成状态优先级。
- 决策:扩展 `src/components/platform-entry/platformDraftGenerationShelfModel.ts`,以 `resolvePuzzleDraftOpenIntent(...)``resolveMatch3DDraftOpenIntent(...)` 返回纯打开计划和 notice keys壳层只按 intent 执行网络读取、生成态 rebase、试玩启动、错误写入、路由 / stage 和 notice seen 副作用。
- 影响范围:创作中心作品架打开拼图 / 抓大鹅草稿、公开码搜索强制打开抓大鹅草稿、生成完成后 ready 未读试玩、失败草稿恢复和后续 pending / persisted generating 判定。
- 验证方式:`npm run test -- src/components/platform-entry/platformDraftGenerationShelfModel.test.ts`、针对 Draft Shelf Module 与平台壳执行 ESLint、`npm run typecheck``npm run check:encoding`
- 关联文档:`docs/technical/【前端架构】DraftGenerationShelfModel收口计划-2026-06-03.md`
## 2026-06-04 Bark Battle Work Cache 草稿状态收口
- 背景:`PlatformEntryFlowShellImpl.tsx` 仍内联维护 Bark Battle 草稿三图完整性、生成状态归一、作品架摘要恢复草稿配置,以及草稿 / 已发布作品进入 runtime 前的 `BarkBattlePublishedConfig` 字段映射,导致结果页试玩、作品架启动、草稿恢复和公开详情启动都要理解同一份资产字段清单。
- 决策:扩展 `src/components/platform-entry/barkBattleWorkCache.ts`,以 `hasBarkBattleDraftRequiredImages``resolveBarkBattleDraftGenerationStatus``buildBarkBattleDraftConfigFromWorkSummary``buildBarkBattlePublishedConfigFromDraft``buildBarkBattlePublishedConfigFromWork``buildBarkBattlePublishSnapshot``mergeBarkBattlePublishedConfigAssets` 收口 Bark Battle 纯规则。平台壳只保留 API、缓存刷新、React state、URL 和 stage 副作用。
- 影响范围Bark Battle 草稿生成完成、结果页保存、作品架摘要恢复草稿、草稿试玩、作品架 / 公开详情启动正式 runtime以及后续 Bark Battle 资产字段或 ruleset 默认值调整。
- 验证方式:`npm run test -- src/components/platform-entry/barkBattleWorkCache.test.ts`、针对 Bark Battle Work Cache Module 与平台壳执行 ESLint、`npm run typecheck``npm run check:encoding`
- 关联文档:`docs/technical/【前端架构】BarkBattleWorkCache草稿状态收口计划-2026-06-04.md`
## 2026-06-04 Platform Recommend Runtime Auth Model 收口
- 背景:平台推荐 runtime 的 embedded 启动需要在匿名 Runtime Guest Token、已登录 background auth 和非 embedded 默认鉴权之间分流,拼图还额外维护 `isolated` / `default` runtime auth mode旧规则散在顶层 helper 与多个启动 callback。
- 决策:新增 `src/components/platform-entry/platformRecommendRuntimeAuthModel.ts`,以 `resolvePlatformRecommendRuntimeAuthPlan(input)``shouldUsePlatformRecommendRuntimeGuestAuth(input)` 收口纯鉴权计划。壳层仍负责读取 `getStoredAccessToken()`、申请 `ensureRuntimeGuestToken()`、拼装 request options 和写入拼图 runtime auth mode。
- 影响范围:推荐 Tab 内嵌 runtime 启动、拼图公开详情 isolated 入口、推荐运行态后续 action 的局部鉴权口径,以及后续新增可嵌入推荐 runtime 的玩法。
- 验证方式:`npm run test -- src/components/platform-entry/platformRecommendRuntimeAuthModel.test.ts`、针对新 Module 与平台壳执行 ESLint、`npm run typecheck``npm run check:encoding`
- 关联文档:`docs/technical/【前端架构】PlatformRecommendRuntimeAuthModel收口计划-2026-06-04.md`
## 2026-06-04 Platform Recommend Runtime Auto Start 收口
- 背景:推荐 runtime 自动启动 effect 同时判断桌面断点、stage、Tab、loading、推荐列表、active entry、ready 状态和启动中状态,导致壳层 effect 依赖过长且混合推荐流状态机知识。
- 决策:扩展 `src/components/platform-entry/platformPublicGalleryFlow.ts`,新增 `resolvePlatformRecommendRuntimeAutoStartDecision(input)`,只返回 `noop``clear``start(entry)`。平台壳只执行清空 active runtime state 或调用 `selectRecommendRuntimeEntry(entry)`
- 影响范围:移动端首页推荐 runtime 自动启动、推荐列表为空时清空状态、active entry ready 判定,以及后续新增推荐 runtime 玩法的启动时机。
- 验证方式:`npm run test -- src/components/platform-entry/platformPublicGalleryFlow.test.ts`、针对 Flow Module 与平台壳执行 ESLint、`npm run typecheck``npm run check:encoding`
- 关联文档:`docs/technical/【前端架构】PlatformRecommendRuntimeAutoStart收口计划-2026-06-04.md`
## 2026-06-04 Platform Creation Launch Model 收口
- 背景:平台创作入口点击回调曾在 `PlatformEntryFlowShellImpl.tsx` 内联判断 `airp` 占位、隐藏的 `baby-object-match`、未知入口和各玩法工作台启动目标,壳层同时承接入口 ID 规则、启动前准备顺序和副作用。
- 决策:新增 `src/components/platform-entry/platformCreationLaunchModel.ts`,以 `resolvePlatformCreationLaunchIntent({ type, isBabyObjectMatchVisible })` 收口创作入口启动意图。`airp` 返回 `noop` 且不触发 `prepareCreationLaunch()`;隐藏 `baby-object-match` 返回 blocked intent 且仍在 prepare 后显示 `EDUTAINMENT_HIDDEN_MESSAGE`;未知入口保持旧语义,先 prepare 后 no-op已知入口返回稳定 launch target。壳层只执行 prepare、错误提示和 `runProtectedAction(...)`
- 影响范围:底部加号创作入口模板卡点击、入口可见性拦截、后续新增可启动模板的 launch target 接入。
- 验证方式:`npm run test -- src/components/platform-entry/platformCreationLaunchModel.test.ts`、针对新 Module 与壳层执行 ESLint、`npm run typecheck``npm run check:encoding`
- 关联文档:`docs/technical/【前端架构】PlatformCreationLaunchModel收口计划-2026-06-04.md`
## 2026-06-04 Platform Selection Stage Model 收口
- 背景:平台入口在受保护数据失效后会清空当前用户私有作品、草稿、运行态和生成状态,但哪些 `SelectionStage` 可保留、哪些必须回首页曾以内联长否定串散在 `PlatformEntryFlowShellImpl.tsx`
- 决策:新增 `src/components/platform-entry/platformSelectionStageModel.ts`,以 `resolveSelectionStageAfterProtectedDataLoss(stage)` 收口受保护数据失效后的 stage 去留判定。模型内部使用 `satisfies Record<SelectionStage, boolean>` 全量分类,新增 stage 时必须明确保留或回首页。壳层仍负责检测权限变化、清 state 和调用 `setSelectionStage`
- 追加决策:缺失草稿 / 作品 / run 时的阶段回退也归入 `platformSelectionStageModel.ts`,由 `resolveSelectionStageAfterMissingCreationState(params)` 统一判断 big-fish、match3d、square-hole、visual-novel 和 baby-object-match 的 result / runtime / gallery-detail 是否还能被当前状态支撑。壳层只汇总布尔事实并按输出 stage 跳转big-fish、match3d、square-hole 的草稿事实固定来自 `Boolean(session?.draft)`visual-novel 的 session draft 与 work draft 可独立支撑结果页baby-object-match runtime 缺 draft 时直接回首页。
- 影响范围:退出登录、鉴权上下文收回、平台入口公开页 / 工作台 / 结果页 / 生成页 / 运行态的阶段恢复规则,以及后续新增 `SelectionStage`
- 验证方式:`npm run test -- src/components/platform-entry/platformSelectionStageModel.test.ts`、针对新 Module 与壳层执行 ESLint、`npm run typecheck``npm run check:encoding`
- 关联文档:`docs/technical/【前端架构】PlatformSelectionStageModel收口计划-2026-06-04.md`
## 2026-06-04 Creation Work Delete Flow 收口
- 背景:平台入口作品架删除入口在 RPG、拼图、抓大鹅、方洞挑战、大鱼吃小鱼、视觉小说和宝贝识物 handler 内重复计算确认标题、删除说明、草稿 notice key 与拼图派生稳定 ID导致删除确认规则散在巨型壳层。
- 决策:新增 `src/components/platform-entry/platformCreationWorkDeleteFlow.ts`,以 `resolvePlatformCreationWorkDeleteConfirmationModel(input)` 收口作品架删除确认纯模型;输出 `id/title/detail/noticeKeys``PlatformEntryFlowShellImpl.tsx` 仍作为副作用 Adapter保留删除 API、刷新作品架 / 公开广场、错误状态、`markDraftNoticeSeen` 和页面跳转。
- 影响范围:创作中心作品架删除确认弹窗、删除后生成 notice 清理、拼图稳定 result ID 清理、宝贝识物已发布删除说明,以及后续新增玩法作品架删除接入。
- 验证方式:`npm run test -- src/components/platform-entry/platformCreationWorkDeleteFlow.test.ts``npm run test -- src/components/platform-entry/platformDraftGenerationShelfModel.test.ts`、针对新 Module 与平台壳执行 ESLint、`npm run typecheck``npm run check:encoding`
- 关联文档:`docs/technical/【前端架构】CreationWorkDeleteFlow收口计划-2026-06-04.md`
## 2026-06-03 平台入口公开作品详情 Strategy 收口
- 背景:平台壳层直接判断公开作品详情入口的玩法类型、是否需要补读完整详情,以及自有作品按钮显示“编辑”还是“改造”,导致统一作品详情的纯决策散落在巨型 Implementation 内。
- 决策:新增 `src/components/platform-entry/platformPublicWorkDetailFlow.ts`,以 `getPlatformPublicWorkDetailKind``resolvePlatformPublicWorkDetailOpenStrategy``resolvePlatformPublicWorkActionMode``resolvePlatformPublicWorkDetailOpenDecision``resolveActivePlatformPublicWorkAuthorEntry` 收口公开作品详情 Strategy。`PlatformEntryFlowShellImpl.tsx` 只按 Strategy 调用现有详情读取 / 直接展示 Adapter并保留作者请求竞态控制启动、点赞、remix 和编辑副作用不搬入 Module。
- 追加决策:公开详情 entry 映射与公开详情反推玩法 work 摘要也归入 `platformPublicWorkDetailFlow.ts`,包括 RPG、拼图、大鱼吃小鱼、方洞挑战、视觉小说、跳一跳、敲木鱼和汪汪声浪的通用映射。抓大鹅 `mapMatch3DWorkToPublicWorkDetail` 归入 `platformMatch3DRuntimeProfile.ts`,继续委托 `normalizeMatch3DWorkForRuntimeUi` 做素材归一和背景资产提升,避免把 Match3D 运行态规则复制到公开详情 Flow Module。
- 追加决策:拼图公开详情封面解锁数由 `resolveVisiblePuzzleDetailCoverCount(entry, run)` 收口;非拼图、无当前 run 或 run 不匹配当前公开详情时只展示首图,匹配当前公开详情时按 `clearedLevelCount + 1` 解锁且至少为 1。`PlatformWorkDetailView` 只接收 `visibleCoverCount` 展示,不读取 run。
- 追加决策:公开详情点赞能力矩阵由 `resolvePlatformPublicWorkLikeIntent(entry)` 收口Module 只返回大鱼吃小鱼、拼图、旧 RPG gallery fallback 或不可用文案壳层仍执行鉴权、API 调用、缓存同步、错误展示和 busy 状态。
- 追加决策:公开详情改造能力矩阵由 `resolvePlatformPublicWorkRemixIntent(entry)` 收口Module 只返回大鱼吃小鱼、拼图、旧 RPG gallery fallback 或不可用文案壳层仍执行鉴权、remix API、session / 缓存写入、stage 切换、错误展示和 busy 状态。
- 追加决策:公开详情启动分流由 `resolvePlatformPublicWorkStartIntent(entry, deps)` 收口Module 只返回大鱼吃小鱼、拼图、跳一跳、敲木鱼、抓大鹅、方洞挑战、视觉小说、汪汪声浪、宝贝识物或旧 RPG gallery 记录游玩的 intent。壳层仍执行登录保护、运行态启动、RPG 游玩记录、详情更新、busy 状态和错误展示;抓大鹅 public detail -> work mapper 作为 Adapter 注入,继续由 Match3D Runtime Profile Module 维护素材归一与背景资产提升。
- 追加决策:自有公开作品编辑分流由 `resolvePlatformPublicWorkEditIntent(entry, deps)` 收口Module 只返回可编辑草稿目标、需解析宝贝识物本地草稿 intent、旧 RPG gallery 编辑 intent 或原阻断文案。壳层仍执行登录保护、草稿恢复、宝贝识物异步草稿解析、RPG 编辑导航和错误展示;抓大鹅 public detail -> work mapper 仍作为 Adapter 注入,不复制 Match3D 素材归一规则。
- 影响范围:统一作品详情入口、公开详情打开策略、自有公开作品编辑 / 改造动作模式,以及后续新增玩法公开详情接入。
- 验证方式:`npm run test -- src/components/platform-entry/platformPublicWorkDetailFlow.test.ts``npm run test -- src/components/platform-entry/platformMatch3DRuntimeProfile.test.ts`、公开详情壳层交互回归、`npm run typecheck``npm run check:encoding`
- 关联文档:`docs/technical/【前端架构】PlatformPublicWorkDetailFlow收口计划-2026-06-03.md`
## 2026-06-03 平台入口弹窗状态规则收口
- 背景:`PlatformEntryFlowShellImpl.tsx` 曾同时持有平台级错误 / 完成弹窗的文案归一、来源格式、候选择一、dismiss key、后台生成 still-running 识别和任务完成文案,导致壳层 Interface 偏浅,测试面不稳定。
- 决策:新增 `src/components/platform-entry/platformDialogStateModel.ts` 作为 Platform Dialog State Module统一导出 `normalizePlatformDialogMessage``formatPlatformDialogSource``resolvePlatformErrorDialog`、dismiss key builder、`resolveActivePlatformDialog``isBackgroundGenerationStillRunningMessage``PLATFORM_TASK_COMPLETION_MESSAGE`。平台壳只汇总候选、持有 React state并在关闭弹窗时作为 Adapter 清理对应副作用 setter。
- 影响范围:平台入口错误弹窗、任务完成弹窗、后台生成仍在处理识别、草稿生成完成 / 失败通知。
- 验证方式:`npm run test -- src/components/platform-entry/platformDialogStateModel.test.ts``npm run test -- src/components/platform-entry/PlatformErrorDialog.test.tsx`、相关壳层交互测试、`npm run typecheck``npm run check:encoding`
- 关联文档:`docs/technical/【前端架构】PlatformDialogStateModel收口计划-2026-06-03.md`
## 2026-06-03 前端 SSE 客户端传输层统一收口
- 背景:创作 Agent、创意互动 Agent、视觉小说运行态和微信充值订单状态等多个前端 client 曾各自手写 SSE 边界扫描、`TextDecoder` 解码、JSON 解析和流结束 flush导致 CRLF / LF、UTF-8 尾部、多行 `data:` 和提前停止释放 reader 的处理容易漂移。
- 决策:前端 SSE 传输层统一使用 `src/services/sseStream.ts``readSseStream` 负责事件边界、解码 flush、多行 data 和提前停止取消 reader`readSseJsonStream` 负责 JSON object 事件解析与异常 JSON 静默跳过。业务 client 只保留领域事件归一化、结果聚合和中文错误文案OpenAI 兼容文本流通过 `readSseStream` 处理 `[DONE]` 哨兵,后续不得复制 `findSseEventBoundary``parseSseEventBlock` 或手写 reader 循环。
- 影响范围:`src/services/sseStream.ts``src/services/aiService.ts``src/services/llmClient.ts``src/services/creation-agent/creationAgentSse.ts``src/services/creative-agent/creativeAgentSse.ts``src/services/visual-novel-runtime/visualNovelRuntimeSse.ts``src/services/rpg-entry/rpgProfileClient.ts`、前端 SSE 相关测试与架构文档。
- 验证方式:`npm run test -- src/services/sseStream.test.ts src/services/llmClient.test.ts src/services/creation-agent/creationAgentSse.test.ts src/services/creative-agent/creativeAgentSse.test.ts src/services/visual-novel-runtime/visualNovelRuntimeSse.test.ts src/services/rpg-entry/rpgProfileClient.test.ts src/services/ai.test.ts``npm run typecheck``npm run check:encoding`、相关文件 `npx eslint ... --max-warnings 0` 通过。
- 关联文档:`docs/technical/【前端架构】SSE客户端传输层收口约定-2026-06-03.md`
## 2026-06-03 平台入口公开作品流身份规则收口
- 背景:平台入口公开作品推荐流需要同时处理 RPG、拼图、抓大鹅、跳一跳、敲木鱼、视觉小说、Bark Battle、宝贝识物等卡片公开作品身份、跨玩法去重、排序和推荐运行态 kind 判定曾放在 `PlatformEntryFlowShellImpl.tsx` 巨型实现里。
- 决策:公开作品身份、排序规则、公开作品流聚合矩阵、推荐 runtime 启动意图和 ready 判定统一收口到 `src/components/platform-entry/platformPublicGalleryFlow.ts`;入口壳层只调用该 Module 的 `getPlatformPublicGalleryEntryKey``getPlatformRecommendRuntimeKind``buildPlatformPublicGalleryFeeds``resolvePlatformRecommendRuntimeStartIntent``isPlatformRecommendRuntimeReadyForEntry``isSamePlatformPublicGalleryEntry``mergePlatformPublicGalleryEntries``edutainment` key 必须带 `templateId`RPG 卡片回退为 `rpg`。公开作品流聚合负责 featured / latest、玩法可见性 gate、汪汪声浪 works fallback 和首屏 `slice(0, 6)`;推荐 runtime 启动 intent 只返回启动目标、`embedded` / `returnStage` 参数、阻断文案和错误落点ready 判定只接布尔值与拼图 profile id避免把各玩法 run snapshot 类型拖入 Module。壳层仍执行 request key、运行态 API、错误 setter 与 UI 状态。
- 影响范围:平台入口推荐流、最新公开作品流、公开作品详情、推荐 runtime 启动、跨玩法公开作品合并,以及后续新增玩法的入口接入。
- 验证方式:`npm run test -- src/components/platform-entry/platformPublicGalleryFlow.test.ts``npm run typecheck``npm run check:encoding`、相关文件 ESLint 通过。
- 关联文档:`docs/technical/【前端架构】平台入口PublicGalleryFlowModule收口计划-2026-06-03.md`
## 2026-06-03 Work Shelf 打开动作交由 item Adapter
- 背景:`creationWorkShelf.ts` 已经为每个 `CreationWorkShelfItem` 生成 `actions.open`,但 `CustomWorldCreationHub.tsx` 点击卡片后仍按 `item.source.kind` 重复分发 RPG、拼图、抓大鹅、方洞、跳一跳、敲木鱼、视觉小说、Bark Battle 和宝贝识物的打开逻辑。
- 决策:`CreationWorkShelfItem.actions.open` 作为作品架打开动作的正式 InterfaceHub 只保留 `onOpenShelfItem` 通知和 `item.actions.open()` 调用,不再读取玩法 kind 做打开分支。`buildCreationWorkShelfItemsFromSources``CreationWorkShelfSourceAdapter` 作为 source registry Interface统一执行 flatten、运行态覆盖、持久化生成态兜底和更新时间排序`buildCreationWorkShelfItems` 保留兼容,但内部改为组装 source adapters。
- 影响范围:创作中心作品架卡片点击、作品架动作 Adapter、source registry、后续新增玩法作品架接入。
- 验证方式:`npm run test -- src/components/custom-world-home/creationWorkShelf.test.ts src/components/custom-world-home/CustomWorldCreationHub.interaction.test.tsx``npm run typecheck``npm run check:encoding`、相关文件 ESLint 通过。
- 关联文档:`docs/technical/【前端架构】WorkShelfModule收口计划-2026-06-03.md`
## 2026-06-03 Runtime Client Family 请求骨架收口
- 背景Match3D、SquareHole、Puzzle、Jump Hop 等 runtime client 重复手写 path segment 编码、JSON header / body、runtime guest token、auth options 和 retry options新增玩法容易遗漏同一请求骨架。
- 决策:新增 `src/services/runtimeRequest.ts`,以 `buildRuntimeApiPath` 统一 runtime path 编码,以 `requestRuntimeJson` 统一 JSON 请求、runtime guest auth 和 retry 合并。Match3D 与 SquareHole runtime client 已先迁移,保留原导出函数名、错误文案、返回契约和重试常量。
- 追加决策Big Fish 与 Bark Battle runtime client 也迁入 `runtimeRequest.ts`;玩法专属 payload 归一化(如 Bark Battle start / finish 自动补 `workId``runId`)仍留在各玩法 client通用 Module 只承接请求骨架。
- 追加决策Puzzle 的 start / get / swap / drag / next-level / leaderboard 与 Jump Hop 的 start / jump / restart 也迁入 `runtimeRequest.ts`Puzzle `pause``props` 仍保留原账号态 auth options不直接接入 runtime guest auth。
- 追加决策Wooden Fish 的 start / checkpoint / finish 与 Visual Novel 的 gallery / run / history / regenerate JSON 请求也迁入 `runtimeRequest.ts`Wooden Fish 的 `clientEventId` 生成仍留在木鱼 clientVisual Novel start 因 `timeoutMs`、SSE 因流式 `fetchWithApiAuth` 仍暂留原实现。
- 影响范围:`src/services/runtimeRequest.ts`、Match3D / SquareHole / Big Fish / Bark Battle / Puzzle / Jump Hop / Wooden Fish / Visual Novel runtime client。
- 验证方式:`npm run test -- src/services/runtimeRequest.test.ts src/services/recommendedRuntimeGuestLaunch.test.ts src/services/match3d-runtime/match3dRuntimeAdapter.test.ts``npm run typecheck``npm run check:encoding`、相关文件 ESLint 通过。
- 关联文档:`docs/technical/【前端架构】RuntimeClientFamily收口计划-2026-06-03.md`
## 2026-06-03 Public Gallery ViewModel 收口
- 背景:`RpgEntryHomeView.tsx` 巨型页面内混合了公开作品分类、跨来源去重、搜索归一化、作品号匹配、时间戳解析和排序规则,新增玩法时页面与 ViewModel 规则容易纠缠。
- 决策:新增 `src/components/rpg-entry/rpgEntryPublicGalleryViewModel.ts`,把 `buildPublicGalleryCardKey``buildPublicCategoryGroups``getPlatformPublicEntries``getAllPlatformPublicEntries``getPlatformSearchableWorkIds``filterPlatformWorkSearchResults``isExactPublicWorkCodeSearch``filterTodayPublishedEntries`、公开卡片指标 getter、`buildPlatformRankingEntries``getPlatformRankingMetricValue``getPlatformCategoryKindFilter``matchesPlatformCategoryKindFilter``sortPlatformCategoryEntries``getPlatformCategoryPrimaryMetric``parsePlatformEntryTimestamp``getPlatformWorldTimestamp` 收口为公开作品 ViewModel Interface。公开作品 key 复用平台入口身份规则,补齐 jump-hop / wooden-fish 等玩法区分。
- 影响范围RPG 首页公开作品发现、分类、搜索、排行数据准备,以及后续新增玩法公开卡片接入。
- 验证方式:`npm run test -- src/components/rpg-entry/rpgEntryPublicGalleryViewModel.test.ts``npm run typecheck``npm run check:encoding`、相关文件 ESLint 通过。
- 关联文档:`docs/technical/【前端架构】PublicGalleryViewModel收口计划-2026-06-03.md`
## 2026-06-03 Profile Task ViewModel 收口
- 背景:`RpgEntryHomeView.tsx` 同时持有每日任务卡片和任务中心弹窗的任务选择、进度 clamp、奖励兜底、状态标签和按钮文案导致任务展示规则和 JSX 缠在一起。
- 决策:新增 `src/components/rpg-entry/rpgEntryProfileTaskViewModel.ts`,把 `selectProfileTaskCenterTasks``selectProfileTaskCardTask``buildProfileTaskCardSummary``buildProfileTaskProgressLabel``getProfileTaskStatusLabel``getProfileTaskClaimButtonLabel` 收口为每日任务 ViewModel Interface。任务中心仍只展示一条 claimable / incomplete 优先任务任务卡按可操作、claimed、非 disabled 的顺序兜底。
- 影响范围RPG 首页“每日任务”卡片、任务中心弹窗、后续任务状态和任务展示文案调整。
- 验证方式:`npm run test -- src/components/rpg-entry/rpgEntryProfileTaskViewModel.test.ts``npm run typecheck``npm run check:encoding`、相关文件 ESLint 通过。
- 关联文档:`docs/technical/【前端架构】ProfileTaskViewModel收口计划-2026-06-03.md`
## 2026-06-03 最近创作只复用创作模板入口
- 背景:底部加号创作入口的“最近创作”最初由真实作品架摘要驱动,但页面曾按作品标题、摘要和生成状态渲染独立最近创作卡,和其它模板页签的卡片样式及点击语义不一致。
@@ -240,7 +387,7 @@
## 2026-05-26 推荐页拼图下一关 pending 时保留当前运行态
- 背景:推荐页嵌入拼图在点击“下一关”时,`advancePuzzleNextLevel` 的服务端请求会短暂处于 pending。旧逻辑把推荐卡的 `isStartingRecommendEntry` 和拼图局部 busy 混在一起,导致外层直接切回“加载中...”,把当前 `PuzzleRuntimeShell` 一起卸载,视觉上像是切关闪回。
- 决策:推荐页嵌入拼图切关 pending 期间必须保留当前运行态与棋盘,只让拼图壳内部 busy 表现承接同步;`isStartingRecommendEntry` 只表示推荐作品尚未真正启动出来,不再把已有嵌入拼图 run 的局部 busy 一并当成整卡加载态。若下一关落到相似作品,前端还必须把新作品写回推荐缓存并同步 `activeRecommendEntryKey`,避免运行态进入新作品但推荐卡元信息、分享 / 点赞 / 改造和后续“下一个”仍锚定旧作品;但这个同步仍属于同一个 run 内部推进,不得触发推荐 rail 切卡动画、纵向位移或启动封面重置
- 决策:推荐页嵌入拼图切关 pending 期间必须保留当前运行态与棋盘,只让拼图壳内部 busy 表现承接同步;`isStartingRecommendEntry` 只表示推荐作品尚未真正启动出来,不再把已有嵌入拼图 run 的局部 busy 一并当成整卡加载态。若下一关落到相似作品,前端还必须把新作品写回推荐缓存并同步 `activeRecommendEntryKey`,避免运行态进入新作品但推荐卡元信息、分享 / 点赞 / 改造和后续“下一个”仍锚定旧作品。
- 影响范围:`src/components/platform-entry/PlatformEntryFlowShellImpl.tsx``src/components/rpg-entry/RpgEntryHomeView.tsx`、推荐页拼图切关测试与平台链路文档。
- 验证方式:点击推荐页拼图“下一关”后,在 `advancePuzzleNextLevel` 未返回前,页面仍应保留 `puzzle-board`,且不出现 `加载中...` 占位;返回相似作品后,当前推荐卡的 `作品信息` 应显示新作品标题。
- 关联文档:`docs/【玩法创作】平台入口与玩法链路-2026-05-15.md`
@@ -281,7 +428,6 @@
- 背景创作页顶部、banner 奖池和玩法卡消耗口径曾经混在一起,容易把活动奖池误认成账号余额,也让横向空间被外部边框和过大的卡片高度挤占。
- 决策:移动端创作 Tab 顶栏与 `陶泥儿` 品牌同一行只显示真实账户泥点数,数据直接取 `profileDashboard.walletBalance`banner 内只展示赛事奖池,新增拼图主题创作赛和抓大鹅主题创作赛,两个主题奖池各 `1000` 泥点数;玩法卡封面右下角固定展示 `10-20泥点数`,列表外框取消,卡片高度和横向间距一起收紧。
- 追加决策:创作页和草稿页顶栏右上泥点余额胶囊是补足泥点入口;当前环境开启充值入口时直接打开账户充值弹窗,否则打开运营兑换码弹窗,不再跳到账户面板或泥点账单。
- 影响范围:`src/components/custom-world-home/CustomWorldCreationStartCard.tsx``src/components/rpg-entry/RpgEntryHomeView.tsx`、创作页相关测试和玩法链路文档。
- 验证方式:移动端浏览器检查应看到创作顶栏余额、卡内分页点、内嵌横向 banner 和更紧凑的玩法卡;`CustomWorldCreationHub.test.tsx``RpgEntryHomeView.recharge.test.tsx` 的定向断言应保持通过。
- 关联文档:`docs/【玩法创作】平台入口与玩法链路-2026-05-15.md`
@@ -500,7 +646,7 @@
## 2026-05-19 生产 provision 改为 Windows 下载包后由目标机本地安装
- 后续更新:该口径被 2026-06-01 Linux 优先方案取代,又在 2026-06-05 被 Server-Provision 专用口径覆盖;当前 `Genarrative-Server-Provision` 不走 Windows 下载阶段,也不在 Linux build 节点中转工具包,而是在目标 dev / release agent 内准备 `provision-tools/`
- 后续更新:该口径`2026-06-01 生产 Jenkins 流水线统一改为 Linux 优先并先查 localhost` 取代;当前 `Genarrative-Server-Provision`走 Windows 下载阶段,而是在 Linux build 节点直接准备 `provision-tools/`
- 背景:当前 `development` provision 目标实际就是 Linux agent `genarrative-build-01`,之前把 `Prepare Provision Tools` 放在 `linux && genarrative-build` 会让目标机自己连 GitHub 和 `install.spacetimedb.com`违背“Windows 本机先下载再传到目标机”的运维要求。
- 决策:`Genarrative-Server-Provision` 拆成 Windows 下载阶段和 Linux 目标机安装阶段。Windows 节点的 `Download Provision Tool Archives` 只下载 `spacetime-x86_64-unknown-linux-gnu.tar.gz``otelcol-contrib_0.151.0_linux_amd64.tar.gz`,通过 `stash/unstash` 传到目标 Linux 节点;目标机执行 `scripts/prepare-server-provision-tools.sh` 时设置 `PROVISION_REQUIRE_LOCAL_DOWNLOADS=true`,只消费已下载件生成 `provision-tools/`,缺包直接失败,不回退外网下载。
- 追加决策Server-Provision 的 Windows helper 不再对 Jenkins `writeFile` 刚写出的 `.ps1` 做原地 UTF-8 BOM 重写,而是由显式 `powershell.exe` 按 UTF-8 读入脚本文本,并用 `ScriptBlock::Create(...)` 在内存中执行;这样既保留中文脚本内容,又避免同一个 workspace 脚本被立即重写时触发 `拒绝访问`
@@ -1158,7 +1304,6 @@
## 2026-06-01 生产 Jenkins 流水线统一改为 Linux 优先并先查 localhost
- 后续更新:该条仍适用于常规构建 / 发布流水线;`Genarrative-Server-Provision` 已在 2026-06-05 改为目标部署 agent 全程执行,并禁止公网 Git fallback 与 build 节点工具包中转。
- 背景:生产流水线长期混用 Windows、Linux 和公网 Git 入口,导致构建 / 发布 / provision 的 checkout 口径分叉;同时 `Genarrative-Server-Provision` 还残留过 Windows 下载 helper和当前 Linux 构建 / 发布部署路径不一致。
- 决策:生产 Jenkins 流水线统一把执行节点收口到 Linux label`Pipeline script from SCM` 仍保留公网域名,但所有生产流水线首次 `GitSCM checkout` 先尝试 `http://127.0.0.1:3000/GenarrativeAI/Genarrative.git`,失败后再回退到 `https://git.genarrative.world/GenarrativeAI/Genarrative.git``Genarrative-Stdb-Module-Build``Genarrative-Server-Provision``Genarrative-Notify-Email` 也都切到 Linux 节点。`Genarrative-Server-Provision` 的工具准备不再依赖 Windows helper而是在 Linux build 节点直接生成 `provision-tools/` 后交给后续 Linux 发布阶段。
- 影响范围:`jenkins/Jenkinsfile.production-*``scripts/jenkins-checkout-source.sh``scripts/prepare-server-provision-tools.sh`、生产运维文档。
@@ -1274,6 +1419,186 @@
- 验证方式:工作台首屏不再出现标题 / 简介 / 标签输入;结果页修改后点试玩或发布会先写回当前作品信息。
- 关联文档:`docs/prd/【玩法创作】敲木鱼玩法模板PRD-2026-05-20.md``docs/【玩法创作】平台入口与玩法链路-2026-05-15.md`
## 2026-06-03 Profile Dashboard Presentation 收口
- 背景:`RpgEntryHomeView.tsx` 同时承载个人数据卡、钱包 chip 与“玩过”弹窗,计数压缩、累计时长、单作品时长、玩法标签和作品号兜底散在页面 Implementation 内,修改展示口径时缺少稳定测试面。
- 决策:新增 `src/components/rpg-entry/rpgEntryProfileDashboardPresentation.ts` 作为个人数据展示 ModuleInterface 收口为 `buildProfileDashboardPresentation`、计数 / 时长格式化和“玩过”列表标签 / 作品号格式化函数;页面只消费结果并保留 UI 编排与点击处理。
- 影响范围RPG 首页“我的数据”卡片、移动端 / 桌面端钱包 chip、个人数据弹窗与“玩过”列表。
- 验证方式:`npm run test -- src/components/rpg-entry/rpgEntryProfileDashboardPresentation.test.ts`、针对变更文件执行 ESLint、`npm run typecheck``npm run check:encoding`
- 关联文档:`docs/technical/【前端架构】ProfileDashboardPresentation收口计划-2026-06-03.md`
## 2026-06-03 Recommend Feed ViewModel 收口
- 背景:推荐 feed 与正式 runtime 的上一条 / 下一条选择分别在 `RpgEntryHomeView.tsx``PlatformEntryFlowShellImpl.tsx` 手写公开作品去重、隐藏内容过滤、active key 兜底和相邻回环,存在推荐预览与 runtime 口径漂移风险。
- 决策:在 `src/components/rpg-entry/rpgEntryPublicGalleryViewModel.ts` 追加推荐 feed Module Interface`dedupePlatformPublicGalleryEntries``buildPlatformRecommendFeedEntries``selectPlatformRecommendFeedWindow``selectAdjacentPlatformRecommendEntry`;首页与 FlowShell 均消费该 Interface。
- 影响范围:移动端首页推荐 swipe、发现页推荐频道、桌面推荐格、推荐 runtime 队列与上一条 / 下一条跳转。
- 验证方式:`npm run test -- src/components/rpg-entry/rpgEntryPublicGalleryViewModel.test.ts``npm run test -- src/components/rpg-entry/RpgEntryHomeView.recharge.test.tsx -t "recommend|edutainment"``npm run test -- src/components/rpg-entry/RpgEntryFlowShell.agent.interaction.test.tsx -t "logged out home recommendation next starts the next puzzle work"`、针对变更文件执行 ESLint、`npm run typecheck``npm run check:encoding`
- 关联文档:`docs/technical/【前端架构】RecommendFeedViewModel收口计划-2026-06-03.md`
## 2026-06-03 Recommend Swipe Deck Model 收口
- 背景:移动端推荐首页 swipe deck 的拖拽阈值、offset clamp、commit 方向、rail class 和分享文案仍留在 `RpgEntryHomeView.tsx` 页面 Implementation 内,页面同时承载 DOM pointer 副作用和纯规则。
- 决策:新增 `src/components/rpg-entry/rpgEntryRecommendSwipeDeckModel.ts` 作为 Recommend Swipe Deck ModuleInterface 收口 `hasRecommendDragStarted``clampRecommendDragOffset``resolveRecommendDragCommitDirection``resolveRecommendCommitOffset``buildRecommendSwipeRailClassName``shouldAnimateRecommendSwipe``buildRecommendShareText`;页面仅保留 pointer capture、DOM 高度读取、动画 timer、clipboard 与 like/remix/open 副作用 Adapter。
- 影响范围:移动端推荐首页 swipe 手势、上一条 / 下一条动画、推荐分享文案与未登录时的直接切换行为。
- 验证方式:`npm run test -- src/components/rpg-entry/rpgEntryRecommendSwipeDeckModel.test.ts``npm run test -- src/components/rpg-entry/RpgEntryHomeView.recharge.test.tsx -t "recommend|edutainment"``npm run test -- src/components/rpg-entry/rpgEntryPublicGalleryViewModel.test.ts -t "recommend"`、针对新 Module 执行 ESLint、`npm run typecheck``npm run check:encoding`
- 关联文档:`docs/technical/【前端架构】RecommendSwipeDeckModel收口计划-2026-06-03.md`
## 2026-06-03 Ranking ViewModel 收口
- 背景:排行 tab 的文案、metric label 与空态文案在 `RpgEntryHomeView.tsx`,排序和 metric value 在 `rpgEntryPublicGalleryViewModel.ts`,同一 `PlatformRankingTab` 的 Interface 分散且页面需要类型断言取 active config。
- 决策:在 `src/components/rpg-entry/rpgEntryPublicGalleryViewModel.ts` 收口 `DEFAULT_PLATFORM_RANKING_TAB``PLATFORM_RANKING_TABS``getPlatformRankingTabConfig``getPlatformRankingMetric`;页面仅保留 active tab 状态和渲染。
- 影响范围:发现页排行频道 tab 顺序、tab 文案、空态文案、排行项指标 label/value。
- 验证方式:`npm run test -- src/components/rpg-entry/rpgEntryPublicGalleryViewModel.test.ts``npm run test -- src/components/rpg-entry/RpgEntryHomeView.recharge.test.tsx -t "bottom category tab becomes ranking and switches ranking metrics|ranking"`、针对变更文件执行 ESLint、`npm run typecheck``npm run check:encoding`
- 关联文档:`docs/technical/【前端架构】RankingViewModel收口计划-2026-06-03.md`
## 2026-06-03 Category Option ViewModel 收口
- 背景分类频道的筛选选项、排序选项、默认值、active label fallback 和排序循环仍留在 `RpgEntryHomeView.tsx` 页面 Implementation 内,而玩法过滤、排序和主指标已经在 `rpgEntryPublicGalleryViewModel.ts`,同一分类 Interface 被拆成两处。
- 决策:在 `src/components/rpg-entry/rpgEntryPublicGalleryViewModel.ts` 收口 `DEFAULT_PLATFORM_CATEGORY_KIND_FILTER``DEFAULT_PLATFORM_CATEGORY_SORT_MODE``PLATFORM_CATEGORY_KIND_FILTERS``PLATFORM_CATEGORY_SORT_OPTIONS``getPlatformCategoryKindFilterOption``getPlatformCategorySortOption``getNextPlatformCategorySortMode`;页面仅保留当前筛选 / 排序状态和渲染。
- 影响范围:发现页分类频道筛选弹窗、筛选按钮 label、排序按钮 label 与排序循环。
- 验证方式:`npm run test -- src/components/rpg-entry/rpgEntryPublicGalleryViewModel.test.ts``npm run test -- src/components/rpg-entry/RpgEntryHomeView.recharge.test.tsx -t "category"`、针对变更文件执行 ESLint、`npm run typecheck``npm run check:encoding`
- 关联文档:`docs/technical/【前端架构】PublicGalleryViewModel收口计划-2026-06-03.md`
## 2026-06-03 Match3D Runtime Profile 收口
- 背景:`PlatformEntryFlowShellImpl.tsx` 内仍直接承载抓大鹅公开详情转 work、session draft 转 profile、生成背景资产提升、runtime active profile 选择和 run / profile / public detail 素材优先级,平台壳需要理解抓大鹅生成素材内部结构。
- 决策:新增 `src/components/platform-entry/platformMatch3DRuntimeProfile.ts` 作为抓大鹅 runtime profile ModuleInterface 收口 `mapPublicWorkDetailToMatch3DWork``buildMatch3DProfileFromSession``normalizeMatch3DWorkForRuntimeUi``mapMatch3DWorksForRuntimeUi``promoteMatch3DGeneratedBackgroundAsset``hasMatch3DRuntimeAsset``hasMatch3DRuntimeBackgroundAsset``resolveActiveMatch3DRuntimeProfile` 与 runtime item/background/backgroundImage 解析函数;平台壳只保留启动 run、预加载、路由、错误和 state 编排。
- 影响范围:抓大鹅作品架、公开详情试玩、推荐 runtime、正式 runtime 与草稿结果页试玩前素材规范化。
- 验证方式:`npm run test -- src/components/platform-entry/platformMatch3DRuntimeProfile.test.ts``npm run test -- src/components/rpg-entry/RpgEntryFlowShell.agent.interaction.test.tsx -t "match3d|抓大鹅"`、针对新 Module 执行 ESLint、`npm run typecheck``npm run check:encoding`
- 关联文档:`docs/technical/【前端架构】Match3DRuntimeProfile收口计划-2026-06-03.md`
## 2026-06-03 Draft Generation Shelf Model 收口
- 背景:平台壳内散落创作生成 notice key、pending 作品架占位、作品详情更新回填、失败文案覆盖、拼图稳定 ID、持久化 generating/failed 判断与草稿 Tab 未读点,新增或调整玩法时需要在多处理解 `workId` / `profileId` / `sourceSessionId` / `draftId` 形状。
- 决策:新增 `src/components/platform-entry/platformDraftGenerationShelfModel.ts` 作为 Draft Generation Shelf ModuleInterface 收口 `collectDraftNoticeKeys``getGenerationNoticeShelfKeys``createPendingDraftShelfState`、各玩法 `buildPending*Works``buildCreationWorkShelfRuntimeState``collectVisibleDraftNoticeKeys``hasUnreadDraftGenerationUpdates``mergePuzzleWorkSummary``mergeBigFishWorkSummary`、拼图稳定 ID 与持久化状态判断;`PlatformEntryFlowShellImpl.tsx` 仅作为 React state、网络刷新、路由和弹窗副作用 Adapter。
- 影响范围:创作中心草稿 Tab 未读点、作品架生成中遮罩、作品详情更新回填、失败草稿摘要、pending 草稿占位、拼图 / 抓大鹅生成恢复和各玩法生成完成通知。
- 验证方式:`npm run test -- src/components/platform-entry/platformDraftGenerationShelfModel.test.ts``npm run test -- src/components/custom-world-home/creationWorkShelf.test.ts -t "generation state|failure notice|failed puzzle"``npm run test -- src/components/rpg-entry/RpgEntryFlowShell.agent.interaction.test.tsx -t "persisted generating puzzle draft|persisted generating match3d draft|completed baby object match draft"`、针对新 Module 执行 ESLint、`npm run typecheck``npm run check:encoding`
- 关联文档:`docs/technical/【前端架构】DraftGenerationShelfModel收口计划-2026-06-03.md`
## 2026-06-03 Creation Hub Shelf Items Interface 收口
- 背景:`creationWorkShelf.ts` 已把各玩法作品映射为 `CreationWorkShelfItem.actions`,但 `CustomWorldCreationHub.tsx` 的生产 Interface 仍接收 raw items 与 open/delete/claim 回调列阵,新增玩法时 Hub props 继续膨胀。
- 决策:`CustomWorldCreationHub.tsx` 生产 Interface 收敛为 `shelfItems: CreationWorkShelfItem[]` 与少量 UI 状态;`PlatformEntryFlowShellImpl.tsx` 在外层作为 Adapter 调用 `buildCreationWorkShelfItems` 注入完整 actionsHub 测试改经 `CustomWorldCreationHub.testAdapter.tsx` 把旧 fixture 转成 shelf items不让测试继续依赖旧浅 Interface。
- 影响范围:创作 Tab / 草稿 Tab 作品架、RPG / 拼图 / 抓大鹅 / 方洞 / 跳一跳 / 敲木鱼 / 视觉小说 / Bark Battle / 宝贝识物作品打开、删除、生成态与拼图奖励领取。
- 验证方式:`npm run test -- src/components/custom-world-home/creationWorkShelf.test.ts``npm run test -- src/components/custom-world-home/CustomWorldCreationHub.test.tsx``npm run test -- src/components/custom-world-home/CustomWorldCreationHub.interaction.test.tsx`、相关 FlowShell creation hub 交互片段、针对变更文件执行 ESLint、`npm run typecheck``npm run check:encoding`
- 关联文档:`docs/technical/【前端架构】WorkShelfModule收口计划-2026-06-03.md`
## 2026-06-03 Creation URL State Model 收口
- 背景:平台壳内散落各玩法创作恢复 URL 的 `sessionId` / `profileId` / `draftId` / `workId` 组装、空值归一化、拼图 runtime query key 与拼图稳定身份互推,导致刷新恢复规则缺少稳定测试面。
- 决策:新增 `src/components/platform-entry/platformCreationUrlStateModel.ts` 作为 Creation URL State ModuleInterface 收口各玩法 `build*CreationUrlState`、拼图 `buildPuzzle*RuntimeUrlState`、URL state 非空判断和 runtime state key新增 `src/components/platform-entry/platformPuzzleIdentityModel.ts` 作为拼图稳定身份 Module`platformDraftGenerationShelfModel.ts` 仅 re-export 旧入口以保持兼容。`PlatformEntryFlowShellImpl.tsx` 只保留路由、URL 写入和网络副作用 Adapter。
- 追加决策:初始创作 URL 恢复的已处理、非创作路径、无私有 query、平台配置加载中、受保护数据暂不可读与可恢复判定也收口到 `resolveInitialCreationUrlRestoreDecision`;壳层只按 `skip``mark-handled``wait``restore` 执行 ref 标记或进入原恢复副作用。
- 追加决策:创作直达恢复目标解析收口到 `resolveCreationUrlRestoreTarget(pathname, state)`Module 统一识别 big-fish、match3d、square-hole、puzzle、visual-novel、bark-battle、baby-object-match、jump-hop、wooden-fish 的 path、私有 query 归一化、生成路径标记和 big-fish workId 到 sessionId 兜底。壳层仍执行作品列表读取、草稿恢复、错误处理、stage 切换和 URL 写回;`/creation/rpg` 继续保持无具体恢复目标,后续要接入需先补规则与测试。
- 追加决策:创作 URL 恢复的作品 / 草稿身份匹配谓词、以及跳一跳 / 敲木鱼恢复后的阶段落点也归入 `platformCreationUrlStateModel.ts`。身份匹配只允许非空目标值命中,避免 query 缺失时用空值误开草稿壳层只把已读取的列表项、session 或 work 交给 Module 判定,然后执行对应打开 / restore 副作用。
- 影响范围:创作流程刷新恢复、拼图草稿 / 发布 runtime 深链、作品架打开试玩、跳一跳 / 敲木鱼 work-backed 恢复、Bark Battle / 宝贝识物本地草稿恢复。
- 验证方式:`npm run test -- src/components/platform-entry/platformCreationUrlStateModel.test.ts src/components/platform-entry/platformPuzzleIdentityModel.test.ts``npm run test -- src/services/creationUrlState.test.ts``npm run test -- src/components/platform-entry/platformDraftGenerationShelfModel.test.ts`、针对新 Module 执行 ESLint、`npm run typecheck``npm run check:encoding`
- 关联文档:`docs/technical/【前端架构】CreationUrlStateModel收口计划-2026-06-03.md`
## 2026-06-04 Platform Public Code Search Model 收口
- 背景:`PlatformEntryFlowShellImpl.tsx` 的公开搜索回调内联判断内部用户 ID、陶泥号、RPG 作品号、各玩法公开作品号前缀和 fallback 顺序,壳层同时承担纯搜索计划与网络 / 打开副作用。
- 决策:新增 `src/components/platform-entry/platformPublicCodeSearchModel.ts`,以 `resolvePlatformPublicCodeSearchPlan(keyword)` 返回 `normalizedKeyword``steps``user_` / `user-` 只查用户 ID玩法前缀直达对应作品`CW` / 纯数字先查 RPG 作品再查陶泥号;普通关键词和 `SY` 保持既有用户号、RPG 作品、汪汪声浪、用户号兜底顺序。壳层只按 step 执行既有查找、详情打开、Bark Battle runtime 特例和 missing work 归航。
- 影响范围:发现页 / 推荐页公开搜索、作品详情深链初始搜索、陶泥号命中面板、各玩法公开作品号直达。
- 验证方式:`npm run test -- src/components/platform-entry/platformPublicCodeSearchModel.test.ts`、针对新 Module 和 `PlatformEntryFlowShellImpl.tsx` 执行 ESLint、`npm run typecheck``npm run check:encoding`
- 关联文档:`docs/technical/【前端架构】PlatformPublicCodeSearchModel收口计划-2026-06-04.md``docs/【玩法创作】平台入口与玩法链路-2026-05-15.md`
## 2026-06-04 Platform Played Work Open Model 收口
- 背景:`PlatformEntryFlowShellImpl.tsx` 的个人“玩过作品”点击回调内联判断 `worldType``worldKey` 前缀、玩法别名、目标 ID、RPG fallback 详情和大鱼吃小鱼 fallback work壳层同时承担打开意图与异步副作用。
- 决策:新增 `src/components/platform-entry/platformPlayedWorkOpenModel.ts`,以 `resolvePlatformPlayedWorkOpenIntent(work)` 返回 `noop`、各玩法公开详情打开意图、`open-big-fish``open-rpg`。Module 负责玩法别名、`worldKey` 前缀兜底、big-fish gallery miss `fallbackWork` 和 RPG `CustomWorldGalleryCard` payload壳层继续负责关闭面板、刷新 gallery、命中真实作品、打开详情和错误提示。
- 影响范围:个人“玩过作品”面板点击打开、拼图 / 抓大鹅 / 方洞 / 跳一跳 / 敲木鱼 / 大鱼吃小鱼 / RPG 公开详情入口。
- 验证方式:`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-04 Platform Mini Game Session Mapping Model 收口
- 背景:`PlatformEntryFlowShellImpl.tsx` 顶部仍保留拼图 runtime 恢复、方洞 session draft 转 profile、视觉小说 work detail 转 Agent session、跳一跳 pending session、敲木鱼 detail 恢复、敲木鱼生成中作品摘要和敲木鱼 pending session 等纯 DTO 映射,壳层需要理解 sessionId 优先级、拼图稳定 ID、方洞草稿 profile 默认值、视觉小说 work/session fallback、敲木鱼生成中摘要和 pending draft 默认值。
- 决策:新增 `src/components/platform-entry/platformMiniGameSessionMappingModel.ts`,收口 `buildPuzzleRuntimeWorkFromSession``buildSquareHoleProfileFromSession``buildVisualNovelSessionFromWorkDetail``buildJumpHopPendingSession``buildWoodenFishSessionFromWorkDetail``buildWoodenFishGeneratingWorkSummary``buildWoodenFishPendingSession`。Module 复用 `normalizeCreationUrlValue``platformPuzzleIdentityModel`壳层只保留网络读取、React state、URL 写入和 stage 切换副作用。
- 影响范围:拼图 runtime URL 恢复、方洞挑战草稿 profile 构造、视觉小说草稿作品架恢复、跳一跳生成中作品架打开、敲木鱼生成中作品架摘要 / 作品架打开和敲木鱼草稿 detail 恢复。
- 验证方式:`npm run test -- src/components/platform-entry/platformMiniGameSessionMappingModel.test.ts`、针对新 Module 和 `PlatformEntryFlowShellImpl.tsx` 执行 ESLint、`npm run typecheck``npm run check:encoding`
- 关联文档:`docs/technical/【前端架构】PlatformMiniGameSessionMappingModel收口计划-2026-06-04.md``docs/【玩法创作】平台入口与玩法链路-2026-05-15.md`
## 2026-06-04 Platform RPG Agent Result Preview Model 收口
- 背景:`PlatformEntryFlowShellImpl.tsx` 内联维护 RPG Agent 结果页发布门禁展示修正和 result preview source label 映射,壳层需要理解 `CustomWorldProfile` 顶层字段、`creatorIntent``anchorContent`、章节蓝图和首幕 acts。
- 决策:新增 `src/components/platform-entry/platformRpgAgentResultPreviewModel.ts`,收口 `buildPlatformRpgAgentResultPublishGateView``resolvePlatformRpgAgentResultPreviewSourceLabel`。Module 只做展示层纯判定;壳层继续负责 session/profile 编排、发布副作用和结果页 props 传递。
- 影响范围RPG Agent 结果页发布按钮门禁 blockers、publishReady 展示修正和预览来源 label。
- 验证方式:`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 切换。
- 追加决策:抓大鹅轮询作品素材时的旁路进度合并也归入该 Module`mergeMatch3DGeneratedAssetsIntoGenerationState(state, assets)` 统一统计可用图片素材、至少 5 个总素材计数、`match3d-generate-views` phase 推进和首个素材错误传播;壳层只负责轮询 session / work detail 与写入 state。
- 影响范围:拼图 / 抓大鹅 / 大鱼吃小鱼 / 方洞 / 跳一跳 / 敲木鱼 / 宝贝识物生成状态恢复、完成失败收尾、生成页返回展示和拼图轮询进度合并。
- 验证方式:`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-04 Platform Mini Game Draft Payload Model 收口
- 背景:`PlatformEntryFlowShellImpl.tsx` 内联维护拼图 / 抓大鹅表单 payload、拼图作品更新 payload、拼图编译 action、跳一跳 / 敲木鱼生成 action、作品摘要回填 payload 和 pending 草稿 metadata壳层需要理解描述字段优先级、formDraft 回退、结果页 draft 到作品更新字段的映射、跳一跳 / 敲木鱼 payload 与 draft 优先级、Match3D config / draft / anchorPack 优先级和数字解析。
- 决策:新增 `src/components/platform-entry/platformMiniGameDraftPayloadModel.ts`,收口 `buildPuzzleFormPayloadFromWork``buildPuzzleFormPayloadFromSession``buildPuzzleFormPayloadFromAction``buildPuzzleCompileActionFromFormPayload``buildPuzzleWorkUpdatePayloadFromDraft``buildJumpHopDraftActionPayload``buildWoodenFishDraftActionPayload``buildPendingPuzzleDraftMetadata``isPuzzleFormOnlyDraft``isEmptyPuzzleFormOnlyDraft``buildMatch3DFormPayloadFromSession``buildMatch3DFormPayloadFromWork``buildPendingMatch3DDraftMetadata``parseOptionalFiniteNumber` 留在 Module 内部。
- 影响范围:拼图 action 完成 / 执行前 / 失败恢复、拼图结果页试玩前作品更新、跳一跳 / 敲木鱼生成与重生成 action、拼图表单直生草稿、拼图 form-only 草稿恢复 / 分流 / 结果页渲染、拼图草稿架恢复、抓大鹅表单直生草稿与失败恢复。
- 验证方式:`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-04 Platform Puzzle Runtime State Model 收口
- 背景:`PlatformEntryFlowShellImpl.tsx` 在拼图排行榜提交回包后内联合并服务端 run 快照,壳层需要理解 `PuzzleRunSnapshot` 中哪些字段由前端即时裁决、哪些字段只由服务端补齐。
- 决策:新增 `src/components/platform-entry/platformPuzzleRuntimeStateModel.ts`,以 `mergePuzzleServiceRuntimeState(currentRun, serviceRun)` 收口服务端 run 合并规则。Module 保留当前前端关卡状态、棋盘和计时,只合并服务端 run 身份、`clearedLevelCount` 上限、排行榜与下一关 handoff任一 run 缺 `currentLevel` 时直接返回当前 run。
- 影响范围:拼图排行榜提交、推荐 runtime isolated / default 运行态回包合并、下一关同作品 / 相似作品 handoff以及后续 Puzzle runtime 快照字段调整。
- 验证方式:`npm run test -- src/components/platform-entry/platformPuzzleRuntimeStateModel.test.ts`、针对新 Module 和 `PlatformEntryFlowShellImpl.tsx` 执行 ESLint、`npm run typecheck``npm run check:encoding`
- 关联文档:`docs/technical/【前端架构】PlatformPuzzleRuntimeStateModel收口计划-2026-06-04.md``docs/【玩法创作】平台入口与玩法链路-2026-05-15.md`
## 2026-06-04 Puzzle Publish Asset Gate 收紧
- 背景:后端拼图待发布门槛与前端历史恢复逻辑一样偏弱,只要求标题、描述、标签、关卡名和 cover导致缺关卡画面、UI spritesheet 或关卡背景的半成品可能被标为 `publishReady` / `ready_to_publish`
- 决策:`module-puzzle::validate_publish_requirements` 新增三类资产 blocker要求每关具备 `level_scene_image_*``ui_spritesheet_image_*``level_background_image_*``api-server::puzzle::tags::is_puzzle_session_snapshot_publish_ready` 同步使用完整资产包判定。
- 影响范围:拼图 result preview blockers、publishReady、标签生成后 session stage、从 action payload 构造 fallback session 的 ready 判定。
- 验证方式:`cargo test -p module-puzzle --manifest-path server-rs/Cargo.toml validate_publish_requirements``cargo test -p api-server --manifest-path server-rs/Cargo.toml puzzle_image_generation_builds_fallback_session_from_levels_snapshot``cargo test -p api-server --manifest-path server-rs/Cargo.toml puzzle_image_generation_fallback_session_ready_when_asset_pack_complete``npm run check:encoding`
- 关联文档:`docs/technical/【后端架构】PuzzlePublishAssetGate收紧计划-2026-06-04.md``docs/【后端架构】server-rs与SpacetimeDB数据契约-2026-05-15.md``docs/【玩法创作】平台入口与玩法链路-2026-05-15.md`
## 2026-06-04 Platform Profile Wallet Delta Model 收口
- 背景:`PlatformEntryFlowShellImpl.tsx` 内联维护钱包余额归一、本地 delta 乐观更新和服务端 dashboard 刷新后的 delta 抵消,壳层需要理解余额非负、整数截断、借贷方向和服务端快照对账。
- 决策:新增 `src/components/platform-entry/platformProfileWalletDeltaModel.ts`,收口 `resolveProfileWalletBalance``adjustProfileDashboardWalletBalance``reconcileProfileWalletLocalDeltaWithServerDashboard`。壳层只保留 API 请求、React ref、state 写入和刷新触发副作用。
- 影响范围:创作入口泥点展示、生成前泥点校验、扣点 / 返还后的个人 dashboard 乐观更新、后台刷新 dashboard 时的本地 delta 对账。
- 验证方式:`npm run test -- src/components/platform-entry/platformProfileWalletDeltaModel.test.ts`、针对新 Module 和 `PlatformEntryFlowShellImpl.tsx` 执行 ESLint、`npm run typecheck``npm run check:encoding`
- 关联文档:`docs/technical/【前端架构】PlatformProfileWalletDeltaModel收口计划-2026-06-04.md``docs/【玩法创作】平台入口与玩法链路-2026-05-15.md`
## 2026-06-03 Public Work Presentation 收口
- 背景:作品卡、推荐 runtime meta、排行项、分类项、搜索结果和桌面 hero 共用玩法类型 label 与紧凑计数格式,但规则仍在 `RpgEntryHomeView.tsx` 页面 Implementation 内。
- 决策:在 `src/components/rpg-entry/rpgEntryWorldPresentation.ts` 追加单作品展示 Interface`describePlatformPublicWorkKind``formatPlatformCompactCount``resolvePlatformPublicWorkAuthorLookup``formatPlatformPublicAuthorAvatarLabel`;页面删除本地玩法类型、紧凑计数、公开作者 lookup 和头像首字实现。集合筛选、排序和指标选择继续留在 `rpgEntryPublicGalleryViewModel.ts`
- 影响范围:公开作品卡片 aria label、推荐点赞 / 改造文案、排行数值、分类主指标、搜索结果、桌面 hero 玩法 label、公开作者摘要缓存 key 与无头像首字兜底。
- 验证方式:`npm run test -- src/components/rpg-entry/rpgEntryWorldPresentation.test.ts``npm run test -- src/components/rpg-entry/RpgEntryHomeView.recharge.test.tsx -t "recommend|ranking|category"`、针对变更文件执行 ESLint、`npm run typecheck``npm run check:encoding`
- 关联文档:`docs/technical/【前端架构】PublicWorkPresentation收口计划-2026-06-03.md`
## 2026-06-03 Profile Funds ViewModel 收口
- 背景:个人资金展示规则散在 `RpgEntryHomeView.tsx`,且账单来源 label 表漏掉后端契约已有的 `puzzle_author_incentive_claim`,会把原始枚举值直接外显。
- 决策:新增 `src/components/rpg-entry/rpgEntryProfileFundsViewModel.ts` 作为个人资金展示 ModuleInterface 收口账单来源文案、金额正负号、余额兜底、充值价格、商品主值与会员摘要;页面保留弹窗布局、支付流程、微信渠道和订单轮询副作用。
- 影响范围:泥点账单弹窗、充值商品卡片、账户充值弹窗会员摘要。
- 验证方式:`npm run test -- src/components/rpg-entry/rpgEntryProfileFundsViewModel.test.ts``npm run test -- src/components/rpg-entry/RpgEntryHomeView.recharge.test.tsx -t "wallet ledger|profile recharge modal shows native qr code"`、针对变更文件执行 ESLint、`npm run typecheck``npm run check:encoding`
- 关联文档:`docs/technical/【前端架构】ProfileFundsViewModel收口计划-2026-06-03.md`
## 2026-05-26 前端不外露图片模型名
- 背景:拼图与相关结果页、生成进度和错误提示里直接显示 `gpt-image-2``gemini-3.1-flash-image-preview``image-2` 等名称,会把内部模型路由暴露给普通用户。

View File

@@ -1293,10 +1293,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` 或返回 `publishReady=true`
- 关联:`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 移动端放大溢出

View File

@@ -37,6 +37,74 @@ SpacetimeDB 表结构变更、自动迁移边界和保留旧数据的分阶段
AI 文字游戏模板接入以 [AI_NATIVE_TEXT_GAME_TEMPLATE_MOKU_REFERENCE_PRD_2026-05-05.md](./prd/AI_NATIVE_TEXT_GAME_TEMPLATE_MOKU_REFERENCE_PRD_2026-05-05.md) 为最新口径:只吸收 MOKU / 幕间类 AI 文游的剧本游乐场、自由行动、AI GM、记忆和模拟器强反馈经验禁止迁入外部社区、支付、榜单、私有存档或回放。
前端 Server-Sent Events 客户端传输层收口到 `src/services/sseStream.ts`事件边界、UTF-8 flush、JSON 解析跳过和提前取消约定见 [【前端架构】SSE客户端传输层收口约定-2026-06-03.md](./technical/%E3%80%90%E5%89%8D%E7%AB%AF%E6%9E%B6%E6%9E%84%E3%80%91SSE%E5%AE%A2%E6%88%B7%E7%AB%AF%E4%BC%A0%E8%BE%93%E5%B1%82%E6%94%B6%E5%8F%A3%E7%BA%A6%E5%AE%9A-2026-06-03.md)。
平台入口公开作品身份、跨玩法去重、公开作品流聚合、推荐运行态 kind 判定、推荐 runtime 启动意图、ready 判定和最新排序收口到 `src/components/platform-entry/platformPublicGalleryFlow.ts`,规则见 [【前端架构】平台入口PublicGalleryFlowModule收口计划-2026-06-03.md](./technical/%E3%80%90%E5%89%8D%E7%AB%AF%E6%9E%B6%E6%9E%84%E3%80%91%E5%B9%B3%E5%8F%B0%E5%85%A5%E5%8F%A3PublicGalleryFlowModule%E6%94%B6%E5%8F%A3%E8%AE%A1%E5%88%92-2026-06-03.md)。
统一作品详情页的玩法 kind、详情打开策略、自有作品动作模式、编辑 / 点赞 / 改造 / 启动意图和公开详情映射收口到 `src/components/platform-entry/platformPublicWorkDetailFlow.ts`;抓大鹅公开详情映射与启动 / 编辑 Adapter 的素材归一仍归 `platformMatch3DRuntimeProfile.ts`,规则见 [【前端架构】PlatformPublicWorkDetailFlow收口计划-2026-06-03.md](./technical/【前端架构】PlatformPublicWorkDetailFlow收口计划-2026-06-03.md)。
创作中心作品架打开动作由 `CreationWorkShelfItem.actions.open` 统一承载,生产 Hub 只接收 `CreationWorkShelfItem[]` 与 UI 状态,不再接收各玩法 raw items 和回调列阵,规则见 [【前端架构】WorkShelfModule收口计划-2026-06-03.md](./technical/%E3%80%90%E5%89%8D%E7%AB%AF%E6%9E%B6%E6%9E%84%E3%80%91WorkShelfModule%E6%94%B6%E5%8F%A3%E8%AE%A1%E5%88%92-2026-06-03.md)。
作品架删除确认的标题、删除说明、草稿 notice key 和拼图派生稳定 ID 收口到 `src/components/platform-entry/platformCreationWorkDeleteFlow.ts`,平台壳只保留删除 API、刷新、错误和页面跳转副作用规则见 [【前端架构】CreationWorkDeleteFlow收口计划-2026-06-04.md](./technical/【前端架构】CreationWorkDeleteFlow收口计划-2026-06-04.md)。
创作入口点击的占位、隐藏模板拦截、未知入口 no-op 与工作台启动目标收口到 `src/components/platform-entry/platformCreationLaunchModel.ts`,壳层只执行启动前准备、错误提示和受保护动作,规则见 [【前端架构】PlatformCreationLaunchModel收口计划-2026-06-04.md](./technical/【前端架构】PlatformCreationLaunchModel收口计划-2026-06-04.md)。
平台入口公开码搜索的用户 ID、陶泥号、RPG 作品号、各玩法作品号前缀、per-play 公开码匹配、详情卡 DTO 映射和失败回退顺序收口到 `src/components/platform-entry/platformPublicCodeSearchModel.ts`,壳层只按计划执行网络读取、详情打开和错误归航副作用,规则见 [【前端架构】PlatformPublicCodeSearchModel收口计划-2026-06-04.md](./technical/【前端架构】PlatformPublicCodeSearchModel收口计划-2026-06-04.md)。
个人“玩过作品”面板的玩法别名、`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)。
平台壳的拼图 runtime 恢复 work、方洞 session draft 转 profile、视觉小说 work detail 转 Agent session、跳一跳 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)。
平台小游戏草稿恢复和提交所需的拼图 / 抓大鹅表单 payload、拼图作品更新 payload、拼图编译 action、跳一跳 / 敲木鱼生成 action、pending metadata 与拼图 form-only 草稿判定收口到 `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)。
拼图排行榜提交回包后的服务端 run 快照合并收口到 `src/components/platform-entry/platformPuzzleRuntimeStateModel.ts`只合并排行榜、run 身份、通关数上限和下一关 handoff保留前端即时裁决的关卡状态与棋盘规则见 [【前端架构】PlatformPuzzleRuntimeStateModel收口计划-2026-06-04.md](./technical/【前端架构】PlatformPuzzleRuntimeStateModel收口计划-2026-06-04.md)。
后端拼图发布 / 待发布门槛收紧到首图、关卡画面、UI spritesheet 与关卡背景资产包完整,`module-puzzle` 的 preview blockers 与 `api-server` 的 session stage 判定保持同一规则,方案见 [【后端架构】PuzzlePublishAssetGate收紧计划-2026-06-04.md](./technical/【后端架构】PuzzlePublishAssetGate收紧计划-2026-06-04.md)。
平台入口个人钱包本地 delta、dashboard 乐观更新与服务端快照对账规则收口到 `src/components/platform-entry/platformProfileWalletDeltaModel.ts`,平台壳只保留 API、ref 与 state 副作用,规则见 [【前端架构】PlatformProfileWalletDeltaModel收口计划-2026-06-04.md](./technical/【前端架构】PlatformProfileWalletDeltaModel收口计划-2026-06-04.md)。
Bark Battle 草稿三图完整性、生成状态归一、作品架摘要恢复草稿配置、发布快照 / 发布回包资产兜底和草稿 / 已发布作品进入 runtime 前的 `BarkBattlePublishedConfig` 映射收口到 `src/components/platform-entry/barkBattleWorkCache.ts`,规则见 [【前端架构】BarkBattleWorkCache草稿状态收口计划-2026-06-04.md](./technical/【前端架构】BarkBattleWorkCache草稿状态收口计划-2026-06-04.md)。
平台首页推荐 runtime 的匿名 Runtime Guest Token、已登录 background auth、非 embedded no-op 和拼图 isolated/default auth mode 计划收口到 `src/components/platform-entry/platformRecommendRuntimeAuthModel.ts`,规则见 [【前端架构】PlatformRecommendRuntimeAuthModel收口计划-2026-06-04.md](./technical/【前端架构】PlatformRecommendRuntimeAuthModel收口计划-2026-06-04.md)。
平台首页推荐 runtime 自动启动的桌面 / Tab / stage / loading gate、active entry 查找、ready 判定和 clear/start/noop 决策收口到 `src/components/platform-entry/platformPublicGalleryFlow.ts`,规则见 [【前端架构】PlatformRecommendRuntimeAutoStart收口计划-2026-06-04.md](./technical/【前端架构】PlatformRecommendRuntimeAutoStart收口计划-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)。
平台入口创作恢复 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)。
平台入口错误 / 完成弹窗的文案归一、来源格式、候选择一、dismiss key 与任务完成文案收口到 `src/components/platform-entry/platformDialogStateModel.ts`,规则见 [【前端架构】PlatformDialogStateModel收口计划-2026-06-03.md](./technical/【前端架构】PlatformDialogStateModel收口计划-2026-06-03.md)。
平台入口受保护数据失效后的 stage 去留判定,以及缺失草稿 / 作品 / run 时的阶段回退,收口到 `src/components/platform-entry/platformSelectionStageModel.ts`,壳层只执行缓存清空、布尔事实汇总和必要跳转,规则见 [【前端架构】PlatformSelectionStageModel收口计划-2026-06-04.md](./technical/【前端架构】PlatformSelectionStageModel收口计划-2026-06-04.md)。
小游戏 runtime client 的路径编码、JSON 请求、runtime guest auth 与 retry 选项收口到 `src/services/runtimeRequest.ts`Match3D、SquareHole、Big Fish、Bark Battle、Puzzle 公开 / 推荐运行态请求、Jump Hop / Wooden Fish 正式 run 请求和 Visual Novel 局部 JSON runtime 请求已先迁移,规则见 [【前端架构】RuntimeClientFamily收口计划-2026-06-03.md](./technical/%E3%80%90%E5%89%8D%E7%AB%AF%E6%9E%B6%E6%9E%84%E3%80%91RuntimeClientFamily%E6%94%B6%E5%8F%A3%E8%AE%A1%E5%88%92-2026-06-03.md)。
抓大鹅 runtime profile 的公开详情转 work、session draft 转 profile、生成背景资产提升和 run/profile/public detail 素材优先级收口到 `src/components/platform-entry/platformMatch3DRuntimeProfile.ts`,规则见 [【前端架构】Match3DRuntimeProfile收口计划-2026-06-03.md](./technical/%E3%80%90%E5%89%8D%E7%AB%AF%E6%9E%B6%E6%9E%84%E3%80%91Match3DRuntimeProfile%E6%94%B6%E5%8F%A3%E8%AE%A1%E5%88%92-2026-06-03.md)。
公开作品分类选项、搜索、跨来源去重、今日筛选、排行排序和时间戳解析收口到 `src/components/rpg-entry/rpgEntryPublicGalleryViewModel.ts`,规则见 [【前端架构】PublicGalleryViewModel收口计划-2026-06-03.md](./technical/%E3%80%90%E5%89%8D%E7%AB%AF%E6%9E%B6%E6%9E%84%E3%80%91PublicGalleryViewModel%E6%94%B6%E5%8F%A3%E8%AE%A1%E5%88%92-2026-06-03.md)。
公开作品的玩法类型 label、公开作者 lookup 与游玩 / 改造 / 点赞等紧凑计数格式收口到 `src/components/rpg-entry/rpgEntryWorldPresentation.ts`,规则见 [【前端架构】PublicWorkPresentation收口计划-2026-06-03.md](./technical/%E3%80%90%E5%89%8D%E7%AB%AF%E6%9E%B6%E6%9E%84%E3%80%91PublicWorkPresentation%E6%94%B6%E5%8F%A3%E8%AE%A1%E5%88%92-2026-06-03.md)。
推荐 feed 的公开作品去重、普通内容过滤、active 窗口与上一条 / 下一条回环选择也收口到 `src/components/rpg-entry/rpgEntryPublicGalleryViewModel.ts`,规则见 [【前端架构】RecommendFeedViewModel收口计划-2026-06-03.md](./technical/%E3%80%90%E5%89%8D%E7%AB%AF%E6%9E%B6%E6%9E%84%E3%80%91RecommendFeedViewModel%E6%94%B6%E5%8F%A3%E8%AE%A1%E5%88%92-2026-06-03.md)。
移动端推荐首页 swipe deck 的拖拽阈值、offset clamp、commit 方向、rail class 和分享文案收口到 `src/components/rpg-entry/rpgEntryRecommendSwipeDeckModel.ts`,规则见 [【前端架构】RecommendSwipeDeckModel收口计划-2026-06-03.md](./technical/【前端架构】RecommendSwipeDeckModel收口计划-2026-06-03.md)。
排行频道的默认 tab、tab 文案、空态文案、排序字段与指标 label/value 收口到 `src/components/rpg-entry/rpgEntryPublicGalleryViewModel.ts`,规则见 [【前端架构】RankingViewModel收口计划-2026-06-03.md](./technical/%E3%80%90%E5%89%8D%E7%AB%AF%E6%9E%B6%E6%9E%84%E3%80%91RankingViewModel%E6%94%B6%E5%8F%A3%E8%AE%A1%E5%88%92-2026-06-03.md)。
每日任务卡片与任务中心弹窗的任务选择、进度、状态标签和按钮文案收口到 `src/components/rpg-entry/rpgEntryProfileTaskViewModel.ts`,规则见 [【前端架构】ProfileTaskViewModel收口计划-2026-06-03.md](./technical/%E3%80%90%E5%89%8D%E7%AB%AF%E6%9E%B6%E6%9E%84%E3%80%91ProfileTaskViewModel%E6%94%B6%E5%8F%A3%E8%AE%A1%E5%88%92-2026-06-03.md)。
个人数据卡、钱包 chip 与“玩过”弹窗的计数、时长、作品类型和作品号展示收口到 `src/components/rpg-entry/rpgEntryProfileDashboardPresentation.ts`,规则见 [【前端架构】ProfileDashboardPresentation收口计划-2026-06-03.md](./technical/%E3%80%90%E5%89%8D%E7%AB%AF%E6%9E%B6%E6%9E%84%E3%80%91ProfileDashboardPresentation%E6%94%B6%E5%8F%A3%E8%AE%A1%E5%88%92-2026-06-03.md)。
个人资金展示的账单来源、金额正负号、余额兜底、充值价格、商品主值和会员摘要收口到 `src/components/rpg-entry/rpgEntryProfileFundsViewModel.ts`,规则见 [【前端架构】ProfileFundsViewModel收口计划-2026-06-03.md](./technical/%E3%80%90%E5%89%8D%E7%AB%AF%E6%9E%B6%E6%9E%84%E3%80%91ProfileFundsViewModel%E6%94%B6%E5%8F%A3%E8%AE%A1%E5%88%92-2026-06-03.md)。
## 推荐阅读顺序
1. 先看 [经验沉淀](./experience/README.md),快速建立这个项目的开发共识。

View File

@@ -0,0 +1,46 @@
# 【前端架构】Bark Battle Work Cache 草稿状态收口计划
## 背景
`PlatformEntryFlowShellImpl.tsx` 仍内联维护 Bark Battle 草稿三图完整性、生成状态归一、作品架摘要恢复草稿配置,以及草稿 / 已发布作品进入 runtime 前的 `BarkBattlePublishedConfig` 映射。壳层因此需要同时理解三图资产字段、`partial_failed``pending_assets` 的差异、`publishedAt` 兜底、作品摘要字段和草稿试玩配置默认值。
这些规则属于 Bark Battle 作品摘要与草稿缓存的纯模型。若留在平台壳层,后续发布、作品架刷新、公开详情启动或草稿试玩都容易重复一份字段清单。
## 决策
扩展 `src/components/platform-entry/barkBattleWorkCache.ts`,作为 Bark Battle Work Cache **Module** 继续承接作品摘要缓存和草稿 runtime 配置规则。新增公开 **Interface**
- `hasBarkBattleDraftRequiredImages(draft)`:判断草稿是否已具备玩家形象、对手形象和竞技背景三图。
- `resolveBarkBattleDraftGenerationStatus(draft, partialFailed)`:三图齐备返回 `ready`,否则按是否部分失败返回 `partial_failed``pending_assets`
- `buildBarkBattleDraftConfigFromWorkSummary(work)`:把作品架摘要恢复成可编辑 / 可试玩的 `BarkBattleDraftConfig`
- `buildBarkBattlePublishedConfigFromDraft(draft)`:把草稿结果页试玩所需配置映射为 `BarkBattlePublishedConfig`
- `buildBarkBattlePublishedConfigFromWork(work)`:把作品架 / 公开详情启动正式 runtime 所需配置映射为 `BarkBattlePublishedConfig`
- `buildBarkBattlePublishSnapshot(draft)`:拼装发布接口所需的最终草稿快照。
- `mergeBarkBattlePublishedConfigAssets(published, draft)`:发布回包缺少三图字段时沿用结果页草稿图。
`PlatformEntryFlowShellImpl.tsx` 继续作为 **Adapter**:它只负责 API 请求、React state、URL、运行态 stage 切换和错误提示,不再持有 Bark Battle 三图完整性与 runtime config 字段清单。
## Interface 约束
- 草稿三图必须同时具备 `playerCharacterImageSrc``opponentCharacterImageSrc``uiBackgroundImageSrc` 的非空值,才视为 `ready`
- 未齐三图且 `partialFailed=true` 时返回 `partial_failed`,否则返回 `pending_assets`
- 作品摘要恢复草稿时,`draftId` 缺失回退 `workId``description` 来自 summary三图 null 归一为 `undefined``configVersion=1``rulesetVersion='bark-battle-ruleset-v1'`
- 草稿试玩配置的 `workId` 优先使用草稿稳定 `workId`,缺失时回退 `draftId`
- 草稿试玩配置的 `configVersion``rulesetVersion` 使用草稿值,缺失时回退 `1``bark-battle-ruleset-v1`
- 已发布作品配置的 `publishedAt` 缺失时回退 `updatedAt`,保持旧 runtime 启动语义。
- 发布快照只携带草稿已有的三图字段,不凭空补空字符串。
- 发布接口回包缺少三图字段时,结果页草稿图继续作为 runtime 和作品摘要的兜底。
## Depth / Leverage / Locality
- **Depth**:壳层传入草稿或作品摘要,即可得到生成状态、草稿配置或 runtime 配置;字段归一、默认值和三图完整性藏入 Module Implementation。
- **Leverage**:作品架草稿恢复、结果页试玩、作品架启动、公开详情启动和缓存刷新可复用同一组 Bark Battle 规则。
- **Locality**Bark Battle 资产完整性与配置映射集中到纯测试面,后续变更三图字段或规则集默认值时无需搜索巨型平台壳。
## 验收
- `npm run test -- src/components/platform-entry/barkBattleWorkCache.test.ts`
- `npx eslint --max-warnings 0 src/components/platform-entry/barkBattleWorkCache.ts src/components/platform-entry/barkBattleWorkCache.test.ts`
- `npx eslint src/components/platform-entry/PlatformEntryFlowShellImpl.tsx --quiet`
- `npm run typecheck`
- `npm run check:encoding`

View File

@@ -0,0 +1,41 @@
# CreationUrlStateModel 收口计划
## 背景
`PlatformEntryFlowShellImpl.tsx` 曾直接承载多玩法创作恢复 URL 的拼装规则:`sessionId``profileId``draftId``workId` 的优先级、拼图草稿 runtime query、以及空值归一化散在壳层 Implementation 内。平台壳因此需要理解各玩法快照结构,新增玩法或修复刷新恢复时缺少稳定测试面。
## 决策
- 新增 `src/components/platform-entry/platformCreationUrlStateModel.ts` 作为 Creation URL State Module。
- 该 Module 的 Interface 收口为各玩法 `build*CreationUrlState`、拼图 `buildPuzzle*RuntimeUrlState``normalizeCreationUrlValue``hasCreationUrlStateValue``hasPuzzleRuntimeUrlStateValue``buildPuzzleRuntimeUrlStateKey`、初始创作 URL 恢复判定 `resolveInitialCreationUrlRestoreDecision`、创作直达恢复目标解析 `resolveCreationUrlRestoreTarget`、恢复目标身份匹配谓词,以及跳一跳 / 敲木鱼恢复后的阶段落点判定。
- 新增 `src/components/platform-entry/platformPuzzleIdentityModel.ts` 作为拼图稳定身份 Module统一 `puzzle-session-*``puzzle-profile-*``puzzle-work-*` 的互推规则。
- `PlatformEntryFlowShellImpl.tsx` 保留 React state、路由、登录门禁、网络请求和 URL 写入副作用 Adapter不再在壳层内定义各玩法 URL 状态构造函数,也不直接内联初始恢复的已处理 / 等待 / 可恢复判定。
## Interface 约束
- 创作恢复私有 query 只使用 `sessionId``profileId``draftId``workId`;不得新增说明性 query 字段。
- 空字符串、全空白字符串统一视为 `null`,避免刷新恢复时写入无效私有参数。
- work-backed 玩法优先使用后端 work summary 的公开 `workId` / `profileId`;仅缺失时才回退 session draft。
- 拼图 runtime query 独立使用 `mode``runtimeSessionId``runtimeProfileId``runtimeLevelId``publicWorkCode`,不与创作恢复 query 混写。
- 拼图 draft runtime 若没有 `sourceSessionId`,只允许从 `puzzle-profile-*` 反推出 `puzzle-session-*`
- 初始创作 URL 恢复只在未处理、当前路径属于创作恢复路径、私有 query 有值、平台配置加载完成且受保护数据可读时执行;非创作路径或无私有 query 时标记已处理,加载中或暂不可读时等待。
- 创作直达恢复目标由 `resolveCreationUrlRestoreTarget(pathname, state)` 统一识别;它只返回玩法 kind、归一化后的四个私有 query、生成路径标记和大鱼吃小鱼 session 兜底不执行网络请求、草稿打开、stage 切换或 URL 写回。
- 作品 / 草稿身份匹配只允许非空目标值命中,避免 query 缺失时用 `null` / 空值误匹配到无效草稿。匹配谓词仍只判断身份,不触发列表读取或打开动作。
- 跳一跳和敲木鱼的恢复阶段落点由 `resolveJumpHopCreationUrlRestoreStage``resolveWoodenFishCreationUrlRestoreStage` 决定;生成路径优先进入生成页,否则按是否恢复到 draft / work 落到结果页或工作台。
- `/creation/rpg` 当前仍不归入具体恢复目标;若后续要恢复 RPG 直达,需要先补明确恢复规则和测试,不得让壳层重新内联路径判定。
## Depth / Leverage / Locality
- **Depth**:调用方只传玩法快照或作品摘要,即可得到规范化 URL state各玩法字段优先级藏在 Module Implementation 内。
- **Leverage**:新增或调整玩法恢复规则、恢复目标或恢复等待条件时,优先补 Module Interface 测试,再接壳层 Adapter。
- **Locality**:恢复 query、拼图 runtime query 和拼图稳定身份规则集中在两个小 Module避免散落在页面壳、作品架和 runtime 打开逻辑中。
## 验收
- `npm run test -- src/components/platform-entry/platformCreationUrlStateModel.test.ts src/components/platform-entry/platformPuzzleIdentityModel.test.ts`
- `npm run test -- src/services/creationUrlState.test.ts`
- `npm run test -- src/components/platform-entry/platformDraftGenerationShelfModel.test.ts`
- `npx eslint src/components/platform-entry/platformCreationUrlStateModel.ts src/components/platform-entry/platformCreationUrlStateModel.test.ts src/components/platform-entry/platformPuzzleIdentityModel.ts src/components/platform-entry/platformPuzzleIdentityModel.test.ts --max-warnings 0`
- `npx eslint src/components/platform-entry/PlatformEntryFlowShellImpl.tsx src/components/platform-entry/platformDraftGenerationShelfModel.ts --quiet`
- `npm run typecheck`
- `npm run check:encoding`

View File

@@ -0,0 +1,33 @@
# 【前端架构】Creation Work Delete Flow 收口计划
## 背景
平台入口作品架的删除入口覆盖 RPG、拼图、抓大鹅、方洞挑战、大鱼吃小鱼、视觉小说和宝贝识物。此前 `PlatformEntryFlowShellImpl.tsx` 在每个删除 handler 内重复计算确认框标题、删除说明、草稿 notice key 和拼图派生稳定 ID。壳层既要理解每种玩法的作品身份又要承接异步删除、刷新列表、错误状态和页面跳转导致删除确认规则缺少稳定测试面。
**Interface** 过浅:页面只想展示“删除哪个作品、会从哪里移除、删除成功后清哪些生成 notice”却必须知道 `workId` / `profileId` / `sourceSessionId` / `draftId``status` / `publicationStatus` / `publishStatus` 和宝贝识物特殊公开去向。
## 决策
新增 `src/components/platform-entry/platformCreationWorkDeleteFlow.ts` 作为 Creation Work Delete Flow **Module**。其唯一公开 **Interface**`resolvePlatformCreationWorkDeleteConfirmationModel(input)`,输入为带 `kind` 的 union输出
- `id`:确认框和删除 busy 使用的稳定作品 ID。
- `title`:确认框标题,含拼图、视觉小说和宝贝识物标题兜底。
- `detail`:草稿 / 已发布删除说明,宝贝识物已发布使用“寓教于乐板块”文案。
- `noticeKeys`:删除成功后应标记已读的草稿生成 notice keys拼图包含 `buildPuzzleResultWorkId` / `buildPuzzleResultProfileId` 派生 key。
`PlatformEntryFlowShellImpl.tsx` 仍作为副作用 **Adapter**:负责鉴权保护、确认框 state、调用各玩法删除 API、清错误、刷新作品架 / 公开广场、`markDraftNoticeSeen` 和必要的页面跳转。`run` 不进入纯 **Module**,避免把网络副作用和 React state 写入藏入模型层。
## 约定
- 新玩法接入作品架删除时,先补齐后端删除链路、作品架 action 和本 **Module** 的确认模型,再开放删除按钮。
- Jump Hop、Wooden Fish 和 Bark Battle 当前仅有作品架 action 预留,平台壳不传删除 handler不得因本 Module 存在而默认开放删除。
- 删除确认文案不得散回平台壳;若公开去向不是公开广场,应在本 **Module** 明确分支。
- 草稿 notice key 的身份扩展必须复用 `collectDraftNoticeKeys`,保持 trim、去空和去重语义一致。
## 验证
- `npm run test -- src/components/platform-entry/platformCreationWorkDeleteFlow.test.ts`
- `npm run test -- src/components/platform-entry/platformDraftGenerationShelfModel.test.ts`
- `npx eslint src/components/platform-entry/platformCreationWorkDeleteFlow.ts src/components/platform-entry/platformCreationWorkDeleteFlow.test.ts src/components/platform-entry/PlatformEntryFlowShellImpl.tsx --quiet`
- `npm run typecheck`
- `npm run check:encoding`

View File

@@ -0,0 +1,40 @@
# 【前端架构】Draft Generation Shelf Model 收口计划
## 背景
`PlatformEntryFlowShellImpl.tsx` 同时承载创作生成状态、草稿 Tab 未读点、pending 作品架占位、失败文案覆盖、作品详情更新回填和跨玩法 notice key。拼图、抓大鹅、方洞、跳一跳、敲木鱼、视觉小说、汪汪声浪、大鱼吃小鱼和宝贝识物各有不同的 `workId` / `profileId` / `sourceSessionId` / `draftId`,这些规则散在平台壳 **Implementation** 内,导致调用方必须理解每种玩法的草稿身份形状。
**Interface** 过浅:页面看似只关心“生成中 / 已完成未读 / 失败”,却要知道多 ID 去重、pending 草稿去重、失败摘要、拼图空标题兜底和持久化 generating 覆盖规则。
## 决策
新增 `src/components/platform-entry/platformDraftGenerationShelfModel.ts` 作为 Draft Generation Shelf **Module**。其 **Interface** 收口为:
- `collectDraftNoticeKeys(kind, ids)` / `getGenerationNoticeShelfKeys(item)`:统一把玩法草稿身份映射为 notice key。
- `createPendingDraftShelfState(...)``buildPending*Works(...)`:统一把本地 pending 生成状态映射成作品架占位,并避免与后端已有草稿重复。
- `buildCreationWorkShelfRuntimeState({ item, notices, pendingShelfItems })`:统一输出 `CreationWorkShelfRuntimeState`,处理失败覆盖、拼图空标题 `拼图草稿` 兜底、summary 占位覆盖、生成中遮罩和 ready 未读点。
- `collectVisibleDraftNoticeKeys(...)` / `hasUnreadDraftGenerationUpdates(...)`:统一草稿 Tab 顶部未读点规则。
- `mergePuzzleWorkSummary(current, updated)``mergeBigFishWorkSummary(current, updated)`:统一作品详情更新后回填作品架和当前详情的身份匹配规则。
- `resolvePuzzleDraftOpenIntent(...)``resolveMatch3DDraftOpenIntent(...)``resolveSquareHoleDraftOpenIntent(...)``resolveBigFishDraftOpenIntent(...)``resolveVisualNovelDraftOpenIntent(...)``resolveJumpHopDraftOpenIntent(...)``resolveWoodenFishDraftOpenIntent(...)`:统一拼图、抓大鹅、方洞挑战、大鱼吃小鱼、视觉小说、跳一跳和敲木鱼草稿打开时的已发布详情、缺 session、ready 未读试玩、失败 / active / background 生成页、当前结果页、持久化 generating 恢复、失败 fallback stage 和普通草稿恢复优先级。
- `buildPuzzleResultWorkId(...)` / `buildPuzzleResultProfileId(...)``isPersistedDraftGenerating(...)` / `isPersistedDraftFailed(...)`:把拼图稳定 ID 与持久化状态判断收在同一 **Seam**
`PlatformEntryFlowShellImpl.tsx` 仍作为 React state 与副作用 **Adapter**:负责写入 `draftGenerationNotices` / `pendingDraftShelfItems`、读取生成 session、启动 ready 草稿试玩、刷新后端列表、打开结果页和弹窗;它不再内联 pending shelf row shape、notice key 汇总、作品架 runtime state 和上述玩法草稿打开优先级。
## 约定
- 新玩法若需进入草稿生成通知,必须在此 **Module** 补 notice key、pending 占位和 visible key 映射,避免在平台壳里新增散落 switch。
- pending 作品只用于本地生成任务尚未被后端作品架返回时的临时展示;一旦后端已有同一 `sourceSessionId` / `profileId` / `workId`pending 占位必须让位。
- 拼图作品详情更新只以 `profileId` 匹配回填;大鱼吃小鱼作品详情更新只以 `sourceSessionId` 匹配回填。
- 失败 notice 优先级高于持久化 generating且可通过 pending metadata 提供更具体 summary否则回退玩法默认失败摘要。
- 已有封面的拼图草稿即使局部关卡仍在后台生成,也不得被整卡遮罩为不可打开的生成中状态。
- 草稿打开 intent 只返回纯计划、notice keys 与必要稳定 ID不创建失败生成态、不请求详情、不写 stage这些仍由壳层 Adapter 执行。
-**Module** 不做网络请求、路由切换、弹窗副作用或 React state 写入,只保留纯 **Implementation**,以提高 **Depth**、**Leverage** 与 **Locality**
## 验证
- `npm run test -- src/components/platform-entry/platformDraftGenerationShelfModel.test.ts`
- `npm run test -- src/components/custom-world-home/creationWorkShelf.test.ts -t "generation state|failure notice|failed puzzle"`
- `npm run test -- src/components/rpg-entry/RpgEntryFlowShell.agent.interaction.test.tsx -t "persisted generating puzzle draft|persisted generating match3d draft|completed baby object match draft"`
- 针对新 **Module** 与测试执行 ESLint`PlatformEntryFlowShellImpl.tsx` 保留既有 hook dependency warnings不在本切片扩大处理。
- `npm run typecheck`
- `npm run check:encoding`

View File

@@ -0,0 +1,34 @@
# 【前端架构】Match3D Runtime Profile 收口计划
## 背景
`PlatformEntryFlowShellImpl.tsx` 同时编排抓大鹅创作、作品详情、推荐 runtime 和正式 runtime。运行态启动前的 profile 规范化、公开详情转 work、生成背景资产提升、run / profile / public detail 优先级和 runtime 素材选择原本都在平台壳 **Implementation** 内,导致平台壳必须理解抓大鹅生成素材的内部结构。
## 决策
新增 `src/components/platform-entry/platformMatch3DRuntimeProfile.ts`,作为抓大鹅 runtime profile **Module**。该 **Module****Interface** 收口为:
- `mapPublicWorkDetailToMatch3DWork(entry)`:把公开作品详情映射为可启动 runtime 的 Match3D work并补齐生成背景资产。
- `buildMatch3DProfileFromSession(session)`:从创作 session draft 生成 runtime profile。
- `normalizeMatch3DWorkForRuntimeUi(profile)` / `mapMatch3DWorksForRuntimeUi(profiles)`:统一作品列表进入 UI / runtime 前的素材规范化。
- `promoteMatch3DGeneratedBackgroundAsset(profile)`:从 `generatedBackgroundAsset``generatedItemAssets[].backgroundAsset` 提升背景图、对象 key 与 prompt。
- `hasMatch3DRuntimeAsset(profile.generatedItemAssets)` / `hasMatch3DRuntimeBackgroundAsset(profile)`:统一判断 runtime 是否具备物品与背景素材。
- `resolveActiveMatch3DRuntimeProfile(run, runtimeProfile, profile)`:按 run 的 `profileId` 选择当前 profile避免切屏时误用旧草稿。
- `resolveMatch3DRuntimeGeneratedItemAssets(...)``resolveMatch3DRuntimeGeneratedBackgroundAsset(...)``resolveMatch3DRuntimeBackgroundImageSrc(...)`:统一 run / profile / public detail 的素材优先级。
`PlatformEntryFlowShellImpl.tsx` 只保留启动 run、预加载、路由、错误和 state 编排;抓大鹅素材规则集中到该 **Module**,提升 **Locality** 与测试 **Leverage**
## 约定
- 公开详情补 runtime 素材时,只有 `profileId` 与 run 匹配才优先使用公开详情;错配时不得污染当前 run。
- 当前启动时拿到的 `runtimeProfile` 优先于旧草稿 profile若 run 指向旧草稿 profile才使用草稿 profile。
- 背景资产提升不得覆盖已有显式 `backgroundImageSrc` / `backgroundImageObjectKey` / `generatedBackgroundAsset`,只补缺。
-**Module** 只放纯 profile / asset 规则,不引入启动 run、预加载、URL、状态机或 UI 副作用。
## 验证
- `npm run test -- src/components/platform-entry/platformMatch3DRuntimeProfile.test.ts`
- `npm run test -- src/components/rpg-entry/RpgEntryFlowShell.agent.interaction.test.tsx -t "match3d|抓大鹅"`
- `npm run typecheck`
- `npm run check:encoding`
- 针对新 Module 与测试执行 ESLint`PlatformEntryFlowShellImpl.tsx` 保留既有 hook dependency warnings不在本切片扩大处理。

View File

@@ -0,0 +1,29 @@
# 【前端架构】Platform Creation Launch Model 收口计划
## 背景
`PlatformEntryFlowShellImpl.tsx` 的创作入口点击回调曾直接以内联 `if` 链判断 `airp` 占位、隐藏的 `baby-object-match`、RPG 与各小游戏工作台启动目标。壳层因此同时理解入口 ID、是否需要执行启动前准备、隐藏模板错误文案和具体工作台分流。
这类规则属于创作入口启动意图。壳层应只执行准备、错误提示和受保护动作,不应持有入口 ID 到工作台目标的长链判定。
## 决策
新增 `src/components/platform-entry/platformCreationLaunchModel.ts` 作为 Platform Creation Launch **Module**。其公开 **Interface** 为:
- `resolvePlatformCreationLaunchIntent({ type, isBabyObjectMatchVisible })`:输入后端入口配置下发的模板 ID 与幼教入口可见性,输出 `noop``blocked``launch` 意图。
`PlatformEntryFlowShellImpl.tsx` 仍作为副作用 **Adapter**:根据 intent 决定是否调用 `prepareCreationLaunch()`,对 blocked intent 写入 `sessionController.setCreationTypeError(...)`,对 launch intent 进入 `runProtectedAction(...)` 并调用具体工作台打开函数。
## 约定
- `airp` 是占位入口,必须在 `prepareCreationLaunch()` 之前返回 `noop`,避免触发新游戏初始化、返回目标复位或错误清理。
- 隐藏的 `baby-object-match` 必须在 `prepareCreationLaunch()` 之后返回 blocked intent错误文案仍使用 `EDUTAINMENT_HIDDEN_MESSAGE`
- 未知入口 ID 保持旧语义:先允许壳层执行启动前准备,再作为 `noop` 结束,避免改变未来后端配置异常时的准备流程。
- 新增可启动模板时,先在本 **Module** 的 launch target union、目标集合和测试中列明再在壳层 Adapter 中补具体启动函数。
## 验收
- `npm run test -- src/components/platform-entry/platformCreationLaunchModel.test.ts`
- `npx eslint src/components/platform-entry/platformCreationLaunchModel.ts src/components/platform-entry/platformCreationLaunchModel.test.ts src/components/platform-entry/PlatformEntryFlowShellImpl.tsx --quiet`
- `npm run typecheck`
- `npm run check:encoding`

View File

@@ -0,0 +1,46 @@
# PlatformDialogStateModel 收口计划
## 背景
`PlatformEntryFlowShellImpl.tsx` 曾直接承载平台级错误 / 完成弹窗的纯状态规则:错误文案 trim、来源 label 与 id 拼接、后台生成仍在处理的识别、错误候选优先级、dismiss key 与生成完成文案都散在壳层 Implementation 内。壳层因此既要管理 React state 与副作用清理,又要记住弹窗判定细则;新增玩法错误或调整弹窗展示时缺少稳定测试面。
## 决策
- 新增 `src/components/platform-entry/platformDialogStateModel.ts` 作为 Platform Dialog State Module。
- Module Interface 收口:
- `normalizePlatformDialogMessage`
- `formatPlatformDialogSource`
- `isBackgroundGenerationStillRunningMessage`
- `resolvePlatformErrorDialog`
- `buildPlatformErrorDialogDismissKey`
- `buildPlatformTaskCompletionDialogDismissKey`
- `resolveActivePlatformDialog`
- `PLATFORM_TASK_COMPLETION_MESSAGE`
- `PlatformErrorDialogState``PlatformTaskFailureDialogState``PlatformTaskCompletionDialogState`
- `PlatformEntryFlowShellImpl.tsx` 继续作为 Adapter汇总各玩法候选、持有 React state、关闭弹窗时清理对应 setter。副作用清理不下沉到 Module避免把大量壳层 setter 变成浅 Interface。
## Interface 约束
- 错误与完成弹窗文案先 trim空字符串或全空白字符串统一视为 `null`
- 来源格式固定为 `label + 空格 + trimmed id`;缺 id 时只返回 label。
- 平台错误候选按数组顺序取第一个有效文案;候选本身只描述 `key/source/message`
- 错误 dismiss key 固定为 `key:source:message`;完成 dismiss key 固定为 `key:source:message:completedAtMs`,缺完成时间时补 `0`
- `resolveActivePlatformDialog` 只根据当前弹窗 dismiss key 与已记录 dismiss key 决定是否隐藏,不修改底层错误或完成状态。
- 任务完成弹窗文案统一使用 `PLATFORM_TASK_COMPLETION_MESSAGE`,不得在壳层重复写同一中文 literal。
- `closePlatformErrorDialog` 保持在壳层 Adapter它负责按错误来源清理 `creationEntryConfigError`、玩法 error、作品详情 error 等副作用状态,不属于纯状态 Module。
## Depth / Leverage / Locality
- **Depth**:壳层传入候选和 dismiss 记录,即可得到当前平台弹窗状态;文案归一、来源格式和 dismiss 规则藏在 Module Implementation 内。
- **Leverage**:新增玩法错误来源时只需补候选;调整弹窗纯规则时优先改 Module 与单测。
- **Locality**:平台错误弹窗、任务完成弹窗和后台生成 still-running 识别集中在一个小 Module避免继续散落在大型平台壳 Implementation 内。
## 验收
- `npm run test -- src/components/platform-entry/platformDialogStateModel.test.ts`
- `npm run test -- src/components/platform-entry/PlatformErrorDialog.test.tsx`
- `npm run test -- src/components/rpg-entry/RpgEntryFlowShell.agent.interaction.test.tsx -t "background match3d draft failure notifies and reopens failed retry page|completed match3d draft notice first opens trial then reopens result|puzzle compile timeout shows failure dialog when reread session is still generating"`
- `npx eslint src/components/platform-entry/platformDialogStateModel.ts src/components/platform-entry/platformDialogStateModel.test.ts --max-warnings 0`
- `npx eslint src/components/platform-entry/PlatformEntryFlowShellImpl.tsx --quiet`
- `npm run typecheck`
- `npm run check:encoding`

View File

@@ -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`

View File

@@ -0,0 +1,47 @@
# 【前端架构】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 推进。
- `mergeMatch3DGeneratedAssetsIntoGenerationState(state, assets)`:抓大鹅轮询到作品素材后,按可用图片数量推进生成页资产计数,并把首个素材错误传播到生成态。
- `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 不变时保留旧值。
- 抓大鹅资产旁路进度不得覆盖 `ready``failed` 终态;非终态下只统计有 `imageViews[].imageObjectKey` / `imageViews[].imageSrc`、顶层 `imageObjectKey` 或顶层 `imageSrc` 的素材。
- 抓大鹅资产旁路进度的 `totalAssetCount` 至少为 `5`,保留当前五物品首批生成节奏;已有素材数量超过 `5` 时按真实素材数量展示。
- 抓大鹅已有可用素材时 phase 推进到 `match3d-generate-views`;无可用素材时保留原 phase首个素材错误写入 `error`,无素材错误时保留原错误。
- 展示 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`

View File

@@ -0,0 +1,52 @@
# 【前端架构】Platform Mini Game Draft Payload Model 收口计划
## 背景
`PlatformEntryFlowShellImpl.tsx` 曾内联维护拼图和抓大鹅草稿恢复所需的表单 payload、拼图编译 action payload、拼图作品更新 payload、跳一跳 / 敲木鱼生成 action payload、作品摘要回填 payload 和 pending 草稿 metadata。壳层因此需要理解拼图描述字段优先级、formDraft 回退、结果页 draft 到作品更新字段的映射、Match3D config / draft / anchorPack 优先级、跳一跳 / 敲木鱼 payload 与 session draft 优先级,以及 pending 作品架标题摘要如何从 payload 派生。后续还残留拼图 form-only 草稿判定,影响 action 分流、草稿恢复阶段和结果页渲染。
这些逻辑都是 DTO 变换;不读取 React state不请求网络也不写 URL。壳层只应决定何时恢复、何时提交 action、何时写入生成状态。
## 决策
新增 `src/components/platform-entry/platformMiniGameDraftPayloadModel.ts` 作为 Platform Mini Game Draft Payload **Module**。其公开 **Interface** 为:
- `buildPuzzleFormPayloadFromWork(item)`:从拼图作品摘要恢复创作表单 payload。
- `buildPuzzleFormPayloadFromSession(session)`:从拼图 session 恢复创作表单 payload。
- `buildPuzzleFormPayloadFromAction(payload)`:从拼图 action 还原表单 payload仅接受 `compile_puzzle_draft``save_puzzle_form_draft`
- `buildPuzzleCompileActionFromFormPayload(payload)`:从表单 payload 构造拼图编译 action。
- `buildPuzzleWorkUpdatePayloadFromDraft(draft)`:从拼图结果 draft 构造 `updatePuzzleWork(...)` 所需 payload。
- `buildJumpHopDraftActionPayload(actionType, { payload, draft })`:从跳一跳表单 payload / session draft 构造生成或重生成 action。
- `buildWoodenFishDraftActionPayload(actionType, { payload, draft })`:从敲木鱼表单 payload / session draft 构造生成或重生成 action。
- `buildPendingPuzzleDraftMetadata(payload)`:从拼图 payload 派生 pending 作品架 metadata。
- `isPuzzleFormOnlyDraft(session)``isEmptyPuzzleFormOnlyDraft(session)`:判断拼图 session 是否仍只是表单草稿,以及表单草稿是否没有任何可提交内容。
- `buildMatch3DFormPayloadFromSession(session)``buildMatch3DFormPayloadFromWork(item)`:从抓大鹅 session / work 恢复表单 payload。
- `buildPendingMatch3DDraftMetadata(payload)`:从抓大鹅 payload 派生 pending metadata。
`PlatformEntryFlowShellImpl.tsx` 仍作为 **Adapter**:它继续负责 API、Action 执行、background task、生成状态、错误提示、作品架和阶段切换。
## Interface 约束
- 拼图 work payload 的 `pictureDescription` 优先级固定为 `workDescription > summary > first level pictureDescription > levelName > workTitle > ''`
- 拼图 session payload 的 `pictureDescription` 优先级固定为 `formDraft.pictureDescription > first level pictureDescription > anchorPack.visualSubject.value > seedText > ''`
- 拼图编译 action 的 `promptText` 来自 `pictureDescription || seedText``workDescription` 缺省回退到图片描述;`candidateCount` 固定为 `1`
- 拼图 action 还原只接受 `compile_puzzle_draft``save_puzzle_form_draft`;其它 action 返回 `null`
- 拼图作品更新 payload 必须直接映射 `workTitle``workDescription``levelName``summary``themeTags``coverImageSrc``coverAssetId``levels` 缺失时回退空数组。
- 跳一跳和敲木鱼生成 action payload 的字段优先级固定为表单 payload 优先,其次 session draft重生成 action 只传 session draft 字段。
- 拼图 form-only 草稿只在 `session.stage === 'collecting_anchors'` 且存在 `draft.formDraft` 时成立。
- 空 form-only 草稿必须同时缺少 `seedText``formDraft.workTitle``formDraft.workDescription``formDraft.pictureDescription`
- 抓大鹅 session payload 优先读取 `config`,其次 `draft`,最后 `anchorPack``anchorPack.clearCount``anchorPack.difficulty` 只接受有限数字字符串或数字。
- 抓大鹅 work payload 的 `themeText` 优先 `themeText`,缺失回退 `gameName`
- pending metadata 只收非空 trim 后标题和摘要;抓大鹅 metadata 用 `themeText || seedText` 同时作为 title 和 summary。
## Depth / Leverage / Locality
- **Depth**:壳层以一组表意函数取得 payload / metadata字段优先级、结果页 draft 更新字段、跳一跳 / 敲木鱼 action 字段、默认空资产和数字解析藏入 Module Implementation。
- **Leverage**:后续调整拼图或抓大鹅草稿恢复表单、拼图作品更新字段、跳一跳 / 敲木鱼生成 action 字段时,先改 Module 与单测,再保持壳层 API / state 副作用不变。
- **Locality**:表单恢复、作品更新与 action payload 规则集中到一个纯测试面,避免在大型平台壳的生成、重试和恢复流程里重复散落 DTO 拼装。
## 验收
- `npm run test -- src/components/platform-entry/platformMiniGameDraftPayloadModel.test.ts`
- `npx eslint src/components/platform-entry/platformMiniGameDraftPayloadModel.ts src/components/platform-entry/platformMiniGameDraftPayloadModel.test.ts src/components/platform-entry/PlatformEntryFlowShellImpl.tsx --quiet`
- `npm run typecheck`
- `npm run check:encoding`

View File

@@ -0,0 +1,47 @@
# 【前端架构】Platform Mini Game Session Mapping Model 收口计划
## 背景
`PlatformEntryFlowShellImpl.tsx` 顶部曾保留拼图 runtime 恢复、方洞挑战 session draft 转 profile、视觉小说 work detail 转 Agent session、跳一跳 pending session、敲木鱼 work detail 恢复、敲木鱼生成中作品摘要和敲木鱼 pending session 多段纯 DTO 映射。它们没有 React state、网络请求、路由、弹窗或计时副作用却住在大型平台壳内新增或修正生成中草稿恢复时需要在壳层里理解 sessionId 优先级、拼图稳定 ID、方洞 profile 默认值、视觉小说 work/session fallback、pending draft 默认值和木鱼 fallback 规则。
这些规则属于平台壳 session / work 恢复映射,应成为可测试的 **Module**。壳层只负责调用网络、写 React state、写 URL 和切换 stage。
## 决策
新增 `src/components/platform-entry/platformMiniGameSessionMappingModel.ts` 作为 Platform Mini Game Session Mapping **Module**。其公开 **Interface** 为:
- `buildPuzzleRuntimeWorkFromSession(session, owner)`:从拼图 Agent session 构造可进入 runtime 的 draft `PuzzleWorkSummary`,缺草稿、缺 profile 或缺封面时返回 `null`
- `buildSquareHoleProfileFromSession(session)`:从方洞挑战 Agent session draft 构造草稿 `SquareHoleWorkProfile`,缺 session、缺 draft 或缺 profileId 时返回 `null`
- `buildVisualNovelSessionFromWorkDetail(work)`:从视觉小说 work detail 恢复 `VisualNovelAgentSessionSnapshot`,供草稿作品架回到结果页继续编辑。
- `buildJumpHopPendingSession(item)`:从跳一跳作品架 summary 构造生成中 pending session。
- `buildWoodenFishSessionFromWorkDetail(work, fallbackItem?)`:从敲木鱼 work detail 恢复 session并按 summary / fallback / profileId 决定 sessionId。
- `buildWoodenFishGeneratingWorkSummary(session, payload?)`:从敲木鱼生成 session 和可选表单 payload 构造作品架生成中摘要。
- `buildWoodenFishPendingSession(item)`:从敲木鱼作品架 summary 构造生成中 pending session。
`PlatformEntryFlowShellImpl.tsx` 仍作为 **Adapter**:调用这些映射后继续负责 `set*Session``set*Work``set*Run``createMiniGameDraftGenerationState(...)``writeCreationUrlState(...)``enterCreateTab()``setSelectionStage(...)`
## Interface 约束
- 拼图 runtime work 必须保留 `draft.coverImageSrc` 非空门槛,避免启动缺封面的草稿运行态。
- 拼图 profileId 优先 `publishedProfileId`,否则用 `buildPuzzleResultProfileId(sessionId)`workId 使用 `buildPuzzleResultWorkId(sessionId)`,缺失时回退 profileId。
- 拼图 owner 缺省为 `current-user` / `玩家``publishReady` 来自 `session.resultPreview?.publishReady`
- 方洞 profile 的 `workId``profileId` 都来自 draft `profileId`owner 固定为 `current-user``sourceSessionId` 来自 sessionId。
- 方洞 profile 的 `updatedAt` 优先 session `updatedAt`,缺失时使用当前时间;`publicationStatus='draft'``playCount=0``publishedAt=null``publishReady` 来自 draft。
- 视觉小说恢复 session 的 `sessionId` 优先归一化后的 `sourceSessionId`,为空时回退 `workId``status='ready'``messages=[]``pendingAction=null``sourceMode` 来自 draft`updatedAt` 来自 summary。
- 跳一跳 pending sessionId 优先 `sourceSessionId`,缺失时用 `profileId`;素材、路径和 prompt 维持空值兜底。
- 敲木鱼 detail sessionId 优先级固定为 `work.summary.sourceSessionId > fallbackItem.sourceSessionId > profileId`
- 敲木鱼生成中摘要的 `workId/profileId/sourceSessionId` 都来自 sessionId标题、描述和标签优先表单 payload其次 session draft最后回退 `敲木鱼` / 空描述 / `['敲木鱼']`
- 敲木鱼 pending session 保持 `floatingWords=['功德 +1']`、素材 / 音效 / 背景为空的旧默认。
## Depth / Leverage / Locality
- **Depth**:壳层以少量函数取得恢复用 DTOID 优先级、方洞 profile 默认值、视觉小说 session fallback、敲木鱼生成中摘要和 pending draft 字段藏入 Module Implementation。
- **Leverage**:后续新增生成中作品恢复或修改 sessionId 规则时,先改 Module 与单测,再保持壳层 Adapter 副作用不变。
- **Locality**:拼图、方洞、视觉小说、跳一跳和敲木鱼的恢复 / 生成中映射集中在一个纯测试面,避免在大型壳层顶部继续堆积 DTO 构造。
## 验收
- `npm run test -- src/components/platform-entry/platformMiniGameSessionMappingModel.test.ts`
- `npx eslint src/components/platform-entry/platformMiniGameSessionMappingModel.ts src/components/platform-entry/platformMiniGameSessionMappingModel.test.ts src/components/platform-entry/PlatformEntryFlowShellImpl.tsx --quiet`
- `npm run typecheck`
- `npm run check:encoding`

View File

@@ -0,0 +1,39 @@
# 【前端架构】Platform Played Work Open Model 收口计划
## 背景
`PlatformEntryFlowShellImpl.tsx` 的个人“玩过作品”点击回调曾在壳层内直接判断 `worldType``worldKey` 前缀、玩法别名、目标 ID 兜底、RPG 公开详情 payload 和大鱼吃小鱼 gallery miss fallback。壳层因此同时承载纯打开意图与异步副作用后续新增玩法或修正玩过作品身份时缺少稳定测试面。
个人“玩过作品”的点击规则属于打开意图。壳层应只关闭面板、读取 gallery、打开详情和写错误玩法别名、目标 ID、fallback payload 应收口到纯 **Module**
## 决策
新增 `src/components/platform-entry/platformPlayedWorkOpenModel.ts` 作为 Platform Played Work Open **Module**。其公开 **Interface** 为:
- `resolvePlatformPlayedWorkOpenIntent(work)`:输入 `ProfilePlayedWorkSummary`,输出 `noop`、各玩法公开详情打开意图、`open-big-fish``open-rpg`
- `PlatformPlayedWorkOpenIntent`:描述壳层可执行的打开动作;大鱼吃小鱼意图包含 `sessionId` 和 gallery miss 时使用的 `fallbackWork`RPG 意图包含 `CustomWorldGalleryCard` 详情 payload。
`PlatformEntryFlowShellImpl.tsx` 仍作为 **Adapter**:它保留 `setIsProfilePlayStatsOpen(false)`、各玩法 `open*PublicWorkDetail``refreshBigFishGallery()`、大鱼 gallery 命中优先逻辑、`mapBigFishWorkToPublicWorkDetail(...)` 与错误 setter。
## Interface 约束
- `worldType` 只做小写归一,不 trim`worldKey` 前缀匹配保持大小写敏感,延续旧行为。
- `profileId` 使用 nullish 优先级:只在 `profileId``null` / `undefined` 时从 `worldKey` 前缀兜底;空字符串仍视为缺目标并返回 `noop`
- `puzzle` 打开时固定携带 `{ tab: 'profile' }`
- `match3d` / `match_3d``square-hole` / `square_hole``jump-hop` / `jump_hop``wooden-fish` / `wooden_fish``big-fish` / `big_fish` 均保持既有别名。
- `big-fish` 缺 gallery 命中时使用 Module 生成的 `fallbackWork`,默认 `ownerUserId` 为空串、`authorDisplayName``worldSubtitle || '玩家'`、关卡和素材 ready 计数为 `0` / `false`
- 未识别的 `worldType` 仍按 RPG 公开详情打开;缺 `ownerUserId` 或缺 profile 目标时返回 `noop`
## Depth / Leverage / Locality
- **Depth**:调用方只消费一个打开 intent玩法别名、目标 ID 兜底和 fallback payload 藏入 Module Implementation。
- **Leverage**:新增“玩过作品”玩法时,先在 intent union、resolver 与单测中定义,再让壳层 Adapter 绑定对应打开副作用。
- **Locality**RPG fallback payload 与大鱼 fallback work 不再散落在大型壳层里,维护者可在纯测试中锁定字段契约。
## 验收
- `npm run test -- src/components/platform-entry/platformPlayedWorkOpenModel.test.ts`
- `npx eslint src/components/platform-entry/platformPlayedWorkOpenModel.ts src/components/platform-entry/platformPlayedWorkOpenModel.test.ts src/components/platform-entry/PlatformEntryFlowShellImpl.tsx --quiet`
- `npm run test -- src/components/rpg-entry/RpgEntryFlowShell.agent.interaction.test.tsx -t "authenticated users can open save archives from the profile played panel"`
- `npm run typecheck`
- `npm run check:encoding`

View File

@@ -0,0 +1,32 @@
# 【前端架构】Platform Profile Wallet Delta Model 收口计划
## 背景
`PlatformEntryFlowShellImpl.tsx` 仍内联维护个人钱包余额的本地 delta 规则:余额归一化、本地扣点 / 返还后的 dashboard 乐观更新,以及刷新服务端 dashboard 时如何抵消已经被服务端反映的本地 delta。
这些规则是纯展示状态计算,但留在平台壳层会让壳层同时理解钱包余额边界、整数截断、负数保护和服务端快照对账。
## 决策
新增 `platformProfileWalletDeltaModel.ts`,收口钱包余额本地 delta 的纯规则:
- `resolveProfileWalletBalance(...)` 负责把 dashboard 余额归一为非负整数。
- `adjustProfileDashboardWalletBalance(...)` 负责把本地 delta 应用到 dashboard并刷新 `updatedAt`
- `reconcileProfileWalletLocalDeltaWithServerDashboard(...)` 负责在拿到新服务端 dashboard 后扣除已被服务端反映的本地借贷变化。
`PlatformEntryFlowShellImpl.tsx` 继续保留 API 请求、React ref、state 写入和刷新触发副作用。
## 接口约束
- 非数字、无穷值或空 dashboard 的余额按 `0` 处理。
- 本地 delta 必须先 `Math.trunc`,余额不得低于 `0`
- 当服务端最新余额已经反映本地扣点时,剩余负 delta 应减少;已经全部反映时归零。
- 当服务端最新余额已经反映本地返还 / 奖励时,剩余正 delta 应减少;已经全部反映时归零。
- 服务端余额变化方向与本地 delta 相反时,不得错误抵消。
## 验收
- `npm run test -- src/components/platform-entry/platformProfileWalletDeltaModel.test.ts`
- 针对新 Module 与 `PlatformEntryFlowShellImpl.tsx` 执行 ESLint。
- `npm run typecheck`
- `npm run check:encoding`

View File

@@ -0,0 +1,40 @@
# 【前端架构】Platform Public Code Search Model 收口计划
## 背景
`PlatformEntryFlowShellImpl.tsx` 的公开搜索回调曾直接在壳层内判断 `user_` / `user-``PZ``BF``JH``WF``BO``M3``SH``VN``BB``CW`、纯数字和普通关键词的优先级。壳层因此既要持有搜索输入到查找顺序的纯规则,又要执行各玩法公开详情读取、用户读取、运行态启动和错误归航副作用。
公开搜索的“先查什么、失败后回退什么”是稳定的分流规则,应有独立测试面;壳层只应作为副作用 Adapter按计划执行网络读取与打开动作。
## 决策
新增 `src/components/platform-entry/platformPublicCodeSearchModel.ts` 作为 Platform Public Code Search **Module**。其公开 **Interface** 为:
- `resolvePlatformPublicCodeSearchPlan(keyword)`:输入用户搜索词,输出 `{ normalizedKeyword, steps }`;空输入返回 `null`
- `PlatformPublicCodeSearchStep`:枚举壳层可执行的查找步骤,包括 `user-id``public-user-code``rpg-work`、各玩法公开作品步骤与 `bark-battle-work`
- `mapRpgPublicCodeSearchDetailToGalleryCard(entry)`:把 RPG by-code 详情响应映射为公开作品卡,收口 `playCount` / `remixCount` / `likeCount``0` 兜底。
- `resolve*PublicCodeSearchMatch(entries, keyword)`:统一各玩法公开作品列表的公开码匹配、公开可见性过滤和详情卡 DTO 映射;拼图、大鱼吃小鱼、跳一跳、敲木鱼、宝贝识物、抓大鹅、方洞挑战、视觉小说和汪汪声浪都走此接口。
`PlatformEntryFlowShellImpl.tsx` 仍作为 **Adapter**:它保留 `getPublicAuthUserByCode`、各玩法 gallery 刷新 / 详情打开、Bark Battle runtime 特例和 missing work 归航副作用,只按 `steps` 顺序执行,前一步失败才尝试下一步;壳层不再重复维护 per-play `isSame*PublicWorkCode` 匹配和 DTO 映射。
## Interface 约束
- 空白搜索词返回 `null`,壳层不得进入搜索 loading。
- `user_` / `user-` 开头的内部用户 ID 只执行 `user-id`,不回退作品号。
- `PZ``BF``JH``WF``BO``M3``SH``VN``BB` 前缀只进入对应玩法公开作品查找;`M3D-*` 继续归入并匹配 `M3` / 抓大鹅。
- `CW``1-8` 位纯数字先查 RPG 公开作品,再回退陶泥号。
- 普通关键词与 `SY` 陶泥号保持既有顺序:先查陶泥号,再查 RPG 公开作品,再查汪汪声浪作品,最后再以陶泥号兜底。
## Depth / Leverage / Locality
- **Depth**:壳层只消费短小的 `steps` 与 match result Interface搜索前缀、优先级、回退顺序、per-play 匹配和 DTO 映射藏入 Module Implementation。
- **Leverage**:新增公开作品前缀时,先扩展 Module 的 step union、前缀表、matcher 和单测,再在壳层 Adapter 绑定对应网络读取与打开动作。
- **Locality**:搜索计划与作品命中规则集中在一个纯 ModuleUI、网络、详情打开与 runtime 启动副作用继续留在壳层,避免把副作用 setter 变成浅 Interface。
## 验收
- `npm run test -- src/components/platform-entry/platformPublicCodeSearchModel.test.ts`
- `npm run test -- src/services/publicWorkCode.test.ts`
- `npx eslint src/components/platform-entry/platformPublicCodeSearchModel.ts src/components/platform-entry/platformPublicCodeSearchModel.test.ts src/components/platform-entry/PlatformEntryFlowShellImpl.tsx --quiet`
- `npm run typecheck`
- `npm run check:encoding`

View File

@@ -0,0 +1,68 @@
# PlatformPublicWorkDetailFlow 收口计划
## 背景
`PlatformEntryFlowShellImpl.tsx` 已把公开作品身份、去重和推荐 runtime kind 收口到 `platformPublicGalleryFlow.ts`,但统一作品详情入口仍在壳层 Implementation 内直接判断 RPG、拼图、跳一跳、敲木鱼、视觉小说和其它玩法。壳层既要知道哪些公开详情可直接使用当前 entry又要知道哪些玩法必须先补读完整详情还要按当前用户判断详情按钮是“编辑”还是“改造”。这些是纯决策规则继续留在巨型壳层会削弱 Locality。
## 决策
- 新增 `src/components/platform-entry/platformPublicWorkDetailFlow.ts` 作为 Platform Public Work Detail Flow Module。
- Module Interface 收口:
- `getPlatformPublicWorkDetailKind(entry)`
- `resolvePlatformPublicWorkDetailOpenStrategy(entry)`
- `resolvePlatformPublicWorkActionMode(entry, viewerUserId)`
- `resolvePlatformPublicWorkEditIntent(entry, deps)`
- `resolvePlatformPublicWorkLikeIntent(entry)`
- `resolvePlatformPublicWorkRemixIntent(entry)`
- `resolvePlatformPublicWorkStartIntent(entry, deps)`
- `resolvePlatformPublicWorkDetailOpenDecision(entry, deps)`
- `resolveActivePlatformPublicWorkAuthorEntry(args)`
- `map*WorkToPublicWorkDetail(...)`
- `mapPublicWorkDetailToPuzzleWork(entry)`
- `mapPublicWorkDetailToBigFishWork(entry)`
- `mapPublicWorkDetailToSquareHoleWork(entry)`
- `mapBarkBattlePublicDetailToWorkSummary(entry)`
- `resolveVisiblePuzzleDetailCoverCount(entry, run)`
- `PlatformEntryFlowShellImpl.tsx` 继续作为 Adapter根据 open strategy 调用 `openPublicWorkDetail``openPuzzlePublicWorkDetail``openJumpHopPublicWorkDetail``openWoodenFishPublicWorkDetail``openVisualNovelPublicWorkDetail``openRpgPublicWorkDetail`
- 公开详情 entry 映射与公开详情反推玩法 work 摘要也收口到 Module。壳层只在运行态启动、编辑、改造、推荐缓存和详情展示时调用映射 Interface不再在壳层顶部持有每个玩法的 DTO 拼装 Implementation。
- `mapMatch3DWorkToPublicWorkDetail` 归入 `platformMatch3DRuntimeProfile.ts`,继续委托 `normalizeMatch3DWorkForRuntimeUi` 处理素材归一和背景资产提升;`platformPublicWorkDetailFlow.ts` 不复制 Match3D 运行态素材规则。
- 公开详情启动、编辑、点赞和改造只抽“意图” Interface不把整个 callback 搬进 Module。壳层继续作为 Adapter 执行鉴权、API 调用、运行态启动、草稿恢复、busy 状态、缓存同步、stage 切换和错误 setter避免形成伪 Seam。
## Interface 约束
- `getPlatformPublicWorkDetailKind` 只根据 `PlatformPublicGalleryCard` 的玩法判定 helper 归一 kind没有 `sourceType` 的公开 RPG 作品回退为 `rpg`
- `resolvePlatformPublicWorkDetailOpenStrategy` 只表达“如何打开详情”,不执行网络请求或 state setter。
- 拼图、跳一跳、敲木鱼、视觉小说需要按 `profileId` 补读完整详情;返回对应 `load-*` strategy。
- 大鱼吃小鱼、抓大鹅、方洞挑战、汪汪声浪、宝贝识物和其它可直接展示的公开 entry 返回 `use-entry` strategy。
- RPG 返回 `load-rpg-detail` strategy由壳层 Adapter 继续调用 RPG 详情读取流程。
- `resolvePlatformPublicWorkActionMode` 只比较 `entry.ownerUserId` 与当前 viewer user id当前用户拥有该公开作品时返回 `edit`,否则返回 `remix`
- `resolvePlatformPublicWorkEditIntent` 只表达自有公开作品编辑意图:大鱼吃小鱼、拼图、抓大鹅、方洞挑战、视觉小说和汪汪声浪在能定位原草稿时返回对应 draft open 目标;跳一跳、敲木鱼和缺草稿作品返回原阻断文案;宝贝识物只返回需解析本地草稿的 intent旧 RPG gallery fallback 只在完整 RPG 详情已补读且 profile 匹配时返回编辑 intent。壳层仍执行草稿恢复、宝贝识物异步草稿解析、RPG 编辑导航和错误展示。
- `resolvePlatformPublicWorkEditIntent``deps` 只接编辑决策所需的当前拼图详情、当前 RPG 详情、视觉小说作品缓存、汪汪声浪作品缓存,以及抓大鹅 public detail -> work 的 Adapter。抓大鹅 Adapter 必须来自 Match3D Runtime Profile Module以保留 `generatedItemAssets` 归一化与背景资产提升的 Locality。
- `resolvePlatformPublicWorkLikeIntent` 只表达公开作品点赞意图:大鱼吃小鱼、拼图和旧 RPG gallery fallback 返回可执行 intent宝贝识物、汪汪声浪、方洞挑战和视觉小说返回不可用文案。壳层只按 intent 调用 API、写缓存和展示错误不再持有这组能力矩阵。
- `resolvePlatformPublicWorkRemixIntent` 只表达公开作品改造意图:大鱼吃小鱼和拼图返回可执行 intent 与成功后目标 stage旧 RPG gallery fallback 返回可执行 intent其它玩法返回原未开放文案。壳层只按 intent 调用 remix API、写 session / 缓存、切 stage 和展示错误。
- `resolvePlatformPublicWorkStartIntent` 只表达公开作品“开始游玩”意图:大鱼吃小鱼、拼图、跳一跳、敲木鱼、抓大鹅、方洞挑战、视觉小说、汪汪声浪和宝贝识物返回对应启动目标;旧 RPG gallery fallback 只在完整 RPG 详情已补读且 profile 匹配时返回记录游玩 intent否则返回原阻断文案。壳层仍执行登录保护、运行态启动、RPG 游玩记录、详情更新、busy 状态和错误展示。
- `resolvePlatformPublicWorkStartIntent``deps` 只接启动决策所需的当前拼图详情、当前 RPG 详情、汪汪声浪作品缓存,以及抓大鹅 public detail -> work 的 Adapter。抓大鹅 Adapter 必须来自 Match3D Runtime Profile Module以保留 `generatedItemAssets` 归一化与背景资产提升的 Locality。
- `resolvePlatformPublicWorkDetailOpenDecision` 只表达直接展示公开详情的打开 / 阻断结果、错误文案、目标 stage 与可写入历史的路径;真正执行 setter、push history 的副作用仍由壳层 Adapter 执行。
- `resolveActivePlatformPublicWorkAuthorEntry` 只在 `work-detail` 阶段选择统一公开详情 entry在 RPG `detail` 阶段只选择非 draft 的 RPG 详情 entry作者请求、竞态 request key 和缓存仍留壳层。
- `map*WorkToPublicWorkDetail` 只把各玩法已存在的 work / gallery summary 映射为统一详情 entry公开码、封面、统计与标题字段继续复用 `rpgEntryWorldPresentation.ts` 的平台公开卡片映射。
- `mapPublicWorkDetailToPuzzleWork``mapPublicWorkDetailToBigFishWork``mapPublicWorkDetailToSquareHoleWork``mapBarkBattlePublicDetailToWorkSummary` 只用于公开详情 CTA、推荐缓存或运行态启动前的兼容 work 摘要拼装;缺省值必须留在 Module 测试中固定,壳层不得重复推导。
- `resolveVisiblePuzzleDetailCoverCount` 只表达拼图公开详情封面解锁规则:非拼图、无当前 run 或 run 不属于当前公开详情时只展示首图;当前 run 属于该公开详情时按 `clearedLevelCount + 1` 解锁,但至少为 1。`PlatformWorkDetailView` 只接收 `visibleCoverCount` 展示,不读取 run。
- Match3D 的公开详情与 work 摘要互转仍属于 Match3D Runtime Profile Module因为它依赖 `generatedItemAssets` 归一化与背景资产提升。公开详情 Flow 只接统一详情策略,不复制该运行态规则。
## Depth / Leverage / Locality
- **Depth**:壳层传入公开作品 entry、玩法 work summary、当前用户 id、当前拼图 run 或少量启动 / 编辑 deps即可得到详情打开策略、动作模式、编辑 / 点赞 / 改造 / 启动意图、统一详情映射和封面可见数;玩法判定、能力矩阵与 DTO 默认值藏在 Module Implementation 内。
- **Leverage**:新增玩法公开详情时先补 Strategy / Mapping 单测,再接壳层 Adapter不必在多个 JSX / callback 位置重复 sourceType 判断或 DTO 回填。
- **Locality**:公开作品详情入口的纯策略与通用映射集中到一个小 ModuleMatch3D 素材归一仍在 Match3D Module启动运行态、点赞、改造、编辑等副作用仍留在壳层避免伪 Seam。
## 验收
- `npm run test -- src/components/platform-entry/platformPublicWorkDetailFlow.test.ts`
- `npm run test -- src/components/platform-entry/platformMatch3DRuntimeProfile.test.ts`
- `npm run test -- src/components/platform-entry/platformPublicGalleryFlow.test.ts`
- `npm run test -- src/components/platform-entry/PlatformWorkDetailView.test.tsx`
- `npm run test -- src/components/rpg-entry/RpgEntryFlowShell.agent.interaction.test.tsx -t "public detail|owned public puzzle detail|direct missing public work detail"`
- `npx eslint src/components/platform-entry/platformPublicWorkDetailFlow.ts src/components/platform-entry/platformPublicWorkDetailFlow.test.ts --max-warnings 0`
- `npx eslint src/components/platform-entry/PlatformEntryFlowShellImpl.tsx --quiet`
- `npm run typecheck`
- `npm run check:encoding`

View File

@@ -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`

View File

@@ -0,0 +1,36 @@
# 【前端架构】Platform Puzzle Runtime State Model 收口计划
## 背景
`PlatformEntryFlowShellImpl.tsx` 曾内联 `mergePuzzleServiceRuntimeState(...)`,在拼图排行榜提交回包后,把服务端 run 快照合并回当前前端 run。此逻辑没有 React state、网络、URL 或弹窗副作用,却需要理解 `PuzzleRunSnapshot` 的局部真相分工拼块布局、当前关卡状态和计时结果由前端即时裁决服务端回包只补排行榜、run 身份、通关数上限和下一关 handoff。
若该合并规则继续留在平台壳,后续调整排行榜来源、相似作品下一关或本地 / 服务端 run 混合策略时,维护者必须翻大型壳层并同时避开大量副作用代码。
## 决策
新增 `src/components/platform-entry/platformPuzzleRuntimeStateModel.ts` 作为 Platform Puzzle Runtime State **Module**。公开 **Interface**
- `mergePuzzleServiceRuntimeState(currentRun, serviceRun)`:当双方都有 `currentLevel` 时,保留当前前端关卡状态与棋盘,只合并服务端 run 身份、`clearedLevelCount` 上限、排行榜与下一关 handoff任一方缺 `currentLevel` 时返回当前 run。
`PlatformEntryFlowShellImpl.tsx` 继续作为 **Adapter**:它负责提交排行榜、读取回包、写 React state、刷新 archive 和错误提示,不再持有拼图 run 快照合并字段清单。
## Interface 约束
- 缺少 `currentRun.currentLevel``serviceRun.currentLevel` 时不得合并,直接返回当前 run。
- `clearedLevelCount` 取当前 run 与服务端 run 的最大值,避免服务端较旧回包降低本地通关数。
- 排行榜优先取 `serviceRun.currentLevel.leaderboardEntries`;为空时取 `serviceRun.leaderboardEntries`;两者皆空时保留当前关卡榜单。
- `currentLevel` 的棋盘、状态、计时和关卡字段来自当前 run不被服务端回包覆盖。
- `runId``entryProfileId``recommendedNextProfileId``nextLevelMode``nextLevelProfileId``nextLevelId``recommendedNextWorks` 来自服务端 run。
## Depth / Leverage / Locality
- **Depth**:壳层传入当前 run 与服务端 run即取得合并后的稳定快照排行榜来源、下一关 handoff 和前端局部真相保留规则藏入 Module Implementation。
- **Leverage**:排行榜提交、后续相似作品推荐或服务端 run 字段变化时,先改纯 Module 与单测,壳层提交副作用不变。
- **Locality**:拼图 runtime 快照合并规则集中到一个纯测试面,避免在平台壳中继续散落 `PuzzleRunSnapshot` 字段判断。
## 验收
- `npm run test -- src/components/platform-entry/platformPuzzleRuntimeStateModel.test.ts`
- `npx eslint src/components/platform-entry/platformPuzzleRuntimeStateModel.ts src/components/platform-entry/platformPuzzleRuntimeStateModel.test.ts src/components/platform-entry/PlatformEntryFlowShellImpl.tsx --quiet`
- `npm run typecheck`
- `npm run check:encoding`

View File

@@ -0,0 +1,36 @@
# 【前端架构】Platform Recommend Runtime Auth Model 收口计划
## 背景
平台首页推荐流会以 embedded runtime 方式启动跳一跳、抓大鹅、方洞挑战、拼图、敲木鱼、视觉小说、大鱼吃小鱼和汪汪声浪等玩法。旧规则散在 `PlatformEntryFlowShellImpl.tsx` 顶层 helper 与多个启动 callback匿名访客应申请 Runtime Guest Token已登录或已有 access token 时应走 background auth非 embedded 正常启动则不改普通鉴权。拼图还额外维护 `isolated` / `default` runtime auth mode容易与通用推荐流口径漂移。
## 决策
新增 `src/components/platform-entry/platformRecommendRuntimeAuthModel.ts`,以纯 **Module** 收口推荐 runtime 鉴权计划:
- `resolvePlatformRecommendRuntimeAuthPlan(input)`:返回 `requestKind``none``background``runtime-guest`,并给出拼图 runtime 应落到 `default` 还是 `isolated`
- `shouldUsePlatformRecommendRuntimeGuestAuth(input)`:只判断当前用户状态和是否允许 guest auth不读取本地 token。
`PlatformEntryFlowShellImpl.tsx` 继续作为 **Adapter**:它读取 `getStoredAccessToken()`、调用 `ensureRuntimeGuestToken()`、拼装具体 request options并在启动拼图时写入 `setPuzzleRuntimeAuthMode(...)`
## Interface 约束
- 非 embedded 且未显式允许 runtime guest auth 时,计划为 `none`
- embedded 推荐 runtime 若无登录用户且无本地 access token计划为 `runtime-guest`
- embedded 推荐 runtime 若已有登录用户或本地 access token计划为 `background`
- 拼图公开详情要求 `authMode='isolated'` 时,匿名状态应返回 `runtime-guest``puzzleRuntimeAuthMode='isolated'`
- 拼图公开详情要求 `authMode='isolated'` 但已登录或已有 access token 时,应回到 `default`,避免把账号态伪装成匿名 isolated guest。
## Depth / Leverage / Locality
- **Depth**:壳层传入 embedded、是否允许 guest、用户 ID 与本地 token 布尔值,即得 request 计划和拼图 runtime auth mode。
- **Leverage**:所有推荐 runtime 启动复用同一鉴权矩阵;新增玩法只需消费计划,不再重写匿名 / 已登录分支。
- **Locality**guest token 选择规则集中在纯测试面,具体 token 获取和 request options 仍留在壳层副作用 Adapter。
## 验收
- `npm run test -- src/components/platform-entry/platformRecommendRuntimeAuthModel.test.ts`
- `npx eslint --max-warnings 0 src/components/platform-entry/platformRecommendRuntimeAuthModel.ts src/components/platform-entry/platformRecommendRuntimeAuthModel.test.ts`
- `npx eslint src/components/platform-entry/PlatformEntryFlowShellImpl.tsx --quiet`
- `npm run typecheck`
- `npm run check:encoding`

View File

@@ -0,0 +1,40 @@
# 【前端架构】Platform Recommend Runtime Auto Start 收口计划
## 背景
平台推荐页的 embedded runtime 会在移动端首页自动选择当前推荐作品并启动对应玩法。旧 `useEffect` 同时判断桌面断点、当前 stage、当前 Tab、平台 loading、推荐列表是否为空、active entry 是否仍存在、对应 runtime 是否 ready、是否已有启动请求以及下一条 entry 应选谁。
这组判断是纯推荐流自动启动决策,但留在 `PlatformEntryFlowShellImpl.tsx` 会让 effect 依赖很长,也让后续新增玩法时容易把 ready 判定和启动时机混在副作用里。
## 决策
扩展 `src/components/platform-entry/platformPublicGalleryFlow.ts`,新增 `resolvePlatformRecommendRuntimeAutoStartDecision(input)`
- `noop`:当前不需要改变推荐 runtime。
- `clear`:推荐列表为空,壳层应清空 active entry、runtime kind 和错误。
- `start`:壳层应调用既有 `selectRecommendRuntimeEntry(entry)` 启动指定作品。
`PlatformEntryFlowShellImpl.tsx` 继续作为 **Adapter**:它负责收集 React state、清空 state、调用 `selectRecommendRuntimeEntry(...)` 和执行各玩法 runtime 副作用。
## Interface 约束
- 桌面端、非 `platform` stage、非 `home` Tab 或平台仍在 loading 时返回 `noop`
- 推荐列表为空时返回 `clear`
- active entry 存在且对应 runtime 已 ready 时返回 `noop`
- 当前已有启动请求时返回 `noop`
- active entry 存在但未 ready 时返回 `start(activeEntry)`
- active key 缺失或已不在列表中时返回 `start(firstEntry)`
## Depth / Leverage / Locality
- **Depth**壳层只消费三态决策列表查找、ready 判定和自动启动门禁藏入 Flow Module Implementation。
- **Leverage**:后续推荐流新增玩法或改 ready 判定,只需补 `platformPublicGalleryFlow.ts` 的模型测试。
- **Locality**effect 只保留副作用动作,不再承载推荐流状态机知识。
## 验收
- `npm run test -- src/components/platform-entry/platformPublicGalleryFlow.test.ts`
- `npx eslint --max-warnings 0 src/components/platform-entry/platformPublicGalleryFlow.ts src/components/platform-entry/platformPublicGalleryFlow.test.ts`
- `npx eslint src/components/platform-entry/PlatformEntryFlowShellImpl.tsx --quiet`
- `npm run typecheck`
- `npm run check:encoding`

View File

@@ -0,0 +1,38 @@
# 【前端架构】Platform RPG Agent Result Preview Model 收口计划
## 背景
`PlatformEntryFlowShellImpl.tsx` 曾内联维护 RPG Agent 结果页的发布门禁展示规则:从 `CustomWorldProfile` 顶层字段、`creatorIntent``anchorContent`、章节蓝图和场景章节中反证服务端返回的 legacy blocker 是否已经被当前结果页 profile 补齐,并同时在壳层内把 result preview source 映射成展示标签。
这些逻辑不读取 React state不请求网络不写 URL也不操作弹窗它们属于 RPG Agent 结果预览展示的纯判定。壳层继续负责 session、profile、发布动作和结果页 props 编排。
## 决策
新增 `src/components/platform-entry/platformRpgAgentResultPreviewModel.ts` 作为 Platform RPG Agent Result Preview **Module**。其公开 **Interface** 为:
- `buildPlatformRpgAgentResultPublishGateView(profile, fallbackBlockers, fallbackPublishReady)`:无 profile 时沿用服务端 fallback有 profile 时过滤已经被当前 profile 结构字段满足的发布 blocker并按剩余 blocker 重算展示态 `publishReady`
- `resolvePlatformRpgAgentResultPreviewSourceLabel(source)`:把 `published_profile``session_preview` 和未知 future source 映射成结果页预览来源标签。
`PlatformEntryFlowShellImpl.tsx` 仍作为 **Adapter**:它只把 `agentResultPreview``generatedCustomWorldProfile` 交给 Module并将返回的 blocker / label 传入结果页组件。
## Interface 约束
- 无 profile 时不得自行修正 blocker必须保留 fallback blocker message 与 fallback `publishReady`
- 有 profile 时只过滤已知结构 blocker`publish_missing_world_hook``publish_missing_player_premise``publish_missing_core_conflict``publish_missing_main_chapter``publish_missing_first_act`
- 世界钩子兼容读取 `worldHook``creatorIntent.worldHook``anchorContent.worldPromise``anchorContent.worldPromise.hook``settingText`
- 玩家前提兼容读取 `playerPremise``creatorIntent.playerPremise``anchorContent.playerEntryPoint.openingIdentity``openingProblem``entryMotivation`
- 主章节兼容读取 `chapters``sceneChapterBlueprints``sceneChapters`;首幕读取 `sceneChapterBlueprints` / `sceneChapters` 下的 `acts`
- 未知 blocker code 不得被前端过滤;未知 source 保留“服务端预览”兜底,不做穷尽删除。
## Depth / Leverage / Locality
- **Depth**:壳层以两个函数取得发布门禁展示和 source labelprofile 兼容字段路径、legacy blocker code 与兜底规则藏入 Module Implementation。
- **Leverage**:后续后端调整 RPG result preview blocker 或新增 source 时,先改 Module 与单测,再让壳层 Adapter 保持结果页 props 编排不变。
- **Locality**RPG Agent 结果预览展示规则集中到一个纯测试面,避免在大型平台壳中继续混杂 profile 结构探测。
## 验收
- `npm run test -- src/components/platform-entry/platformRpgAgentResultPreviewModel.test.ts`
- `npx eslint src/components/platform-entry/platformRpgAgentResultPreviewModel.ts src/components/platform-entry/platformRpgAgentResultPreviewModel.test.ts src/components/platform-entry/PlatformEntryFlowShellImpl.tsx --quiet`
- `npm run typecheck`
- `npm run check:encoding`

View File

@@ -0,0 +1,32 @@
# 【前端架构】Platform Selection Stage Model 收口计划
## 背景
`PlatformEntryFlowShellImpl.tsx` 在受保护数据失效后会清空当前用户的私有作品、运行态、草稿 notice 和生成状态。清理完成后,壳层还要判断当前 `SelectionStage` 是否还能继续展示:公开首页、公开详情、工作台入口等阶段可保留;结果页、生成页、运行态、个人反馈等依赖私有数据或运行态快照的阶段必须回到首页。
此外,平台壳还曾在多个 `useEffect` 中分别判断 big-fish、match3d、square-hole、visual-novel、baby-object-match 缺少草稿、作品或 run 时应回工作台、结果页还是首页。这类“当前 stage 已不能被现有状态支撑”的规则同样属于 stage 纯判定,不应散在壳层。
此前这些规则以内联长否定串或多段相似 effect 维护在壳层 **Implementation** 内。新增玩法 stage 或调整登录态行为时,维护者必须在巨型壳层中查找白名单和状态缺失回退,缺少独立测试面。
## 决策
新增 `src/components/platform-entry/platformSelectionStageModel.ts` 作为 Platform Selection Stage **Module**。其公开 **Interface** 为:
- `resolveSelectionStageAfterProtectedDataLoss(stage)`:输入当前 `SelectionStage`,输出受保护数据失效后应停留的 stage可保留则原样返回否则返回 `platform`
- `resolveSelectionStageAfterMissingCreationState(params)`:输入当前 `SelectionStage` 与各玩法“是否有 session / draft / run / work / formPayload”等可渲染事实输出状态缺失后应停留的 stage仍可展示则原样返回。
`PlatformEntryFlowShellImpl.tsx` 仍作为副作用 **Adapter**:负责检测受保护数据从可读变为不可读、清空各玩法缓存、重置生成和错误状态,或把当前 React state 汇总为布尔事实,并只在模型输出与当前 stage 不一致时调用 `setSelectionStage(nextStage)`
## 约定
- 新增 `SelectionStage` 时,必须判断它在退出登录或鉴权上下文收回后是否仍可展示,并在本 **Module** 的全量 `Record<SelectionStage, boolean>` 与测试中列明。
- 公开列表、公开详情和创作工作台入口可保留;依赖当前用户私有数据、生成 session、运行态 run 或个人资料的 stage 默认回 `platform`
- 缺失状态回退只读取壳层传入的布尔事实,不直接读取玩法 session / work / run 对象。big-fish、match3d、square-hole 的草稿事实必须来自 `Boolean(session?.draft)`visual-novel 的 session draft 与 work draft 可独立支撑结果页baby-object-match runtime 缺 draft 时不看 formPayload直接回 `platform`
-**Module** 不清理 state、不调用路由、不触发登录弹窗只表达纯 stage 决策。
## 验收
- `npm run test -- src/components/platform-entry/platformSelectionStageModel.test.ts`
- `npx eslint src/components/platform-entry/platformSelectionStageModel.ts src/components/platform-entry/platformSelectionStageModel.test.ts src/components/platform-entry/PlatformEntryFlowShellImpl.tsx --quiet`
- `npm run typecheck`
- `npm run check:encoding`

View File

@@ -0,0 +1,30 @@
# 【前端架构】Profile Dashboard Presentation 收口计划
## 背景
`RpgEntryHomeView.tsx` 的“我的数据”、钱包 chip 和“玩过”弹窗共用一批展示规则:泥点数量压缩、累计时长固定小时展示、单作品游玩时长压缩、作品类型标签和作品 ID 兜底。原先这些规则散在页面 **Implementation** 内,导致格式口径只能靠 UI 集成测试间接保护。
## 决策
新增 `src/components/rpg-entry/rpgEntryProfileDashboardPresentation.ts`,作为个人数据展示 **Module**。该 **Module****Interface** 收口为:
- `buildProfileDashboardPresentation(dashboard)`:统一生成钱包余额、钱包文案、累计时长文案和已玩数量文案。
- `formatDashboardCount(value)`:统一泥点和计数压缩规则。
- `formatTotalPlayTimeHours(playTimeMs)`:统一“累计游戏时长”固定小时口径。
- `formatCompactPlayTime(playTimeMs)`:统一“玩过”单作品紧凑时长。
- `formatPlayedWorkType(value)``formatPlayedWorkId(work)`:统一“玩过”列表里的玩法标签和作品号兜底。
`RpgEntryHomeView.tsx` 只消费这些 presentation 函数,保留卡片、弹窗和点击处理。个人数据展示规则的 **Locality** 转移到该 **Module** 与纯测试,后续修改计数、时长或作品类型标签不再穿透页面 JSX。
## 约定
- `formatDashboardCount` 与公开作品卡片的 `formatCompactCount` 不合并,二者展示口径不同。
- “累计游戏时长”固定以小时展示,避免个人数据卡在分钟 / 天之间跳动。
- “玩过”列表当前仍按历史契约用 `profileId || worldKey` 展示作品号;若后端未来下发 `publicWorkCode`,应在此 **Module** 改口径。
## 验证
- `npm run test -- src/components/rpg-entry/rpgEntryProfileDashboardPresentation.test.ts`
- `npm run typecheck`
- `npm run check:encoding`
- 针对变更文件执行 ESLint

View File

@@ -0,0 +1,31 @@
# 【前端架构】Profile Funds ViewModel 收口计划
## 背景
`RpgEntryHomeView.tsx` 原先直接维护钱包账单来源文案、金额正负号、账单余额兜底、充值价格、充值商品主值和会员摘要文案。这些规则散在页面 **Implementation** 内,且已与 `ProfileWalletLedgerEntry.sourceType` 契约产生漂移:后端可返回 `puzzle_author_incentive_claim`,页面没有对应中文 label会把原始枚举值外显给用户。
## 决策
新增 `src/components/rpg-entry/rpgEntryProfileFundsViewModel.ts` 作为个人资金展示 **Module**。该 **Module****Interface** 收口为:
- `getWalletLedgerSourceLabel(sourceType)`:统一账单来源中文文案,补齐 `puzzle_author_incentive_claim`
- `formatWalletLedgerAmount(amountDelta)`:统一账单金额正负号。
- `buildWalletLedgerPresentation(ledger, fallbackBalance)`:统一余额兜底与账单行 presentation。
- `formatRechargePrice(priceCents)``buildRechargeProductValueLabel(product)`:统一充值商品价格与主值文案。
- `buildMembershipLabel(membership, formatTime)`:统一会员摘要文案,并保留页面现有时间格式 Adapter。
`RpgEntryHomeView.tsx` 只消费该 **Module** 输出,保留弹窗布局、支付流程、微信渠道和轮询副作用。资金展示规则的 **Locality** 收口到纯函数测试,后续新增账单来源或调整价格 / 会员文案时不再穿透页面 JSX。
## 约定
- 未知账单来源仍保留原始 sourceType 兜底,避免新后端枚举被空白吞掉。
- 账单余额继续沿用既有口径:有账单时取第一条 `balanceAfter`,无账单时使用外部 fallback balance。
- 本次只收展示 **Interface**,不迁移支付确认、微信跳转、订单轮询或弹窗状态。
## 验证
- `npm run test -- src/components/rpg-entry/rpgEntryProfileFundsViewModel.test.ts`
- `npm run test -- src/components/rpg-entry/RpgEntryHomeView.recharge.test.tsx -t "wallet ledger|profile recharge modal shows native qr code"`
- 针对变更文件执行 ESLint
- `npm run typecheck`
- `npm run check:encoding`

View File

@@ -0,0 +1,29 @@
# 【前端架构】Profile Task ViewModel 收口计划
## 背景
`RpgEntryHomeView.tsx` 的“每日任务”卡片与任务弹窗共用同一批展示规则:任务优先级、可领取 / 未完成选择、进度 clamp、奖励兜底、状态标签和按钮文案。原先这些规则散在巨型页面 **Implementation**UI JSX 既要渲染,又要知道任务状态排序和兜底口径。
## 决策
新增 `src/components/rpg-entry/rpgEntryProfileTaskViewModel.ts`,作为每日任务展示模型 **Module**。该 **Module****Interface** 收口为:
- `selectProfileTaskCenterTasks(tasks)`:统一任务中心只展示一条可操作任务,按 claimable / incomplete 优先级并保持原始顺序。
- `selectProfileTaskCardTask(tasks)`:统一任务卡兜底顺序,先可操作,再 claimed再非 disabled。
- `buildProfileTaskCardSummary(center)`:统一任务卡的奖励、阈值、进度百分比与动作文案。
- `buildProfileTaskProgressLabel(task)``getProfileTaskStatusLabel(status)``getProfileTaskClaimButtonLabel(task, isClaiming)`:统一任务弹窗中的进度、状态和按钮文案。
`RpgEntryHomeView.tsx` 只消费这些 ViewModel 函数,保留弹窗、按钮和点击处理。每日任务展示规则的 **Locality** 转移到 ViewModel **Module** 与纯测试,后续新增任务状态或修改展示优先级不再穿透 UI。
## 约定
- 任务中心只露出当前最需要用户处理的一条任务。
- 任务进度必须按 `0..threshold` clamp避免异常后端进度撑破卡片进度条。
- `pause` / `claim` 等副作用仍留在页面和后端 clientViewModel 只做展示派生。
## 验证
- `npm run test -- src/components/rpg-entry/rpgEntryProfileTaskViewModel.test.ts`
- `npm run typecheck`
- `npm run check:encoding`
- 针对变更文件执行 ESLint

View File

@@ -0,0 +1,40 @@
# 【前端架构】Public Gallery ViewModel 收口计划
## 背景
`RpgEntryHomeView.tsx` 同时承担首页、发现、分类、排行、搜索和公开作品卡片渲染。公开作品的 category 分组、跨来源去重、搜索归一化、作品号匹配、时间戳解析和列表排序原本都放在页面巨型 **Implementation** 中,导致公开作品规则与 JSX 交错,新增玩法时难以判断该改页面、卡片还是平台入口规则。
## 决策
新增 `src/components/rpg-entry/rpgEntryPublicGalleryViewModel.ts`,作为公开作品 ViewModel **Module**。该 **Module****Interface** 收口为:
- `buildPublicGalleryCardKey(entry)`:复用平台公开作品身份规则,补齐 jump-hop / wooden-fish 等玩法 key。
- `buildPublicCategoryGroups(featuredEntries, latestEntries)`:统一去重、标签兜底和分类排序。
- `getPlatformPublicEntries(featuredEntries, latestEntries)` / `getAllPlatformPublicEntries(featuredEntries, latestEntries)`:统一公开作品合并规则。
- `getPlatformSearchableWorkIds(entry)``filterPlatformWorkSearchResults(entries, keyword)``isExactPublicWorkCodeSearch(entries, keyword)`统一搜索归一化、compact code 匹配和排序。
- `parsePlatformEntryTimestamp(value)` / `getPlatformWorldTimestamp(entry)`:统一兼容 ISO 与后端 seconds.microsZ 时间戳。
- `filterTodayPublishedEntries(entries)`:统一“今日游戏”本地自然日筛选。
- `getPlatformWorldLikeCount(entry)` / `getPlatformWorldPlayCount(entry)` / `getPlatformWorldRemixCount(entry)``buildPlatformRankingEntries(entries, tab)``getPlatformRankingMetricValue(entry, tab)`:统一公开卡片指标读取、排行 Tab 排序与取值。
- `DEFAULT_PLATFORM_CATEGORY_KIND_FILTER``DEFAULT_PLATFORM_CATEGORY_SORT_MODE``PLATFORM_CATEGORY_KIND_FILTERS``PLATFORM_CATEGORY_SORT_OPTIONS``getPlatformCategoryKindFilterOption(kindFilter)``getPlatformCategorySortOption(sortMode)``getNextPlatformCategorySortMode(sortMode)`:统一分类频道的筛选 / 排序选项、默认值、label 兜底和排序循环。
- `getPlatformCategoryKindFilter(entry)``matchesPlatformCategoryKindFilter(entry, kindFilter)``sortPlatformCategoryEntries(entries, sortMode)``getPlatformCategoryPrimaryMetric(entry)`:统一分类频道的玩法过滤、排序和主指标展示。
`RpgEntryHomeView.tsx` 只消费这些 ViewModel 函数,保留渲染、事件处理和账号状态。公开作品规则的 **Locality** 转移到 ViewModel **Module** 与其测试,页面不再持有这批纯规则。
## 约定
- 公开作品身份 key 与平台入口推荐流保持一致,优先复用 `platformPublicGalleryFlow`
- 搜索应同时匹配作品号、`profileId``workId`、标题、作者、摘要和副标题。
- 搜索排序先看标题前缀,再看作品号 compact 前缀,最后按发布时间 / 更新时间倒序。
- 时间解析必须保留后端 `seconds.microsZ` 兼容。
- 分类筛选与排序的选项顺序、默认值、中文 label 和“综合 -> 最新 -> 游玩 -> 点赞 -> 综合”循环属于 ViewModel **Interface**;页面只能消费该 **Interface**,不得在 `RpgEntryHomeView.tsx` 复写数组或 fallback 文案。
## 后续深化
下一步可把移动 / 桌面 discover feed 的数据准备继续迁入 ViewModel但卡片 JSX 与交互状态仍留页面内。
## 验证
- `npm run test -- src/components/rpg-entry/rpgEntryPublicGalleryViewModel.test.ts`
- `npm run typecheck`
- `npm run check:encoding`
- 针对变更文件执行 ESLint

View File

@@ -0,0 +1,31 @@
# 【前端架构】Public Work Presentation 收口计划
## 背景
`RpgEntryHomeView.tsx` 的作品卡、推荐 runtime meta、排行项、分类项、搜索结果和桌面 hero 共用公开作品玩法类型 label 与紧凑计数格式。原先 `describePublicGalleryCardKind``formatCompactCount` 放在页面 **Implementation** 内,导致新增玩法或调整数字展示时需要穿过多段 JSX。公开作者 lookup key 与头像首字也曾由页面手写,页面既要知道公开作品作者来源优先级,又要知道 `code:` / `id:` 前缀约定。
## 决策
`src/components/rpg-entry/rpgEntryWorldPresentation.ts` 追加单作品展示 **Interface**
- `describePlatformPublicWorkKind(entry)`:统一公开作品玩法类型 label并继续复用 `formatPlatformWorkDisplayTag` 的 4 字截断口径。
- `formatPlatformCompactCount(value)`:统一游玩、改造、点赞、排行和分类指标的紧凑数字展示。
- `resolvePlatformPublicWorkAuthorLookup(entry)`:统一公开作者查询 lookup优先使用 `authorPublicUserCode`,否则回退 `ownerUserId`,并用结构化 `{ key, source, value }` 避免页面复写前缀规则。
- `formatPlatformPublicAuthorAvatarLabel(authorDisplayName)`:统一公开作者头像无图时的首字兜底。
`RpgEntryHomeView.tsx` 删除本地类型 label、紧凑计数、公开作者 lookup 与头像首字 **Implementation**,仅消费 `rpgEntryWorldPresentation.ts`。认证请求、缓存和失败兜底仍留页面侧 Adapter集合筛选、排序和指标选择仍留在 `rpgEntryPublicGalleryViewModel.ts`,避免单作品展示 **Module** 与集合 **Module** 混杂。
## 约定
- 紧凑计数保留既有口径:`10000` 显示 `1.0万``100000000` 显示 `1.0亿`,一万以下不加千分位。
- 玩法类型 label 继续遵循 4 字展示限制,例如“大鱼吃小鱼”外显为“大鱼吃小”。
- 公开作者 lookup 的 `key` 只用于缓存索引;真正调用公开用户 Adapter 时以 `source``value` 分发,页面不得解析 `code:` / `id:` 前缀。
- 本次不迁移排行 metric label / value 配对;该规则属于集合排序 **Module** 的后续切片。
## 验证
- `npm run test -- src/components/rpg-entry/rpgEntryWorldPresentation.test.ts`
- `npm run test -- src/components/rpg-entry/RpgEntryHomeView.recharge.test.tsx -t "recommend|ranking|category"`
- 针对变更文件执行 ESLint
- `npm run typecheck`
- `npm run check:encoding`

View File

@@ -0,0 +1,32 @@
# 【前端架构】Ranking ViewModel 收口计划
## 背景
平台发现页排行频道以 `PlatformRankingTab` 决定 tab 文案、空态文案、排序字段和指标展示。原先排序与指标取值在 `rpgEntryPublicGalleryViewModel.ts`,而 tab label、metric label 与 empty text 留在 `RpgEntryHomeView.tsx`,页面还用类型断言寻找 active config导致同一个排行语义的 **Interface** 分散。
## 决策
`src/components/rpg-entry/rpgEntryPublicGalleryViewModel.ts` 收口排行 **Interface**
- `DEFAULT_PLATFORM_RANKING_TAB``PLATFORM_RANKING_TABS`:统一 tab 顺序、tab label、metric label 与空态文案。
- `getPlatformRankingTabConfig(tab)`:统一 active tab 配置兜底。
- `getPlatformRankingMetric(entry, tab)`:统一 metric label 与 value避免 label/value 漂移。
- `buildPlatformRankingEntries(entries, tab)` 继续承载排序规则。
`RpgEntryHomeView.tsx` 只保留 active tab 状态、点击与渲染,不再理解“热门榜=游玩值”“新品榜=近 7 日值”等映射。排行规则的 **Locality** 收口到 PublicGallery ViewModel。
## 约定
- 默认排行 tab 保持 `hot`
- tab 顺序保持“热门榜 / 改造榜 / 新品榜 / 点赞榜”。
- 排序口径保持:`hot=playCount``remix=remixCount``new=recentPlayCount7d``like=likeCount`
- “新品榜”仍按近 7 日游玩数排序,不改为发布时间排序。
- 页面层继续保留最多显示 30 条的展示限制。
## 验证
- `npm run test -- src/components/rpg-entry/rpgEntryPublicGalleryViewModel.test.ts`
- `npm run test -- src/components/rpg-entry/RpgEntryHomeView.recharge.test.tsx -t "bottom category tab becomes ranking and switches ranking metrics|ranking"`
- 针对变更文件执行 ESLint
- `npm run typecheck`
- `npm run check:encoding`

View File

@@ -0,0 +1,31 @@
# 【前端架构】Recommend Feed ViewModel 收口计划
## 背景
平台首页推荐 feed、发现页推荐频道、桌面推荐格和正式 runtime 的上一条 / 下一条选择共用一批展示规则公开作品跨来源去重、过滤寓教于乐隐藏内容、按精选优先再最新兜底、active key 失效时回到首项、前后相邻条目回环且单条目不自循环。原先这些规则分别散在 `RpgEntryHomeView.tsx``PlatformEntryFlowShellImpl.tsx`**Implementation** 内,导致推荐预览与正式 runtime 之间存在口径漂移风险。
## 决策
`src/components/rpg-entry/rpgEntryPublicGalleryViewModel.ts` 追加推荐 feed **Interface**
- `dedupePlatformPublicGalleryEntries(entries)`:统一公开作品按 `buildPublicGalleryCardKey` 去重,后出现来源覆盖旧值。
- `buildPlatformRecommendFeedEntries(featuredEntries, latestEntries)`:统一推荐 feed 的精选 + 最新合并、隐藏寓教于乐内容与去重顺序。
- `selectPlatformRecommendFeedWindow(entries, activeEntryKey)`:统一推荐页当前项、上一项、下一项和 active key 失效兜底。
- `selectAdjacentPlatformRecommendEntry(entries, direction, baseEntryKey)`:统一正式 runtime 上一条 / 下一条回环选择,并避免单作品自循环。
`RpgEntryHomeView.tsx` 不再自建 `Map` 或手写取模;`PlatformEntryFlowShellImpl.tsx` 的 runtime 推荐条目也改用同一 **Module**。推荐 feed 的 **Locality** 回到 PublicGallery ViewModel页面与 runtime 只保留 UI、动画和启动副作用。
## 约定
- 推荐 feed 仍只展示普通公开作品;寓教于乐内容由独立频道控制,不进入推荐 runtime 队列。
- 去重保留既有“后出现来源覆盖旧值、插入位置不变”的行为。
- active key 缺失或失效时,展示窗口回到首个推荐作品;单个作品没有上一条 / 下一条预览。
## 验证
- `npm run test -- src/components/rpg-entry/rpgEntryPublicGalleryViewModel.test.ts`
- `npm run test -- src/components/rpg-entry/RpgEntryHomeView.recharge.test.tsx -t "recommend|edutainment"`
- `npm run test -- src/components/rpg-entry/RpgEntryFlowShell.agent.interaction.test.tsx -t "logged out home recommendation next starts the next puzzle work"`
- 针对变更文件执行 ESLint
- `npm run typecheck`
- `npm run check:encoding`

View File

@@ -0,0 +1,42 @@
# RecommendSwipeDeckModel 收口计划
## 背景
移动端推荐首页的纵向 swipe deck 曾把拖拽阈值、offset clamp、commit 方向、rail class 和分享文案直接放在 `RpgEntryHomeView.tsx` Implementation 内。页面因此同时理解 DOM pointer 副作用、动画副作用与推荐卡纯规则,后续调整手势阈值或分享文案时缺少稳定测试面。
## 决策
- 新增 `src/components/rpg-entry/rpgEntryRecommendSwipeDeckModel.ts` 作为 Recommend Swipe Deck Module。
- Module Interface 收口:
- `hasRecommendDragStarted`
- `clampRecommendDragOffset`
- `resolveRecommendDragCommitDirection`
- `resolveRecommendCommitOffset`
- `buildRecommendSwipeRailClassName`
- `shouldAnimateRecommendSwipe`
- `buildRecommendShareText`
- `RpgEntryHomeView.tsx` 保留 pointer capture、DOM 高度读取、`setTimeout`、clipboard、like/remix/open 等副作用 Adapter推荐卡纯规则不再散落在页面 Implementation 内。
## Interface 约束
- swipe 阈值、commit 动画时长和 drag fallback limit 只从 Module 导出,不在页面重复定义。
- `deltaY < 0` 表示上滑进入下一条,返回方向 `1``deltaY > 0` 表示下滑进入上一条,返回方向 `-1`
- 未达到 commit 阈值时必须返回 `null`,页面 Adapter 只负责把 offset 归零。
- rail class 仅由 `offsetY``commitDirection` 决定CSS class 名保持现有命名。
- 分享文案只使用公开作品名、作品号和详情 URL公开作品码解析与复制副作用仍在页面 Adapter。
## Depth / Leverage / Locality
- **Depth**:页面传入少量数值或公开作品身份,即可得到拖拽状态、提交方向、动画 class 和分享文案。
- **Leverage**:调整推荐 swipe 体验时只需改 Module 与单测,交互测试仍护页面 Adapter。
- **Locality**pointer 事件生命周期与纯规则分离,推荐卡手势和分享规则集中到一个小 Module。
## 验收
- `npm run test -- src/components/rpg-entry/rpgEntryRecommendSwipeDeckModel.test.ts`
- `npm run test -- src/components/rpg-entry/RpgEntryHomeView.recharge.test.tsx -t "recommend|edutainment"`
- `npm run test -- src/components/rpg-entry/rpgEntryPublicGalleryViewModel.test.ts -t "recommend"`
- `npx eslint src/components/rpg-entry/rpgEntryRecommendSwipeDeckModel.ts src/components/rpg-entry/rpgEntryRecommendSwipeDeckModel.test.ts --max-warnings 0`
- `npx eslint src/components/rpg-entry/RpgEntryHomeView.tsx --quiet`
- `npm run typecheck`
- `npm run check:encoding`

View File

@@ -0,0 +1,32 @@
# 【前端架构】Runtime Client Family 收口计划
## 背景
多个小游戏 runtime client 都重复实现路径编码、JSON header / body、runtime guest token、认证影响策略和重试参数。重复逻辑分散在各玩法文件后新增玩法容易遗漏 guest auth 或 retry 语义,也让测试必须逐玩法检查同一请求骨架。
## 决策
新增 `src/services/runtimeRequest.ts`,作为 Runtime Client Family 的请求 **Module**。其 **Interface** 包含:
- `buildRuntimeApiPath(basePath, ...segments)`:统一对 runtime path segment 执行 `encodeURIComponent`
- `requestRuntimeJson(params)`:统一设置 method、JSON body、`Content-Type`、runtime guest `Authorization`、auth options 和 retry options。
`match3dRuntimeClient.ts``squareHoleRuntimeClient.ts``bigFishRuntimeClient.ts``barkBattleRuntimeClient.ts``puzzleRuntimeClient.ts` 的公开 / 推荐运行态请求、`jumpHopClient.ts``woodenFishClient.ts` 的正式 run 请求,以及 `visualNovelRuntimeClient.ts` 的公开列表、run 读取、history 读取和 regenerate JSON 请求已迁入此 **Module**,并保留原有导出函数名、错误文案、返回契约和重试常量。点击 / 投入 / 成绩提交等玩法专属 payload 归一化仍留在各自 client 内,避免把领域规则塞进通用请求 **Implementation**
## 约定
- Runtime client 不再手写 `encodeURIComponent` 拼 path应优先使用 `buildRuntimeApiPath`
- Runtime JSON 请求不再手写 `Content-Type`、guest `Authorization``buildRuntimeGuestAuthOptions` 合并;应优先使用 `requestRuntimeJson`
- 玩法专属 payload 归一化、返回值适配和中文错误文案仍属于各玩法 client。
- 每迁移一个 client必须保留原导出函数名与原调用方契约。
## 后续深化
下一批可评估是否扩展 `requestRuntimeJson` 支持 `timeoutMs` / `signal`,再迁移 Visual Novel start 请求Visual Novel SSE、平台存档、平台 checkpoint以及 Puzzle `pause` / `props` 继续保留各自现有 auth / stream 语义,暂不纳入通用 JSON helper。
## 验证
- `npm run test -- src/services/runtimeRequest.test.ts src/services/recommendedRuntimeGuestLaunch.test.ts src/services/match3d-runtime/match3dRuntimeAdapter.test.ts`
- `npm run typecheck`
- `npm run check:encoding`
- 针对变更文件执行 ESLint

View File

@@ -0,0 +1,36 @@
# SSE 客户端传输层收口约定
更新时间:`2026-06-03`
## 背景
前端多个服务 client 需要读取 Server-Sent Events包括创作 Agent、创意互动 Agent、视觉小说运行态和微信充值订单状态。旧实现分别在各自文件里手写事件边界查找、`TextDecoder` 解码、JSON 解析和流结束 flush容易出现 CRLF / LF 边界不一致、UTF-8 多字节字符尾部丢失、错误事件处理漂移,以及长连接达到最终状态后没有及时释放的问题。
## 决策
前端 SSE 的传输层统一收口到 `src/services/sseStream.ts`
- `readSseStream` 负责读取 `Response.body`、识别 `\n\n``\r\n\r\n` 事件边界、合并多行 `data:`、flush `TextDecoder` 尾部缓冲,并支持事件处理函数返回 `false` 后取消 reader。
- `readSseJsonStream` 只在传输事件基础上解析 JSON object空 data 与异常 JSON 继续按旧口径静默跳过。
- 各业务 client 只保留领域事件归一化、最终结果聚合和中文错误文案,不再重复实现 SSE 边界扫描、reader 循环或 UTF-8 flush。
- OpenAI 兼容流、`[DONE]` 哨兵或其它非 JSON SSE 可直接使用 `readSseStream`;业务 JSON 事件优先使用 `readSseJsonStream`
## 落地范围
本次先收口以下客户端:
- `src/services/aiService.ts`
- `src/services/creation-agent/creationAgentSse.ts`
- `src/services/creative-agent/creativeAgentSse.ts`
- `src/services/visual-novel-runtime/visualNovelRuntimeSse.ts`
- `src/services/rpg-entry/rpgProfileClient.ts`
- `src/services/llmClient.ts`
后续新增 SSE client 时不得复制 `findSseEventBoundary``parseSseEventBlock` 或手写 reader 循环;若确实需要特殊 framing应先扩展 `sseStream.ts` 的传输能力,再在业务 client 中处理领域语义。
## 验收
- `src/services/sseStream.test.ts` 覆盖 CRLF / LF 边界、UTF-8 尾部 flush、异常 JSON 跳过和提前停止取消 reader。
- `src/services/llmClient.test.ts` 覆盖 OpenAI 兼容文本流、异常 JSON 跳过和 `[DONE]` 后提前停止。
- 已有 OpenAI 兼容文本流、NPC 聊天流、创作 Agent、创意互动 Agent、视觉小说运行态和充值订单状态测试继续通过。
- `npm run typecheck` 不产生新的类型错误。

View File

@@ -0,0 +1,34 @@
# 【前端架构】Work Shelf Module 收口计划
## 背景
创作中心作品架需要同时展示 RPG、拼图、抓大鹅、方洞、跳一跳、敲木鱼、视觉小说、Bark Battle 和宝贝识物等作品。`creationWorkShelf.ts` 已经统一了卡片标题、摘要、封面、发布码、分享路径、指标、生成态和动作 Adapter。后续深化前`CustomWorldCreationHub.tsx` 虽已不再按玩法 `kind` 分发点击,但生产调用仍向 Hub 传入多玩法 raw items 与 open/delete/claim 回调列阵Hub Interface 仍偏 shallow。
## 决策
`CreationWorkShelfItem.actions.open` 是打开作品的正式 **Interface**`CustomWorldCreationHub.tsx` 只负责卡片点击与 `onOpenShelfItem` 通知,然后调用 `item.actions.open()`,不再根据 `item.source.kind` 分发玩法。
`buildCreationWorkShelfItemsFromSources` 是作品架 source registry 的正式 **Interface**。每个玩法提供一个 `CreationWorkShelfSourceAdapter`Adapter 负责把玩法数据、删除权限、打开动作和特殊动作映射为 `CreationWorkShelfItem[]`。registry 统一执行 flatten、运行态覆盖、持久化生成态兜底和更新时间排序。
`CustomWorldCreationHub.tsx` 的生产 **Interface** 收敛为 `shelfItems: CreationWorkShelfItem[]``loading/error/onRetry/mode/recentWorkItems/onOpenShelfItem/deletingWorkId/claimingPuzzleProfileId` 等 UI 状态。平台壳 `PlatformEntryFlowShellImpl.tsx` 在外层作为 Adapter 调用 `buildCreationWorkShelfItems` 注入完整 open/delete/claim actions 后再传给 HubHub 不再接触各玩法 raw items、删除权限布尔值或玩法专属打开回调。
测试文件通过 `CustomWorldCreationHub.testAdapter.tsx` 把旧 fixture 转成 `shelfItems`,避免测试继续强化生产 Hub 的旧浅 Interface。
此决策让 `creationWorkShelf.ts`**Module** 更 deep
- **Implementation**:玩法差异、草稿 / 已发布分支、profileId 进入方式和回调绑定都留在 Work Shelf Adapter 内。
- **Interface**Hub 只需要 `CreationWorkShelfItem[]`;后续调用方也可只传 `CreationWorkShelfSourceAdapter[]`,不需要知道每种玩法的打开规则、状态覆盖和排序规则。
- **Leverage**:新增玩法时只补 shelf item 映射与 AdapterHub 不再新增 switch 分支。
- **Locality**作品架点击行为、source flatten、运行态覆盖和排序错误集中在 `creationWorkShelf.ts` 与其测试里定位。
## 后续深化
`buildCreationWorkShelfItems` 仍保留旧长参数兼容入口,但其 **Implementation** 已改为组装 `CreationWorkShelfSourceAdapter[]` 后复用 `buildCreationWorkShelfItemsFromSources`。下一步可让平台壳直接传入 source adapters从而继续减少按玩法平铺的参数数量。`deletingWorkId``claimingPuzzleProfileId` 仍是 Hub UI 状态,可后续下沉到 shelf item/action busy state。
## 验证
- `npm run test -- src/components/custom-world-home/creationWorkShelf.test.ts src/components/custom-world-home/CustomWorldCreationHub.test.tsx src/components/custom-world-home/CustomWorldCreationHub.interaction.test.tsx`
- `npm run test -- src/components/rpg-entry/RpgEntryFlowShell.agent.interaction.test.tsx -t "creation hub published work can open detail view before deleting from detail page|creation hub published work enters existing detail view|creation hub published work card reveals delete action after card action reveal"`
- `npm run typecheck`
- `npm run check:encoding`
- 针对变更文件执行 ESLint

View File

@@ -0,0 +1,56 @@
# 【前端架构】平台入口 Public Gallery Flow Module 收口计划
## 背景
`PlatformEntryFlowShellImpl.tsx` 同时承载平台入口、推荐流、公开作品详情、运行态启动和作品架刷新。公开作品列表中的身份识别、跨玩法去重、时间排序和推荐运行态类型判定原本散落在入口巨型实现中,后续每新增一种玩法都需要在巨型文件内追加判断,影响前端架构的复用、统一和扩展。
## 决策
新增 `src/components/platform-entry/platformPublicGalleryFlow.ts`,作为平台入口公开作品流的 **Module**。该 Module 的 **Interface** 固定收口为:
- `getPlatformPublicGalleryEntryKey(entry)`:按玩法类型、作者和 `profileId` 生成公开作品身份。
- `getPlatformRecommendRuntimeKind(entry)`:把公开作品卡映射为推荐运行态 kind。
- `resolvePlatformRecommendRuntimeStartIntent(entry, deps)`:把公开作品卡映射为推荐 runtime 启动意图、错误落点和 embedded / returnStage 参数。
- `isPlatformRecommendRuntimeReadyForEntry(entry, state)`:用标量 ready state 判定当前推荐 runtime 是否已能承接该公开作品。
- `isSamePlatformPublicGalleryEntry(left, right)`:按公开作品身份比较。
- `mergePlatformPublicGalleryEntries(rpgEntries, puzzleEntries)`:统一完成 RPG 与各玩法公开作品去重、覆盖和倒序排序。
- `buildPlatformPublicGalleryFeeds(input)`:统一构造 `featuredEntries``latestEntries`,收口各玩法可见性 gate、mapper 矩阵、汪汪声浪 works fallback 和推荐首屏 `slice(0, 6)`
入口壳层只调用这些函数,不再在 `PlatformEntryFlowShellImpl.tsx` 内手写公开作品身份、排序规则、公开作品流聚合矩阵、推荐 runtime 启动能力矩阵和 ready 判定。ready 判定只接布尔值与拼图 profile id不把各玩法 run snapshot 类型拖入 Module。
## 玩法身份规则
- `big-fish``puzzle``jump-hop``wooden-fish``match3d``square-hole``visual-novel``bark-battle` 使用自身 `sourceType` 作为 key kind。
- `edutainment` 使用 `edutainment:${templateId}` 作为 key kind避免后续幼教类模板共用 `sourceType` 时互相覆盖。
- 没有 `sourceType` 的 RPG 公开作品回退为 `rpg`
- 最终 key 格式为 `${kind}:${ownerUserId}:${profileId}`
- 合并时后进入的相同 key 会覆盖先进入的卡片,然后按 `publishedAt ?? updatedAt` 新到旧排序;非法时间按 `0` 处理。
- 公开作品流聚合时,大鱼吃小鱼、宝贝识物和视觉小说必须受各自可见性 gate 控制;汪汪声浪优先用 gallery entriesgallery 为空时才从 works 中筛 `status === 'published'` 作为 fallback。
## 推荐 runtime 启动意图
- `resolvePlatformRecommendRuntimeStartIntent` 只表达推荐 runtime 的启动目标,不执行鉴权、运行态 API、错误 setter、缓存、request key 或 UI 状态更新。
- 大鱼吃小鱼、拼图、跳一跳、敲木鱼、抓大鹅、方洞挑战、视觉小说、汪汪声浪和宝贝识物返回对应启动 intentRPG 维持当前无嵌入 runtime 的 `mark-ready` 行为。
- 大鱼吃小鱼、拼图、抓大鹅、方洞挑战和汪汪声浪在公开卡无法拼出启动 work 时返回 `blocked`,同时给出 `errorTarget`,由壳层 Adapter 分发到对应玩法错误 setter。
- 拼图优先使用同 `profileId``selectedPuzzleDetail`,否则从公开卡映射兼容 work 摘要。
- 抓大鹅 public detail -> work mapper 必须作为 Adapter 注入,继续由 Match3D Runtime Profile Module 维护 `generatedItemAssets` 归一化与背景资产提升。推荐 runtime 固定沿用旧参数 `returnStage = 'work-detail'``embedded = true`
- 汪汪声浪优先使用推荐流已持有的 `barkBattleGalleryEntries`,再回退公开卡映射;不额外读取作品架列表。
## 推荐 runtime ready 判定
- `isPlatformRecommendRuntimeReadyForEntry` 先要求 `state.activeKind` 与当前公开作品的 `getPlatformRecommendRuntimeKind(entry)` 相同,否则返回 `false`
- 大鱼吃小鱼、跳一跳、敲木鱼、抓大鹅、方洞挑战和视觉小说只看对应 `has*Run` 布尔值,保持旧行为,不在本 Module 内解析 run snapshot。
- 拼图只看 `puzzleRunEntryProfileId``puzzleRunCurrentLevelProfileId` 是否等于当前公开作品 `profileId`
- 汪汪声浪和 RPG 在 kind 匹配时沿用旧 `ready = true` 行为;宝贝识物只看 `hasBabyObjectMatchDraft`
- 若未来要修正同玩法旧 run 误判或 RPG 无嵌入 runtime 的旧行为,应另立行为变更任务;本 Module 先只收口现有规则。
## 后续深化
下一步可继续把平台入口的作品架刷新、删除确认和直达恢复逻辑收口成更深的 Work Shelf **Module**。当前 `platformPublicGalleryFlow` 先提供一个稳定 seam使公开作品 identity、runtime kind、推荐 runtime 启动意图与 ready 判定的修改集中在一处。
## 验证
- `npm run test -- src/components/platform-entry/platformPublicGalleryFlow.test.ts`
- `npm run typecheck`
- `npm run check:encoding`
- 针对变更文件执行 ESLint

View File

@@ -0,0 +1,38 @@
# 【后端架构】Puzzle Publish Asset Gate 收紧计划
## 背景
拼图前端恢复链路已由 `platformPuzzleDraftRecoveryModel.ts` 收紧只有首图、关卡画面、UI spritesheet 与关卡背景资产包完整时,才把恢复草稿抬为完成态。但后端仍有两处待发布门槛偏弱:
- `module-puzzle::validate_publish_requirements(...)` 只校验作品名、描述、标签、关卡名与 cover。
- `api-server::puzzle::tags::is_puzzle_session_snapshot_publish_ready(...)` 也只校验同一组轻字段,并据此把 session stage 置为 `ready_to_publish`
这会让只有首图但缺关卡正式画面、UI spritesheet 或关卡背景的半成品显示为可发布或进入待发布 stage。
## 决策
后端拼图待发布门槛统一收紧到完整首关资产包:
- `module-puzzle``validate_publish_requirements` 中新增资产 blocker业务规则继续留在领域模块。
- `api-server` 的 session snapshot ready 判定复用同一资产语言,避免标签生成后把半成品 session stage 改成 `ready_to_publish`
- 本切片不改 SpacetimeDB schema、不改 DTO 字段、不改路由、不改计费和发布动作副作用。
## 接口约束
- 仍保留既有作品名、描述、标签数量、关卡名、cover 校验。
- 每个关卡必须具备:
- `cover_image_src`
- `level_scene_image_src``level_scene_image_object_key`
- `ui_spritesheet_image_src``ui_spritesheet_image_object_key`
- `level_background_image_src``level_background_image_object_key`
- 缺正式关卡画面、UI spritesheet、关卡背景时各自输出明确 blocker。
- `build_result_preview(...).publish_ready``is_puzzle_session_snapshot_publish_ready(...)` 必须在同一类缺资产草稿上返回 false。
- `api-server` 从 action payload 构造 fallback session 时,缺资产 levels snapshot 应停留 `image_refining`,不得进入 `ready_to_publish`
## 验收
- `cargo test -p module-puzzle --manifest-path server-rs/Cargo.toml validate_publish_requirements`
- `cargo test -p api-server --manifest-path server-rs/Cargo.toml puzzle_image_generation`
- `npm run check:encoding`
- `git diff --check`
- 修改后按仓规尝试 `npm run api-server` 拉起后端并确认 `/healthz`

View File

@@ -104,6 +104,8 @@ npm run check:server-rs-ddd
- `server-rs/crates/api-server/src/puzzle/mappers.rs` 承接 SpacetimeDB record 到 shared-contracts DTO 的映射。
- `server-rs/crates/api-server/src/puzzle/tags.rs` 保留拼图标签生成、拼图通用错误映射和 SSE helper。
拼图发布 / 待发布门槛必须同时要求首图、关卡画面、UI spritesheet 与关卡背景资产包完整;`module-puzzle::validate_publish_requirements``api-server::puzzle::tags::is_puzzle_session_snapshot_publish_ready` 使用同一资产语言,不得只凭 cover、标题、描述和标签把半成品标为 `publishReady``ready_to_publish`
该拆分只改变 `api-server` 文件组织,不改变 `/api/runtime/puzzle/*` route、DTO、error envelope、SpacetimeDB schema、公开 gallery cache 语义或计费语义;后续继续细分时也必须先保持行为不变,再单独讨论领域规则下沉。
`/api/runtime/puzzle/runs*` 当前接受 `RuntimePrincipal`,可同时识别登录用户 Bearer 和 runtime guest token。推荐页嵌入运行态的正式开局、交换、拖拽、下一关、暂停、道具与排行榜请求应由前端在登录态下继续携带账号 access token匿名游客仅在确认为未登录时走 runtime guest token。不要再把拼图 runtime 当成只认普通 Bearer 的纯账号接口。

View File

@@ -1,18 +1,32 @@
# 平台入口与玩法链路
更新时间:`2026-06-06`
更新时间:`2026-06-04`
## 平台创作入口
创作入口配置事实源在 SpacetimeDB通过 `GET /api/creation-entry/config` 下发;后台通过 `/admin/api/creation-entry/config` 管理。前端只在展示层派生可见卡片和入口状态,`api-server` 路由熔断也使用同一份配置。不要恢复前端硬编码入口配置文件。
当前点击底部加号进入的创作入口页承载后台公告位、创作入口页签和两列模板卡;页签中只有真实后端作品架摘要存在时才展示“最近创作”,其余为玩法模板分类。点击模板卡后直接进入对应玩法已有的入口创作表单 stage不再经过空白占位页也不把旧表单嵌进创作入口页。移动端创作入口页顶栏在 `陶泥儿` 品牌同一行显示真实账户泥点数,数据来自 `profileDashboard.walletBalance`,不得再把公告内容或活动奖池当作账号余额展示。创作入口页公告位数据优先读取 `GET /api/creation-entry/config``eventBanners` 数组,多条配置时前端自动轮播`eventBanner` 只保留字段回显与旧客户端兼容,不再作为前端公告数组的兜底来源。后台公告配置面向表单:每条公告包含标题和 HTML 内容,后台保存时序列化为后端 `eventBannersJson` 传输字段,由前端空权限沙箱 iframe 展示;旧结构化 banner 字段仅保留回显兼容,不再作为后台公告配置主格式;不得执行 JSX 或把后台代码直接注入 DOM。玩法列表不再套外部边框卡片移动端需要压缩横向边距和两列间距玩法卡统一按“上图、左上状态标签仅非开放态显示、封面右下 `10-20泥点数`、下方白底标题/描述”结构展示,卡片高度保持紧凑但标题、描述和预估消耗点数都必须可见。创作入口页根容器不再使用 `platform-page-stage` 这类全局内容卡片壳,但继续保留 `platform-remap-surface` 作为主题和输入框样式命中钩子。创作入口页字号需要对齐平台普通 UI 档位:顶栏泥点组件、公告正文、分类 Tab 和玩法卡标题 / 副标题 / 消耗说明优先使用 `11px``14px`,不使用 `text-lg``text-xl` 或更大的展示级字号。草稿 Tab 继续承接作品架;底部加号入口页的“最近创作”只用 7 天内的真实后端作品架摘要判断是否展示,并从摘要里推导最近使用过的模板 ID页面必须展示“仅显示最近7天内使用过的模板”提示列表内容必须复用其它页签里的模板卡样式、文案和点击行为不展示具体作品名称、摘要或生成状态也不新增独立最近创作卡组件。RPG、RPG 之外的各玩法入口分别落到既有的 `agent-workspace``big-fish-agent-workspace``match3d-agent-workspace``square-hole-agent-workspace``jump-hop-workspace``wooden-fish-workspace``puzzle-agent-workspace``bark-battle-workspace``visual-novel-agent-workspace``baby-object-match-workspace`,这些入口继续承接各玩法自己的表单、草稿恢复和后续编排,不作为创作入口页内容。
旧库或旧迁移包没有 `event_banners_json` 时,后端读取层必须把 `eventBanners` 归一到 `module-runtime` 默认公告数组,不能把旧结构化 `eventBanner` 当成前端优先数组下发。默认公告引用的背景图必须指向 `public/` 下真实存在的站内静态资源,当前默认使用 `/creation-type-references/puzzle.webp`,避免创作入口顶部 banner 出现失效图片。
当前点击底部加号进入的创作入口页承载后台公告位、创作入口页签和两列模板卡;页签中只有真实后端作品架摘要存在时才展示“最近创作”,其余为玩法模板分类。点击模板卡后直接进入对应玩法已有的入口创作表单 stage不再经过空白占位页也不把旧表单嵌进创作入口页;模板点击的占位 no-op、隐藏模板拦截、未知入口 no-op 和工作台启动目标统一由 `platformCreationLaunchModel.ts` 判定,壳层只执行启动前准备、错误提示和受保护动作。移动端创作入口页顶栏在 `陶泥儿` 品牌同一行显示真实账户泥点数,数据来自 `profileDashboard.walletBalance`,不得再把公告内容或活动奖池当作账号余额展示。创作入口页公告位数据优先读取 `GET /api/creation-entry/config``eventBanners` 数组,多条配置时前端自动轮播`eventBanner` 仅作为单条兼容兜底。后台公告配置面向表单:每条公告包含标题和 HTML 内容,后台保存时序列化为后端 `eventBannersJson` 传输字段,由前端空权限沙箱 iframe 展示;旧结构化 banner 字段仅保留回显兼容,不再作为后台公告配置主格式;不得执行 JSX 或把后台代码直接注入 DOM。玩法列表不再套外部边框卡片移动端需要压缩横向边距和两列间距玩法卡统一按“上图、左上状态标签仅非开放态显示、封面右下 `10-20泥点数`、下方白底标题/描述”结构展示,卡片高度保持紧凑但标题、描述和预估消耗点数都必须可见。创作入口页根容器不再使用 `platform-page-stage` 这类全局内容卡片壳,但继续保留 `platform-remap-surface` 作为主题和输入框样式命中钩子。创作入口页字号需要对齐平台普通 UI 档位:顶栏泥点组件、公告正文、分类 Tab 和玩法卡标题 / 副标题 / 消耗说明优先使用 `11px``14px`,不使用 `text-lg``text-xl` 或更大的展示级字号。草稿 Tab 继续承接作品架;底部加号入口页的“最近创作”只用 7 天内的真实后端作品架摘要判断是否展示,并从摘要里推导最近使用过的模板 ID页面必须展示“仅显示最近7天内使用过的模板”提示列表内容必须复用其它页签里的模板卡样式、文案和点击行为不展示具体作品名称、摘要或生成状态也不新增独立最近创作卡组件。RPG、RPG 之外的各玩法入口分别落到既有的 `agent-workspace``big-fish-agent-workspace``match3d-agent-workspace``square-hole-agent-workspace``jump-hop-workspace``wooden-fish-workspace``puzzle-agent-workspace``bark-battle-workspace``visual-novel-agent-workspace``baby-object-match-workspace`,这些入口继续承接各玩法自己的表单、草稿恢复和后续编排,不作为创作入口页内容。
创作页和草稿页顶栏右上角的泥点余额胶囊是补足泥点入口:如果当前运行环境开启充值入口,点击后直接打开账户充值弹窗;否则直接打开运营兑换码弹窗。该入口不再跳到账户面板或泥点账单,头像 / 设置等账号入口继续保留各自语义。
创作恢复参数只保留 `sessionId``profileId``draftId``workId` 这四个私有 query。它们只允许在同一条创作链路的结果页、生成页、工作台之间保留切到首页、公开作品详情、runtime 或另一条玩法链路时必须清掉。生成页等待时间统一以生成状态里的 `startedAtMs` 为准;创建该状态时优先使用后端 session 下发的时间戳,作品摘要里的 `updatedAt` 仍只用于排序与摘要展示,不作为前端自行推导业务状态的真相。
创作恢复参数只保留 `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 的三元链。
拼图 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 合并规则。
拼图 / 抓大鹅草稿恢复和提交所需的表单 payload、拼图编译 action、pending metadata 与拼图 form-only 草稿判定统一由 `platformMiniGameDraftPayloadModel.ts` 构造。平台壳不得重新手写拼图描述字段优先级、formDraft 回退、form-only 空草稿判定、Match3D config / draft / anchorPack 优先级、数字解析或 pending 标题摘要派生规则。
拼图生成完成后刷新恢复的草稿归一化与可恢复完成态判定统一由 `platformPuzzleDraftRecoveryModel.ts` 处理。恢复链路只有在首图、关卡画面、UI spritesheet 与关卡背景资产包完整时才可把 draft 和首关状态抬为 `ready`;只有 cover 或候选图的半成品不得直接进入结果页完成态。
后端拼图发布 / 待发布门槛同样必须要求首图、关卡画面、UI spritesheet 与关卡背景资产包完整:`module-puzzle` preview blockers 与 `api-server` session stage 判定不得只凭 cover、标题、描述和标签把半成品标为 `publishReady``ready_to_publish`
平台入口个人钱包本地 delta 由 `platformProfileWalletDeltaModel.ts` 判定:余额归一、本地扣点 / 返还后的 dashboard 乐观更新,以及服务端 dashboard 刷新后的 delta 对账不得散落在平台壳层;壳层只负责 API、React ref 和 state 写入。
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*` 类。敲木鱼的音效和功德词条面板不得放进独立内部滚动容器,移动端应跟随页面自然滚动展开。生成页统一展示阶段、当前步骤、总进度、错误和重试动作。
@@ -61,14 +75,15 @@
9. 私有 generated 图片必须通过 `ResolvedAssetImage` / `/api/assets/read-url` 换签读取。
10. 敲木鱼作品架读取当前用户作品列表时走 `GET /api/creation/wooden-fish/works`;发布成功后平台壳必须同时刷新作品架与公开广场,避免作品刚发布时仍停留在旧列表。
11. 移动端草稿页整体禁止长按选择文字,避免误触系统选区;输入框、文本域和可编辑区域仍必须保留文本选择能力。
12. 作品架删除确认的纯规则统一由 `platformCreationWorkDeleteFlow.ts` 解析,输出确认框 `id/title/detail` 与删除成功后清理的草稿 notice keys平台壳只接回该模型执行删除 API、刷新列表、清错误和跳转。Jump Hop、Wooden Fish、Bark Battle 虽在作品架 action 层有预留删除入口,但未补齐删除 API 前不得传入删除 handler 或开放按钮。
发现页 / 推荐页公开作品卡的作者行只显示可读公开昵称;不得把手机号掩码、账号生成的脱敏手机号、`SY-*` 陶泥号或作品号拼接进卡片作者名。陶泥号搜索、作品号复制和完整作品身份只在搜索、详情页或明确的复制入口展示,避免卡片列表暴露账号标识。推荐页运行态、标题和作者信息必须使用同一套公开作品 key 选中当前条目;新增或补齐公开玩法类型时复用 `buildPlatformPublicGalleryCardKey(...)`,避免运行内容已切换但标题 / 作者仍退回第一条作品。
移动端底部导航的创作按钮在登录前后必须保持同一个图片化创作图标,不因登录态切换成加号
平台公开搜索的分流顺序、per-play 公开码匹配、公开可见性过滤和详情卡 DTO 映射统一由 `platformPublicCodeSearchModel.ts` 判定:`user_` / `user-` 内部用户 ID 只查用户 ID`PZ``BF``JH``WF``BO``M3``SH``VN``BB` 前缀分别直达对应玩法公开作品;`M3D-*` 作为抓大鹅旧前缀继续匹配;`CW` 与 1-8 位纯数字先查 RPG 公开作品再回退陶泥号;普通关键词和 `SY` 陶泥号保持先查陶泥号、再查 RPG 作品、再查汪汪声浪作品、最后陶泥号兜底的既有顺序。平台壳只按计划执行网络读取、详情打开、Bark Battle runtime 特例和缺失作品归航,不在壳层重复维护前缀布尔链、`isSame*PublicWorkCode` 或 DTO 映射
发现 Tab、创作 Tab 与草稿 Tab 的页面根内容区不再套 `platform-page-stage` 外层全局卡片壳,让列表、筛选和玩法卡获得更宽的横向空间;推荐页和我的页仍按各自页面设计保留原有全局卡片口径。移动端“我的”页仍按顶部头像 / 昵称 / 陶泥号、会员横幅、三张统计卡、每日任务、五项常用功能宫格、通用设置入口和法律信息组织,不保留旧的底部“填邀请码”次级入口;主题设置、账号与安全只放在通用设置弹窗下一级,不在外层单独占行;常用功能当前只展示四项常驻入口时必须按四列铺满整行,不保留五列网格导致左对齐空位;每日任务卡必须读取 `/api/profile/tasks` 的当前任务摘要并在领取后同步刷新卡片进度,外层卡片不展示“去完成”等行动按钮。字号必须维持平台普通 UI 档位,不能因为窄屏把卡片标题、功能 label 或法律信息撑成展示级字号;最后一屏内容必须能在底部 dock 上方完整滚动露出,不得被固定底部导航遮挡
个人“玩过作品”面板点击作品时,玩法别名、`worldKey` 前缀兜底、RPG 公开详情 payload 和大鱼吃小鱼缺 gallery 命中时的 fallback work 统一由 `platformPlayedWorkOpenModel.ts` 判定。平台壳只负责关闭面板、调用对应公开详情打开函数、刷新大鱼 gallery、优先使用真实 gallery 命中项和写入错误提示;不要在壳层重新维护 `worldType` / `worldKey` 分支链
平台应用隐藏浏览器根节点 `html` / `body` / `#root` 和平台页面级滚动容器的最外层滚动条可见轨道;弹窗、列表、运行态侧栏等内部滚动容器继续使用原有滚动条样式或显式 `.scrollbar-hide` 控制
发现 Tab、创作 Tab 与草稿 Tab 的页面根内容区不再套 `platform-page-stage` 外层全局卡片壳,让列表、筛选和玩法卡获得更宽的横向空间;推荐页和我的页仍按各自页面设计保留原有全局卡片口径。移动端“我的”页仍按顶部头像 / 昵称 / 陶泥号、会员横幅、三张统计卡、每日任务、五项常用功能宫格、设置入口和法律信息组织,不保留旧的底部“填邀请码”次级入口;常用功能当前只展示四项常驻入口时必须按四列铺满整行,不保留五列网格导致左对齐空位;每日任务卡必须读取 `/api/profile/tasks` 的当前任务摘要并在领取后同步刷新卡片进度。字号必须维持平台普通 UI 档位,不能因为窄屏把卡片标题、功能 label 或法律信息撑成展示级字号;最后一屏内容必须能在底部 dock 上方完整滚动露出,不得被固定底部导航遮挡
## RPG / 自定义世界
@@ -132,8 +147,7 @@ RPG / 拼图等运行态存档仍以 `/api/profile/save-archives` 的后端列
- 拼图运行态顶部关卡信息采用游戏化铭牌样式:橘棕横向关卡名牌承载 `第 N 关` 和关卡名,左侧固定使用 `media/logo.png` 卡通形象;倒计时作为下挂米白小牌独立显示,紧贴铭牌但不遮挡棋盘。该样式只改变运行态 HUD 视觉,不改变计时、暂停、失败同步或关卡推进规则。
- 拼图运行态进行中关卡的 `elapsedMs` 仍是结算字段,设置面板的“当前用时”必须按 `startedAtMs`、暂停累计和冻结累计实时派生;不要直接把进行中的 `currentLevel.elapsedMs` 当作展示值。
- 推荐页嵌入拼图运行态时,通关结算弹层必须挂到页面级 fixed 浮层,不能留在推荐卡片视觉区内的 absolute 覆盖层;推荐页滑动卡片和运行态视口都使用 `overflow: hidden`,半屏内容区会裁剪排行榜、下一关按钮和相似作品卡。
- 推荐页嵌入拼图运行态时,“下一关”应优先切到相似作品;如果当前推荐候选为空,才回退到同作品下一关,避免匿名推荐流在多关卡作品上持续停留在同一作品内。下一关请求 pending 期间必须保留当前 `PuzzleRuntimeShell` 和棋盘,不得把推荐卡整体切回 `加载中...` 占位态;局部同步状态由拼图运行态自己的 busy 表现承接。后端返回的新关卡属于其它作品时,前端必须同步 `selectedPuzzleDetail`、推荐页 `puzzleGalleryEntries` 缓存和 `activeRecommendEntryKey`,让底部作品信息、分享 / 点赞 / 改造和下一次“下一个”基准都指向新作品;但这仍属于同一个 runtime run 内部推进,不能触发推荐 rail 切卡动画、纵向位移或启动封面重置,已挂载且 ready 的运行态画面应保持稳定,只静默更新作品信息和操作基准
- 推荐页作品信息区的分享按钮统一唤起发布分享弹窗 `PublishShareModal`,不在推荐卡内部单独拼接分享文案或只做剪贴板复制反馈;拼图推荐作品的分享链接继续沿用 `/gallery/puzzle/detail?work=...`,其它统一公开作品默认走 `/works/detail?work=...`
- 推荐页嵌入拼图运行态时,“下一关”应优先切到相似作品;如果当前推荐候选为空,才回退到同作品下一关,避免匿名推荐流在多关卡作品上持续停留在同一作品内。下一关请求 pending 期间必须保留当前 `PuzzleRuntimeShell` 和棋盘,不得把推荐卡整体切回 `加载中...` 占位态;局部同步状态由拼图运行态自己的 busy 表现承接。后端返回的新关卡属于其它作品时,前端必须同步 `selectedPuzzleDetail`、推荐页 `puzzleGalleryEntries` 缓存和 `activeRecommendEntryKey`,让底部作品信息、分享 / 点赞 / 改造和下一次“下一个”基准都指向新作品。
- 推荐页里的拼图作品如果从运行态进入“改造”结果页,返回平台后要清掉推荐嵌入态的 `activeRecommendEntryKey` / `activeRecommendRuntimeKind` / `isStartingRecommendEntry`,再重新按推荐页自动启动逻辑进入作品,不能复用已经被清空的旧 `puzzleRun`
- 拼图运行态允许前端低延迟交互表现,但通关、排行榜、奖励和作品状态仍以后端确认为准。
@@ -173,7 +187,7 @@ RPG / 拼图等运行态存档仍以 `/api/profile/save-archives` 的后端列
跳一跳作品架删除入口必须走 `/api/creation/jump-hop/works/{profile_id}`,并通过 SpacetimeDB 同步删除 work profile、源 session、运行态 run 与事件,再刷新作品架和公开广场;不得只做前端本地隐藏。
推荐页匿名游玩不再限定为跳一跳。移动端一级 `推荐` Tab 是内嵌运行态刷卡流,会自动选择推荐作品并启动对应玩法;桌面端首页不启动这套移动推荐运行态,而是渲染桌面发现壳,展示 `今日游戏``推荐``作品分类` 等桌面内容。断点事实统一走 `platformEntryResponsive.ts``usePlatformDesktopLayout()`,平台壳和首页视图必须共用同一个判断,避免桌面发现页与移动推荐页同时挂载、重复触发请求或启动运行态。移动端推荐页启动或切换作品时先展示当前作品封面,嵌入 runtime 在封面下层加载;只有对应运行态 run / profile 已准备且 lazy runtime 组件完成挂载后,封面才渐隐,不在中途展示“加载中”文案。拼图下一关在同一个 run 内推进到相似作品时不视为推荐作品切换,不能重新显示启动封面。推荐页嵌入运行态启动时按真实身份分流:已登录用户或本地已有 access token 时继续使用账号 Bearer但请求选项必须是 local auth impact避免单卡 401 清空整站登录态;只有确认为匿名访客时才申请短期 Runtime Guest Token并只把它作为局部请求头传给运行态客户端不写入全局登录态、不触发 refresh也不把匿名流量伪装成普通用户。当前覆盖矩阵为跳一跳、视觉小说、抓大鹅 Match3D、方洞挑战、拼图、敲木鱼、大鱼吃小鱼、汪汪声浪。每个模板的启动请求、推荐页内后续运行态动作以及需要上报的 play/finish/leaderboard/next-level 类请求都必须继续按该身份分流公开读取入口仍可匿名读取创作、个人作品、删除、发布、Remix 等账号/所有权动作仍保持普通用户鉴权。
推荐页匿名游玩不再限定为跳一跳。移动端一级 `推荐` Tab 是内嵌运行态刷卡流,会自动选择推荐作品并启动对应玩法;桌面端首页不启动这套移动推荐运行态,而是渲染桌面发现壳,展示 `今日游戏``推荐``作品分类` 等桌面内容。断点事实统一走 `platformEntryResponsive.ts``usePlatformDesktopLayout()`,平台壳和首页视图必须共用同一个判断,避免桌面发现页与移动推荐页同时挂载、重复触发请求或启动运行态。移动端推荐页启动或切换作品时先展示当前作品封面,嵌入 runtime 在封面下层加载;只有对应运行态 run / profile 已准备且 lazy runtime 组件完成挂载后,封面才渐隐,不在中途展示“加载中”文案。拼图下一关在同一个 run 内推进到相似作品时不视为推荐作品切换,不能重新显示启动封面。推荐页嵌入运行态启动时按真实身份分流:已登录用户或本地已有 access token 时继续使用账号 Bearer但请求选项必须是 local auth impact避免单卡 401 清空整站登录态;只有确认为匿名访客时才申请短期 Runtime Guest Token并只把它作为局部请求头传给运行态客户端不写入全局登录态、不触发 refresh也不把匿名流量伪装成普通用户。当前覆盖矩阵为跳一跳、视觉小说、抓大鹅 Match3D、方洞挑战、拼图、敲木鱼、大鱼吃小鱼、汪汪声浪。每个模板的启动请求、推荐页内后续运行态动作以及需要上报的 play/finish/leaderboard/next-level 类请求都必须继续按该身份分流公开读取入口仍可匿名读取创作、个人作品、删除、发布、Remix 等账号/所有权动作仍保持普通用户鉴权。推荐 runtime 的 `none` / `background` / `runtime-guest` 请求计划和拼图 `default` / `isolated` runtime auth mode 由 `platformRecommendRuntimeAuthModel.ts` 统一判定,平台壳只负责读取 token、申请 Runtime Guest Token 和传递 request options。推荐 runtime 自动启动只由 `platformPublicGalleryFlow.ts` 输出 `noop` / `clear` / `start(entry)` 决策,平台壳只执行清空 state 或启动指定作品。
## 敲木鱼
@@ -336,8 +350,8 @@ RPG / 拼图等运行态存档仍以 `/api/profile/save-archives` 的后端列
- 结果页:围绕三图槽位展示错误态与已生成结果,只保留单槽重试、重新生成和上传,不再提供一次生成按钮、音频配置入口或排名配置;生成回写 `partial_failed` 时作品架不再显示整卡“生成中”遮罩,由结果页槽位错误承接失败。
- 手动上传:结果页通过平台资产直传 `/api/assets/direct-upload-tickets``/api/assets/objects/confirm` 写入私有资产,再把返回的历史 generated 路径写回草稿配置。
- 发布:结果页确认后必须携带草稿返回的同一个 `workId` 和结果页最终 `publishedSnapshot` 调用 `POST /api/creation/bark-battle/works/publish`SpacetimeDB 发布态的 `config_json` 必须使用该最终快照works summary 若拿到 `publishedSnapshotJson` 也优先使用最终快照映射封面三图。发布成功后先进入统一作品详情页,再由详情页进入正式 runtime缺少 `workId` 的旧草稿状态需要重新生成草稿。
- 作品架Bark Battle 草稿 / 已发布列表优先读取后端 `/works`,但创建、生成完成、保存或发布后的本地摘要必须在后端 read model 尚未回读到同 `workId` 前继续保留;创作中心作品架同时接入 pending shelf 兜底,避免 ready 且三图齐全的草稿在刷新窗口期从“我的草稿 / 已发布”中消失。
- 试玩与正式 runtime草稿试玩使用 `runtimeMode=draft` 和 mock 输入,不写正式 run正式 runtime 使用 `runtimeMode=published`,进入运行态后直接申请真实麦克风权限,授权成功后立刻进入倒计时,启动对局时调用 `POST /api/runtime/bark-battle/works/{workId}/runs` 登记 start run并以返回的 `runtimeConfig` 作为本局前端规则参数;结算时调用 `POST /api/runtime/bark-battle/runs/{runId}/finish` 写入基础统计派生指标;对局会在能量条推到任一侧边界时提前结算并弹出独立结算弹窗,运行态内固定提供返回按钮。
- 作品架Bark Battle 草稿 / 已发布列表优先读取后端 `/works`,但创建、生成完成、保存或发布后的本地摘要必须在后端 read model 尚未回读到同 `workId` 前继续保留;创作中心作品架同时接入 pending shelf 兜底,避免 ready 且三图齐全的草稿在刷新窗口期从“我的草稿 / 已发布”中消失。草稿三图完整性、`pending_assets` / `partial_failed` / `ready` 生成状态归一和作品摘要合并规则统一由 `barkBattleWorkCache.ts` 承接,平台壳只执行读取、刷新与 React state 副作用。
- 试玩与正式 runtime草稿试玩使用 `runtimeMode=draft` 和 mock 输入,不写正式 run正式 runtime 使用 `runtimeMode=published`,进入运行态后直接申请真实麦克风权限,授权成功后立刻进入倒计时,启动对局时调用 `POST /api/runtime/bark-battle/works/{workId}/runs` 登记 start run并以返回的 `runtimeConfig` 作为本局前端规则参数;结算时调用 `POST /api/runtime/bark-battle/runs/{runId}/finish` 写入基础统计派生指标;对局会在能量条推到任一侧边界时提前结算并弹出独立结算弹窗,运行态内固定提供返回按钮。发布快照拼装、发布回包缺图时沿用草稿图,以及草稿 / 已发布作品进入前端 runtime 前的 `BarkBattlePublishedConfig` 映射也统一由 `barkBattleWorkCache.ts` 提供,缺失 `publishedAt` 时仍按 `updatedAt` 兜底。
支持的创作者可替换内容:

View File

@@ -307,13 +307,18 @@ pub(crate) fn build_puzzle_session_snapshot_from_action_payload(
levels,
form_draft: None,
};
let stage = if is_puzzle_session_snapshot_publish_ready(&draft) {
"ready_to_publish"
} else {
"image_refining"
};
Ok(PuzzleAgentSessionRecord {
session_id: session_id.to_string(),
seed_text: String::new(),
current_turn: 0,
progress_percent: 94,
stage: "ready_to_publish".to_string(),
stage: stage.to_string(),
anchor_pack,
draft: Some(draft),
messages: Vec::new(),
@@ -1783,7 +1788,11 @@ pub(crate) fn apply_generated_puzzle_candidates_to_session_snapshot(
sync_puzzle_primary_draft_fields_from_level(draft);
}
session.progress_percent = session.progress_percent.max(94);
session.stage = "ready_to_publish".to_string();
session.stage = if is_puzzle_session_snapshot_publish_ready(draft) {
"ready_to_publish".to_string()
} else {
"image_refining".to_string()
};
session.last_assistant_reply = Some("拼图图片已经生成,并已替换当前正式图。".to_string());
session.updated_at = format_timestamp_micros(updated_at_micros);
session

View File

@@ -248,6 +248,17 @@ pub(super) fn apply_generated_puzzle_tags_to_session_snapshot(
session
}
fn has_required_puzzle_asset_ref(image_src: &Option<String>, object_key: &Option<String>) -> bool {
image_src
.as_deref()
.map(str::trim)
.is_some_and(|value| !value.is_empty())
|| object_key
.as_deref()
.map(str::trim)
.is_some_and(|value| !value.is_empty())
}
pub(super) fn is_puzzle_session_snapshot_publish_ready(draft: &PuzzleResultDraftRecord) -> bool {
!draft.work_title.trim().is_empty()
&& !draft.work_description.trim().is_empty()
@@ -261,6 +272,18 @@ pub(super) fn is_puzzle_session_snapshot_publish_ready(draft: &PuzzleResultDraft
.as_deref()
.map(str::trim)
.is_some_and(|value| !value.is_empty())
&& has_required_puzzle_asset_ref(
&level.level_scene_image_src,
&level.level_scene_image_object_key,
)
&& has_required_puzzle_asset_ref(
&level.ui_spritesheet_image_src,
&level.ui_spritesheet_image_object_key,
)
&& has_required_puzzle_asset_ref(
&level.level_background_image_src,
&level.level_background_image_object_key,
)
})
}

View File

@@ -474,7 +474,7 @@ fn puzzle_image_generation_builds_fallback_session_from_levels_snapshot() {
.expect("fallback session");
let draft = session.draft.expect("draft");
assert_eq!(session.stage, "ready_to_publish");
assert_eq!(session.stage, "image_refining");
assert_eq!(draft.work_title, "暖灯猫街作品");
assert_eq!(draft.theme_tags, vec!["猫咪", "雨夜"]);
assert_eq!(draft.levels[0].level_id, "puzzle-level-1");
@@ -484,6 +484,62 @@ fn puzzle_image_generation_builds_fallback_session_from_levels_snapshot() {
);
}
#[test]
fn puzzle_image_generation_fallback_session_ready_when_asset_pack_complete() {
let levels_json = serde_json::to_string(&vec![json!({
"level_id": "puzzle-level-1",
"level_name": "雨夜猫街",
"picture_description": "一只猫在雨夜灯牌下回头。",
"candidates": [],
"selected_candidate_id": null,
"cover_image_src": "/generated/puzzle/cover.png",
"cover_asset_id": "asset-cover",
"level_scene_image_src": "/generated/puzzle/level-scene.png",
"level_scene_image_object_key": "generated/puzzle/level-scene.png",
"ui_spritesheet_image_src": "/generated/puzzle/ui-spritesheet.png",
"ui_spritesheet_image_object_key": "generated/puzzle/ui-spritesheet.png",
"level_background_image_src": "/generated/puzzle/level-background.png",
"level_background_image_object_key": "generated/puzzle/level-background.png",
"generation_status": "ready",
})])
.expect("levels json");
let payload = ExecutePuzzleAgentActionRequest {
action: "generate_puzzle_images".to_string(),
prompt_text: None,
reference_image_src: None,
reference_image_srcs: Vec::new(),
reference_image_asset_object_id: None,
reference_image_asset_object_ids: Vec::new(),
image_model: Some(PUZZLE_IMAGE_MODEL_GPT_IMAGE_2.to_string()),
ai_redraw: None,
candidate_count: Some(1),
should_auto_name_level: None,
candidate_id: None,
level_id: Some("puzzle-level-1".to_string()),
work_title: Some("暖灯猫街作品".to_string()),
work_description: Some("一套雨夜猫街主题拼图。".to_string()),
picture_description: None,
level_name: None,
summary: Some("当前关卡画面。".to_string()),
theme_tags: Some(vec![
"猫咪".to_string(),
"雨夜".to_string(),
"灯牌".to_string(),
]),
levels_json: Some(levels_json.clone()),
};
let session = build_puzzle_session_snapshot_from_action_payload(
"puzzle-session-1",
&payload,
Some(levels_json.as_str()),
1_713_686_401_234_567,
)
.expect("fallback session");
assert_eq!(session.stage, "ready_to_publish");
}
#[test]
fn puzzle_first_level_name_parser_accepts_json_and_normalizes_text() {
assert_eq!(

View File

@@ -541,6 +541,17 @@ pub fn build_result_preview(
}
}
fn has_required_puzzle_asset_ref(image_src: &Option<String>, object_key: &Option<String>) -> bool {
image_src
.as_deref()
.map(str::trim)
.is_some_and(|value| !value.is_empty())
|| object_key
.as_deref()
.map(str::trim)
.is_some_and(|value| !value.is_empty())
}
pub fn validate_publish_requirements(
draft: &PuzzleResultDraft,
author_display_name: Option<&str>,
@@ -582,6 +593,36 @@ pub fn validate_publish_requirements(
message: "正式拼图图片尚未确定".to_string(),
});
}
if !has_required_puzzle_asset_ref(
&level.level_scene_image_src,
&level.level_scene_image_object_key,
) {
blockers.push(PuzzleResultPreviewBlocker {
id: format!("missing-level-scene-image-{}", level.level_id),
code: "MISSING_LEVEL_SCENE_IMAGE".to_string(),
message: "正式关卡画面尚未生成".to_string(),
});
}
if !has_required_puzzle_asset_ref(
&level.ui_spritesheet_image_src,
&level.ui_spritesheet_image_object_key,
) {
blockers.push(PuzzleResultPreviewBlocker {
id: format!("missing-ui-spritesheet-image-{}", level.level_id),
code: "MISSING_UI_SPRITESHEET_IMAGE".to_string(),
message: "UI spritesheet 尚未生成".to_string(),
});
}
if !has_required_puzzle_asset_ref(
&level.level_background_image_src,
&level.level_background_image_object_key,
) {
blockers.push(PuzzleResultPreviewBlocker {
id: format!("missing-level-background-image-{}", level.level_id),
code: "MISSING_LEVEL_BACKGROUND_IMAGE".to_string(),
message: "关卡背景图尚未生成".to_string(),
});
}
}
if draft.theme_tags.len() < PUZZLE_MIN_TAG_COUNT
|| draft.theme_tags.len() > PUZZLE_MAX_TAG_COUNT
@@ -4011,4 +4052,37 @@ mod tests {
.any(|blocker| blocker.code == "MISSING_LEVEL_NAME")
);
}
#[test]
fn validate_publish_requirements_requires_generated_level_asset_pack() {
let anchor_pack = infer_anchor_pack("雨夜猫咪神庙", Some("雨夜猫咪神庙"));
let mut draft = compile_result_draft(&anchor_pack, &[]);
draft.levels[0].cover_image_src = Some("/cover.png".to_string());
let blockers = validate_publish_requirements(&draft, Some("玩家"));
let blocker_codes = blockers
.iter()
.map(|blocker| blocker.code.as_str())
.collect::<Vec<_>>();
assert!(blocker_codes.contains(&"MISSING_LEVEL_SCENE_IMAGE"));
assert!(blocker_codes.contains(&"MISSING_UI_SPRITESHEET_IMAGE"));
assert!(blocker_codes.contains(&"MISSING_LEVEL_BACKGROUND_IMAGE"));
draft.levels[0].level_scene_image_object_key =
Some("generated/puzzle/level-scene.png".to_string());
draft.levels[0].ui_spritesheet_image_object_key =
Some("generated/puzzle/ui-spritesheet.png".to_string());
draft.levels[0].level_background_image_object_key =
Some("generated/puzzle/level-background.png".to_string());
let blockers = validate_publish_requirements(&draft, Some("玩家"));
assert!(!blockers.iter().any(|blocker| {
matches!(
blocker.code.as_str(),
"MISSING_LEVEL_SCENE_IMAGE"
| "MISSING_UI_SPRITESHEET_IMAGE"
| "MISSING_LEVEL_BACKGROUND_IMAGE"
)
}));
}
}

View File

@@ -161,9 +161,8 @@ fn normalize_creation_entry_announcement_banner_value(
);
}
let banner = serde_json::from_value::<CreationEntryEventBannerResponse>(Value::Object(
object.clone(),
))
let banner =
serde_json::from_value::<CreationEntryEventBannerResponse>(Value::Object(object.clone()))
.map_err(|error| format!("{} 条公告对象非法:{error}", index + 1))?;
normalize_creation_entry_event_banner_response(index, banner)
}
@@ -332,10 +331,7 @@ fn normalize_banner_html_code(
}
let lower_html_code = html_code.to_ascii_lowercase();
if lower_html_code.contains("<script") || lower_html_code.contains("javascript:") {
return Err(format!(
"{} 条 HTML 公告含有不允许的脚本代码",
index + 1
));
return Err(format!("{} 条 HTML 公告含有不允许的脚本代码", index + 1));
}
Ok(Some(html_code))

View File

@@ -172,18 +172,8 @@ pub fn build_phase1_unified_creation_spec(play_id: &str) -> Option<UnifiedCreati
vec![
unified_creation_field("title", "text", "作品标题", true),
unified_creation_field("themeDescription", "text", "主题/场景描述", true),
unified_creation_field(
"playerImageDescription",
"text",
"玩家形象描述",
true,
),
unified_creation_field(
"opponentImageDescription",
"text",
"对手形象描述",
true,
),
unified_creation_field("playerImageDescription", "text", "玩家形象描述", true),
unified_creation_field("opponentImageDescription", "text", "对手形象描述", true),
unified_creation_field("onomatopoeia", "text", "拟声词", false),
unified_creation_field("difficultyPreset", "select", "难度", true),
],

View File

@@ -10,7 +10,7 @@ import type { BabyObjectMatchDraft } from '../../../packages/shared/src/contract
import type { SquareHoleWorkSummary } from '../../../packages/shared/src/contracts/squareHoleWorks';
import type { CreationEntryConfig } from '../../services/creationEntryConfigService';
import { derivePlatformCreationTypes } from '../platform-entry/platformEntryCreationTypes';
import { CustomWorldCreationHub } from './CustomWorldCreationHub';
import { CustomWorldCreationHub } from './CustomWorldCreationHub.testAdapter';
const noopCreateType = () => {};

View File

@@ -4,7 +4,7 @@ import { expect, test } from 'vitest';
import type { CreationEntryConfig } from '../../services/creationEntryConfigService';
import { derivePlatformCreationTypes } from '../platform-entry/platformEntryCreationTypes';
import { buildCreationWorkShelfItems } from './creationWorkShelf';
import { CustomWorldCreationHub } from './CustomWorldCreationHub';
import { CustomWorldCreationHub } from './CustomWorldCreationHub.testAdapter';
const noopCreateType = () => {};
const DAY_MS = 24 * 60 * 60 * 1000;

View File

@@ -0,0 +1,128 @@
import { isPlatformCreationTypeVisible } from '../platform-entry/platformEntryCreationTypes';
import {
buildCreationWorkShelfItems,
type CreationWorkShelfItem,
} from './creationWorkShelf';
import {
CustomWorldCreationHub as CustomWorldCreationHubView,
} from './CustomWorldCreationHub';
type ShelfBuilderParams = Parameters<typeof buildCreationWorkShelfItems>[0];
type HubViewProps = Parameters<typeof CustomWorldCreationHubView>[0];
type LegacyCustomWorldCreationHubProps = Omit<HubViewProps, 'shelfItems'> &
Partial<
Omit<ShelfBuilderParams, 'rpgItems' | 'bigFishItems' | 'puzzleItems'>
> & {
shelfItems?: CreationWorkShelfItem[];
items?: ShelfBuilderParams['rpgItems'];
bigFishItems?: ShelfBuilderParams['bigFishItems'];
puzzleItems?: ShelfBuilderParams['puzzleItems'];
onOpenDraft?: ShelfBuilderParams['onOpenRpgDraft'];
onEnterPublished?: ShelfBuilderParams['onEnterRpgPublished'];
onDeletePublished?: ShelfBuilderParams['onDeleteRpg'] | null;
getWorkState?: ShelfBuilderParams['getItemState'];
};
/** 测试用 Adapter旧 fixture 先转成 shelfItems生产 Hub Interface 保持窄面。 */
export function CustomWorldCreationHub({
shelfItems,
items = [],
rpgLibraryEntries = [],
bigFishItems = [],
match3dItems = [],
squareHoleItems = [],
jumpHopItems = [],
woodenFishItems = [],
puzzleItems = [],
babyObjectMatchItems = [],
barkBattleItems = [],
visualNovelItems = [],
onOpenDraft,
onEnterPublished,
onDeletePublished = null,
onOpenBigFishDetail,
onDeleteBigFish,
onOpenMatch3DDetail,
onDeleteMatch3D,
onOpenSquareHoleDetail,
onDeleteSquareHole,
onOpenJumpHopDetail,
onDeleteJumpHop,
onOpenWoodenFishDetail,
onDeleteWoodenFish,
onOpenPuzzleDetail,
onDeletePuzzle,
onClaimPuzzlePointIncentive,
onOpenBabyObjectMatchDetail,
onDeleteBabyObjectMatch,
onOpenBarkBattleDetail,
onDeleteBarkBattle,
onOpenVisualNovelDetail,
onDeleteVisualNovel,
getItemState,
getWorkState,
creationTypes,
...props
}: LegacyCustomWorldCreationHubProps) {
const isSquareHoleCreationVisible = isPlatformCreationTypeVisible(
creationTypes,
'square-hole',
);
const resolvedShelfItems =
shelfItems ??
buildCreationWorkShelfItems({
rpgItems: items,
rpgLibraryEntries,
bigFishItems,
match3dItems,
squareHoleItems: isSquareHoleCreationVisible ? squareHoleItems : [],
jumpHopItems,
woodenFishItems,
puzzleItems,
babyObjectMatchItems,
barkBattleItems,
visualNovelItems,
canDeleteRpg: Boolean(onDeletePublished),
canDeleteBigFish: Boolean(onDeleteBigFish),
canDeleteMatch3D: Boolean(onDeleteMatch3D),
canDeleteSquareHole: Boolean(onDeleteSquareHole),
canDeleteJumpHop: Boolean(onDeleteJumpHop),
canDeleteWoodenFish: Boolean(onDeleteWoodenFish),
canDeletePuzzle: Boolean(onDeletePuzzle),
canDeleteBabyObjectMatch: Boolean(onDeleteBabyObjectMatch),
canDeleteBarkBattle: Boolean(onDeleteBarkBattle),
canDeleteVisualNovel: Boolean(onDeleteVisualNovel),
onOpenRpgDraft: onOpenDraft,
onEnterRpgPublished: onEnterPublished,
onDeleteRpg: onDeletePublished ?? undefined,
onOpenBigFishDetail,
onDeleteBigFish,
onOpenMatch3DDetail,
onDeleteMatch3D,
onOpenSquareHoleDetail,
onDeleteSquareHole,
onOpenJumpHopDetail,
onDeleteJumpHop,
onOpenWoodenFishDetail,
onDeleteWoodenFish,
onOpenPuzzleDetail,
onDeletePuzzle,
onClaimPuzzlePointIncentive,
onOpenBabyObjectMatchDetail,
onDeleteBabyObjectMatch,
onOpenBarkBattleDetail,
onDeleteBarkBattle,
onOpenVisualNovelDetail,
onDeleteVisualNovel,
getItemState: getItemState ?? getWorkState,
});
return (
<CustomWorldCreationHubView
{...props}
creationTypes={creationTypes}
shelfItems={resolvedShelfItems}
/>
);
}

View File

@@ -1,30 +1,14 @@
import { useEffect, useMemo, useState } from 'react';
import type { BarkBattleWorkSummary } from '../../../packages/shared/src/contracts/barkBattle';
import type { BigFishWorkSummary } from '../../../packages/shared/src/contracts/bigFishWorkSummary';
import type { CustomWorldWorkSummary } from '../../../packages/shared/src/contracts/customWorldAgent';
import type { BabyObjectMatchDraft } from '../../../packages/shared/src/contracts/edutainmentBabyObject';
import type { JumpHopWorkSummaryResponse } from '../../../packages/shared/src/contracts/jumpHop';
import type { Match3DWorkSummary } from '../../../packages/shared/src/contracts/match3dWorks';
import type { PuzzleClearWorkSummaryResponse } from '../../../packages/shared/src/contracts/puzzleClear';
import type { PuzzleWorkSummary } from '../../../packages/shared/src/contracts/puzzleWorkSummary';
import type { WoodenFishWorkSummaryResponse } from '../../../packages/shared/src/contracts/woodenFish';
import type { CustomWorldLibraryEntry } from '../../../packages/shared/src/contracts/runtime';
import type { SquareHoleWorkSummary } from '../../../packages/shared/src/contracts/squareHoleWorks';
import type { VisualNovelWorkSummary } from '../../../packages/shared/src/contracts/visualNovel';
import type { CreationEntryConfig } from '../../services/creationEntryConfigService';
import type { CustomWorldProfile } from '../../types';
import type {
PlatformCreationTypeCard,
PlatformCreationTypeId,
} from '../platform-entry/platformEntryCreationTypes';
import { isPlatformCreationTypeVisible } from '../platform-entry/platformEntryCreationTypes';
import {
buildCreationWorkShelfItems,
getCreationWorkShelfItemTime,
type CreationWorkShelfItem,
type CreationWorkShelfMetricId,
type CreationWorkShelfRuntimeState,
getCreationWorkShelfItemTime,
} from './creationWorkShelf';
import {
CustomWorldCreationStartCard,
@@ -48,7 +32,7 @@ type WorkMetricSnapshot = Record<
>;
type CustomWorldCreationHubProps = {
items: CustomWorldWorkSummary[];
shelfItems: CreationWorkShelfItem[];
loading: boolean;
error: string | null;
onRetry: () => void;
@@ -56,48 +40,8 @@ type CustomWorldCreationHubProps = {
entryConfig: CreationEntryConfig;
creationTypes: readonly PlatformCreationTypeCard[];
onCreateType: (type: PlatformCreationTypeId) => void;
onOpenDraft: (item: CustomWorldWorkSummary) => void;
onEnterPublished: (profileId: string) => void;
onDeletePublished?: ((item: CustomWorldWorkSummary) => void) | null;
deletingWorkId?: string | null;
rpgLibraryEntries?: CustomWorldLibraryEntry<CustomWorldProfile>[];
bigFishItems?: BigFishWorkSummary[];
onOpenBigFishDetail?: (item: BigFishWorkSummary) => void;
onDeleteBigFish?: ((item: BigFishWorkSummary) => void) | null;
match3dItems?: Match3DWorkSummary[];
onOpenMatch3DDetail?: (item: Match3DWorkSummary) => void;
onDeleteMatch3D?: ((item: Match3DWorkSummary) => void) | null;
squareHoleItems?: SquareHoleWorkSummary[];
onOpenSquareHoleDetail?: (item: SquareHoleWorkSummary) => void;
onDeleteSquareHole?: ((item: SquareHoleWorkSummary) => void) | null;
jumpHopItems?: JumpHopWorkSummaryResponse[];
onOpenJumpHopDetail?: (item: JumpHopWorkSummaryResponse) => void;
onDeleteJumpHop?: ((item: JumpHopWorkSummaryResponse) => void) | null;
woodenFishItems?: WoodenFishWorkSummaryResponse[];
onOpenWoodenFishDetail?:
| ((item: WoodenFishWorkSummaryResponse) => void)
| null;
onDeleteWoodenFish?: ((item: WoodenFishWorkSummaryResponse) => void) | null;
puzzleClearItems?: PuzzleClearWorkSummaryResponse[];
onOpenPuzzleClearDetail?: ((item: PuzzleClearWorkSummaryResponse) => void) | null;
onDeletePuzzleClear?: ((item: PuzzleClearWorkSummaryResponse) => void) | null;
puzzleItems?: PuzzleWorkSummary[];
onOpenPuzzleDetail?: (item: PuzzleWorkSummary) => void;
onDeletePuzzle?: ((item: PuzzleWorkSummary) => void) | null;
onClaimPuzzlePointIncentive?: ((item: PuzzleWorkSummary) => void) | null;
claimingPuzzleProfileId?: string | null;
babyObjectMatchItems?: BabyObjectMatchDraft[];
onOpenBabyObjectMatchDetail?: ((item: BabyObjectMatchDraft) => void) | null;
onDeleteBabyObjectMatch?: ((item: BabyObjectMatchDraft) => void) | null;
barkBattleItems?: BarkBattleWorkSummary[];
onOpenBarkBattleDetail?: ((item: BarkBattleWorkSummary) => void) | null;
onDeleteBarkBattle?: ((item: BarkBattleWorkSummary) => void) | null;
visualNovelItems?: VisualNovelWorkSummary[];
onOpenVisualNovelDetail?: ((item: VisualNovelWorkSummary) => void) | null;
onDeleteVisualNovel?: ((item: VisualNovelWorkSummary) => void) | null;
getWorkState?: (
item: CreationWorkShelfItem,
) => CreationWorkShelfRuntimeState | null;
onOpenShelfItem?: (item: CreationWorkShelfItem) => void;
// 中文注释:底部加号入口可传入后端作品架摘要,用于推导最近使用过的模板。
recentWorkItems?: CreationWorkShelfItem[];
@@ -169,7 +113,7 @@ function writeWorkMetricSnapshot(items: CreationWorkShelfItem[]) {
/** 渲染底部加号创作入口页与草稿作品架,最近创作复用最近使用过的模板入口。 */
export function CustomWorldCreationHub({
items,
shelfItems,
loading,
error,
onRetry,
@@ -177,147 +121,14 @@ export function CustomWorldCreationHub({
entryConfig,
creationTypes,
onCreateType,
onOpenDraft,
onEnterPublished,
onDeletePublished = null,
deletingWorkId = null,
rpgLibraryEntries = [],
bigFishItems = [],
onOpenBigFishDetail,
onDeleteBigFish = null,
match3dItems = [],
onOpenMatch3DDetail,
onDeleteMatch3D = null,
squareHoleItems = [],
onOpenSquareHoleDetail,
onDeleteSquareHole = null,
jumpHopItems = [],
onOpenJumpHopDetail,
onDeleteJumpHop = null,
woodenFishItems = [],
onOpenWoodenFishDetail = null,
onDeleteWoodenFish = null,
puzzleClearItems = [],
onOpenPuzzleClearDetail = null,
onDeletePuzzleClear = null,
puzzleItems = [],
onOpenPuzzleDetail,
onDeletePuzzle = null,
onClaimPuzzlePointIncentive = null,
claimingPuzzleProfileId = null,
babyObjectMatchItems = [],
onOpenBabyObjectMatchDetail = null,
onDeleteBabyObjectMatch = null,
barkBattleItems = [],
onOpenBarkBattleDetail = null,
onDeleteBarkBattle = null,
visualNovelItems = [],
onOpenVisualNovelDetail = null,
onDeleteVisualNovel = null,
getWorkState,
onOpenShelfItem,
recentWorkItems: recentWorkSourceItems,
mode = 'full',
}: CustomWorldCreationHubProps) {
const [activeFilter, setActiveFilter] =
useState<CustomWorldWorkFilter>('all');
const isSquareHoleCreationVisible = isPlatformCreationTypeVisible(
creationTypes,
'square-hole',
);
const shelfItems = useMemo(
() =>
buildCreationWorkShelfItems({
rpgItems: items,
rpgLibraryEntries,
bigFishItems,
match3dItems,
squareHoleItems: isSquareHoleCreationVisible ? squareHoleItems : [],
jumpHopItems,
woodenFishItems,
puzzleClearItems,
puzzleItems,
babyObjectMatchItems,
barkBattleItems,
visualNovelItems,
canDeleteRpg: Boolean(onDeletePublished),
canDeleteBigFish: Boolean(onDeleteBigFish),
canDeleteMatch3D: Boolean(onDeleteMatch3D),
canDeleteSquareHole:
isSquareHoleCreationVisible && Boolean(onDeleteSquareHole),
canDeleteJumpHop: Boolean(onDeleteJumpHop),
canDeleteWoodenFish: Boolean(onDeleteWoodenFish),
canDeletePuzzleClear: Boolean(onDeletePuzzleClear),
canDeletePuzzle: Boolean(onDeletePuzzle),
canDeleteBabyObjectMatch: Boolean(onDeleteBabyObjectMatch),
canDeleteBarkBattle: Boolean(onDeleteBarkBattle),
canDeleteVisualNovel: Boolean(onDeleteVisualNovel),
onOpenRpgDraft: onOpenDraft,
onEnterRpgPublished: onEnterPublished,
onDeleteRpg: onDeletePublished ?? undefined,
onOpenBigFishDetail,
onDeleteBigFish: onDeleteBigFish ?? undefined,
onOpenMatch3DDetail,
onDeleteMatch3D: onDeleteMatch3D ?? undefined,
onOpenSquareHoleDetail,
onDeleteSquareHole: onDeleteSquareHole ?? undefined,
onOpenJumpHopDetail: onOpenJumpHopDetail ?? undefined,
onDeleteJumpHop: onDeleteJumpHop ?? undefined,
onOpenWoodenFishDetail: onOpenWoodenFishDetail ?? undefined,
onDeleteWoodenFish: onDeleteWoodenFish ?? undefined,
onOpenPuzzleClearDetail: onOpenPuzzleClearDetail ?? undefined,
onDeletePuzzleClear: onDeletePuzzleClear ?? undefined,
onOpenPuzzleDetail,
onDeletePuzzle: onDeletePuzzle ?? undefined,
onClaimPuzzlePointIncentive: onClaimPuzzlePointIncentive ?? undefined,
onOpenBabyObjectMatchDetail: onOpenBabyObjectMatchDetail ?? undefined,
onDeleteBabyObjectMatch: onDeleteBabyObjectMatch ?? undefined,
onOpenBarkBattleDetail: onOpenBarkBattleDetail ?? undefined,
onDeleteBarkBattle: onDeleteBarkBattle ?? undefined,
onOpenVisualNovelDetail: onOpenVisualNovelDetail ?? undefined,
onDeleteVisualNovel: onDeleteVisualNovel ?? undefined,
getItemState: getWorkState,
}),
[
bigFishItems,
isSquareHoleCreationVisible,
babyObjectMatchItems,
barkBattleItems,
items,
match3dItems,
onDeleteBigFish,
onDeleteMatch3D,
onDeleteSquareHole,
onDeletePublished,
onDeletePuzzle,
onDeleteBabyObjectMatch,
onDeleteBarkBattle,
onDeleteVisualNovel,
onDeleteJumpHop,
onDeleteWoodenFish,
onDeletePuzzleClear,
onClaimPuzzlePointIncentive,
onOpenBigFishDetail,
onOpenDraft,
onOpenMatch3DDetail,
onOpenBabyObjectMatchDetail,
onOpenBarkBattleDetail,
onOpenPuzzleDetail,
onOpenSquareHoleDetail,
onOpenVisualNovelDetail,
onOpenWoodenFishDetail,
onOpenPuzzleClearDetail,
onEnterPublished,
getWorkState,
puzzleClearItems,
puzzleItems,
rpgLibraryEntries,
onOpenJumpHopDetail,
jumpHopItems,
woodenFishItems,
visualNovelItems,
],
);
const [metricSnapshot] = useState<WorkMetricSnapshot>(() =>
readWorkMetricSnapshot(),
);
@@ -355,47 +166,8 @@ export function CustomWorldCreationHub({
function handleOpenShelfItem(item: CreationWorkShelfItem) {
onOpenShelfItem?.(item);
switch (item.source.kind) {
case 'puzzle':
onOpenPuzzleDetail?.(item.source.item);
return;
case 'baby-object-match':
onOpenBabyObjectMatchDetail?.(item.source.item);
return;
case 'visual-novel':
onOpenVisualNovelDetail?.(item.source.item);
return;
case 'bark-battle':
onOpenBarkBattleDetail?.(item.source.item);
return;
case 'big-fish':
onOpenBigFishDetail?.(item.source.item);
return;
case 'match3d':
onOpenMatch3DDetail?.(item.source.item);
return;
case 'square-hole':
onOpenSquareHoleDetail?.(item.source.item);
return;
case 'jump-hop':
onOpenJumpHopDetail?.(item.source.item);
return;
case 'wooden-fish':
onOpenWoodenFishDetail?.(item.source.item);
return;
case 'puzzle-clear':
onOpenPuzzleClearDetail?.(item.source.item);
return;
case 'rpg':
if (item.status === 'draft') {
onOpenDraft(item.source.item);
return;
}
if (item.source.item.profileId) {
onEnterPublished(item.source.item.profileId);
}
}
// 中文注释:玩法差异由 Work Shelf Adapter 承载Hub 只负责响应卡片点击。
item.actions.open();
}
function buildDeleteAction(item: CreationWorkShelfItem) {

View File

@@ -5,10 +5,11 @@ import { expect, test, vi } from 'vitest';
import type { BabyObjectMatchDraft } from '../../../packages/shared/src/contracts/edutainmentBabyObject';
import {
buildCreationWorkShelfItems,
buildCreationWorkShelfItemsFromSources,
type CreationWorkShelfItem,
getCreationWorkShelfItemTime,
hasBarkBattleRequiredImages,
isPersistedBarkBattleDraftGenerating,
type CreationWorkShelfItem,
} from './creationWorkShelf';
import { CustomWorldWorkCard } from './CustomWorldWorkCard';
@@ -56,6 +57,86 @@ test('buildCreationWorkShelfItems maps visual novel items with VN public code',
expect(items[1]?.publicWorkCode).toBeNull();
});
test('buildCreationWorkShelfItemsFromSources flattens source adapters and applies runtime state', () => {
const [staleRpgItem] = buildCreationWorkShelfItems({
rpgItems: [
{
workId: 'draft:rpg-source-adapter',
sourceType: 'agent_session',
status: 'draft',
title: '旧 RPG 草稿',
subtitle: '待完善',
summary: '通过 source adapter 输入。',
coverImageSrc: null,
updatedAt: '2026-05-01T00:00:00.000Z',
publishedAt: null,
stage: 'clarifying',
stageLabel: '待完善',
playableNpcCount: 0,
landmarkCount: 0,
sessionId: 'rpg-source-adapter',
profileId: null,
canResume: true,
canEnterWorld: false,
},
],
bigFishItems: [],
puzzleItems: [],
});
const [freshPuzzleItem] = buildCreationWorkShelfItems({
rpgItems: [],
bigFishItems: [],
puzzleItems: [
{
workId: 'puzzle:source-adapter',
profileId: 'puzzle-source-adapter',
ownerUserId: 'user-1',
authorDisplayName: '拼图作者',
levelName: '新拼图',
summary: '新近拼图。',
themeTags: ['灯塔'],
coverImageSrc: null,
publicationStatus: 'draft',
updatedAt: '2026-05-03T00:00:00.000Z',
publishedAt: null,
playCount: 0,
remixCount: 0,
likeCount: 0,
publishReady: false,
},
],
});
const items = buildCreationWorkShelfItemsFromSources({
sources: [
{
kind: 'rpg',
buildItems: () => (staleRpgItem ? [staleRpgItem] : []),
},
{
kind: 'puzzle',
buildItems: () => (freshPuzzleItem ? [freshPuzzleItem] : []),
},
],
getItemState: (item) =>
item.id === staleRpgItem?.id
? {
isGenerating: true,
hasUnreadUpdate: true,
titleOverride: '生成中 RPG 草稿',
}
: null,
});
expect(items.map((item) => item.id)).toEqual([
'puzzle:source-adapter',
'draft:rpg-source-adapter',
]);
expect(items[1]?.title).toBe('生成中 RPG 草稿');
expect(items[1]?.isGenerating).toBe(true);
expect(items[1]?.hasUnreadUpdate).toBe(true);
});
test('buildCreationWorkShelfItems maps wooden fish items with WF public code', () => {
const onOpenWoodenFishDetail = vi.fn();
const woodenFishWork = {

View File

@@ -2,20 +2,20 @@ import type { BarkBattleWorkSummary } from '../../../packages/shared/src/contrac
import type { BigFishWorkSummary } from '../../../packages/shared/src/contracts/bigFishWorkSummary';
import type { CustomWorldWorkSummary } from '../../../packages/shared/src/contracts/customWorldAgent';
import type { BabyObjectMatchDraft } from '../../../packages/shared/src/contracts/edutainmentBabyObject';
import type { JumpHopWorkSummaryResponse } from '../../../packages/shared/src/contracts/jumpHop';
import type { Match3DWorkSummary } from '../../../packages/shared/src/contracts/match3dWorks';
import type { PuzzleClearWorkSummaryResponse } from '../../../packages/shared/src/contracts/puzzleClear';
import type { PuzzleWorkSummary } from '../../../packages/shared/src/contracts/puzzleWorkSummary';
import type { CustomWorldLibraryEntry } from '../../../packages/shared/src/contracts/runtime';
import type { SquareHoleWorkSummary } from '../../../packages/shared/src/contracts/squareHoleWorks';
import type { VisualNovelWorkSummary } from '../../../packages/shared/src/contracts/visualNovel';
import type { JumpHopWorkSummaryResponse } from '../../../packages/shared/src/contracts/jumpHop';
import type { WoodenFishWorkSummaryResponse } from '../../../packages/shared/src/contracts/woodenFish';
import { buildPublicWorkStagePath } from '../../routing/appPageRoutes';
import {
buildBabyObjectMatchPublicWorkCode,
buildCustomWorldPublicWorkCode,
buildBarkBattlePublicWorkCode,
buildBigFishPublicWorkCode,
buildCustomWorldPublicWorkCode,
buildJumpHopPublicWorkCode,
buildMatch3DPublicWorkCode,
buildPuzzleClearPublicWorkCode,
@@ -164,6 +164,11 @@ export type CreationWorkShelfRuntimeState = {
summaryOverride?: string;
};
export type CreationWorkShelfSourceAdapter = {
kind: CreationWorkShelfKind;
buildItems: () => readonly CreationWorkShelfItem[];
};
export function buildCreationWorkShelfItems(params: {
rpgItems: CustomWorldWorkSummary[];
rpgLibraryEntries?: CustomWorldLibraryEntry<CustomWorldProfile>[];
@@ -267,76 +272,135 @@ export function buildCreationWorkShelfItems(params: {
getItemState,
} = params;
return [
...rpgItems.map((item) =>
return buildCreationWorkShelfItemsFromSources({
sources: [
{
kind: 'rpg',
buildItems: () =>
rpgItems.map((item) =>
mapRpgWorkToShelfItem(item, canDeleteRpg, rpgLibraryEntries, {
onOpenDraft: onOpenRpgDraft,
onEnterPublished: onEnterRpgPublished,
onDelete: onDeleteRpg,
}),
),
...bigFishItems.map((item) =>
},
{
kind: 'big-fish',
buildItems: () =>
bigFishItems.map((item) =>
mapBigFishWorkToShelfItem(item, canDeleteBigFish, {
onOpen: onOpenBigFishDetail,
onDelete: onDeleteBigFish,
}),
),
...match3dItems.map((item) =>
},
{
kind: 'match3d',
buildItems: () =>
match3dItems.map((item) =>
mapMatch3DWorkToShelfItem(item, canDeleteMatch3D, {
onOpen: onOpenMatch3DDetail,
onDelete: onDeleteMatch3D,
}),
),
...squareHoleItems.map((item) =>
},
{
kind: 'square-hole',
buildItems: () =>
squareHoleItems.map((item) =>
mapSquareHoleWorkToShelfItem(item, canDeleteSquareHole, {
onOpen: onOpenSquareHoleDetail,
onDelete: onDeleteSquareHole,
}),
),
...jumpHopItems.map((item) =>
},
{
kind: 'jump-hop',
buildItems: () =>
jumpHopItems.map((item) =>
mapJumpHopWorkToShelfItem(item, canDeleteJumpHop, {
onOpen: onOpenJumpHopDetail,
onDelete: onDeleteJumpHop,
}),
),
...woodenFishItems.map((item) =>
},
{
kind: 'wooden-fish',
buildItems: () =>
woodenFishItems.map((item) =>
mapWoodenFishWorkToShelfItem(item, canDeleteWoodenFish, {
onOpen: onOpenWoodenFishDetail,
onDelete: onDeleteWoodenFish,
}),
),
...puzzleClearItems.map((item) =>
mapPuzzleClearWorkToShelfItem(item, canDeletePuzzleClear, {
onOpen: onOpenPuzzleClearDetail,
onDelete: onDeletePuzzleClear,
}),
),
...puzzleItems.map((item) =>
},
{
kind: 'puzzle',
buildItems: () =>
puzzleItems.map((item) =>
mapPuzzleWorkToShelfItem(item, canDeletePuzzle, {
onOpen: onOpenPuzzleDetail,
onDelete: onDeletePuzzle,
onClaimPointIncentive: onClaimPuzzlePointIncentive,
}),
),
...babyObjectMatchItems.map((item) =>
mapBabyObjectMatchDraftToShelfItem(item, canDeleteBabyObjectMatch, {
},
{
kind: 'baby-object-match',
buildItems: () =>
babyObjectMatchItems.map((item) =>
mapBabyObjectMatchDraftToShelfItem(
item,
canDeleteBabyObjectMatch,
{
onOpen: onOpenBabyObjectMatchDetail,
onDelete: onDeleteBabyObjectMatch,
}),
},
),
...mergeBarkBattleShelfSourceItems(barkBattleItems).map((item) =>
),
},
{
kind: 'bark-battle',
buildItems: () =>
mergeBarkBattleShelfSourceItems(barkBattleItems).map((item) =>
mapBarkBattleWorkToShelfItem(item, canDeleteBarkBattle, {
onOpen: onOpenBarkBattleDetail,
onDelete: onDeleteBarkBattle,
}),
),
...visualNovelItems.map((item) =>
},
{
kind: 'visual-novel',
buildItems: () =>
visualNovelItems.map((item) =>
mapVisualNovelWorkToShelfItem(item, canDeleteVisualNovel, {
onOpen: onOpenVisualNovelDetail,
onDelete: onDeleteVisualNovel,
}),
),
]
},
],
getItemState,
});
}
export function buildCreationWorkShelfItemsFromSources(params: {
sources: readonly CreationWorkShelfSourceAdapter[];
getItemState?: (
item: CreationWorkShelfItem,
) => CreationWorkShelfRuntimeState | null;
}) {
const { sources, getItemState } = params;
const sourceItems = sources.reduce<CreationWorkShelfItem[]>(
(items, source) => {
items.push(...source.buildItems());
return items;
},
[],
);
return sourceItems
.map((item) => {
const state = getItemState?.(item);
const persistedIsGenerating = isPersistedCreationWorkGenerating(item);

File diff suppressed because it is too large Load Diff

View File

@@ -1,9 +1,19 @@
import { expect, test } from 'vitest';
import type { BarkBattleWorkSummary } from '../../../packages/shared/src/contracts/barkBattle';
import type {
BarkBattleDraftConfig,
BarkBattlePublishedConfig,
BarkBattleWorkSummary,
} from '../../../packages/shared/src/contracts/barkBattle';
import {
buildBarkBattleDraftConfigFromWorkSummary,
buildBarkBattlePublishedConfigFromDraft,
buildBarkBattlePublishedConfigFromWork,
buildBarkBattlePublishSnapshot,
mergeBarkBattlePublishedConfigAssets,
mergeBarkBattleWorksByWorkId,
mergeBarkBattleWorkSummary,
resolveBarkBattleDraftGenerationStatus,
shouldPreserveLocalBarkBattleWorkOnRefresh,
} from './barkBattleWorkCache';
@@ -20,6 +30,7 @@ function buildBarkBattleWork(
themeDescription: '阳光草坪声浪竞技场',
playerImageDescription: '戴红色围巾的柯基选手',
opponentImageDescription: '蓝色护目镜哈士奇对手',
onomatopoeia: ['汪', '破阵'],
playerCharacterImageSrc: '/generated-bark-battle/player.png',
opponentCharacterImageSrc: '/generated-bark-battle/opponent.png',
uiBackgroundImageSrc: '/generated-bark-battle/background.png',
@@ -34,6 +45,29 @@ function buildBarkBattleWork(
};
}
function buildBarkBattleDraft(
overrides: Partial<BarkBattleDraftConfig> = {},
): BarkBattleDraftConfig {
return {
draftId: 'bark-battle-draft-1',
workId: 'BB-cache-race-12345678',
configVersion: 2,
rulesetVersion: 'bark-battle-ruleset-v2',
title: '汪汪测试杯',
description: '测试声浪赛',
themeDescription: '阳光草坪声浪竞技场',
playerImageDescription: '戴红色围巾的柯基选手',
opponentImageDescription: '蓝色护目镜哈士奇对手',
onomatopoeia: ['汪', '破阵'],
playerCharacterImageSrc: '/generated-bark-battle/player.png',
opponentCharacterImageSrc: '/generated-bark-battle/opponent.png',
uiBackgroundImageSrc: '/generated-bark-battle/background.png',
difficultyPreset: 'normal',
updatedAt: '2026-05-21T10:00:00.000Z',
...overrides,
};
}
test('preserves local published bark battle when refresh only returns same work draft', () => {
const published = buildBarkBattleWork({
status: 'published',
@@ -106,3 +140,124 @@ test('preserves local ready bark battle draft when refresh has not returned it y
expect(merged[0]?.generationStatus).toBe('ready');
});
test('resolves bark battle draft generation status from required images', () => {
expect(
resolveBarkBattleDraftGenerationStatus(
buildBarkBattleDraft({ uiBackgroundImageSrc: undefined }),
false,
),
).toBe('pending_assets');
expect(
resolveBarkBattleDraftGenerationStatus(
buildBarkBattleDraft({ opponentCharacterImageSrc: '' }),
true,
),
).toBe('partial_failed');
expect(resolveBarkBattleDraftGenerationStatus(buildBarkBattleDraft(), true)).toBe(
'ready',
);
});
test('builds draft runtime config with stable defaults', () => {
const config = buildBarkBattlePublishedConfigFromDraft(
buildBarkBattleDraft({
workId: undefined,
configVersion: undefined,
rulesetVersion: undefined,
}),
);
expect(config.workId).toBe('bark-battle-draft-1');
expect(config.draftId).toBe('bark-battle-draft-1');
expect(config.configVersion).toBe(1);
expect(config.rulesetVersion).toBe('bark-battle-ruleset-v1');
expect(config.playTypeId).toBe('bark-battle');
expect(config.publishedAt).toBe('2026-05-21T10:00:00.000Z');
});
test('builds work runtime config with publishedAt fallback', () => {
const config = buildBarkBattlePublishedConfigFromWork(
buildBarkBattleWork({ publishedAt: null }),
);
expect(config.workId).toBe('BB-cache-race-12345678');
expect(config.description).toBe('测试声浪赛');
expect(config.publishedAt).toBe('2026-05-21T10:00:00.000Z');
expect(config.playerCharacterImageSrc).toBe('/generated-bark-battle/player.png');
});
test('builds draft config from work summary with stable defaults', () => {
const config = buildBarkBattleDraftConfigFromWorkSummary(
buildBarkBattleWork({
draftId: null,
playerCharacterImageSrc: null,
opponentCharacterImageSrc: null,
uiBackgroundImageSrc: null,
}),
);
expect(config).toMatchObject({
draftId: 'BB-cache-race-12345678',
workId: 'BB-cache-race-12345678',
title: '汪汪测试杯',
description: '测试声浪赛',
themeDescription: '阳光草坪声浪竞技场',
playerImageDescription: '戴红色围巾的柯基选手',
opponentImageDescription: '蓝色护目镜哈士奇对手',
onomatopoeia: ['汪', '破阵'],
difficultyPreset: 'normal',
configVersion: 1,
rulesetVersion: 'bark-battle-ruleset-v1',
updatedAt: '2026-05-21T10:00:00.000Z',
});
expect(config.playerCharacterImageSrc).toBeUndefined();
expect(config.opponentCharacterImageSrc).toBeUndefined();
expect(config.uiBackgroundImageSrc).toBeUndefined();
});
test('builds publish snapshot without empty asset fields', () => {
const snapshot = buildBarkBattlePublishSnapshot(
buildBarkBattleDraft({
playerCharacterImageSrc: '',
opponentCharacterImageSrc: undefined,
}),
);
expect(snapshot).not.toHaveProperty('playerCharacterImageSrc');
expect(snapshot).not.toHaveProperty('opponentCharacterImageSrc');
expect(snapshot.uiBackgroundImageSrc).toBe(
'/generated-bark-battle/background.png',
);
});
test('merges draft assets into published config when publish response omits them', () => {
const draft = buildBarkBattleDraft();
const published: BarkBattlePublishedConfig = {
workId: 'BB-cache-race-12345678',
draftId: 'bark-battle-draft-1',
configVersion: 2,
rulesetVersion: 'bark-battle-ruleset-v2',
playTypeId: 'bark-battle',
title: '汪汪测试杯',
description: '测试声浪赛',
themeDescription: '阳光草坪声浪竞技场',
playerImageDescription: '戴红色围巾的柯基选手',
opponentImageDescription: '蓝色护目镜哈士奇对手',
onomatopoeia: ['汪', '破阵'],
difficultyPreset: 'normal',
updatedAt: '2026-05-21T10:01:00.000Z',
publishedAt: '2026-05-21T10:01:00.000Z',
};
const merged = mergeBarkBattlePublishedConfigAssets(published, draft);
expect(merged.playerCharacterImageSrc).toBe(
'/generated-bark-battle/player.png',
);
expect(merged.opponentCharacterImageSrc).toBe(
'/generated-bark-battle/opponent.png',
);
expect(merged.uiBackgroundImageSrc).toBe(
'/generated-bark-battle/background.png',
);
});

View File

@@ -1,6 +1,8 @@
import type {
BarkBattleConfigEditorPayload,
BarkBattleDraftConfig,
BarkBattleGenerationStatus as SharedBarkBattleGenerationStatus,
BarkBattlePublishedConfig,
BarkBattleWorkSummary,
} from '../../../packages/shared/src/contracts/barkBattle';
@@ -36,6 +38,132 @@ export function hasBarkBattleSummaryRequiredImages(item: BarkBattleWorkSummary)
);
}
export function hasBarkBattleDraftRequiredImages(draft: BarkBattleDraftConfig) {
return Boolean(
draft.playerCharacterImageSrc?.trim() &&
draft.opponentCharacterImageSrc?.trim() &&
draft.uiBackgroundImageSrc?.trim(),
);
}
export function resolveBarkBattleDraftGenerationStatus(
draft: BarkBattleDraftConfig,
partialFailed: boolean,
): BarkBattleGenerationStatus {
if (hasBarkBattleDraftRequiredImages(draft)) {
return 'ready';
}
return partialFailed ? 'partial_failed' : 'pending_assets';
}
export function buildBarkBattlePublishedConfigFromDraft(
draft: BarkBattleDraftConfig,
): BarkBattlePublishedConfig {
return {
workId: draft.workId ?? draft.draftId,
draftId: draft.draftId,
configVersion: draft.configVersion ?? 1,
rulesetVersion: draft.rulesetVersion ?? 'bark-battle-ruleset-v1',
playTypeId: 'bark-battle',
title: draft.title,
description: draft.description,
themeDescription: draft.themeDescription,
playerImageDescription: draft.playerImageDescription,
opponentImageDescription: draft.opponentImageDescription,
onomatopoeia: draft.onomatopoeia,
playerCharacterImageSrc: draft.playerCharacterImageSrc,
opponentCharacterImageSrc: draft.opponentCharacterImageSrc,
uiBackgroundImageSrc: draft.uiBackgroundImageSrc,
difficultyPreset: draft.difficultyPreset,
updatedAt: draft.updatedAt,
publishedAt: draft.updatedAt,
};
}
export function buildBarkBattlePublishSnapshot(
draft: BarkBattleDraftConfig,
): BarkBattleConfigEditorPayload {
return {
title: draft.title,
description: draft.description,
themeDescription: draft.themeDescription,
playerImageDescription: draft.playerImageDescription,
opponentImageDescription: draft.opponentImageDescription,
onomatopoeia: draft.onomatopoeia,
...(draft.playerCharacterImageSrc
? { playerCharacterImageSrc: draft.playerCharacterImageSrc }
: {}),
...(draft.opponentCharacterImageSrc
? { opponentCharacterImageSrc: draft.opponentCharacterImageSrc }
: {}),
...(draft.uiBackgroundImageSrc
? { uiBackgroundImageSrc: draft.uiBackgroundImageSrc }
: {}),
difficultyPreset: draft.difficultyPreset,
};
}
export function mergeBarkBattlePublishedConfigAssets(
published: BarkBattlePublishedConfig,
draft: BarkBattleDraftConfig,
): BarkBattlePublishedConfig {
return {
...published,
playerCharacterImageSrc:
published.playerCharacterImageSrc ?? draft.playerCharacterImageSrc,
opponentCharacterImageSrc:
published.opponentCharacterImageSrc ?? draft.opponentCharacterImageSrc,
uiBackgroundImageSrc:
published.uiBackgroundImageSrc ?? draft.uiBackgroundImageSrc,
};
}
export function buildBarkBattlePublishedConfigFromWork(
work: BarkBattleWorkSummary,
): BarkBattlePublishedConfig {
return {
workId: work.workId,
draftId: work.draftId ?? null,
configVersion: 1,
rulesetVersion: 'bark-battle-ruleset-v1',
playTypeId: 'bark-battle',
title: work.title,
description: work.summary,
themeDescription: work.themeDescription,
playerImageDescription: work.playerImageDescription,
opponentImageDescription: work.opponentImageDescription,
onomatopoeia: work.onomatopoeia,
playerCharacterImageSrc: work.playerCharacterImageSrc ?? undefined,
opponentCharacterImageSrc: work.opponentCharacterImageSrc ?? undefined,
uiBackgroundImageSrc: work.uiBackgroundImageSrc ?? undefined,
difficultyPreset: work.difficultyPreset,
updatedAt: work.updatedAt,
publishedAt: work.publishedAt ?? work.updatedAt,
};
}
export function buildBarkBattleDraftConfigFromWorkSummary(
work: BarkBattleWorkSummary,
): BarkBattleDraftConfig {
return {
draftId: work.draftId ?? work.workId,
workId: work.workId,
title: work.title,
description: work.summary,
themeDescription: work.themeDescription,
playerImageDescription: work.playerImageDescription,
opponentImageDescription: work.opponentImageDescription,
onomatopoeia: work.onomatopoeia,
playerCharacterImageSrc: work.playerCharacterImageSrc ?? undefined,
opponentCharacterImageSrc: work.opponentCharacterImageSrc ?? undefined,
uiBackgroundImageSrc: work.uiBackgroundImageSrc ?? undefined,
difficultyPreset: work.difficultyPreset,
configVersion: 1,
rulesetVersion: 'bark-battle-ruleset-v1',
updatedAt: work.updatedAt,
};
}
export function shouldPreserveLocalBarkBattleWorkOnRefresh(
item: BarkBattleWorkSummary,
refreshed: readonly BarkBattleWorkSummary[],
@@ -85,11 +213,7 @@ export function buildBarkBattleWorkSummaryFromDraft(
difficultyPreset: draft.difficultyPreset,
status: 'draft',
generationStatus,
publishReady: Boolean(
draft.playerCharacterImageSrc?.trim() &&
draft.opponentCharacterImageSrc?.trim() &&
draft.uiBackgroundImageSrc?.trim(),
),
publishReady: hasBarkBattleDraftRequiredImages(draft),
playCount: 0,
updatedAt: draft.updatedAt,
publishedAt: null,

View File

@@ -0,0 +1,76 @@
import { describe, expect, test } from 'vitest';
import {
type PlatformCreationLaunchTarget,
resolvePlatformCreationLaunchIntent,
} from './platformCreationLaunchModel';
import { EDUTAINMENT_HIDDEN_MESSAGE } from './platformEdutainmentVisibility';
describe('platformCreationLaunchModel', () => {
test('keeps airp as a placeholder noop before prepare', () => {
expect(
resolvePlatformCreationLaunchIntent({
type: 'airp',
isBabyObjectMatchVisible: true,
}),
).toEqual({
type: 'noop',
shouldPrepare: false,
reason: 'placeholder',
});
});
test('blocks hidden baby object match after prepare', () => {
expect(
resolvePlatformCreationLaunchIntent({
type: 'baby-object-match',
isBabyObjectMatchVisible: false,
}),
).toEqual({
type: 'blocked',
shouldPrepare: true,
message: EDUTAINMENT_HIDDEN_MESSAGE,
});
});
test('resolves known creation launch targets', () => {
const targets: PlatformCreationLaunchTarget[] = [
'rpg',
'big-fish',
'match3d',
'square-hole',
'jump-hop',
'wooden-fish',
'puzzle',
'bark-battle',
'visual-novel',
'baby-object-match',
];
targets.forEach((target) => {
expect(
resolvePlatformCreationLaunchIntent({
type: target,
isBabyObjectMatchVisible: true,
}),
).toEqual({
type: 'launch',
shouldPrepare: true,
target,
});
});
});
test('keeps unknown creation type as a prepared noop', () => {
expect(
resolvePlatformCreationLaunchIntent({
type: 'unknown-template',
isBabyObjectMatchVisible: true,
}),
).toEqual({
type: 'noop',
shouldPrepare: true,
reason: 'unknown',
});
});
});

View File

@@ -0,0 +1,87 @@
import { EDUTAINMENT_HIDDEN_MESSAGE } from './platformEdutainmentVisibility';
import type { PlatformCreationTypeId } from './platformEntryCreationTypes';
export type PlatformCreationLaunchTarget =
| 'rpg'
| 'big-fish'
| 'match3d'
| 'square-hole'
| 'jump-hop'
| 'wooden-fish'
| 'puzzle'
| 'bark-battle'
| 'visual-novel'
| 'baby-object-match';
export type PlatformCreationLaunchIntent =
| {
type: 'noop';
shouldPrepare: false;
reason: 'placeholder';
}
| {
type: 'noop';
shouldPrepare: true;
reason: 'unknown';
}
| {
type: 'blocked';
shouldPrepare: true;
message: string;
}
| {
type: 'launch';
shouldPrepare: true;
target: PlatformCreationLaunchTarget;
};
const PLATFORM_CREATION_LAUNCH_TARGETS = new Set<PlatformCreationTypeId>([
'rpg',
'big-fish',
'match3d',
'square-hole',
'jump-hop',
'wooden-fish',
'puzzle',
'bark-battle',
'visual-novel',
'baby-object-match',
]);
export function resolvePlatformCreationLaunchIntent(params: {
type: PlatformCreationTypeId;
isBabyObjectMatchVisible: boolean;
}): PlatformCreationLaunchIntent {
if (params.type === 'airp') {
return {
type: 'noop',
shouldPrepare: false,
reason: 'placeholder',
};
}
if (
params.type === 'baby-object-match' &&
!params.isBabyObjectMatchVisible
) {
return {
type: 'blocked',
shouldPrepare: true,
message: EDUTAINMENT_HIDDEN_MESSAGE,
};
}
if (!PLATFORM_CREATION_LAUNCH_TARGETS.has(params.type)) {
return {
type: 'noop',
shouldPrepare: true,
reason: 'unknown',
};
}
return {
type: 'launch',
shouldPrepare: true,
target: params.type as PlatformCreationLaunchTarget,
};
}

View File

@@ -0,0 +1,498 @@
import { describe, expect, test } from 'vitest';
import type { BarkBattleDraftConfig } from '../../../packages/shared/src/contracts/barkBattle';
import type { BigFishSessionSnapshotResponse } from '../../../packages/shared/src/contracts/bigFish';
import type { BabyObjectMatchDraft } from '../../../packages/shared/src/contracts/edutainmentBabyObject';
import type { Match3DAgentSessionSnapshot } from '../../../packages/shared/src/contracts/match3dAgent';
import type { PuzzleAgentSessionSnapshot } from '../../../packages/shared/src/contracts/puzzleAgentSession';
import type { PuzzleWorkSummary } from '../../../packages/shared/src/contracts/puzzleWorkSummary';
import type { SquareHoleSessionSnapshot } from '../../../packages/shared/src/contracts/squareHoleAgent';
import type { VisualNovelAgentSessionSnapshot } from '../../../packages/shared/src/contracts/visualNovel';
import type {
JumpHopSessionSnapshotResponse,
JumpHopWorkProfileResponse,
} from '../../services/jump-hop/jumpHopClient';
import type {
WoodenFishSessionSnapshotResponse,
WoodenFishWorkProfileResponse,
} from '../../services/wooden-fish/woodenFishClient';
import {
buildBabyObjectMatchCreationUrlState,
buildBarkBattleCreationUrlState,
buildBigFishCreationUrlState,
buildJumpHopCreationUrlState,
buildMatch3DCreationUrlState,
buildPuzzleCreationUrlState,
buildPuzzleDraftRuntimeUrlState,
buildPuzzlePublishedRuntimeUrlState,
buildPuzzleRuntimeUrlStateKey,
buildSquareHoleCreationUrlState,
buildVisualNovelCreationUrlState,
buildWoodenFishCreationUrlState,
hasCreationUrlStateValue,
hasPuzzleRuntimeUrlStateValue,
matchesBabyObjectMatchCreationUrlRestoreTarget,
matchesBarkBattleCreationUrlRestoreTarget,
matchesBigFishCreationUrlRestoreTarget,
matchesSessionProfileWorkCreationUrlRestoreTarget,
matchesVisualNovelCreationUrlRestoreTarget,
normalizeCreationUrlValue,
resolveCreationUrlRestoreTarget,
resolveInitialCreationUrlRestoreDecision,
resolveJumpHopCreationUrlRestoreStage,
resolveWoodenFishCreationUrlRestoreStage,
} from './platformCreationUrlStateModel';
describe('platformCreationUrlStateModel', () => {
test('normalizes private creation url state values', () => {
expect(normalizeCreationUrlValue(' session-1 ')).toBe('session-1');
expect(normalizeCreationUrlValue(' ')).toBeNull();
expect(
hasCreationUrlStateValue({
sessionId: ' ',
profileId: null,
draftId: undefined,
workId: 'work-1',
}),
).toBe(true);
expect(hasCreationUrlStateValue({})).toBe(false);
});
test('resolves initial creation url restore readiness', () => {
const readyParams = {
handled: false,
pathname: '/creation/puzzle/result',
state: { sessionId: 'puzzle-session-1' },
isLoadingPlatform: false,
canReadProtectedData: true,
};
expect(
resolveInitialCreationUrlRestoreDecision({
...readyParams,
handled: true,
}),
).toEqual({ type: 'skip' });
expect(
resolveInitialCreationUrlRestoreDecision({
...readyParams,
pathname: '/works/detail',
}),
).toEqual({ type: 'mark-handled' });
expect(
resolveInitialCreationUrlRestoreDecision({
...readyParams,
state: {},
}),
).toEqual({ type: 'mark-handled' });
expect(
resolveInitialCreationUrlRestoreDecision({
...readyParams,
isLoadingPlatform: true,
}),
).toEqual({ type: 'wait' });
expect(
resolveInitialCreationUrlRestoreDecision({
...readyParams,
canReadProtectedData: false,
}),
).toEqual({ type: 'wait' });
expect(resolveInitialCreationUrlRestoreDecision(readyParams)).toEqual({
type: 'restore',
});
});
test('resolves supported creation url restore targets from paths', () => {
const state = {
sessionId: ' session-1 ',
profileId: ' profile-1 ',
draftId: ' draft-1 ',
workId: ' work-1 ',
};
const cases = [
['/creation/big-fish/result', 'big-fish'],
['/creation/match3d/result', 'match3d'],
['/creation/square-hole/result', 'square-hole'],
['/creation/puzzle/result', 'puzzle'],
['/creation/visual-novel/result', 'visual-novel'],
['/creation/bark-battle/result', 'bark-battle'],
['/creation/baby-object-match/result', 'baby-object-match'],
['/creation/jump-hop/result', 'jump-hop'],
['/creation/wooden-fish/result', 'wooden-fish'],
] as const;
cases.forEach(([pathname, kind]) => {
expect(resolveCreationUrlRestoreTarget(pathname, state)).toMatchObject({
kind,
sessionId: 'session-1',
profileId: 'profile-1',
draftId: 'draft-1',
workId: 'work-1',
isGeneratingPath: false,
});
});
});
test('normalizes creation url restore target values and generating paths', () => {
expect(
resolveCreationUrlRestoreTarget('/creation/jump-hop/generating', {
sessionId: ' ',
profileId: ' jump-profile-1 ',
draftId: undefined,
workId: null,
}),
).toEqual({
kind: 'jump-hop',
sessionId: null,
profileId: 'jump-profile-1',
draftId: null,
workId: null,
isGeneratingPath: true,
});
});
test('derives big fish restore session from work id when needed', () => {
expect(
resolveCreationUrlRestoreTarget('/creation/big-fish/result', {
workId: 'big-fish-work-river',
}),
).toEqual({
kind: 'big-fish',
sessionId: null,
profileId: null,
draftId: null,
workId: 'big-fish-work-river',
isGeneratingPath: false,
bigFishSessionId: 'river',
});
expect(
resolveCreationUrlRestoreTarget('/creation/big-fish/result', {
sessionId: 'big-fish-session-carp',
workId: 'big-fish-work-river',
}),
).toMatchObject({
kind: 'big-fish',
bigFishSessionId: 'big-fish-session-carp',
});
});
test('keeps unsupported creation paths without a concrete restore target', () => {
expect(
resolveCreationUrlRestoreTarget('/creation/rpg/result', {
sessionId: 'rpg-session-1',
}),
).toBeNull();
expect(
resolveCreationUrlRestoreTarget('/creation/unknown/result', {
sessionId: 'unknown-session-1',
}),
).toBeNull();
expect(
resolveCreationUrlRestoreTarget('/creation/big-fishery/result', {
sessionId: 'big-fish-session-1',
}),
).toBeNull();
expect(
resolveCreationUrlRestoreTarget('/works/detail', {
workId: 'work-1',
}),
).toBeNull();
});
test('matches restore targets against work and draft identities', () => {
const bigFishTarget = resolveCreationUrlRestoreTarget(
'/creation/big-fish/result',
{
workId: 'big-fish-work-river',
},
);
expect(bigFishTarget?.kind).toBe('big-fish');
if (bigFishTarget?.kind !== 'big-fish') {
throw new Error('big fish target expected');
}
expect(
matchesBigFishCreationUrlRestoreTarget(
{ sourceSessionId: 'river' },
bigFishTarget,
),
).toBe(true);
expect(
matchesBigFishCreationUrlRestoreTarget(
{ workId: 'big-fish-work-river' },
bigFishTarget,
),
).toBe(true);
const target = {
sessionId: 'session-1',
profileId: 'profile-1',
draftId: 'draft-1',
workId: 'work-1',
};
expect(
matchesSessionProfileWorkCreationUrlRestoreTarget(
{ sourceSessionId: 'session-1' },
target,
),
).toBe(true);
expect(
matchesSessionProfileWorkCreationUrlRestoreTarget(
{ profileId: 'profile-1' },
target,
),
).toBe(true);
expect(
matchesSessionProfileWorkCreationUrlRestoreTarget(
{ workId: 'work-1' },
target,
),
).toBe(true);
expect(
matchesVisualNovelCreationUrlRestoreTarget(
{ profileId: 'profile-1' },
target,
),
).toBe(true);
expect(
matchesBarkBattleCreationUrlRestoreTarget(
{ draftId: 'draft-1' },
target,
),
).toBe(true);
expect(
matchesBabyObjectMatchCreationUrlRestoreTarget(
{ profileId: 'work-1' },
target,
),
).toBe(true);
expect(
matchesSessionProfileWorkCreationUrlRestoreTarget(
{ sourceSessionId: null, profileId: null, workId: null },
{ sessionId: null, profileId: null, workId: null },
),
).toBe(false);
expect(
matchesBarkBattleCreationUrlRestoreTarget(
{ workId: null, draftId: null },
{ workId: null, draftId: null },
),
).toBe(false);
});
test('resolves work backed restore stages', () => {
expect(
resolveJumpHopCreationUrlRestoreStage({
isGeneratingPath: true,
hasRestoredDraft: false,
hasRestoredWork: true,
}),
).toBe('jump-hop-generating');
expect(
resolveJumpHopCreationUrlRestoreStage({
isGeneratingPath: false,
hasRestoredDraft: false,
hasRestoredWork: true,
}),
).toBe('jump-hop-result');
expect(
resolveJumpHopCreationUrlRestoreStage({
isGeneratingPath: false,
hasRestoredDraft: false,
hasRestoredWork: false,
}),
).toBe('jump-hop-workspace');
expect(
resolveWoodenFishCreationUrlRestoreStage({
isGeneratingPath: true,
hasRestoredDraft: true,
}),
).toBe('wooden-fish-generating');
expect(
resolveWoodenFishCreationUrlRestoreStage({
isGeneratingPath: false,
hasRestoredDraft: true,
}),
).toBe('wooden-fish-result');
expect(
resolveWoodenFishCreationUrlRestoreStage({
isGeneratingPath: false,
hasRestoredDraft: false,
}),
).toBe('wooden-fish-workspace');
});
test('builds creation restore state for core session based plays', () => {
expect(
buildBigFishCreationUrlState({
sessionId: ' big-fish-session-1 ',
} as BigFishSessionSnapshotResponse),
).toEqual({
sessionId: 'big-fish-session-1',
workId: 'big-fish-work-big-fish-session-1',
});
expect(
buildMatch3DCreationUrlState({
sessionId: 'match3d-session-1',
draft: { profileId: 'match3d-profile-draft' },
} as Match3DAgentSessionSnapshot),
).toEqual({
sessionId: 'match3d-session-1',
profileId: 'match3d-profile-draft',
workId: 'match3d-profile-draft',
});
expect(
buildSquareHoleCreationUrlState({
sessionId: 'square-session-1',
publishedProfileId: 'square-profile-published',
} as SquareHoleSessionSnapshot),
).toEqual({
sessionId: 'square-session-1',
profileId: 'square-profile-published',
workId: 'square-profile-published',
});
expect(
buildVisualNovelCreationUrlState({
sessionId: 'visual-session-1',
draft: { profileId: 'visual-profile-1' },
} as VisualNovelAgentSessionSnapshot),
).toEqual({
sessionId: 'visual-session-1',
profileId: 'visual-profile-1',
workId: 'visual-profile-1',
});
});
test('builds puzzle creation and runtime query state', () => {
expect(
buildPuzzleCreationUrlState({
sessionId: 'puzzle-session-ocean',
} as PuzzleAgentSessionSnapshot),
).toEqual({
sessionId: 'puzzle-session-ocean',
profileId: 'puzzle-profile-ocean',
workId: 'puzzle-work-ocean',
});
const draftRuntime = buildPuzzleDraftRuntimeUrlState(
buildPuzzleWork({
profileId: 'puzzle-profile-ocean',
sourceSessionId: null,
}),
'level-2',
);
expect(draftRuntime).toEqual({
mode: 'draft',
runtimeSessionId: 'puzzle-session-ocean',
runtimeProfileId: 'puzzle-profile-ocean',
runtimeLevelId: 'level-2',
});
expect(hasPuzzleRuntimeUrlStateValue(draftRuntime)).toBe(true);
expect(buildPuzzleRuntimeUrlStateKey(draftRuntime)).toBe(
'draft|puzzle-session-ocean|puzzle-profile-ocean|level-2|',
);
const publishedRuntime = buildPuzzlePublishedRuntimeUrlState(
buildPuzzleWork({ profileId: 'puzzle-profile-ocean' }),
);
expect(publishedRuntime.mode).toBe('published');
expect(publishedRuntime.runtimeProfileId).toBe('puzzle-profile-ocean');
expect(publishedRuntime.publicWorkCode).toMatch(/^PZ-/u);
});
test('builds creation state for work backed plays with work id priority', () => {
expect(
buildJumpHopCreationUrlState({
session: {
sessionId: 'jump-session-1',
draft: { profileId: 'jump-profile-draft' },
} as JumpHopSessionSnapshotResponse,
work: {
summary: {
profileId: 'jump-profile-work',
workId: 'jump-work-1',
},
} as JumpHopWorkProfileResponse,
}),
).toEqual({
sessionId: 'jump-session-1',
profileId: 'jump-profile-work',
workId: 'jump-work-1',
});
expect(
buildWoodenFishCreationUrlState({
session: {
sessionId: 'wood-session-1',
draft: { profileId: 'wood-profile-draft' },
} as WoodenFishSessionSnapshotResponse,
work: {
summary: {
profileId: 'wood-profile-work',
workId: 'wood-work-1',
},
} as WoodenFishWorkProfileResponse,
}),
).toEqual({
sessionId: 'wood-session-1',
profileId: 'wood-profile-work',
draftId: 'wood-profile-work',
workId: 'wood-work-1',
});
});
test('builds creation state for draft backed local plays', () => {
expect(
buildBarkBattleCreationUrlState({
draftId: 'bark-draft-1',
workId: 'bark-work-1',
} as BarkBattleDraftConfig),
).toEqual({
draftId: 'bark-draft-1',
workId: 'bark-work-1',
});
expect(
buildBabyObjectMatchCreationUrlState({
draftId: 'baby-draft-1',
profileId: 'baby-profile-1',
} as BabyObjectMatchDraft),
).toEqual({
profileId: 'baby-profile-1',
draftId: 'baby-draft-1',
workId: 'baby-profile-1',
});
});
});
function buildPuzzleWork(
overrides: Partial<PuzzleWorkSummary> = {},
): PuzzleWorkSummary {
return {
workId: 'puzzle-work-base',
profileId: 'puzzle-profile-base',
ownerUserId: 'user-1',
sourceSessionId: 'puzzle-session-base',
authorDisplayName: '测试作者',
workTitle: '潮雾拼图',
workDescription: '潮雾港口拼图。',
levelName: '潮雾拼图',
summary: '潮雾港口拼图。',
themeTags: [],
coverImageSrc: null,
coverAssetId: null,
publicationStatus: 'draft',
updatedAt: '2026-06-03T08:00:00.000Z',
publishedAt: null,
playCount: 0,
remixCount: 0,
likeCount: 0,
publishReady: false,
levels: [],
...overrides,
};
}

View File

@@ -0,0 +1,431 @@
import type { BarkBattleDraftConfig } from '../../../packages/shared/src/contracts/barkBattle';
import type { BigFishSessionSnapshotResponse } from '../../../packages/shared/src/contracts/bigFish';
import type { BabyObjectMatchDraft } from '../../../packages/shared/src/contracts/edutainmentBabyObject';
import type { Match3DAgentSessionSnapshot } from '../../../packages/shared/src/contracts/match3dAgent';
import type { PuzzleAgentSessionSnapshot } from '../../../packages/shared/src/contracts/puzzleAgentSession';
import type { PuzzleWorkSummary } from '../../../packages/shared/src/contracts/puzzleWorkSummary';
import type { SquareHoleSessionSnapshot } from '../../../packages/shared/src/contracts/squareHoleAgent';
import type { VisualNovelAgentSessionSnapshot } from '../../../packages/shared/src/contracts/visualNovel';
import {
type CreationUrlState,
isCreationRestorePath,
} from '../../services/creationUrlState';
import type {
JumpHopSessionSnapshotResponse,
JumpHopWorkProfileResponse,
} from '../../services/jump-hop/jumpHopClient';
import { buildPuzzlePublicWorkCode } from '../../services/publicWorkCode';
import type { PuzzleRuntimeUrlState } from '../../services/puzzleRuntimeUrlState';
import type {
WoodenFishSessionSnapshotResponse,
WoodenFishWorkProfileResponse,
} from '../../services/wooden-fish/woodenFishClient';
import type { SelectionStage } from './platformEntryTypes';
import {
buildPuzzleResultProfileId,
buildPuzzleResultWorkId,
buildPuzzleSessionIdFromProfileId,
} from './platformPuzzleIdentityModel';
/** 平台创作恢复 URL 私有 query 的纯模型,调用方只需传入玩法快照。 */
export function normalizeCreationUrlValue(value: string | null | undefined) {
return value?.trim() || null;
}
export function hasCreationUrlStateValue(state: CreationUrlState) {
return Boolean(
normalizeCreationUrlValue(state.sessionId) ||
normalizeCreationUrlValue(state.profileId) ||
normalizeCreationUrlValue(state.draftId) ||
normalizeCreationUrlValue(state.workId),
);
}
export function hasPuzzleRuntimeUrlStateValue(state: PuzzleRuntimeUrlState) {
return Boolean(
normalizeCreationUrlValue(state.runtimeSessionId) ||
normalizeCreationUrlValue(state.runtimeProfileId) ||
normalizeCreationUrlValue(state.runtimeLevelId) ||
normalizeCreationUrlValue(state.publicWorkCode) ||
normalizeCreationUrlValue(state.mode),
);
}
export function buildPuzzleRuntimeUrlStateKey(state: PuzzleRuntimeUrlState) {
return [
normalizeCreationUrlValue(state.mode),
normalizeCreationUrlValue(state.runtimeSessionId),
normalizeCreationUrlValue(state.runtimeProfileId),
normalizeCreationUrlValue(state.runtimeLevelId),
normalizeCreationUrlValue(state.publicWorkCode),
].join('|');
}
export type CreationUrlRestoreTargetKind =
| 'big-fish'
| 'match3d'
| 'square-hole'
| 'puzzle'
| 'visual-novel'
| 'bark-battle'
| 'baby-object-match'
| 'jump-hop'
| 'wooden-fish';
type CreationUrlRestoreTargetBase = {
kind: CreationUrlRestoreTargetKind;
sessionId: string | null;
profileId: string | null;
draftId: string | null;
workId: string | null;
isGeneratingPath: boolean;
};
export type BigFishCreationUrlRestoreTarget = CreationUrlRestoreTargetBase & {
kind: 'big-fish';
bigFishSessionId: string | null;
};
type NonBigFishCreationUrlRestoreTarget = CreationUrlRestoreTargetBase & {
kind: Exclude<CreationUrlRestoreTargetKind, 'big-fish'>;
};
export type CreationUrlRestoreTarget =
| BigFishCreationUrlRestoreTarget
| NonBigFishCreationUrlRestoreTarget;
export type BigFishRestoreWorkIdentity = {
sourceSessionId?: string | null;
workId?: string | null;
};
export type SessionProfileWorkRestoreIdentity = {
sourceSessionId?: string | null;
profileId?: string | null;
workId?: string | null;
};
export type ProfileRestoreWorkIdentity = {
profileId?: string | null;
};
export type BarkBattleRestoreWorkIdentity = {
workId?: string | null;
draftId?: string | null;
};
export type BabyObjectMatchRestoreDraftIdentity = {
profileId?: string | null;
draftId?: string | null;
};
const CREATION_URL_RESTORE_TARGET_ROUTES = [
['/creation/big-fish', 'big-fish'],
['/creation/match3d', 'match3d'],
['/creation/square-hole', 'square-hole'],
['/creation/puzzle', 'puzzle'],
['/creation/visual-novel', 'visual-novel'],
['/creation/bark-battle', 'bark-battle'],
['/creation/baby-object-match', 'baby-object-match'],
['/creation/jump-hop', 'jump-hop'],
['/creation/wooden-fish', 'wooden-fish'],
] as const satisfies readonly (readonly [
string,
CreationUrlRestoreTargetKind,
])[];
export function resolveCreationUrlRestoreTarget(
pathname: string | undefined,
state: CreationUrlState,
): CreationUrlRestoreTarget | null {
const path = pathname?.trim() ?? '';
const route = CREATION_URL_RESTORE_TARGET_ROUTES.find(([prefix]) =>
path === prefix || path.startsWith(`${prefix}/`),
);
if (!route) {
return null;
}
const kind = route[1];
const sessionId = normalizeCreationUrlValue(state.sessionId);
const profileId = normalizeCreationUrlValue(state.profileId);
const draftId = normalizeCreationUrlValue(state.draftId);
const workId = normalizeCreationUrlValue(state.workId);
const base = {
kind,
sessionId,
profileId,
draftId,
workId,
isGeneratingPath: path.includes('/generating'),
};
if (kind === 'big-fish') {
return {
...base,
kind,
bigFishSessionId:
sessionId ?? workId?.replace(/^big-fish-work-/u, '') ?? null,
};
}
return base as NonBigFishCreationUrlRestoreTarget;
}
function matchesRestoreValue(
itemValue: string | null | undefined,
targetValue: string | null,
) {
return Boolean(targetValue && itemValue === targetValue);
}
export function matchesBigFishCreationUrlRestoreTarget(
item: BigFishRestoreWorkIdentity,
target: BigFishCreationUrlRestoreTarget,
) {
return (
matchesRestoreValue(item.sourceSessionId, target.bigFishSessionId) ||
matchesRestoreValue(item.workId, target.workId)
);
}
export function matchesSessionProfileWorkCreationUrlRestoreTarget(
item: SessionProfileWorkRestoreIdentity,
target: Pick<CreationUrlRestoreTarget, 'sessionId' | 'profileId' | 'workId'>,
) {
return (
matchesRestoreValue(item.sourceSessionId, target.sessionId) ||
matchesRestoreValue(item.profileId, target.profileId) ||
matchesRestoreValue(item.workId, target.workId)
);
}
export function matchesVisualNovelCreationUrlRestoreTarget(
item: ProfileRestoreWorkIdentity,
target: Pick<CreationUrlRestoreTarget, 'profileId'>,
) {
return matchesRestoreValue(item.profileId, target.profileId);
}
export function matchesBarkBattleCreationUrlRestoreTarget(
item: BarkBattleRestoreWorkIdentity,
target: Pick<CreationUrlRestoreTarget, 'workId' | 'draftId'>,
) {
return (
matchesRestoreValue(item.workId, target.workId) ||
matchesRestoreValue(item.draftId, target.draftId)
);
}
export function matchesBabyObjectMatchCreationUrlRestoreTarget(
item: BabyObjectMatchRestoreDraftIdentity,
target: Pick<CreationUrlRestoreTarget, 'profileId' | 'draftId' | 'workId'>,
) {
return (
matchesRestoreValue(item.profileId, target.profileId) ||
matchesRestoreValue(item.draftId, target.draftId) ||
matchesRestoreValue(item.profileId, target.workId)
);
}
export function resolveJumpHopCreationUrlRestoreStage(params: {
isGeneratingPath: boolean;
hasRestoredDraft: boolean;
hasRestoredWork: boolean;
}): SelectionStage {
if (params.isGeneratingPath) {
return 'jump-hop-generating';
}
return params.hasRestoredDraft || params.hasRestoredWork
? 'jump-hop-result'
: 'jump-hop-workspace';
}
export function resolveWoodenFishCreationUrlRestoreStage(params: {
isGeneratingPath: boolean;
hasRestoredDraft: boolean;
}): SelectionStage {
if (params.isGeneratingPath) {
return 'wooden-fish-generating';
}
return params.hasRestoredDraft
? 'wooden-fish-result'
: 'wooden-fish-workspace';
}
export type InitialCreationUrlRestoreDecision =
| { type: 'skip' }
| { type: 'mark-handled' }
| { type: 'wait' }
| { type: 'restore' };
export function resolveInitialCreationUrlRestoreDecision(params: {
handled: boolean;
pathname: string | undefined;
state: CreationUrlState;
isLoadingPlatform: boolean;
canReadProtectedData: boolean;
}): InitialCreationUrlRestoreDecision {
if (params.handled) {
return { type: 'skip' };
}
if (
!isCreationRestorePath(params.pathname) ||
!hasCreationUrlStateValue(params.state)
) {
return { type: 'mark-handled' };
}
if (params.isLoadingPlatform || !params.canReadProtectedData) {
return { type: 'wait' };
}
return { type: 'restore' };
}
export function buildBigFishCreationUrlState(
session: BigFishSessionSnapshotResponse | null,
): CreationUrlState {
const sessionId = normalizeCreationUrlValue(session?.sessionId);
return {
sessionId,
workId: sessionId ? `big-fish-work-${sessionId}` : null,
};
}
export function buildMatch3DCreationUrlState(
session: Match3DAgentSessionSnapshot | null,
): CreationUrlState {
const sessionId = normalizeCreationUrlValue(session?.sessionId);
const profileId = normalizeCreationUrlValue(
session?.draft?.profileId ?? session?.publishedProfileId,
);
return {
sessionId,
profileId,
workId: profileId,
};
}
export function buildSquareHoleCreationUrlState(
session: SquareHoleSessionSnapshot | null,
): CreationUrlState {
const sessionId = normalizeCreationUrlValue(session?.sessionId);
const profileId = normalizeCreationUrlValue(
session?.draft?.profileId ?? session?.publishedProfileId,
);
return {
sessionId,
profileId,
workId: profileId,
};
}
export function buildPuzzleCreationUrlState(
session: PuzzleAgentSessionSnapshot | null,
): CreationUrlState {
const sessionId = normalizeCreationUrlValue(session?.sessionId);
const profileId = normalizeCreationUrlValue(
session?.publishedProfileId ?? buildPuzzleResultProfileId(sessionId),
);
return {
sessionId,
profileId,
workId: sessionId ? buildPuzzleResultWorkId(sessionId) : null,
};
}
export function buildPuzzleDraftRuntimeUrlState(
item: PuzzleWorkSummary,
levelId?: string | null,
): PuzzleRuntimeUrlState {
const runtimeSessionId =
normalizeCreationUrlValue(item.sourceSessionId) ??
buildPuzzleSessionIdFromProfileId(item.profileId);
return {
mode: 'draft',
runtimeSessionId,
runtimeProfileId: normalizeCreationUrlValue(item.profileId),
runtimeLevelId: normalizeCreationUrlValue(levelId),
};
}
export function buildPuzzlePublishedRuntimeUrlState(
item: PuzzleWorkSummary,
levelId?: string | null,
): PuzzleRuntimeUrlState {
return {
mode: 'published',
runtimeProfileId: normalizeCreationUrlValue(item.profileId),
runtimeLevelId: normalizeCreationUrlValue(levelId),
publicWorkCode: buildPuzzlePublicWorkCode(item.profileId),
};
}
export function buildVisualNovelCreationUrlState(
session: VisualNovelAgentSessionSnapshot | null,
): CreationUrlState {
const sessionId = normalizeCreationUrlValue(session?.sessionId);
const profileId = normalizeCreationUrlValue(session?.draft?.profileId);
return {
sessionId,
profileId,
workId: profileId ?? sessionId,
};
}
export function buildJumpHopCreationUrlState(params: {
session?: JumpHopSessionSnapshotResponse | null;
work?: JumpHopWorkProfileResponse | null;
}): CreationUrlState {
const sessionId = normalizeCreationUrlValue(params.session?.sessionId);
const profileId = normalizeCreationUrlValue(
params.work?.summary.profileId ?? params.session?.draft?.profileId,
);
return {
sessionId,
profileId,
workId: normalizeCreationUrlValue(params.work?.summary.workId ?? profileId),
};
}
export function buildWoodenFishCreationUrlState(params: {
session?: WoodenFishSessionSnapshotResponse | null;
work?: WoodenFishWorkProfileResponse | null;
}): CreationUrlState {
const sessionId = normalizeCreationUrlValue(params.session?.sessionId);
const profileId = normalizeCreationUrlValue(
params.work?.summary.profileId ?? params.session?.draft?.profileId,
);
const draftId = profileId ?? sessionId;
return {
sessionId,
profileId,
draftId,
workId: normalizeCreationUrlValue(params.work?.summary.workId ?? profileId),
};
}
export function buildBarkBattleCreationUrlState(
draft: BarkBattleDraftConfig | null,
): CreationUrlState {
return {
draftId: normalizeCreationUrlValue(draft?.draftId),
workId: normalizeCreationUrlValue(draft?.workId ?? draft?.draftId),
};
}
export function buildBabyObjectMatchCreationUrlState(
draft: BabyObjectMatchDraft | null,
): CreationUrlState {
const profileId = normalizeCreationUrlValue(draft?.profileId);
return {
profileId,
draftId: normalizeCreationUrlValue(draft?.draftId),
workId: profileId,
};
}

View File

@@ -0,0 +1,334 @@
import { describe, expect, test } from 'vitest';
import type { BigFishWorkSummary } from '../../../packages/shared/src/contracts/bigFishWorkSummary';
import type { CustomWorldWorkSummary } from '../../../packages/shared/src/contracts/customWorldWorkSummary';
import type { BabyObjectMatchDraft } from '../../../packages/shared/src/contracts/edutainmentBabyObject';
import type { Match3DWorkSummary } from '../../../packages/shared/src/contracts/match3dWorks';
import type { PuzzleWorkSummary } from '../../../packages/shared/src/contracts/puzzleWorkSummary';
import type { SquareHoleWorkSummary } from '../../../packages/shared/src/contracts/squareHoleWorks';
import type { VisualNovelWorkSummary } from '../../../packages/shared/src/contracts/visualNovel';
import { resolvePlatformCreationWorkDeleteConfirmationModel } from './platformCreationWorkDeleteFlow';
describe('platformCreationWorkDeleteFlow', () => {
test('resolves RPG library delete confirmation without draft notice keys', () => {
expect(
resolvePlatformCreationWorkDeleteConfirmationModel({
kind: 'rpg-library',
entry: {
profileId: 'rpg-profile',
worldName: '潮雾列岛',
},
}),
).toEqual({
id: 'rpg-profile',
title: '潮雾列岛',
detail: '删除后会从你的作品列表和公开广场中移除。',
noticeKeys: [],
});
});
test('resolves RPG work delete detail and notice keys by work status', () => {
expect(
resolvePlatformCreationWorkDeleteConfirmationModel({
kind: 'rpg',
work: buildRpgWork(),
}),
).toEqual({
id: 'rpg-work',
title: 'RPG 草稿',
detail: '删除后会从你的作品列表中移除。',
noticeKeys: ['rpg:rpg-work', 'rpg:rpg-session', 'rpg:rpg-profile'],
});
expect(
resolvePlatformCreationWorkDeleteConfirmationModel({
kind: 'rpg',
work: buildRpgWork({ status: 'published' }),
}).detail,
).toBe('删除后会从你的作品列表和公开广场中移除。');
});
test('resolves mini game delete models with shared public and private detail copy', () => {
expect(
resolvePlatformCreationWorkDeleteConfirmationModel({
kind: 'big-fish',
work: buildBigFishWork({ status: 'published' }),
}),
).toMatchObject({
id: 'big-fish-work',
title: '大鱼作品',
detail: '删除后会从你的作品列表和公开广场中移除。',
noticeKeys: ['big-fish:big-fish-work', 'big-fish:big-fish-session'],
});
expect(
resolvePlatformCreationWorkDeleteConfirmationModel({
kind: 'match3d',
work: buildMatch3DWork(),
}).detail,
).toBe('删除后会从你的作品列表中移除。');
expect(
resolvePlatformCreationWorkDeleteConfirmationModel({
kind: 'square-hole',
work: buildSquareHoleWork({ publicationStatus: 'published' }),
}).noticeKeys,
).toEqual([
'square-hole:square-hole-work',
'square-hole:square-hole-profile',
'square-hole:square-hole-session',
]);
});
test('resolves puzzle title fallback and stable result notice keys', () => {
expect(
resolvePlatformCreationWorkDeleteConfirmationModel({
kind: 'puzzle',
work: buildPuzzleWork({
workTitle: ' ',
levelName: ' 雾港第一关 ',
sourceSessionId: 'puzzle-session-ocean',
}),
}),
).toEqual({
id: 'puzzle-work',
title: '雾港第一关',
detail: '删除后会从你的作品列表中移除。',
noticeKeys: [
'puzzle:puzzle-work',
'puzzle:puzzle-profile',
'puzzle:puzzle-session-ocean',
'puzzle:puzzle-work-ocean',
'puzzle:puzzle-profile-ocean',
],
});
expect(
resolvePlatformCreationWorkDeleteConfirmationModel({
kind: 'puzzle',
work: buildPuzzleWork({ workTitle: '', levelName: ' ' }),
}).title,
).toBe('未命名拼图');
});
test('resolves visual novel and baby object match special delete copy', () => {
expect(
resolvePlatformCreationWorkDeleteConfirmationModel({
kind: 'visual-novel',
work: buildVisualNovelWork({ title: '', publishStatus: 'published' }),
}),
).toEqual({
id: 'visual-novel-profile',
title: '未命名视觉小说',
detail: '删除后会从你的作品列表和公开广场中移除。',
noticeKeys: ['visual-novel:visual-novel-profile'],
});
expect(
resolvePlatformCreationWorkDeleteConfirmationModel({
kind: 'baby-object-match',
work: buildBabyObjectMatchDraft({
workTitle: ' ',
publicationStatus: 'published',
}),
}),
).toEqual({
id: 'baby-profile',
title: '宝贝识物',
detail: '删除后会从你的作品列表和寓教于乐板块中移除。',
noticeKeys: [
'baby-object-match:baby-profile',
'baby-object-match:baby-draft',
],
});
});
});
function buildRpgWork(
overrides: Partial<CustomWorldWorkSummary> = {},
): CustomWorldWorkSummary {
return {
workId: 'rpg-work',
sourceType: 'agent_session',
status: 'draft',
title: 'RPG 草稿',
subtitle: '待完善',
summary: 'RPG 摘要。',
coverImageSrc: null,
updatedAt: '2026-06-04T00:00:00.000Z',
publishedAt: null,
stage: 'draft',
stageLabel: '草稿',
playableNpcCount: 1,
landmarkCount: 1,
sessionId: 'rpg-session',
profileId: 'rpg-profile',
canResume: true,
canEnterWorld: false,
...overrides,
};
}
function buildBigFishWork(
overrides: Partial<BigFishWorkSummary> = {},
): BigFishWorkSummary {
return {
workId: 'big-fish-work',
sourceSessionId: 'big-fish-session',
ownerUserId: 'user-1',
authorDisplayName: '玩家',
title: '大鱼作品',
subtitle: '大鱼吃小鱼',
summary: '大鱼摘要。',
coverImageSrc: null,
status: 'draft',
updatedAt: '2026-06-04T00:00:00.000Z',
publishedAt: null,
publishReady: false,
levelCount: 1,
levelMainImageReadyCount: 0,
levelMotionReadyCount: 0,
backgroundReady: true,
...overrides,
};
}
function buildPuzzleWork(
overrides: Partial<PuzzleWorkSummary> = {},
): PuzzleWorkSummary {
return {
workId: 'puzzle-work',
profileId: 'puzzle-profile',
ownerUserId: 'user-1',
sourceSessionId: 'puzzle-session',
authorDisplayName: '玩家',
workTitle: '拼图作品',
workDescription: '拼图摘要。',
levelName: '拼图第一关',
summary: '拼图摘要。',
themeTags: [],
coverImageSrc: null,
coverAssetId: null,
publicationStatus: 'draft',
updatedAt: '2026-06-04T00:00:00.000Z',
publishedAt: null,
playCount: 0,
remixCount: 0,
likeCount: 0,
publishReady: false,
levels: [],
...overrides,
};
}
function buildMatch3DWork(
overrides: Partial<Match3DWorkSummary> = {},
): Match3DWorkSummary {
return {
workId: 'match3d-work',
profileId: 'match3d-profile',
ownerUserId: 'user-1',
sourceSessionId: 'match3d-session',
gameName: '抓大鹅作品',
themeText: '糖果厨房',
summary: '抓大鹅摘要。',
tags: [],
coverImageSrc: null,
referenceImageSrc: null,
clearCount: 12,
difficulty: 4,
publicationStatus: 'draft',
playCount: 0,
updatedAt: '2026-06-04T00:00:00.000Z',
publishedAt: null,
publishReady: false,
generatedItemAssets: [],
...overrides,
};
}
function buildSquareHoleWork(
overrides: Partial<SquareHoleWorkSummary> = {},
): SquareHoleWorkSummary {
return {
workId: 'square-hole-work',
profileId: 'square-hole-profile',
ownerUserId: 'user-1',
sourceSessionId: 'square-hole-session',
gameName: '方洞作品',
themeText: '图形',
twistRule: '反直觉',
summary: '方洞摘要。',
tags: [],
coverImageSrc: null,
backgroundPrompt: '背景',
backgroundImageSrc: null,
shapeOptions: [],
holeOptions: [],
shapeCount: 8,
difficulty: 4,
publicationStatus: 'draft',
playCount: 0,
updatedAt: '2026-06-04T00:00:00.000Z',
publishedAt: null,
publishReady: false,
...overrides,
};
}
function buildVisualNovelWork(
overrides: Partial<VisualNovelWorkSummary> = {},
): VisualNovelWorkSummary {
return {
runtimeKind: 'visual-novel',
profileId: 'visual-novel-profile',
ownerUserId: 'user-1',
title: '视觉小说作品',
description: '视觉小说摘要。',
coverImageSrc: null,
tags: [],
publishStatus: 'draft',
publishReady: false,
playCount: 0,
updatedAt: '2026-06-04T00:00:00.000Z',
publishedAt: null,
...overrides,
};
}
function buildBabyObjectMatchDraft(
overrides: Partial<BabyObjectMatchDraft> = {},
): BabyObjectMatchDraft {
return {
draftId: 'baby-draft',
profileId: 'baby-profile',
templateId: 'baby-object-match',
templateName: '宝贝识物',
workTitle: '宝贝识物作品',
workDescription: '宝贝识物摘要。',
itemNames: ['苹果', '香蕉'],
itemAssets: [
{
itemId: 'apple',
itemName: '苹果',
imageSrc: '/apple.png',
assetObjectId: null,
generationProvider: 'placeholder',
prompt: '苹果',
},
{
itemId: 'banana',
itemName: '香蕉',
imageSrc: '/banana.png',
assetObjectId: null,
generationProvider: 'placeholder',
prompt: '香蕉',
},
],
themeTags: [],
publicationStatus: 'draft',
createdAt: '2026-06-04T00:00:00.000Z',
updatedAt: '2026-06-04T00:00:00.000Z',
publishedAt: null,
...overrides,
};
}

View File

@@ -0,0 +1,288 @@
import type { BigFishWorkSummary } from '../../../packages/shared/src/contracts/bigFishWorkSummary';
import type { CustomWorldWorkSummary } from '../../../packages/shared/src/contracts/customWorldWorkSummary';
import type { BabyObjectMatchDraft } from '../../../packages/shared/src/contracts/edutainmentBabyObject';
import type { Match3DWorkSummary } from '../../../packages/shared/src/contracts/match3dWorks';
import type { PuzzleWorkSummary } from '../../../packages/shared/src/contracts/puzzleWorkSummary';
import type { CustomWorldLibraryEntry } from '../../../packages/shared/src/contracts/runtime';
import type { SquareHoleWorkSummary } from '../../../packages/shared/src/contracts/squareHoleWorks';
import type { VisualNovelWorkSummary } from '../../../packages/shared/src/contracts/visualNovel';
import {
buildPuzzleResultProfileId,
buildPuzzleResultWorkId,
collectDraftNoticeKeys,
} from './platformDraftGenerationShelfModel';
const PRIVATE_WORK_DELETE_DETAIL = '删除后会从你的作品列表中移除。';
const PUBLIC_GALLERY_DELETE_DETAIL = '删除后会从你的作品列表和公开广场中移除。';
const EDUTAINMENT_PUBLIC_DELETE_DETAIL =
'删除后会从你的作品列表和寓教于乐板块中移除。';
export type PlatformCreationWorkDeleteConfirmationModel = {
id: string;
title: string;
detail: string;
noticeKeys: string[];
};
export type PlatformCreationWorkDeleteInput =
| {
kind: 'rpg-library';
entry: Pick<CustomWorldLibraryEntry<unknown>, 'profileId' | 'worldName'>;
}
| {
kind: 'rpg';
work: Pick<
CustomWorldWorkSummary,
'workId' | 'title' | 'status' | 'sessionId' | 'profileId'
>;
}
| {
kind: 'big-fish';
work: Pick<
BigFishWorkSummary,
'workId' | 'title' | 'status' | 'sourceSessionId'
>;
}
| {
kind: 'puzzle';
work: Pick<
PuzzleWorkSummary,
| 'workId'
| 'profileId'
| 'sourceSessionId'
| 'workTitle'
| 'levelName'
| 'publicationStatus'
>;
}
| {
kind: 'match3d';
work: Pick<
Match3DWorkSummary,
| 'workId'
| 'profileId'
| 'sourceSessionId'
| 'gameName'
| 'publicationStatus'
>;
}
| {
kind: 'square-hole';
work: Pick<
SquareHoleWorkSummary,
| 'workId'
| 'profileId'
| 'sourceSessionId'
| 'gameName'
| 'publicationStatus'
>;
}
| {
kind: 'visual-novel';
work: Pick<
VisualNovelWorkSummary,
'profileId' | 'title' | 'publishStatus'
>;
}
| {
kind: 'baby-object-match';
work: Pick<
BabyObjectMatchDraft,
| 'profileId'
| 'draftId'
| 'workTitle'
| 'templateName'
| 'publicationStatus'
>;
};
export function resolvePlatformCreationWorkDeleteConfirmationModel(
input: PlatformCreationWorkDeleteInput,
): PlatformCreationWorkDeleteConfirmationModel {
switch (input.kind) {
case 'rpg-library':
return resolveRpgLibraryDeleteConfirmationModel(input.entry);
case 'rpg':
return resolveRpgWorkDeleteConfirmationModel(input.work);
case 'big-fish':
return resolveBigFishWorkDeleteConfirmationModel(input.work);
case 'puzzle':
return resolvePuzzleWorkDeleteConfirmationModel(input.work);
case 'match3d':
return resolveMatch3DWorkDeleteConfirmationModel(input.work);
case 'square-hole':
return resolveSquareHoleWorkDeleteConfirmationModel(input.work);
case 'visual-novel':
return resolveVisualNovelWorkDeleteConfirmationModel(input.work);
case 'baby-object-match':
return resolveBabyObjectMatchDeleteConfirmationModel(input.work);
default: {
const exhaustive: never = input;
return exhaustive;
}
}
}
function resolveStatusDeleteDetail(
status: string,
publishedDetail = PUBLIC_GALLERY_DELETE_DETAIL,
) {
return status === 'published' ? publishedDetail : PRIVATE_WORK_DELETE_DETAIL;
}
function resolveTrimmedTitle(
value: string | null | undefined,
fallback: string,
) {
const trimmedValue = value?.trim();
return trimmedValue || fallback;
}
function resolveRpgLibraryDeleteConfirmationModel(
entry: Pick<CustomWorldLibraryEntry<unknown>, 'profileId' | 'worldName'>,
): PlatformCreationWorkDeleteConfirmationModel {
return {
id: entry.profileId,
title: entry.worldName,
detail: PUBLIC_GALLERY_DELETE_DETAIL,
noticeKeys: [],
};
}
function resolveRpgWorkDeleteConfirmationModel(
work: Pick<
CustomWorldWorkSummary,
'workId' | 'title' | 'status' | 'sessionId' | 'profileId'
>,
): PlatformCreationWorkDeleteConfirmationModel {
return {
id: work.workId,
title: work.title,
detail: resolveStatusDeleteDetail(work.status),
noticeKeys: collectDraftNoticeKeys('rpg', [
work.workId,
work.sessionId,
work.profileId,
]),
};
}
function resolveBigFishWorkDeleteConfirmationModel(
work: Pick<
BigFishWorkSummary,
'workId' | 'title' | 'status' | 'sourceSessionId'
>,
): PlatformCreationWorkDeleteConfirmationModel {
return {
id: work.workId,
title: work.title,
detail: resolveStatusDeleteDetail(work.status),
noticeKeys: collectDraftNoticeKeys('big-fish', [
work.workId,
work.sourceSessionId,
]),
};
}
function resolvePuzzleWorkDeleteConfirmationModel(
work: Pick<
PuzzleWorkSummary,
| 'workId'
| 'profileId'
| 'sourceSessionId'
| 'workTitle'
| 'levelName'
| 'publicationStatus'
>,
): PlatformCreationWorkDeleteConfirmationModel {
return {
id: work.workId,
title: resolveTrimmedTitle(
work.workTitle,
resolveTrimmedTitle(work.levelName, '未命名拼图'),
),
detail: resolveStatusDeleteDetail(work.publicationStatus),
noticeKeys: collectDraftNoticeKeys('puzzle', [
work.workId,
work.profileId,
work.sourceSessionId,
buildPuzzleResultWorkId(work.sourceSessionId),
buildPuzzleResultProfileId(work.sourceSessionId),
]),
};
}
function resolveMatch3DWorkDeleteConfirmationModel(
work: Pick<
Match3DWorkSummary,
| 'workId'
| 'profileId'
| 'sourceSessionId'
| 'gameName'
| 'publicationStatus'
>,
): PlatformCreationWorkDeleteConfirmationModel {
return {
id: work.workId,
title: work.gameName,
detail: resolveStatusDeleteDetail(work.publicationStatus),
noticeKeys: collectDraftNoticeKeys('match3d', [
work.workId,
work.profileId,
work.sourceSessionId,
]),
};
}
function resolveSquareHoleWorkDeleteConfirmationModel(
work: Pick<
SquareHoleWorkSummary,
| 'workId'
| 'profileId'
| 'sourceSessionId'
| 'gameName'
| 'publicationStatus'
>,
): PlatformCreationWorkDeleteConfirmationModel {
return {
id: work.workId,
title: work.gameName,
detail: resolveStatusDeleteDetail(work.publicationStatus),
noticeKeys: collectDraftNoticeKeys('square-hole', [
work.workId,
work.profileId,
work.sourceSessionId,
]),
};
}
function resolveVisualNovelWorkDeleteConfirmationModel(
work: Pick<VisualNovelWorkSummary, 'profileId' | 'title' | 'publishStatus'>,
): PlatformCreationWorkDeleteConfirmationModel {
return {
id: work.profileId,
title: work.title || '未命名视觉小说',
detail: resolveStatusDeleteDetail(work.publishStatus),
noticeKeys: collectDraftNoticeKeys('visual-novel', [work.profileId]),
};
}
function resolveBabyObjectMatchDeleteConfirmationModel(
work: Pick<
BabyObjectMatchDraft,
'profileId' | 'draftId' | 'workTitle' | 'templateName' | 'publicationStatus'
>,
): PlatformCreationWorkDeleteConfirmationModel {
return {
id: work.profileId,
title: resolveTrimmedTitle(work.workTitle, work.templateName),
detail: resolveStatusDeleteDetail(
work.publicationStatus,
EDUTAINMENT_PUBLIC_DELETE_DETAIL,
),
noticeKeys: collectDraftNoticeKeys('baby-object-match', [
work.profileId,
work.draftId,
]),
};
}

View File

@@ -0,0 +1,113 @@
import { describe, expect, test } from 'vitest';
import {
buildPlatformErrorDialogDismissKey,
buildPlatformTaskCompletionDialogDismissKey,
formatPlatformDialogSource,
isBackgroundGenerationStillRunningMessage,
normalizePlatformDialogMessage,
PLATFORM_TASK_COMPLETION_MESSAGE,
resolveActivePlatformDialog,
resolvePlatformErrorDialog,
} from './platformDialogStateModel';
describe('platformDialogStateModel', () => {
test('normalizes platform dialog messages', () => {
expect(normalizePlatformDialogMessage(' 图片失败 ')).toBe('图片失败');
expect(normalizePlatformDialogMessage(' ')).toBeNull();
expect(normalizePlatformDialogMessage(null)).toBeNull();
});
test('formats dialog source with optional identity', () => {
expect(formatPlatformDialogSource('拼图草稿', ' puzzle-session-1 ')).toBe(
'拼图草稿 puzzle-session-1',
);
expect(formatPlatformDialogSource('拼图草稿', ' ')).toBe('拼图草稿');
});
test('detects background generation still running messages', () => {
expect(
isBackgroundGenerationStillRunningMessage('后台仍在处理,请稍后查看。'),
).toBe(true);
expect(isBackgroundGenerationStillRunningMessage('素材生成失败。')).toBe(
false,
);
});
test('resolves the first non-empty error candidate', () => {
expect(
resolvePlatformErrorDialog([
{
key: 'empty',
source: '空来源',
message: ' ',
},
{
key: 'puzzle',
source: '拼图草稿 puzzle-session-1',
message: ' 素材生成失败。 ',
},
]),
).toEqual({
key: 'puzzle',
source: '拼图草稿 puzzle-session-1',
message: '素材生成失败。',
});
expect(
resolvePlatformErrorDialog([
{
key: 'empty',
source: '空来源',
message: null,
},
]),
).toBeNull();
});
test('builds stable dismiss keys for error and completion dialogs', () => {
expect(
buildPlatformErrorDialogDismissKey({
key: 'puzzle',
source: '拼图草稿 puzzle-session-1',
message: '素材生成失败。',
}),
).toBe('puzzle:拼图草稿 puzzle-session-1:素材生成失败。');
expect(buildPlatformErrorDialogDismissKey(null)).toBeNull();
expect(
buildPlatformTaskCompletionDialogDismissKey({
key: 'match3d',
source: '抓大鹅草稿 match3d-session-1',
message: PLATFORM_TASK_COMPLETION_MESSAGE,
completedAtMs: null,
}),
).toBe(
`match3d:抓大鹅草稿 match3d-session-1:${PLATFORM_TASK_COMPLETION_MESSAGE}:0`,
);
});
test('hides active dialog when the dismiss key has already been recorded', () => {
const dialog = {
key: 'puzzle',
source: '拼图草稿 puzzle-session-1',
message: '素材生成失败。',
};
const dismissKey = buildPlatformErrorDialogDismissKey(dialog);
expect(
resolveActivePlatformDialog(
dialog,
dismissKey,
buildPlatformErrorDialogDismissKey,
),
).toBeNull();
expect(
resolveActivePlatformDialog(
dialog,
'other-dismiss-key',
buildPlatformErrorDialogDismissKey,
),
).toBe(dialog);
});
});

View File

@@ -0,0 +1,85 @@
import type { PlatformErrorDialogPayload } from './PlatformErrorDialog';
import type { PlatformTaskCompletionDialogPayload } from './PlatformTaskCompletionDialog';
export type PlatformErrorDialogState = PlatformErrorDialogPayload & {
key: string;
};
export type PlatformTaskFailureDialogState = PlatformErrorDialogState & {
failedAtMs: number;
};
export type PlatformTaskCompletionDialogState =
PlatformTaskCompletionDialogPayload & {
key: string;
completedAtMs: number | null;
};
export type PlatformDialogCandidate = {
key: string;
source: string;
message: string | null | undefined;
};
export const PLATFORM_TASK_COMPLETION_MESSAGE =
'生成任务已完成,可以继续查看草稿。';
/** 收口平台弹窗候选的纯状态规则,壳层只负责副作用清理。 */
export function normalizePlatformDialogMessage(
message: string | null | undefined,
) {
const normalized = message?.trim();
return normalized ? normalized : null;
}
export function formatPlatformDialogSource(label: string, id?: string | null) {
const normalizedId = id?.trim();
return normalizedId ? `${label} ${normalizedId}` : label;
}
export function isBackgroundGenerationStillRunningMessage(message: string) {
return /|||/u.test(message);
}
export function resolvePlatformErrorDialog(
candidates: readonly PlatformDialogCandidate[],
): PlatformErrorDialogState | null {
for (const candidate of candidates) {
const message = normalizePlatformDialogMessage(candidate.message);
if (message) {
return {
key: candidate.key,
source: candidate.source,
message,
};
}
}
return null;
}
export function buildPlatformErrorDialogDismissKey(
error: PlatformErrorDialogState | null,
) {
return error ? `${error.key}:${error.source}:${error.message}` : null;
}
export function buildPlatformTaskCompletionDialogDismissKey(
completion: PlatformTaskCompletionDialogState | null,
) {
return completion
? `${completion.key}:${completion.source}:${completion.message}:${completion.completedAtMs ?? 0}`
: null;
}
export function resolveActivePlatformDialog<TDialog>(
currentDialog: TDialog | null,
dismissedDialogKey: string | null,
buildDismissKey: (dialog: TDialog | null) => string | null,
): TDialog | null {
const currentDialogDismissKey = buildDismissKey(currentDialog);
return currentDialogDismissKey &&
currentDialogDismissKey === dismissedDialogKey
? null
: currentDialog;
}

View File

@@ -0,0 +1,791 @@
import { describe, expect, test } from 'vitest';
import type { BigFishWorkSummary } from '../../../packages/shared/src/contracts/bigFishWorkSummary';
import type { JumpHopWorkSummaryResponse } from '../../../packages/shared/src/contracts/jumpHop';
import type { Match3DWorkSummary } from '../../../packages/shared/src/contracts/match3dWorks';
import type { PuzzleWorkSummary } from '../../../packages/shared/src/contracts/puzzleWorkSummary';
import type { SquareHoleWorkSummary } from '../../../packages/shared/src/contracts/squareHoleWorks';
import type { VisualNovelWorkSummary } from '../../../packages/shared/src/contracts/visualNovel';
import type { WoodenFishWorkSummaryResponse } from '../../../packages/shared/src/contracts/woodenFish';
import { buildCreationWorkShelfItems } from '../custom-world-home/creationWorkShelf';
import {
buildCreationWorkShelfRuntimeState,
buildPendingPuzzleWorks,
buildPuzzleResultProfileId,
buildPuzzleResultWorkId,
collectVisibleDraftNoticeKeys,
createPendingDraftShelfState,
type DraftGenerationNoticeMap,
getGenerationNoticeShelfKeys,
hasUnreadDraftGenerationUpdates,
mergeBigFishWorkSummary,
mergePuzzleWorkSummary,
resolveBigFishDraftOpenIntent,
resolveJumpHopDraftOpenIntent,
resolveMatch3DDraftOpenIntent,
resolvePuzzleDraftOpenIntent,
resolveSquareHoleDraftOpenIntent,
resolveVisualNovelDraftOpenIntent,
resolveWoodenFishDraftOpenIntent,
} from './platformDraftGenerationShelfModel';
describe('platformDraftGenerationShelfModel', () => {
test('resolvePuzzleDraftOpenIntent sends published puzzle without session to detail', () => {
expect(
resolvePuzzleDraftOpenIntent({
item: buildPuzzleWork({
sourceSessionId: null,
publicationStatus: 'published',
}),
notices: {},
generation: emptyGenerationFacts(),
}),
).toMatchObject({
type: 'open-published-detail',
});
});
test('resolvePuzzleDraftOpenIntent restores failed puzzle generation with notice copy', () => {
expect(
resolvePuzzleDraftOpenIntent({
item: buildPuzzleWork(),
notices: {
'puzzle:puzzle-session-base': {
status: 'failed',
seen: false,
message: '首图生成失败。',
},
},
generation: emptyGenerationFacts(),
}),
).toMatchObject({
type: 'failed-generation',
source: 'restored',
errorMessage: '首图生成失败。',
});
});
test('resolvePuzzleDraftOpenIntent prefers active generation before restoring draft', () => {
expect(
resolvePuzzleDraftOpenIntent({
item: buildPuzzleWork(),
notices: {},
generation: emptyGenerationFacts({
activeSessionId: 'puzzle-session-base',
hasActiveGenerationRunning: true,
}),
}),
).toMatchObject({
type: 'active-generation',
});
});
test('resolvePuzzleDraftOpenIntent does not lock a puzzle draft that already has a cover', () => {
expect(
resolvePuzzleDraftOpenIntent({
item: buildPuzzleWork({
coverImageSrc: '/media/puzzle-cover.png',
}),
notices: {
'puzzle:puzzle-session-base': {
status: 'generating',
seen: false,
},
},
generation: emptyGenerationFacts(),
}),
).toMatchObject({
type: 'restore-draft',
});
});
test('resolveMatch3DDraftOpenIntent opens published work detail unless forced into draft', () => {
const item = buildMatch3DWork({
publicationStatus: 'published',
});
expect(
resolveMatch3DDraftOpenIntent({
item,
notices: {},
generation: emptyGenerationFacts(),
}),
).toMatchObject({
type: 'open-published-detail',
});
expect(
resolveMatch3DDraftOpenIntent({
item,
notices: {},
forceDraft: true,
generation: emptyGenerationFacts(),
}),
).toMatchObject({
type: 'restore-draft',
});
});
test('resolveMatch3DDraftOpenIntent starts ready unread draft before failure fallback', () => {
expect(
resolveMatch3DDraftOpenIntent({
item: buildMatch3DWork(),
notices: {
'match3d:match3d-session-base': {
status: 'ready',
seen: false,
},
},
generation: emptyGenerationFacts({
hasBackgroundGenerationFailure: true,
}),
}),
).toMatchObject({
type: 'ready-unread',
});
});
test('resolveMatch3DDraftOpenIntent restores persisted generating draft', () => {
expect(
resolveMatch3DDraftOpenIntent({
item: buildMatch3DWork({
generationStatus: 'generating',
}),
notices: {},
generation: emptyGenerationFacts(),
}),
).toMatchObject({
type: 'restore-generating',
});
});
test('resolveBigFishDraftOpenIntent reopens active generating session before restoring draft', () => {
expect(
resolveBigFishDraftOpenIntent({
item: buildBigFishWork(),
activeSessionId: 'big-fish-session-base',
hasActiveGenerationRunning: true,
}),
).toMatchObject({
type: 'active-generation',
sourceSessionId: 'big-fish-session-base',
});
expect(
resolveBigFishDraftOpenIntent({
item: buildBigFishWork(),
activeSessionId: 'other-session',
hasActiveGenerationRunning: true,
}),
).toMatchObject({
type: 'restore-draft',
sourceSessionId: 'big-fish-session-base',
});
});
test('resolveSquareHoleDraftOpenIntent handles published, missing, active and restore states', () => {
expect(
resolveSquareHoleDraftOpenIntent({
item: buildSquareHoleWork({ publicationStatus: 'published' }),
activeSessionId: null,
hasActiveGenerationRunning: false,
isGenerationReady: false,
}),
).toMatchObject({
type: 'open-published-detail',
});
expect(
resolveSquareHoleDraftOpenIntent({
item: buildSquareHoleWork({ sourceSessionId: null }),
forceDraft: true,
activeSessionId: null,
hasActiveGenerationRunning: false,
isGenerationReady: false,
}),
).toMatchObject({
type: 'missing-session',
});
expect(
resolveSquareHoleDraftOpenIntent({
item: buildSquareHoleWork(),
activeSessionId: 'square-hole-session-base',
hasActiveGenerationRunning: true,
isGenerationReady: false,
}),
).toMatchObject({
type: 'active-generation',
sourceSessionId: 'square-hole-session-base',
});
expect(
resolveSquareHoleDraftOpenIntent({
item: buildSquareHoleWork(),
activeSessionId: 'other-session',
hasActiveGenerationRunning: false,
isGenerationReady: false,
}),
).toMatchObject({
type: 'restore-draft',
shouldClearGenerationState: true,
});
});
test('resolveVisualNovelDraftOpenIntent handles published, active, current result and load detail states', () => {
expect(
resolveVisualNovelDraftOpenIntent({
item: buildVisualNovelWork({ publishStatus: 'published' }),
activeSessionId: null,
hasActiveGenerationRunning: false,
hasActiveSessionDraft: false,
}),
).toMatchObject({
type: 'open-published-detail',
});
expect(
resolveVisualNovelDraftOpenIntent({
item: buildVisualNovelWork(),
forceDraft: true,
activeSessionId: 'visual-novel-profile-base',
hasActiveGenerationRunning: true,
hasActiveSessionDraft: false,
}),
).toMatchObject({
type: 'active-generation',
profileId: 'visual-novel-profile-base',
});
expect(
resolveVisualNovelDraftOpenIntent({
item: buildVisualNovelWork(),
forceDraft: true,
activeSessionId: 'visual-novel-profile-base',
hasActiveGenerationRunning: false,
hasActiveSessionDraft: true,
}),
).toMatchObject({
type: 'current-result',
profileId: 'visual-novel-profile-base',
});
expect(
resolveVisualNovelDraftOpenIntent({
item: buildVisualNovelWork(),
forceDraft: true,
activeSessionId: 'other-profile',
hasActiveGenerationRunning: false,
hasActiveSessionDraft: false,
}),
).toMatchObject({
type: 'load-detail',
profileId: 'visual-novel-profile-base',
});
});
test('resolveJumpHopDraftOpenIntent handles published, failed current generation, generating and detail states', () => {
expect(
resolveJumpHopDraftOpenIntent({
item: buildJumpHopWork({ publicationStatus: 'published' }),
notices: {},
generation: emptyGenerationFacts(),
}),
).toMatchObject({
type: 'open-published-detail',
});
expect(
resolveJumpHopDraftOpenIntent({
item: buildJumpHopWork(),
notices: {
'jump-hop:jump-hop-session-base': {
status: 'failed',
seen: false,
},
},
generation: emptyGenerationFacts({
activeSessionId: 'jump-hop-session-base',
hasActiveGenerationFailure: true,
}),
}),
).toMatchObject({
type: 'active-failed-generation',
});
expect(
resolveJumpHopDraftOpenIntent({
item: buildJumpHopWork({ generationStatus: 'generating' }),
notices: {},
generation: emptyGenerationFacts(),
}),
).toMatchObject({
type: 'restore-generating',
});
expect(
resolveJumpHopDraftOpenIntent({
item: buildJumpHopWork({ generationStatus: 'generating' }),
notices: {
'jump-hop:jump-hop-session-base': {
status: 'failed',
seen: false,
},
},
generation: emptyGenerationFacts(),
}),
).toMatchObject({
type: 'load-detail',
});
expect(
resolveJumpHopDraftOpenIntent({
item: buildJumpHopWork({ sourceSessionId: null }),
notices: {
'jump-hop:jump-hop-work-base': {
status: 'failed',
seen: false,
},
},
generation: emptyGenerationFacts({
activeSessionId: null,
hasActiveGenerationFailure: true,
}),
}),
).toMatchObject({
type: 'load-detail',
});
});
test('resolveWoodenFishDraftOpenIntent uses profile fallback and failure fallback stage', () => {
expect(
resolveWoodenFishDraftOpenIntent({
item: buildWoodenFishWork({
sourceSessionId: null,
generationStatus: 'generating',
}),
notices: {
'wooden-fish:wooden-fish-profile-base': {
status: 'failed',
seen: false,
},
},
generation: emptyGenerationFacts({
activeSessionId: 'wooden-fish-profile-base',
hasActiveGenerationFailure: true,
}),
}),
).toMatchObject({
type: 'active-failed-generation',
});
expect(
resolveWoodenFishDraftOpenIntent({
item: buildWoodenFishWork({ generationStatus: 'generating' }),
notices: {},
generation: emptyGenerationFacts(),
}),
).toMatchObject({
type: 'restore-generating',
});
expect(
resolveWoodenFishDraftOpenIntent({
item: buildWoodenFishWork(),
notices: {
'wooden-fish:wooden-fish-session-base': {
status: 'failed',
seen: false,
},
},
generation: emptyGenerationFacts(),
}),
).toMatchObject({
type: 'load-detail',
failureFallbackStage: 'wooden-fish-workspace',
});
expect(
resolveWoodenFishDraftOpenIntent({
item: buildWoodenFishWork(),
notices: {},
generation: emptyGenerationFacts(),
}),
).toMatchObject({
type: 'load-detail',
failureFallbackStage: 'wooden-fish-generating',
});
});
test('buildPendingPuzzleWorks creates failed puzzle placeholder with stable ids and fallback title', () => {
const pending = buildPendingPuzzleWorks(
{
'puzzle-session-ocean': createPendingDraftShelfState(
'failed',
false,
'2026-06-03T08:00:00.000Z',
),
},
[],
);
expect(pending).toHaveLength(1);
expect(pending[0]).toMatchObject({
workId: 'puzzle-work-ocean',
profileId: 'puzzle-profile-ocean',
sourceSessionId: 'puzzle-session-ocean',
workTitle: '拼图草稿',
summary: '拼图草稿生成失败,可重新打开处理。',
generationStatus: 'failed',
});
});
test('buildPendingPuzzleWorks skips pending item when backend shelf already has the session', () => {
const pending = buildPendingPuzzleWorks(
{
'puzzle-session-ocean': createPendingDraftShelfState(
'generating',
false,
'2026-06-03T08:00:00.000Z',
),
},
[buildPuzzleWork({ sourceSessionId: 'puzzle-session-ocean' })],
);
expect(pending).toEqual([]);
});
test('mergePuzzleWorkSummary only replaces the matching profile', () => {
const current = buildPuzzleWork({
profileId: 'puzzle-profile-1',
workTitle: '旧拼图',
});
const updated = buildPuzzleWork({
profileId: 'puzzle-profile-1',
workTitle: '新拼图',
});
const other = buildPuzzleWork({
profileId: 'puzzle-profile-2',
workTitle: '别的拼图',
});
expect(mergePuzzleWorkSummary(current, updated)).toBe(updated);
expect(mergePuzzleWorkSummary(current, other)).toBe(current);
});
test('mergeBigFishWorkSummary only replaces the matching source session', () => {
const current = buildBigFishWork({
sourceSessionId: 'big-fish-session-1',
title: '旧大鱼',
});
const updated = buildBigFishWork({
sourceSessionId: 'big-fish-session-1',
title: '新大鱼',
});
const other = buildBigFishWork({
sourceSessionId: 'big-fish-session-2',
title: '别的大鱼',
});
expect(mergeBigFishWorkSummary(current, updated)).toBe(updated);
expect(mergeBigFishWorkSummary(current, other)).toBe(current);
});
test('buildCreationWorkShelfRuntimeState lets failure notice override persisted generating puzzle copy', () => {
const [item] = buildCreationWorkShelfItems({
rpgItems: [],
bigFishItems: [],
puzzleItems: [
buildPuzzleWork({
workId: 'puzzle-work-empty',
profileId: 'puzzle-profile-empty',
sourceSessionId: 'puzzle-session-empty',
workTitle: '',
workDescription: '',
levelName: '',
summary: '正在生成拼图草稿。',
generationStatus: 'generating',
}),
],
});
expect(item).toBeTruthy();
const noticeKeys = getGenerationNoticeShelfKeys(item!);
const notices = Object.fromEntries(
noticeKeys.map((key) => [
key,
{ status: 'failed', seen: false },
]),
) as DraftGenerationNoticeMap;
const state = buildCreationWorkShelfRuntimeState({
item: item!,
notices,
pendingShelfItems: {
puzzle: {
'puzzle-session-empty': createPendingDraftShelfState(
'failed',
false,
'2026-06-03T08:00:00.000Z',
{ summary: '图片生成超时,可重新打开处理。' },
),
},
},
});
expect(state).toMatchObject({
isGenerating: false,
hasGenerationFailure: true,
generationFailureSummary: '拼图草稿生成失败,可重新打开处理。',
hasUnreadUpdate: false,
suppressPersistedGenerating: true,
titleOverride: '拼图草稿',
summaryOverride: '图片生成超时,可重新打开处理。',
});
});
test('collectVisibleDraftNoticeKeys and hasUnreadDraftGenerationUpdates share unread dot rule', () => {
const puzzle = buildPuzzleWork({
workId: 'puzzle-work-ocean',
profileId: 'puzzle-profile-ocean',
sourceSessionId: 'puzzle-session-ocean',
});
const visibleKeys = collectVisibleDraftNoticeKeys({
rpgItems: [],
bigFishItems: [],
jumpHopItems: [],
woodenFishItems: [],
match3dItems: [],
squareHoleItems: [],
puzzleItems: [puzzle],
visualNovelItems: [],
barkBattleItems: [],
babyObjectMatchItems: [],
});
expect(visibleKeys).toContain('puzzle:puzzle-work-ocean');
expect(visibleKeys).toContain('puzzle:puzzle-profile-ocean');
expect(visibleKeys).toContain('puzzle:puzzle-session-ocean');
expect(buildPuzzleResultWorkId('puzzle-session-ocean')).toBe(
'puzzle-work-ocean',
);
expect(buildPuzzleResultProfileId('puzzle-session-ocean')).toBe(
'puzzle-profile-ocean',
);
expect(
hasUnreadDraftGenerationUpdates(
{
'puzzle:puzzle-profile-ocean': {
status: 'ready',
seen: false,
},
},
visibleKeys,
),
).toBe(true);
expect(
hasUnreadDraftGenerationUpdates(
{
'puzzle:puzzle-profile-ocean': {
status: 'ready',
seen: true,
},
},
visibleKeys,
),
).toBe(false);
});
});
function emptyGenerationFacts(
overrides: Partial<Parameters<typeof resolvePuzzleDraftOpenIntent>[0]['generation']> = {},
): Parameters<typeof resolvePuzzleDraftOpenIntent>[0]['generation'] {
return {
activeSessionId: null,
hasActiveGenerationFailure: false,
hasActiveGenerationRunning: false,
hasBackgroundGenerationFailure: false,
hasBackgroundGenerationRunning: false,
...overrides,
};
}
function buildPuzzleWork(
overrides: Partial<PuzzleWorkSummary> = {},
): PuzzleWorkSummary {
return {
workId: 'puzzle-work-base',
profileId: 'puzzle-profile-base',
ownerUserId: 'user-1',
sourceSessionId: 'puzzle-session-base',
authorDisplayName: '测试作者',
workTitle: '潮雾拼图',
workDescription: '潮雾港口拼图。',
levelName: '潮雾拼图',
summary: '潮雾港口拼图。',
themeTags: [],
coverImageSrc: null,
coverAssetId: null,
publicationStatus: 'draft',
updatedAt: '2026-06-03T08:00:00.000Z',
publishedAt: null,
playCount: 0,
remixCount: 0,
likeCount: 0,
publishReady: false,
levels: [],
...overrides,
};
}
function buildMatch3DWork(
overrides: Partial<Match3DWorkSummary> = {},
): Match3DWorkSummary {
return {
workId: 'match3d-work-base',
profileId: 'match3d-profile-base',
ownerUserId: 'user-1',
sourceSessionId: 'match3d-session-base',
gameName: '潮雾抓大鹅',
themeText: '潮雾港口',
summary: '潮雾港口抓大鹅。',
tags: [],
coverImageSrc: null,
referenceImageSrc: null,
clearCount: 0,
difficulty: 1,
publicationStatus: 'draft',
playCount: 0,
updatedAt: '2026-06-03T08:00:00.000Z',
publishedAt: null,
publishReady: false,
generationStatus: 'ready',
generatedItemAssets: [],
...overrides,
};
}
function buildBigFishWork(
overrides: Partial<BigFishWorkSummary> = {},
): BigFishWorkSummary {
return {
workId: 'big-fish-work-base',
sourceSessionId: 'big-fish-session-base',
ownerUserId: 'user-1',
authorDisplayName: '测试作者',
title: '潮雾大鱼',
subtitle: '潮雾港口',
summary: '潮雾港口大鱼吃小鱼。',
coverImageSrc: null,
status: 'draft',
updatedAt: '2026-06-03T08:00:00.000Z',
publishedAt: null,
playCount: 0,
remixCount: 0,
likeCount: 0,
publishReady: false,
levelCount: 1,
levelMainImageReadyCount: 0,
levelMotionReadyCount: 0,
backgroundReady: false,
...overrides,
};
}
function buildSquareHoleWork(
overrides: Partial<SquareHoleWorkSummary> = {},
): SquareHoleWorkSummary {
return {
workId: 'square-hole-work-base',
profileId: 'square-hole-profile-base',
ownerUserId: 'user-1',
sourceSessionId: 'square-hole-session-base',
gameName: '潮雾方洞',
themeText: '潮雾港口',
twistRule: '避开雾门',
summary: '潮雾港口方洞挑战。',
tags: [],
coverImageSrc: null,
backgroundPrompt: '潮雾港口',
backgroundImageSrc: null,
shapeOptions: [],
holeOptions: [],
shapeCount: 1,
difficulty: 1,
publicationStatus: 'draft',
playCount: 0,
updatedAt: '2026-06-03T08:00:00.000Z',
publishedAt: null,
publishReady: false,
...overrides,
};
}
function buildVisualNovelWork(
overrides: Partial<VisualNovelWorkSummary> = {},
): VisualNovelWorkSummary {
return {
runtimeKind: 'visual-novel',
profileId: 'visual-novel-profile-base',
ownerUserId: 'user-1',
title: '潮雾视觉小说',
description: '潮雾港口视觉小说。',
coverImageSrc: null,
tags: [],
publishStatus: 'draft',
publishReady: false,
playCount: 0,
updatedAt: '2026-06-03T08:00:00.000Z',
publishedAt: null,
...overrides,
};
}
function buildJumpHopWork(
overrides: Partial<JumpHopWorkSummaryResponse> = {},
): JumpHopWorkSummaryResponse {
return {
runtimeKind: 'jump-hop',
workId: 'jump-hop-work-base',
profileId: 'jump-hop-profile-base',
ownerUserId: 'user-1',
sourceSessionId: 'jump-hop-session-base',
workTitle: '潮雾跳一跳',
workDescription: '潮雾港口跳一跳。',
themeTags: [],
difficulty: 'standard',
stylePreset: 'minimal-blocks',
coverImageSrc: null,
publicationStatus: 'draft',
playCount: 0,
updatedAt: '2026-06-03T08:00:00.000Z',
publishedAt: null,
publishReady: false,
generationStatus: 'ready',
...overrides,
};
}
function buildWoodenFishWork(
overrides: Partial<WoodenFishWorkSummaryResponse> = {},
): WoodenFishWorkSummaryResponse {
return {
runtimeKind: 'wooden-fish',
workId: 'wooden-fish-work-base',
profileId: 'wooden-fish-profile-base',
ownerUserId: 'user-1',
sourceSessionId: 'wooden-fish-session-base',
workTitle: '潮雾敲木鱼',
workDescription: '潮雾港口敲木鱼。',
themeTags: ['敲木鱼'],
coverImageSrc: null,
publicationStatus: 'draft',
playCount: 0,
updatedAt: '2026-06-03T08:00:00.000Z',
publishedAt: null,
publishReady: false,
generationStatus: 'ready',
...overrides,
};
}

File diff suppressed because it is too large Load Diff

View File

@@ -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,
});
});
});

View File

@@ -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<MiniGameDraftGenerationKind, MiniGameDraftGenerationState | null>
>;
visualNovel: {
startedAtMs: number | null;
phase: PlatformVisualNovelGenerationPhase;
};
};
export type PlatformGenerationProgressTickDecision = {
activeKind: PlatformGenerationProgressTickKind | null;
shouldTick: boolean;
};
const MINI_GAME_GENERATION_STAGE_TO_KIND: Partial<
Record<SelectionStage, 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',
};
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],
),
};
}

View File

@@ -0,0 +1,294 @@
import { expect, test } from 'vitest';
import type { Match3DAgentSessionSnapshot } from '../../../packages/shared/src/contracts/match3dAgent';
import type { Match3DRunSnapshot } from '../../../packages/shared/src/contracts/match3dRuntime';
import type {
Match3DGeneratedBackgroundAsset,
Match3DGeneratedItemAsset,
Match3DWorkProfile,
} from '../../../packages/shared/src/contracts/match3dWorks';
import type { PlatformMatch3DGalleryCard } from '../rpg-entry/rpgEntryWorldPresentation';
import {
buildMatch3DProfileFromSession,
mapMatch3DWorkToPublicWorkDetail,
mapPublicWorkDetailToMatch3DWork,
resolveActiveMatch3DRuntimeProfile,
resolveMatch3DRuntimeBackgroundImageSrc,
resolveMatch3DRuntimeGeneratedBackgroundAsset,
resolveMatch3DRuntimeGeneratedItemAssets,
} from './platformMatch3DRuntimeProfile';
function buildBackgroundAsset(
overrides: Partial<Match3DGeneratedBackgroundAsset> = {},
): Match3DGeneratedBackgroundAsset {
return {
prompt: '森林棋盘',
imageSrc: '/generated/match3d/background.png',
imageObjectKey: null,
status: 'ready',
...overrides,
};
}
function buildItemAsset(
overrides: Partial<Match3DGeneratedItemAsset> = {},
): Match3DGeneratedItemAsset {
return {
itemId: 'item-1',
itemName: '蘑菇',
imageSrc: '/generated/match3d/item.png',
imageObjectKey: null,
status: 'image_ready',
...overrides,
};
}
function buildProfile(
overrides: Partial<Match3DWorkProfile> = {},
): Match3DWorkProfile {
return {
workId: 'match3d-work-1',
profileId: 'match3d-profile-1',
ownerUserId: 'user-1',
sourceSessionId: 'match3d-session-1',
gameName: '森林抓鹅',
themeText: '森林',
summary: '找出蘑菇。',
tags: ['森林', '蘑菇'],
coverImageSrc: '/cover.png',
referenceImageSrc: null,
clearCount: 12,
difficulty: 4,
publicationStatus: 'published',
playCount: 1,
updatedAt: '2026-05-20T00:00:00.000Z',
publishedAt: '2026-05-20T00:00:00.000Z',
publishReady: true,
backgroundPrompt: null,
backgroundImageSrc: null,
backgroundImageObjectKey: null,
generatedBackgroundAsset: null,
generatedItemAssets: [buildItemAsset()],
...overrides,
};
}
function buildRun(overrides: Partial<Match3DRunSnapshot> = {}): Match3DRunSnapshot {
return {
runId: 'match3d-run-1',
profileId: 'match3d-profile-1',
status: 'running',
snapshotVersion: 1,
startedAtMs: 1000,
durationLimitMs: 60000,
remainingMs: 55000,
clearCount: 12,
totalItemCount: 12,
clearedItemCount: 0,
items: [],
traySlots: [],
...overrides,
};
}
function buildPublicWork(
overrides: Partial<PlatformMatch3DGalleryCard> = {},
): PlatformMatch3DGalleryCard {
return {
sourceType: 'match3d',
workId: 'match3d-work-1',
profileId: 'match3d-profile-1',
sourceSessionId: 'match3d-session-1',
publicWorkCode: 'M3D-00000001',
ownerUserId: 'user-1',
authorDisplayName: '玩家',
worldName: '森林抓鹅',
subtitle: '抓大鹅',
summaryText: '找出蘑菇。',
coverImageSrc: '/cover.png',
backgroundPrompt: null,
backgroundImageSrc: null,
backgroundImageObjectKey: null,
generatedBackgroundAsset: null,
generatedItemAssets: [buildItemAsset()],
themeTags: ['森林', '蘑菇'],
visibility: 'published',
publishedAt: '2026-05-20T00:00:00.000Z',
updatedAt: '2026-05-20T00:00:00.000Z',
...overrides,
};
}
test('Match3D runtime profile maps public detail and promotes item background asset', () => {
const backgroundAsset = buildBackgroundAsset({
imageSrc: '/generated/match3d/background-from-item.png',
imageObjectKey: 'oss/background-from-item.png',
});
const work = mapPublicWorkDetailToMatch3DWork(
buildPublicWork({
generatedBackgroundAsset: null,
backgroundImageSrc: null,
generatedItemAssets: [
buildItemAsset({
backgroundAsset,
}),
],
}),
);
expect(work?.generatedBackgroundAsset).toEqual(backgroundAsset);
expect(work?.backgroundImageSrc).toBe(
'/generated/match3d/background-from-item.png',
);
expect(work?.backgroundImageObjectKey).toBe('oss/background-from-item.png');
});
test('Match3D runtime profile maps work summary to public detail with promoted background asset', () => {
const backgroundAsset = buildBackgroundAsset({
imageSrc: '/generated/match3d/detail-background.png',
});
const detail = mapMatch3DWorkToPublicWorkDetail(
buildProfile({
generatedBackgroundAsset: null,
backgroundImageSrc: null,
generatedItemAssets: [
buildItemAsset({
backgroundAsset,
}),
],
}),
);
expect(detail).toMatchObject({
sourceType: 'match3d',
workId: 'match3d-work-1',
profileId: 'match3d-profile-1',
backgroundImageSrc: '/generated/match3d/detail-background.png',
generatedBackgroundAsset: backgroundAsset,
});
});
test('Match3D runtime profile builds draft profile from session snapshot', () => {
const backgroundAsset = buildBackgroundAsset({
imageSrc: '/generated/match3d/draft-background.png',
});
const session: Match3DAgentSessionSnapshot = {
sessionId: 'match3d-session-draft',
currentTurn: 2,
progressPercent: 100,
stage: 'draft_compiled',
anchorPack: {
theme: { key: 'theme', label: '主题', value: '森林', status: 'confirmed' },
clearCount: {
key: 'clearCount',
label: '消除数',
value: '12',
status: 'confirmed',
},
difficulty: {
key: 'difficulty',
label: '难度',
value: '4',
status: 'confirmed',
},
},
messages: [],
lastAssistantReply: null,
updatedAt: '2026-05-21T00:00:00.000Z',
draft: {
profileId: 'match3d-draft-profile',
gameName: '草稿抓鹅',
themeText: '森林',
summaryText: '草稿摘要',
tags: ['森林'],
coverImageSrc: null,
referenceImageSrc: '/reference.png',
clearCount: 12,
difficulty: 4,
publishReady: true,
generatedItemAssets: [
buildItemAsset({
backgroundAsset,
}),
],
},
};
const profile = buildMatch3DProfileFromSession(session);
expect(profile?.profileId).toBe('match3d-draft-profile');
expect(profile?.sourceSessionId).toBe('match3d-session-draft');
expect(profile?.publicationStatus).toBe('draft');
expect(profile?.coverImageSrc).toBe('/reference.png');
expect(profile?.generatedBackgroundAsset).toEqual(backgroundAsset);
expect(profile?.backgroundImageSrc).toBe(
'/generated/match3d/draft-background.png',
);
});
test('Match3D runtime profile selects active profile by run profile id', () => {
const runtimeProfile = buildProfile({
profileId: 'runtime-profile',
gameName: '运行态抓鹅',
});
const draftProfile = buildProfile({
profileId: 'draft-profile',
gameName: '旧草稿抓鹅',
});
expect(
resolveActiveMatch3DRuntimeProfile(
buildRun({ profileId: 'runtime-profile' }),
runtimeProfile,
draftProfile,
),
).toBe(runtimeProfile);
expect(
resolveActiveMatch3DRuntimeProfile(
buildRun({ profileId: 'draft-profile' }),
runtimeProfile,
draftProfile,
),
).toBe(draftProfile);
});
test('Match3D runtime profile resolves generated assets from matching public detail', () => {
const staleProfile = buildProfile({
profileId: 'stale-profile',
generatedBackgroundAsset: buildBackgroundAsset({
imageSrc: '/generated/match3d/stale-background.png',
}),
generatedItemAssets: [
buildItemAsset({
itemId: 'stale-item',
imageSrc: '/generated/match3d/stale-item.png',
}),
],
});
const publicBackground = buildBackgroundAsset({
imageSrc: '/generated/match3d/public-background.png',
});
const publicWork = buildPublicWork({
profileId: 'public-profile',
generatedBackgroundAsset: publicBackground,
generatedItemAssets: [
buildItemAsset({
itemId: 'public-item',
imageSrc: '/generated/match3d/public-item.png',
}),
],
});
const run = buildRun({ profileId: 'public-profile' });
expect(
resolveMatch3DRuntimeGeneratedItemAssets(run, staleProfile, publicWork).some(
(asset) => asset.imageSrc === '/generated/match3d/public-item.png',
),
).toBe(true);
expect(
resolveMatch3DRuntimeGeneratedBackgroundAsset(run, staleProfile, publicWork),
).toEqual(publicBackground);
expect(resolveMatch3DRuntimeBackgroundImageSrc(run, staleProfile, publicWork)).toBe(
'/generated/match3d/public-background.png',
);
});

View File

@@ -0,0 +1,335 @@
import type { Match3DAgentSessionSnapshot } from '../../../packages/shared/src/contracts/match3dAgent';
import type { Match3DRunSnapshot } from '../../../packages/shared/src/contracts/match3dRuntime';
import type {
Match3DGeneratedBackgroundAsset,
Match3DGeneratedItemAsset,
Match3DWorkProfile,
Match3DWorkSummary,
} from '../../../packages/shared/src/contracts/match3dWorks';
import {
hasMatch3DGeneratedImageAsset,
mergeMatch3DGeneratedItemAssetsForRuntime,
normalizeMatch3DGeneratedItemAssetsForRuntime,
} from '../../services/match3dGeneratedModelCache';
import {
isMatch3DGalleryEntry,
mapMatch3DWorkToPlatformGalleryCard,
type PlatformPublicGalleryCard,
} from '../rpg-entry/rpgEntryWorldPresentation';
export function mapMatch3DWorkToPublicWorkDetail(
item: Match3DWorkSummary,
): PlatformPublicGalleryCard {
return mapMatch3DWorkToPlatformGalleryCard(
normalizeMatch3DWorkForRuntimeUi(item),
);
}
export function mapPublicWorkDetailToMatch3DWork(
entry: PlatformPublicGalleryCard,
): Match3DWorkSummary | null {
if (!isMatch3DGalleryEntry(entry)) {
return null;
}
return promoteMatch3DGeneratedBackgroundAsset({
workId: entry.workId,
profileId: entry.profileId,
ownerUserId: entry.ownerUserId,
sourceSessionId:
'sourceSessionId' in entry && typeof entry.sourceSessionId === 'string'
? entry.sourceSessionId
: null,
gameName: entry.worldName,
themeText: entry.themeTags[0] ?? '经典消除',
summary: entry.summaryText,
tags: entry.themeTags,
coverImageSrc: entry.coverImageSrc,
referenceImageSrc: null,
clearCount: 12,
difficulty: 4,
publicationStatus: 'published',
playCount: entry.playCount ?? 0,
updatedAt: entry.updatedAt,
publishedAt: entry.publishedAt,
publishReady: true,
backgroundPrompt: entry.backgroundPrompt ?? null,
backgroundImageSrc: entry.backgroundImageSrc ?? null,
backgroundImageObjectKey: entry.backgroundImageObjectKey ?? null,
generatedBackgroundAsset:
entry.generatedBackgroundAsset ??
findMatch3DGeneratedBackgroundAsset(entry.generatedItemAssets) ??
null,
generatedItemAssets: normalizeMatch3DGeneratedItemAssetsForRuntime(
entry.generatedItemAssets ?? [],
),
});
}
export function findMatch3DGeneratedBackgroundAsset(
generatedItemAssets: readonly Match3DGeneratedItemAsset[] | null | undefined,
): Match3DGeneratedBackgroundAsset | null {
return (
generatedItemAssets
?.map((asset) => asset.backgroundAsset ?? null)
.find(Boolean) ?? null
);
}
export function promoteMatch3DGeneratedBackgroundAsset<
T extends Pick<
Match3DWorkSummary,
| 'backgroundPrompt'
| 'backgroundImageSrc'
| 'backgroundImageObjectKey'
| 'generatedBackgroundAsset'
| 'generatedItemAssets'
>,
>(profile: T): T {
const backgroundAsset =
profile.generatedBackgroundAsset ??
findMatch3DGeneratedBackgroundAsset(profile.generatedItemAssets);
if (!backgroundAsset) {
return profile;
}
return {
...profile,
backgroundPrompt:
profile.backgroundPrompt ?? backgroundAsset.prompt ?? null,
backgroundImageSrc:
profile.backgroundImageSrc ??
backgroundAsset.imageSrc ??
backgroundAsset.imageObjectKey ??
null,
backgroundImageObjectKey:
profile.backgroundImageObjectKey ??
backgroundAsset.imageObjectKey ??
backgroundAsset.imageSrc ??
null,
generatedBackgroundAsset:
profile.generatedBackgroundAsset ?? backgroundAsset,
};
}
export function normalizeMatch3DWorkForRuntimeUi<T extends Match3DWorkSummary>(
profile: T,
): T {
return promoteMatch3DGeneratedBackgroundAsset({
...profile,
generatedItemAssets: normalizeMatch3DGeneratedItemAssetsForRuntime(
profile.generatedItemAssets,
),
});
}
export function mapMatch3DWorksForRuntimeUi<T extends Match3DWorkSummary>(
profiles: readonly T[],
): T[] {
return profiles.map(normalizeMatch3DWorkForRuntimeUi);
}
export function buildMatch3DProfileFromSession(
session: Match3DAgentSessionSnapshot | null,
): Match3DWorkProfile | null {
const draft = session?.draft;
if (!session || !draft?.profileId) {
return null;
}
const now = session.updatedAt || new Date().toISOString();
const generatedItemAssets = normalizeMatch3DGeneratedItemAssetsForRuntime(
draft.generatedItemAssets,
);
return promoteMatch3DGeneratedBackgroundAsset({
workId: draft.profileId,
profileId: draft.profileId,
ownerUserId: 'current-user',
sourceSessionId: session.sessionId,
gameName: draft.gameName,
themeText: draft.themeText,
summary: draft.summary ?? draft.summaryText ?? '',
tags: draft.tags,
coverImageSrc: draft.coverImageSrc ?? draft.referenceImageSrc ?? null,
referenceImageSrc: draft.referenceImageSrc ?? null,
clearCount: draft.clearCount,
difficulty: draft.difficulty,
publicationStatus: 'draft',
playCount: 0,
updatedAt: now,
publishedAt: null,
publishReady: Boolean(draft.publishReady),
backgroundPrompt: draft.backgroundPrompt ?? null,
backgroundImageSrc: draft.backgroundImageSrc ?? null,
backgroundImageObjectKey: draft.backgroundImageObjectKey ?? null,
generatedBackgroundAsset: draft.generatedBackgroundAsset ?? null,
generatedItemAssets,
});
}
export function hasMatch3DRuntimeAsset(
assets: readonly Match3DGeneratedItemAsset[] | null | undefined,
) {
return hasMatch3DGeneratedImageAsset(assets);
}
export function hasMatch3DRuntimeBackgroundAsset(
profile: Pick<
Match3DWorkSummary,
| 'backgroundImageSrc'
| 'backgroundImageObjectKey'
| 'generatedBackgroundAsset'
| 'generatedItemAssets'
>,
) {
return Boolean(
profile.backgroundImageSrc?.trim() ||
profile.backgroundImageObjectKey?.trim() ||
profile.generatedBackgroundAsset?.imageSrc?.trim() ||
profile.generatedBackgroundAsset?.imageObjectKey?.trim() ||
profile.generatedBackgroundAsset?.containerImageSrc?.trim() ||
profile.generatedBackgroundAsset?.containerImageObjectKey?.trim() ||
profile.generatedItemAssets?.some(
(asset) =>
asset.backgroundAsset?.imageSrc?.trim() ||
asset.backgroundAsset?.imageObjectKey?.trim() ||
asset.backgroundAsset?.containerImageSrc?.trim() ||
asset.backgroundAsset?.containerImageObjectKey?.trim(),
),
);
}
export function resolveMatch3DRuntimeGeneratedItemAssets(
run: Match3DRunSnapshot | null,
profile: Match3DWorkProfile | null,
publicWorkDetail: PlatformPublicGalleryCard | null,
) {
const runProfileId = run?.profileId?.trim() ?? '';
const profileAssets = profile?.generatedItemAssets ?? [];
const publicDetailAssets =
publicWorkDetail && isMatch3DGalleryEntry(publicWorkDetail)
? (publicWorkDetail.generatedItemAssets ?? [])
: [];
if (runProfileId && profile?.profileId === runProfileId) {
if (hasMatch3DRuntimeAsset(profileAssets)) {
return normalizeMatch3DGeneratedItemAssetsForRuntime(profileAssets);
}
if (
publicWorkDetail &&
isMatch3DGalleryEntry(publicWorkDetail) &&
publicWorkDetail.profileId === runProfileId
) {
return hasMatch3DRuntimeAsset(publicDetailAssets)
? mergeMatch3DGeneratedItemAssetsForRuntime(
publicDetailAssets,
profileAssets,
)
: normalizeMatch3DGeneratedItemAssetsForRuntime(profileAssets);
}
return normalizeMatch3DGeneratedItemAssetsForRuntime(profileAssets);
}
if (
runProfileId &&
publicWorkDetail &&
isMatch3DGalleryEntry(publicWorkDetail) &&
publicWorkDetail.profileId === runProfileId
) {
return normalizeMatch3DGeneratedItemAssetsForRuntime(publicDetailAssets);
}
if (hasMatch3DRuntimeAsset(profileAssets)) {
return normalizeMatch3DGeneratedItemAssetsForRuntime(profileAssets);
}
return publicDetailAssets.length > 0
? normalizeMatch3DGeneratedItemAssetsForRuntime(publicDetailAssets)
: normalizeMatch3DGeneratedItemAssetsForRuntime(profileAssets);
}
export function resolveMatch3DRuntimeGeneratedBackgroundAsset(
run: Match3DRunSnapshot | null,
profile: Match3DWorkProfile | null,
publicWorkDetail: PlatformPublicGalleryCard | null,
) {
const runProfileId = run?.profileId?.trim() ?? '';
const profileBackground = profile
? (promoteMatch3DGeneratedBackgroundAsset(profile)
.generatedBackgroundAsset ?? null)
: null;
const publicBackground =
publicWorkDetail && isMatch3DGalleryEntry(publicWorkDetail)
? (promoteMatch3DGeneratedBackgroundAsset(publicWorkDetail)
.generatedBackgroundAsset ?? null)
: null;
if (runProfileId && profile?.profileId === runProfileId) {
return profileBackground ?? publicBackground;
}
if (
runProfileId &&
publicWorkDetail &&
isMatch3DGalleryEntry(publicWorkDetail) &&
publicWorkDetail.profileId === runProfileId
) {
return publicBackground ?? profileBackground;
}
return profileBackground ?? publicBackground;
}
export function resolveActiveMatch3DRuntimeProfile(
run: Match3DRunSnapshot | null,
runtimeProfile: Match3DWorkProfile | null,
profile: Match3DWorkProfile | null,
) {
const runProfileId = run?.profileId?.trim() ?? '';
if (runProfileId && runtimeProfile?.profileId === runProfileId) {
return runtimeProfile;
}
if (runProfileId && profile?.profileId === runProfileId) {
return profile;
}
return runtimeProfile ?? profile;
}
export function resolveMatch3DRuntimeBackgroundImageSrc(
run: Match3DRunSnapshot | null,
profile: Match3DWorkProfile | null,
publicWorkDetail: PlatformPublicGalleryCard | null,
) {
const runProfileId = run?.profileId?.trim() ?? '';
const resolvedProfile = profile
? promoteMatch3DGeneratedBackgroundAsset(profile)
: null;
const resolvedPublicWork =
publicWorkDetail && isMatch3DGalleryEntry(publicWorkDetail)
? promoteMatch3DGeneratedBackgroundAsset(publicWorkDetail)
: null;
const profileBackground =
resolvedProfile?.backgroundImageSrc?.trim() ||
resolvedProfile?.generatedBackgroundAsset?.imageSrc?.trim() ||
resolvedProfile?.backgroundImageObjectKey?.trim() ||
resolvedProfile?.generatedBackgroundAsset?.imageObjectKey?.trim() ||
'';
const publicBackground =
resolvedPublicWork?.backgroundImageSrc?.trim() ||
resolvedPublicWork?.generatedBackgroundAsset?.imageSrc?.trim() ||
resolvedPublicWork?.backgroundImageObjectKey?.trim() ||
resolvedPublicWork?.generatedBackgroundAsset?.imageObjectKey?.trim() ||
'';
if (runProfileId && profile?.profileId === runProfileId) {
return profileBackground || publicBackground || null;
}
if (
runProfileId &&
publicWorkDetail &&
isMatch3DGalleryEntry(publicWorkDetail) &&
publicWorkDetail.profileId === runProfileId
) {
return publicBackground || profileBackground || null;
}
return profileBackground || publicBackground || null;
}

View File

@@ -0,0 +1,384 @@
import { afterEach, beforeEach, describe, expect, test, vi } from 'vitest';
import type { Match3DGeneratedItemAsset } from '../../../packages/shared/src/contracts/match3dWorks';
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,
mergeMatch3DGeneratedAssetsIntoGenerationState,
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> = {},
): 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> = {},
): MiniGameDraftGenerationState {
return {
kind: 'puzzle',
phase: 'compile',
startedAtMs: 100,
completedAssetCount: 0,
totalAssetCount: 0,
error: null,
metadata: {
puzzleAiRedraw: true,
puzzleActivePhaseId: 'compile',
puzzleActiveStepStartedAtMs: 200,
puzzleProgressPercent: 20,
},
...overrides,
};
}
function buildMatch3DAsset(
overrides: Partial<Match3DGeneratedItemAsset> = {},
): Match3DGeneratedItemAsset {
return {
itemId: 'item-1',
itemName: '红宝石',
status: 'pending',
...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('merges match3d generated assets into active generation state', () => {
const state = buildState({
kind: 'match3d',
phase: 'match3d-material-sheet',
completedAssetCount: 0,
totalAssetCount: 0,
error: '旧错误',
});
expect(
mergeMatch3DGeneratedAssetsIntoGenerationState(state, [
buildMatch3DAsset({
itemId: 'item-with-view',
imageViews: [
{
viewId: 'front',
viewIndex: 0,
imageObjectKey: 'objects/front.png',
},
],
}),
buildMatch3DAsset({
itemId: 'item-with-src',
imageSrc: '/generated/item.png',
}),
buildMatch3DAsset({
itemId: 'item-with-error',
error: '切图失败',
}),
]),
).toMatchObject({
phase: 'match3d-generate-views',
completedAssetCount: 2,
totalAssetCount: 5,
error: '切图失败',
});
});
test('keeps match3d generated asset merge away from finished states', () => {
const readyState = buildState({
kind: 'match3d',
phase: 'ready',
completedAssetCount: 5,
totalAssetCount: 5,
});
const failedState = buildState({
kind: 'match3d',
phase: 'failed',
error: '已失败',
});
expect(
mergeMatch3DGeneratedAssetsIntoGenerationState(readyState, [
buildMatch3DAsset({ imageSrc: '/generated/new.png' }),
]),
).toBe(readyState);
expect(
mergeMatch3DGeneratedAssetsIntoGenerationState(failedState, [
buildMatch3DAsset({ imageSrc: '/generated/new.png' }),
]),
).toBe(failedState);
expect(
mergeMatch3DGeneratedAssetsIntoGenerationState(null, [
buildMatch3DAsset({ imageSrc: '/generated/new.png' }),
]),
).toBeNull();
});
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);
});
});

View File

@@ -0,0 +1,197 @@
import type { Match3DGeneratedItemAsset } from '../../../packages/shared/src/contracts/match3dWorks';
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 mergeMatch3DGeneratedAssetsIntoGenerationState(
current: MiniGameDraftGenerationState | null,
assets: readonly Match3DGeneratedItemAsset[] | null | undefined,
): MiniGameDraftGenerationState | null {
if (!current || current.phase === 'ready' || current.phase === 'failed') {
return current;
}
const assetList = assets ?? [];
const imageReadyCount = assetList.filter(
(asset) =>
asset.imageViews?.some(
(view) => view.imageObjectKey?.trim() || view.imageSrc?.trim(),
) ||
asset.imageObjectKey?.trim() ||
asset.imageSrc?.trim(),
).length;
const totalAssetCount = Math.max(5, assetList.length);
const failedAsset = assetList.find((asset) => asset.error?.trim());
return {
...current,
phase: imageReadyCount > 0 ? 'match3d-generate-views' : current.phase,
completedAssetCount: imageReadyCount,
totalAssetCount,
error: failedAsset?.error?.trim() || current.error,
};
}
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');
}

View File

@@ -0,0 +1,593 @@
import { describe, expect, test } from 'vitest';
import type {
JumpHopSessionSnapshotResponse,
JumpHopWorkspaceCreateRequest,
} from '../../../packages/shared/src/contracts/jumpHop';
import type {
Match3DAgentSessionSnapshot,
Match3DAnchorPackResponse,
} from '../../../packages/shared/src/contracts/match3dAgent';
import type { Match3DWorkSummary } from '../../../packages/shared/src/contracts/match3dWorks';
import type { PuzzleAgentActionRequest } from '../../../packages/shared/src/contracts/puzzleAgentActions';
import type {
PuzzleAnchorPack,
PuzzleDraftLevel,
} from '../../../packages/shared/src/contracts/puzzleAgentDraft';
import type {
CreatePuzzleAgentSessionRequest,
PuzzleAgentSessionSnapshot,
} from '../../../packages/shared/src/contracts/puzzleAgentSession';
import type { PuzzleWorkSummary } from '../../../packages/shared/src/contracts/puzzleWorkSummary';
import type {
WoodenFishSessionSnapshotResponse,
WoodenFishWorkspaceCreateRequest,
} from '../../../packages/shared/src/contracts/woodenFish';
import {
buildJumpHopDraftActionPayload,
buildMatch3DFormPayloadFromSession,
buildMatch3DFormPayloadFromWork,
buildPendingMatch3DDraftMetadata,
buildPendingPuzzleDraftMetadata,
buildPuzzleCompileActionFromFormPayload,
buildPuzzleFormPayloadFromAction,
buildPuzzleFormPayloadFromSession,
buildPuzzleFormPayloadFromWork,
buildPuzzleWorkUpdatePayloadFromDraft,
buildWoodenFishDraftActionPayload,
isEmptyPuzzleFormOnlyDraft,
isPuzzleFormOnlyDraft,
} from './platformMiniGameDraftPayloadModel';
function buildPuzzleAnchorPack(): PuzzleAnchorPack {
const item = {
key: 'theme',
label: '主题',
value: '星桥机关',
status: 'confirmed' as const,
};
return {
themePromise: item,
visualSubject: item,
visualMood: item,
compositionHooks: item,
tagsAndForbidden: item,
};
}
function buildPuzzleLevel(
overrides: Partial<PuzzleDraftLevel> = {},
): PuzzleDraftLevel {
return {
levelId: 'level-1',
levelName: '星桥机关',
pictureDescription: '关卡画面描述',
candidates: [],
selectedCandidateId: null,
coverImageSrc: null,
coverAssetId: null,
generationStatus: 'idle',
...overrides,
};
}
function buildPuzzleWork(
overrides: Partial<PuzzleWorkSummary> = {},
): PuzzleWorkSummary {
return {
workId: 'puzzle-work-1',
profileId: 'puzzle-profile-1',
ownerUserId: 'user-1',
authorDisplayName: '玩家',
workTitle: ' 星桥拼图 ',
workDescription: ' 修复星桥机关。 ',
levelName: '星桥机关',
summary: '把碎片拼回原位。',
themeTags: ['星桥'],
coverImageSrc: '/cover.png',
coverAssetId: null,
publicationStatus: 'draft',
updatedAt: '2026-06-01T10:00:00.000Z',
publishedAt: null,
publishReady: false,
levels: [buildPuzzleLevel()],
...overrides,
};
}
function buildPuzzleSession(
overrides: Partial<PuzzleAgentSessionSnapshot> = {},
): PuzzleAgentSessionSnapshot {
const anchorPack = buildPuzzleAnchorPack();
return {
sessionId: 'puzzle-session-1',
seedText: '种子描述',
currentTurn: 1,
progressPercent: 20,
stage: 'collecting_anchors',
anchorPack,
draft: {
workTitle: '会话标题',
workDescription: '会话描述',
levelName: '星桥机关',
summary: '会话摘要',
themeTags: ['星桥'],
forbiddenDirectives: [],
creatorIntent: null,
anchorPack,
candidates: [],
selectedCandidateId: null,
coverImageSrc: null,
coverAssetId: null,
generationStatus: 'idle',
levels: [buildPuzzleLevel()],
formDraft: {
workTitle: '表单标题',
workDescription: '表单描述',
pictureDescription: '表单画面',
},
},
messages: [],
lastAssistantReply: null,
publishedProfileId: null,
suggestedActions: [],
resultPreview: null,
updatedAt: '2026-06-01T10:00:00.000Z',
...overrides,
};
}
function buildMatch3DAnchorPack(
overrides: Partial<Match3DAnchorPackResponse> = {},
): Match3DAnchorPackResponse {
return {
theme: {
key: 'theme',
label: '主题',
value: '海岛玩具',
status: 'confirmed',
},
clearCount: {
key: 'clearCount',
label: '消除次数',
value: '12',
status: 'confirmed',
},
difficulty: {
key: 'difficulty',
label: '难度',
value: '3',
status: 'confirmed',
},
...overrides,
};
}
function buildMatch3DSession(
overrides: Partial<Match3DAgentSessionSnapshot> = {},
): Match3DAgentSessionSnapshot {
return {
sessionId: 'match3d-session-1',
currentTurn: 1,
progressPercent: 20,
stage: 'collecting',
anchorPack: buildMatch3DAnchorPack(),
config: null,
draft: null,
messages: [],
lastAssistantReply: null,
publishedProfileId: null,
updatedAt: '2026-06-01T11:00:00.000Z',
...overrides,
};
}
function buildMatch3DWork(
overrides: Partial<Match3DWorkSummary> = {},
): Match3DWorkSummary {
return {
workId: 'match3d-work-1',
profileId: 'match3d-profile-1',
ownerUserId: 'user-1',
gameName: '海岛抓大鹅',
themeText: ' 海岛玩具 ',
summary: '收集海岛玩具。',
tags: ['海岛'],
coverImageSrc: '/match3d-cover.png',
referenceImageSrc: '/match3d-reference.png',
clearCount: 12,
difficulty: 3,
publicationStatus: 'draft',
playCount: 0,
updatedAt: '2026-06-01T11:00:00.000Z',
publishedAt: null,
publishReady: false,
...overrides,
};
}
function buildJumpHopDraft(
overrides: Partial<NonNullable<JumpHopSessionSnapshotResponse['draft']>> = {},
): NonNullable<JumpHopSessionSnapshotResponse['draft']> {
return {
templateId: 'jump-hop',
templateName: '跳一跳',
profileId: 'jump-hop-profile-1',
workTitle: '草稿跳一跳',
workDescription: '从草稿恢复。',
themeTags: ['草稿'],
difficulty: 'standard',
stylePreset: 'paper-toy',
characterPrompt: '草稿角色',
tilePrompt: '草稿平台',
endMoodPrompt: '草稿终点',
characterAsset: null,
tileAtlasAsset: null,
tileAssets: [],
path: null,
coverComposite: null,
generationStatus: 'draft',
...overrides,
};
}
function buildJumpHopPayload(
overrides: Partial<JumpHopWorkspaceCreateRequest> = {},
): JumpHopWorkspaceCreateRequest {
return {
templateId: 'jump-hop',
workTitle: '表单跳一跳',
workDescription: '从表单提交。',
themeTags: ['表单'],
difficulty: 'advanced',
stylePreset: 'neon-glass',
characterPrompt: '表单角色',
tilePrompt: '表单平台',
endMoodPrompt: '表单终点',
...overrides,
};
}
function buildWoodenFishDraft(
overrides: Partial<
NonNullable<WoodenFishSessionSnapshotResponse['draft']>
> = {},
): NonNullable<WoodenFishSessionSnapshotResponse['draft']> {
return {
templateId: 'wooden-fish',
templateName: '敲木鱼',
profileId: 'wooden-fish-profile-1',
workTitle: '草稿木鱼',
workDescription: '从草稿恢复。',
themeTags: ['草稿'],
hitObjectPrompt: '草稿敲击物',
hitObjectReferenceImageSrc: '/draft-hit-ref.png',
hitSoundPrompt: null,
floatingWords: ['草稿 +1'],
hitObjectAsset: null,
backgroundAsset: null,
backButtonAsset: null,
hitSoundAsset: null,
coverImageSrc: null,
generationStatus: 'draft',
...overrides,
};
}
function buildWoodenFishPayload(
overrides: Partial<WoodenFishWorkspaceCreateRequest> = {},
): WoodenFishWorkspaceCreateRequest {
return {
templateId: 'wooden-fish',
workTitle: '表单木鱼',
workDescription: '从表单提交。',
themeTags: ['表单'],
hitObjectPrompt: '表单敲击物',
hitObjectReferenceImageSrc: '/form-hit-ref.png',
hitSoundPrompt: null,
hitSoundAsset: null,
floatingWords: ['表单 +1'],
...overrides,
};
}
describe('platformMiniGameDraftPayloadModel', () => {
test('builds puzzle form payload from work with fallback description priority', () => {
expect(
buildPuzzleFormPayloadFromWork(
buildPuzzleWork({
workDescription: ' ',
summary: ' 摘要描述 ',
levelName: ' 关卡标题 ',
}),
),
).toEqual({
seedText: '摘要描述',
workTitle: '星桥拼图',
workDescription: '摘要描述',
pictureDescription: '摘要描述',
referenceImageSrc: null,
referenceImageSrcs: [],
referenceImageAssetObjectId: null,
referenceImageAssetObjectIds: [],
imageModel: null,
aiRedraw: true,
});
});
test('builds puzzle work update payload from result draft', () => {
const draft = buildPuzzleSession().draft!;
expect(buildPuzzleWorkUpdatePayloadFromDraft(draft)).toEqual({
workTitle: '会话标题',
workDescription: '会话描述',
levelName: '星桥机关',
summary: '会话摘要',
themeTags: ['星桥'],
coverImageSrc: null,
coverAssetId: null,
levels: [buildPuzzleLevel()],
});
expect(
buildPuzzleWorkUpdatePayloadFromDraft({
...draft,
levels: undefined,
}).levels,
).toEqual([]);
});
test('builds jump hop draft action payload from payload or draft', () => {
expect(
buildJumpHopDraftActionPayload('compile-draft', {
payload: buildJumpHopPayload(),
draft: buildJumpHopDraft(),
}),
).toEqual({
actionType: 'compile-draft',
workTitle: '表单跳一跳',
workDescription: '从表单提交。',
themeTags: ['表单'],
difficulty: 'advanced',
stylePreset: 'neon-glass',
characterPrompt: '表单角色',
tilePrompt: '表单平台',
endMoodPrompt: '表单终点',
});
expect(
buildJumpHopDraftActionPayload('regenerate-tiles', {
draft: buildJumpHopDraft(),
}),
).toMatchObject({
actionType: 'regenerate-tiles',
workTitle: '草稿跳一跳',
tilePrompt: '草稿平台',
});
});
test('builds wooden fish draft action payload from payload or draft', () => {
expect(
buildWoodenFishDraftActionPayload('compile-draft', {
payload: buildWoodenFishPayload(),
draft: buildWoodenFishDraft(),
}),
).toEqual({
actionType: 'compile-draft',
workTitle: '表单木鱼',
workDescription: '从表单提交。',
themeTags: ['表单'],
hitObjectPrompt: '表单敲击物',
hitObjectReferenceImageSrc: '/form-hit-ref.png',
hitSoundAsset: null,
floatingWords: ['表单 +1'],
});
expect(
buildWoodenFishDraftActionPayload('regenerate-hit-object', {
draft: buildWoodenFishDraft(),
}),
).toMatchObject({
actionType: 'regenerate-hit-object',
workTitle: '草稿木鱼',
hitObjectPrompt: '草稿敲击物',
floatingWords: ['草稿 +1'],
});
});
test('builds puzzle form payload from session form draft and fallbacks', () => {
expect(buildPuzzleFormPayloadFromSession(buildPuzzleSession())).toEqual({
seedText: '表单画面',
workTitle: '表单标题',
workDescription: '表单描述',
pictureDescription: '表单画面',
referenceImageSrc: null,
referenceImageSrcs: [],
referenceImageAssetObjectId: null,
referenceImageAssetObjectIds: [],
imageModel: null,
aiRedraw: true,
});
expect(
buildPuzzleFormPayloadFromSession(
buildPuzzleSession({
draft: {
...buildPuzzleSession().draft!,
formDraft: null,
levels: [buildPuzzleLevel({ pictureDescription: '关卡优先' })],
},
}),
).pictureDescription,
).toBe('关卡优先');
});
test('resolves puzzle form-only draft state for empty and filled forms', () => {
const baseDraft = buildPuzzleSession().draft!;
const emptySession = buildPuzzleSession({
seedText: ' ',
draft: {
...baseDraft,
formDraft: {
workTitle: ' ',
workDescription: ' ',
pictureDescription: ' ',
},
},
});
expect(isPuzzleFormOnlyDraft(emptySession)).toBe(true);
expect(isEmptyPuzzleFormOnlyDraft(emptySession)).toBe(true);
expect(isPuzzleFormOnlyDraft(buildPuzzleSession())).toBe(true);
expect(isEmptyPuzzleFormOnlyDraft(buildPuzzleSession())).toBe(false);
expect(
isPuzzleFormOnlyDraft(buildPuzzleSession({ stage: 'ready_to_publish' })),
).toBe(false);
});
test('builds puzzle compile action and restores form payload from action', () => {
const payload: CreatePuzzleAgentSessionRequest = {
seedText: '种子',
workTitle: ' 标题 ',
workDescription: '',
pictureDescription: ' 画面 ',
referenceImageSrc: '/ref.png',
referenceImageSrcs: ['/ref-a.png'],
referenceImageAssetObjectId: 'asset-ref',
referenceImageAssetObjectIds: ['asset-ref-a'],
imageModel: 'image-model',
aiRedraw: false,
};
const action = buildPuzzleCompileActionFromFormPayload(payload);
expect(action).toEqual({
action: 'compile_puzzle_draft',
promptText: '画面',
workTitle: '标题',
workDescription: '画面',
pictureDescription: '画面',
referenceImageSrc: '/ref.png',
referenceImageSrcs: ['/ref-a.png'],
referenceImageAssetObjectId: 'asset-ref',
referenceImageAssetObjectIds: ['asset-ref-a'],
imageModel: 'image-model',
aiRedraw: false,
candidateCount: 1,
});
expect(buildPuzzleFormPayloadFromAction(action)).toEqual({
seedText: '画面',
workTitle: '标题',
workDescription: '画面',
pictureDescription: '画面',
referenceImageSrc: '/ref.png',
referenceImageSrcs: ['/ref-a.png'],
referenceImageAssetObjectId: 'asset-ref',
referenceImageAssetObjectIds: ['asset-ref-a'],
imageModel: 'image-model',
aiRedraw: false,
});
expect(
buildPuzzleFormPayloadFromAction({
action: 'publish_puzzle_work',
} as PuzzleAgentActionRequest),
).toBeNull();
});
test('builds pending puzzle metadata from non-empty payload fields', () => {
expect(
buildPendingPuzzleDraftMetadata({
workTitle: ' 标题 ',
workDescription: ' ',
pictureDescription: ' 画面 ',
seedText: '种子',
}),
).toEqual({
title: '标题',
summary: '画面',
});
expect(buildPendingPuzzleDraftMetadata(null)).toEqual({});
});
test('builds match3d form payload from session config, draft and anchors', () => {
expect(
buildMatch3DFormPayloadFromSession(
buildMatch3DSession({
config: {
themeText: ' 配置主题 ',
referenceImageSrc: '/config-ref.png',
clearCount: 9,
difficulty: 4,
assetStyleId: 'style-1',
assetStyleLabel: '手办',
assetStylePrompt: '软陶手办',
generateClickSound: true,
},
draft: {
profileId: 'profile-1',
gameName: '草稿标题',
themeText: '草稿主题',
tags: [],
referenceImageSrc: '/draft-ref.png',
clearCount: 6,
difficulty: 2,
},
}),
),
).toEqual({
seedText: '配置主题',
themeText: '配置主题',
referenceImageSrc: '/config-ref.png',
clearCount: 9,
difficulty: 4,
assetStyleId: 'style-1',
assetStyleLabel: '手办',
assetStylePrompt: '软陶手办',
generateClickSound: true,
});
expect(
buildMatch3DFormPayloadFromSession(
buildMatch3DSession({
anchorPack: buildMatch3DAnchorPack({
clearCount: {
key: 'clearCount',
label: '消除次数',
value: 'not-number',
status: 'confirmed',
},
}),
}),
),
).toMatchObject({
seedText: '海岛玩具',
clearCount: undefined,
difficulty: 3,
});
});
test('builds match3d form payload from work and pending metadata', () => {
expect(
buildMatch3DFormPayloadFromWork(
buildMatch3DWork({
themeText: ' ',
}),
),
).toEqual({
seedText: '海岛抓大鹅',
themeText: '海岛抓大鹅',
referenceImageSrc: '/match3d-reference.png',
clearCount: 12,
difficulty: 3,
});
expect(
buildPendingMatch3DDraftMetadata({
themeText: ' ',
seedText: ' 海岛抓大鹅 ',
}),
).toEqual({
title: '海岛抓大鹅',
summary: '海岛抓大鹅',
});
});
});

View File

@@ -0,0 +1,320 @@
import type {
JumpHopActionRequest,
JumpHopSessionSnapshotResponse,
JumpHopWorkspaceCreateRequest,
} from '../../../packages/shared/src/contracts/jumpHop';
import type {
CreateMatch3DSessionRequest,
Match3DAgentSessionSnapshot,
} from '../../../packages/shared/src/contracts/match3dAgent';
import type { Match3DWorkSummary } from '../../../packages/shared/src/contracts/match3dWorks';
import type { PuzzleAgentActionRequest } from '../../../packages/shared/src/contracts/puzzleAgentActions';
import type {
PuzzleDraftLevel,
PuzzleResultDraft,
} from '../../../packages/shared/src/contracts/puzzleAgentDraft';
import type {
CreatePuzzleAgentSessionRequest,
PuzzleAgentSessionSnapshot,
} from '../../../packages/shared/src/contracts/puzzleAgentSession';
import type { PuzzleWorkSummary } from '../../../packages/shared/src/contracts/puzzleWorkSummary';
import type {
WoodenFishActionRequest,
WoodenFishSessionSnapshotResponse,
WoodenFishWorkspaceCreateRequest,
} from '../../../packages/shared/src/contracts/woodenFish';
export type PuzzleWorkUpdatePayload = {
workTitle?: string;
workDescription?: string;
levelName: string;
summary: string;
themeTags: string[];
coverImageSrc?: string | null;
coverAssetId?: string | null;
levels: PuzzleDraftLevel[];
};
export function buildPuzzleFormPayloadFromWork(
item: PuzzleWorkSummary,
): CreatePuzzleAgentSessionRequest {
const pictureDescription =
item.workDescription?.trim() ||
item.summary?.trim() ||
item.levels?.[0]?.pictureDescription?.trim() ||
item.levelName?.trim() ||
item.workTitle?.trim() ||
'';
return {
seedText: pictureDescription,
workTitle: item.workTitle?.trim() || item.levelName?.trim() || undefined,
workDescription: item.workDescription?.trim() || item.summary?.trim(),
pictureDescription,
referenceImageSrc: null,
referenceImageSrcs: [],
referenceImageAssetObjectId: null,
referenceImageAssetObjectIds: [],
imageModel: null,
aiRedraw: true,
};
}
export function buildPuzzleWorkUpdatePayloadFromDraft(
draft: PuzzleResultDraft,
): PuzzleWorkUpdatePayload {
return {
workTitle: draft.workTitle,
workDescription: draft.workDescription,
levelName: draft.levelName,
summary: draft.summary,
themeTags: draft.themeTags,
coverImageSrc: draft.coverImageSrc,
coverAssetId: draft.coverAssetId,
levels: draft.levels ?? [],
};
}
export function buildJumpHopDraftActionPayload(
actionType: 'compile-draft' | 'regenerate-character' | 'regenerate-tiles',
input: {
payload?: JumpHopWorkspaceCreateRequest | null;
draft?: JumpHopSessionSnapshotResponse['draft'] | null;
},
): JumpHopActionRequest {
const { payload, draft } = input;
return {
actionType,
workTitle: payload?.workTitle ?? draft?.workTitle,
workDescription: payload?.workDescription ?? draft?.workDescription,
themeTags: payload?.themeTags ?? draft?.themeTags,
difficulty: payload?.difficulty ?? draft?.difficulty,
stylePreset: payload?.stylePreset ?? draft?.stylePreset,
characterPrompt: payload?.characterPrompt ?? draft?.characterPrompt,
tilePrompt: payload?.tilePrompt ?? draft?.tilePrompt,
endMoodPrompt: payload?.endMoodPrompt ?? draft?.endMoodPrompt,
};
}
export function buildWoodenFishDraftActionPayload(
actionType: 'compile-draft' | 'regenerate-hit-object',
input: {
payload?: WoodenFishWorkspaceCreateRequest | null;
draft?: WoodenFishSessionSnapshotResponse['draft'] | null;
},
): WoodenFishActionRequest {
const { payload, draft } = input;
return {
actionType,
workTitle: payload?.workTitle ?? draft?.workTitle,
workDescription: payload?.workDescription ?? draft?.workDescription,
themeTags: payload?.themeTags ?? draft?.themeTags,
hitObjectPrompt: payload?.hitObjectPrompt ?? draft?.hitObjectPrompt,
hitObjectReferenceImageSrc:
payload?.hitObjectReferenceImageSrc ??
draft?.hitObjectReferenceImageSrc,
hitSoundAsset: payload?.hitSoundAsset ?? draft?.hitSoundAsset,
floatingWords: payload?.floatingWords ?? draft?.floatingWords,
};
}
function parseOptionalFiniteNumber(value: string | number | null | undefined) {
if (typeof value === 'number') {
return Number.isFinite(value) ? value : undefined;
}
const normalizedValue = value?.trim();
if (!normalizedValue) {
return undefined;
}
const parsedValue = Number(normalizedValue);
return Number.isFinite(parsedValue) ? parsedValue : undefined;
}
export function buildMatch3DFormPayloadFromSession(
session: Match3DAgentSessionSnapshot,
): CreateMatch3DSessionRequest {
const themeText =
session.config?.themeText?.trim() ||
session.draft?.themeText?.trim() ||
session.anchorPack.theme.value.trim() ||
'';
return {
seedText: themeText,
themeText,
referenceImageSrc:
session.config?.referenceImageSrc ??
session.draft?.referenceImageSrc ??
null,
clearCount:
session.config?.clearCount ??
session.draft?.clearCount ??
parseOptionalFiniteNumber(session.anchorPack.clearCount.value) ??
undefined,
difficulty:
session.config?.difficulty ??
session.draft?.difficulty ??
parseOptionalFiniteNumber(session.anchorPack.difficulty.value) ??
undefined,
assetStyleId: session.config?.assetStyleId ?? null,
assetStyleLabel: session.config?.assetStyleLabel ?? null,
assetStylePrompt: session.config?.assetStylePrompt ?? null,
generateClickSound: session.config?.generateClickSound,
};
}
export function buildMatch3DFormPayloadFromWork(
item: Match3DWorkSummary,
): CreateMatch3DSessionRequest {
const themeText = item.themeText?.trim() || item.gameName?.trim() || '';
return {
seedText: themeText,
themeText,
referenceImageSrc: item.referenceImageSrc ?? null,
clearCount: item.clearCount,
difficulty: item.difficulty,
};
}
export function buildPuzzleCompileActionFromFormPayload(
payload: CreatePuzzleAgentSessionRequest | null,
): PuzzleAgentActionRequest {
const pictureDescription =
payload?.pictureDescription?.trim() || payload?.seedText?.trim();
const workTitle = payload?.workTitle?.trim();
const workDescription = payload?.workDescription?.trim() || pictureDescription;
return {
action: 'compile_puzzle_draft',
promptText: pictureDescription,
...(workTitle ? { workTitle } : {}),
...(workDescription ? { workDescription } : {}),
...(pictureDescription ? { pictureDescription } : {}),
referenceImageSrc: payload?.referenceImageSrc || null,
referenceImageSrcs: payload?.referenceImageSrcs ?? [],
referenceImageAssetObjectId: payload?.referenceImageAssetObjectId ?? null,
referenceImageAssetObjectIds: payload?.referenceImageAssetObjectIds ?? [],
imageModel: payload?.imageModel ?? null,
aiRedraw: payload?.aiRedraw ?? true,
candidateCount: 1,
};
}
export function buildPuzzleFormPayloadFromSession(
session: PuzzleAgentSessionSnapshot,
): CreatePuzzleAgentSessionRequest {
const formDraft = session.draft?.formDraft;
const pictureDescription =
formDraft?.pictureDescription?.trim() ||
session.draft?.levels?.[0]?.pictureDescription?.trim() ||
session.anchorPack.visualSubject.value.trim() ||
session.seedText?.trim() ||
'';
const workTitle =
formDraft?.workTitle?.trim() || session.draft?.workTitle?.trim();
const workDescription =
formDraft?.workDescription?.trim() ||
session.draft?.workDescription?.trim() ||
session.draft?.summary?.trim() ||
pictureDescription;
return {
seedText: pictureDescription,
...(workTitle ? { workTitle } : {}),
...(workDescription ? { workDescription } : {}),
pictureDescription,
referenceImageSrc: null,
referenceImageSrcs: [],
referenceImageAssetObjectId: null,
referenceImageAssetObjectIds: [],
imageModel: null,
aiRedraw: true,
};
}
export function isPuzzleFormOnlyDraft(
session: PuzzleAgentSessionSnapshot | null,
) {
return Boolean(
session?.stage === 'collecting_anchors' && session.draft?.formDraft,
);
}
export function isEmptyPuzzleFormOnlyDraft(
session: PuzzleAgentSessionSnapshot | null,
) {
if (!isPuzzleFormOnlyDraft(session)) {
return false;
}
const formDraft = session?.draft?.formDraft;
return !(
session?.seedText?.trim() ||
formDraft?.workTitle?.trim() ||
formDraft?.workDescription?.trim() ||
formDraft?.pictureDescription?.trim()
);
}
export function buildPendingPuzzleDraftMetadata(
payload: CreatePuzzleAgentSessionRequest | null | undefined,
) {
const title = payload?.workTitle?.trim();
const summary =
payload?.workDescription?.trim() ||
payload?.pictureDescription?.trim() ||
payload?.seedText?.trim();
return {
...(title ? { title } : {}),
...(summary ? { summary } : {}),
};
}
export function buildPendingMatch3DDraftMetadata(
payload: CreateMatch3DSessionRequest | null | undefined,
) {
const themeText = payload?.themeText?.trim() || payload?.seedText?.trim();
return {
...(themeText ? { title: themeText, summary: themeText } : {}),
};
}
export function buildPuzzleFormPayloadFromAction(
payload: PuzzleAgentActionRequest,
): CreatePuzzleAgentSessionRequest | null {
if (
payload.action !== 'compile_puzzle_draft' &&
payload.action !== 'save_puzzle_form_draft'
) {
return null;
}
const workTitle = payload.workTitle?.trim() ?? '';
const workDescription = payload.workDescription?.trim() ?? '';
const pictureDescription =
payload.pictureDescription?.trim() || payload.promptText?.trim() || '';
return {
seedText: pictureDescription,
...(workTitle ? { workTitle } : {}),
...(workDescription ? { workDescription } : {}),
pictureDescription,
referenceImageSrc:
payload.action === 'compile_puzzle_draft'
? (payload.referenceImageSrc ?? null)
: (payload.referenceImageSrc ?? null),
referenceImageSrcs: payload.referenceImageSrcs ?? [],
referenceImageAssetObjectId: payload.referenceImageAssetObjectId ?? null,
referenceImageAssetObjectIds: payload.referenceImageAssetObjectIds ?? [],
imageModel:
payload.action === 'compile_puzzle_draft'
? (payload.imageModel ?? null)
: (payload.imageModel ?? null),
aiRedraw:
payload.action === 'compile_puzzle_draft'
? (payload.aiRedraw ?? true)
: (payload.aiRedraw ?? true),
};
}

View File

@@ -0,0 +1,714 @@
import { describe, expect, test } from 'vitest';
import type {
JumpHopWorkSummaryResponse,
} from '../../../packages/shared/src/contracts/jumpHop';
import type {
PuzzleAnchorPack,
PuzzleResultDraft,
} from '../../../packages/shared/src/contracts/puzzleAgentDraft';
import type { PuzzleAgentSessionSnapshot } from '../../../packages/shared/src/contracts/puzzleAgentSession';
import type {
SquareHoleResultDraft,
SquareHoleSessionSnapshot,
} from '../../../packages/shared/src/contracts/squareHoleAgent';
import type {
VisualNovelResultDraft,
VisualNovelWorkDetail,
} from '../../../packages/shared/src/contracts/visualNovel';
import type {
WoodenFishAudioAsset,
WoodenFishImageAsset,
WoodenFishSessionSnapshotResponse,
WoodenFishWorkProfileResponse,
WoodenFishWorkspaceCreateRequest,
WoodenFishWorkSummaryResponse,
} from '../../../packages/shared/src/contracts/woodenFish';
import {
buildJumpHopPendingSession,
buildPuzzleRuntimeWorkFromSession,
buildSquareHoleProfileFromSession,
buildVisualNovelSessionFromWorkDetail,
buildWoodenFishGeneratingWorkSummary,
buildWoodenFishPendingSession,
buildWoodenFishSessionFromWorkDetail,
} from './platformMiniGameSessionMappingModel';
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 buildPuzzleDraft(
overrides: Partial<PuzzleResultDraft> = {},
): PuzzleResultDraft {
const anchorPack = buildAnchorPack();
return {
workTitle: '星桥拼图',
workDescription: '修复星桥机关。',
levelName: '星桥机关',
summary: '把星桥碎片拼回原位。',
themeTags: ['星桥'],
forbiddenDirectives: [],
creatorIntent: null,
anchorPack,
candidates: [],
selectedCandidateId: null,
coverImageSrc: '/puzzle-cover.png',
coverAssetId: 'asset-cover',
generationStatus: 'ready',
levels: [
{
levelId: 'level-1',
levelName: '星桥机关',
pictureDescription: '星桥',
candidates: [],
selectedCandidateId: null,
coverImageSrc: '/puzzle-level-cover.png',
coverAssetId: 'asset-level-cover',
generationStatus: 'ready',
},
],
...overrides,
};
}
function buildPuzzleSession(
overrides: Partial<PuzzleAgentSessionSnapshot> = {},
): PuzzleAgentSessionSnapshot {
const draft = buildPuzzleDraft();
return {
sessionId: 'puzzle-session-12345678',
seedText: '星桥',
currentTurn: 1,
progressPercent: 100,
stage: 'ready_to_publish',
anchorPack: draft.anchorPack,
draft,
messages: [],
lastAssistantReply: null,
publishedProfileId: null,
suggestedActions: [],
resultPreview: {
draft,
blockers: [],
qualityFindings: [],
publishReady: true,
},
updatedAt: '2026-06-01T10:00:00.000Z',
...overrides,
};
}
function buildJumpHopSummary(
overrides: Partial<JumpHopWorkSummaryResponse> = {},
): JumpHopWorkSummaryResponse {
return {
runtimeKind: 'jump-hop',
workId: 'jump-hop-work-1',
profileId: 'jump-hop-profile-1',
ownerUserId: 'user-1',
sourceSessionId: ' jump-hop-session-1 ',
workTitle: '云阶跳跃',
workDescription: '越过云阶。',
themeTags: ['云阶'],
difficulty: 'standard',
stylePreset: 'paper-toy',
coverImageSrc: '/jump-hop-cover.png',
publicationStatus: 'draft',
playCount: 0,
updatedAt: '2026-06-01T11:00:00.000Z',
publishedAt: null,
publishReady: false,
generationStatus: 'generating',
...overrides,
};
}
function buildSquareHoleDraft(
overrides: Partial<SquareHoleResultDraft> = {},
): SquareHoleResultDraft {
return {
profileId: 'square-hole-profile-1',
gameName: '星桥方洞',
themeText: '星桥机关',
twistRule: '只允许相同颜色形状入洞',
summary: '把星桥机关里的形状送入正确孔洞。',
tags: ['星桥', '机关'],
coverImageSrc: '/square-hole-cover.png',
backgroundPrompt: '星桥机关背景',
backgroundImageSrc: '/square-hole-background.png',
shapeOptions: [
{
optionId: 'shape-1',
shapeKind: 'star',
label: '星形',
targetHoleId: 'hole-1',
imagePrompt: '星形积木',
imageSrc: '/shape-star.png',
},
],
holeOptions: [
{
holeId: 'hole-1',
holeKind: 'star-hole',
label: '星形洞',
imagePrompt: '星形洞口',
imageSrc: '/hole-star.png',
},
],
shapeCount: 6,
difficulty: 3,
publishReady: true,
blockers: [],
...overrides,
};
}
function buildSquareHoleSession(
overrides: Partial<SquareHoleSessionSnapshot> = {},
): SquareHoleSessionSnapshot {
return {
sessionId: 'square-hole-session-1',
currentTurn: 2,
progressPercent: 100,
stage: 'draft_ready',
anchorPack: {
theme: {
key: 'theme',
label: '主题',
value: '星桥机关',
status: 'confirmed',
},
twistRule: {
key: 'twistRule',
label: '扭转规则',
value: '只允许相同颜色形状入洞',
status: 'confirmed',
},
shapeCount: {
key: 'shapeCount',
label: '形状数量',
value: '6',
status: 'confirmed',
},
difficulty: {
key: 'difficulty',
label: '难度',
value: '3',
status: 'confirmed',
},
},
config: {
themeText: '星桥机关',
twistRule: '只允许相同颜色形状入洞',
shapeCount: 6,
difficulty: 3,
shapeOptions: [],
holeOptions: [],
backgroundPrompt: '星桥机关背景',
coverImageSrc: null,
backgroundImageSrc: null,
},
draft: buildSquareHoleDraft(),
messages: [],
lastAssistantReply: null,
publishedProfileId: null,
updatedAt: '2026-06-01T12:30:00.000Z',
...overrides,
};
}
function buildVisualNovelDraft(
overrides: Partial<VisualNovelResultDraft> = {},
): VisualNovelResultDraft {
return {
profileId: 'visual-novel-profile-1',
workTitle: '雪线电台',
workDescription: '旧电台牵出雪夜列车谜案。',
workTags: ['雪夜', '电台'],
coverImageSrc: '/visual-novel-cover.png',
sourceMode: 'idea',
sourceAssetIds: ['asset-source-1'],
world: {
title: '北境终点线',
summary: '边境小城与旧电台。',
background: '十二年前的雪崩留下夜间广播。',
premise: '玩家需要在日出前找出列车停摆的原因。',
literaryStyle: '克制冷光感。',
playerRole: '临时广播员',
defaultTone: '安静紧张',
},
characters: [
{
characterId: 'vn-char-1',
name: '林遥',
gender: '女',
role: 'main',
appearance: '灰色长外套。',
personality: '谨慎敏锐。',
tone: '短句多。',
background: '旧电台夜班实习生。',
relationshipToPlayer: '临时搭档',
imageAssets: [],
defaultExpression: 'calm',
isPlayerVisible: false,
},
],
scenes: [
{
sceneId: 'vn-scene-1',
name: '风雪站台',
description: '站灯忽明忽暗。',
backgroundImageSrc: null,
musicSrc: null,
ambientSoundSrc: null,
availability: 'opening',
phaseIds: ['vn-phase-1'],
},
],
storyPhases: [
{
phaseId: 'vn-phase-1',
title: '重启站台',
goal: '确认列车为何停在废弃站台。',
summary: '玩家抵达风雪站台。',
entryCondition: '开场进入',
exitCondition: '找到车长日志',
sceneIds: ['vn-scene-1'],
characterIds: ['vn-char-1'],
suggestedChoices: ['检查广播柜'],
},
],
opening: {
sceneId: 'vn-scene-1',
narration: '雪落得很慢。',
speakerCharacterId: 'vn-char-1',
firstDialogue: '你听见了吗?',
initialChoices: [
{
choiceId: 'vn-choice-1',
text: '靠近广播柜。',
actionHint: 'inspect_radio',
},
],
},
runtimeConfig: {
textModeEnabled: true,
defaultTextMode: false,
maxHistoryEntries: 80,
maxAssistantStepCountPerTurn: 8,
allowFreeTextAction: true,
allowHistoryRegeneration: true,
attributePanelMode: 'template_config',
saveArchiveEnabled: true,
},
publishReady: true,
validationIssues: [],
updatedAt: '2026-06-01T13:00:00.000Z',
...overrides,
};
}
function buildVisualNovelWorkDetail(
overrides: Partial<VisualNovelWorkDetail> = {},
): VisualNovelWorkDetail {
const draft = buildVisualNovelDraft();
return {
workId: 'visual-novel-work-1',
summary: {
runtimeKind: 'visual-novel',
profileId: 'visual-novel-profile-1',
ownerUserId: 'user-visual-novel-1',
title: draft.workTitle,
description: draft.workDescription,
coverImageSrc: draft.coverImageSrc,
tags: draft.workTags,
publishStatus: 'draft',
publishReady: draft.publishReady,
playCount: 0,
updatedAt: '2026-06-01T13:30:00.000Z',
publishedAt: null,
},
sourceSessionId: ' visual-novel-session-1 ',
authorDisplayName: '视觉小说作者',
sourceAssetIds: draft.sourceAssetIds,
draft,
createdAt: '2026-06-01T12:50:00.000Z',
...overrides,
};
}
const woodenFishImageAsset: WoodenFishImageAsset = {
assetId: 'asset-hit',
imageSrc: '/hit.png',
imageObjectKey: 'hit.png',
assetObjectId: 'asset-object-hit',
generationProvider: 'test',
prompt: '木鱼',
width: 512,
height: 512,
};
const woodenFishAudioAsset: WoodenFishAudioAsset = {
assetId: 'asset-sound',
audioSrc: '/hit.mp3',
audioObjectKey: 'hit.mp3',
assetObjectId: 'asset-object-sound',
source: 'test',
};
function buildWoodenFishSummary(
overrides: Partial<WoodenFishWorkSummaryResponse> = {},
): WoodenFishWorkSummaryResponse {
return {
runtimeKind: 'wooden-fish',
workId: 'wooden-fish-work-1',
profileId: 'wooden-fish-profile-1',
ownerUserId: 'user-1',
sourceSessionId: ' wooden-fish-session-1 ',
workTitle: '星灯木鱼',
workDescription: '敲亮星灯。',
themeTags: ['星灯'],
coverImageSrc: '/wooden-fish-cover.png',
publicationStatus: 'draft',
playCount: 0,
updatedAt: '2026-06-01T12:00:00.000Z',
publishedAt: null,
publishReady: false,
generationStatus: 'generating',
...overrides,
};
}
function buildWoodenFishWorkProfile(
overrides: Partial<WoodenFishWorkProfileResponse> = {},
): WoodenFishWorkProfileResponse {
const summary = buildWoodenFishSummary();
const draft = {
templateId: 'wooden-fish',
templateName: '敲木鱼',
profileId: summary.profileId,
workTitle: summary.workTitle,
workDescription: summary.workDescription,
themeTags: summary.themeTags,
hitObjectPrompt: '星灯',
hitObjectReferenceImageSrc: null,
hitSoundPrompt: null,
floatingWords: ['功德 +1'],
hitObjectAsset: woodenFishImageAsset,
backgroundAsset: null,
backButtonAsset: null,
hitSoundAsset: woodenFishAudioAsset,
coverImageSrc: summary.coverImageSrc,
generationStatus: summary.generationStatus,
};
return {
summary,
draft,
hitObjectAsset: woodenFishImageAsset,
backgroundAsset: null,
backButtonAsset: null,
hitSoundAsset: woodenFishAudioAsset,
floatingWords: ['功德 +1'],
...overrides,
};
}
function buildWoodenFishSession(
overrides: Partial<WoodenFishSessionSnapshotResponse> = {},
): WoodenFishSessionSnapshotResponse {
const summary = buildWoodenFishSummary();
return {
sessionId: 'wooden-fish-session-1',
ownerUserId: 'user-1',
status: 'generating',
draft: buildWoodenFishWorkProfile({ summary }).draft,
createdAt: '2026-06-01T11:59:00.000Z',
updatedAt: '2026-06-01T12:00:00.000Z',
...overrides,
};
}
function buildWoodenFishCreatePayload(
overrides: Partial<WoodenFishWorkspaceCreateRequest> = {},
): WoodenFishWorkspaceCreateRequest {
return {
templateId: 'wooden-fish',
workTitle: '表单星灯木鱼',
workDescription: '表单里敲亮星灯。',
themeTags: ['表单星灯'],
hitObjectPrompt: '星灯',
hitObjectReferenceImageSrc: null,
hitSoundPrompt: null,
floatingWords: ['功德 +1'],
...overrides,
};
}
describe('platformMiniGameSessionMappingModel', () => {
test('builds a draft puzzle runtime work from a session', () => {
expect(
buildPuzzleRuntimeWorkFromSession(buildPuzzleSession(), {
userId: 'user-1',
displayName: '玩家一号',
}),
).toMatchObject({
workId: 'puzzle-work-12345678',
profileId: 'puzzle-profile-12345678',
ownerUserId: 'user-1',
sourceSessionId: 'puzzle-session-12345678',
authorDisplayName: '玩家一号',
workTitle: '星桥拼图',
coverImageSrc: '/puzzle-cover.png',
publicationStatus: 'draft',
publishedAt: null,
publishReady: true,
});
});
test('prefers published puzzle profile id when present', () => {
expect(
buildPuzzleRuntimeWorkFromSession(
buildPuzzleSession({
publishedProfileId: 'published-puzzle-profile',
}),
{},
),
).toMatchObject({
profileId: 'published-puzzle-profile',
workId: 'puzzle-work-12345678',
ownerUserId: 'current-user',
authorDisplayName: '玩家',
});
});
test('returns null for puzzle runtime work without draft or cover', () => {
expect(
buildPuzzleRuntimeWorkFromSession(
buildPuzzleSession({
draft: null,
}),
{},
),
).toBeNull();
expect(
buildPuzzleRuntimeWorkFromSession(
buildPuzzleSession({
draft: buildPuzzleDraft({ coverImageSrc: ' ' }),
}),
{},
),
).toBeNull();
});
test('builds jump hop pending session from work summary', () => {
expect(buildJumpHopPendingSession(buildJumpHopSummary())).toEqual({
sessionId: 'jump-hop-session-1',
ownerUserId: 'user-1',
status: 'generating',
draft: {
templateId: 'jump-hop',
templateName: '跳一跳',
profileId: 'jump-hop-profile-1',
workTitle: '云阶跳跃',
workDescription: '越过云阶。',
themeTags: ['云阶'],
difficulty: 'standard',
stylePreset: 'paper-toy',
characterPrompt: '',
tilePrompt: '',
endMoodPrompt: null,
characterAsset: null,
tileAtlasAsset: null,
tileAssets: [],
path: null,
coverComposite: '/jump-hop-cover.png',
generationStatus: 'generating',
},
createdAt: '2026-06-01T11:00:00.000Z',
updatedAt: '2026-06-01T11:00:00.000Z',
});
});
test('builds square hole draft profile from session', () => {
expect(buildSquareHoleProfileFromSession(buildSquareHoleSession())).toEqual({
workId: 'square-hole-profile-1',
profileId: 'square-hole-profile-1',
ownerUserId: 'current-user',
sourceSessionId: 'square-hole-session-1',
gameName: '星桥方洞',
themeText: '星桥机关',
twistRule: '只允许相同颜色形状入洞',
summary: '把星桥机关里的形状送入正确孔洞。',
tags: ['星桥', '机关'],
coverImageSrc: '/square-hole-cover.png',
backgroundPrompt: '星桥机关背景',
backgroundImageSrc: '/square-hole-background.png',
shapeOptions: buildSquareHoleDraft().shapeOptions,
holeOptions: buildSquareHoleDraft().holeOptions,
shapeCount: 6,
difficulty: 3,
publicationStatus: 'draft',
playCount: 0,
updatedAt: '2026-06-01T12:30:00.000Z',
publishedAt: null,
publishReady: true,
});
});
test('returns null for square hole profile without session draft or profile id', () => {
expect(buildSquareHoleProfileFromSession(null)).toBeNull();
expect(
buildSquareHoleProfileFromSession(
buildSquareHoleSession({
draft: null,
}),
),
).toBeNull();
expect(
buildSquareHoleProfileFromSession(
buildSquareHoleSession({
draft: buildSquareHoleDraft({ profileId: '' }),
}),
),
).toBeNull();
});
test('builds visual novel recovered session from work detail', () => {
const work = buildVisualNovelWorkDetail();
expect(buildVisualNovelSessionFromWorkDetail(work)).toEqual({
sessionId: 'visual-novel-session-1',
ownerUserId: 'user-visual-novel-1',
sourceMode: 'idea',
status: 'ready',
messages: [],
draft: work.draft,
pendingAction: null,
createdAt: '2026-06-01T12:50:00.000Z',
updatedAt: '2026-06-01T13:30:00.000Z',
});
});
test('falls back visual novel recovered session id to work id', () => {
expect(
buildVisualNovelSessionFromWorkDetail(
buildVisualNovelWorkDetail({
sourceSessionId: ' ',
workId: 'visual-novel-work-fallback',
}),
).sessionId,
).toBe('visual-novel-work-fallback');
});
test('builds wooden fish pending session from work summary', () => {
expect(buildWoodenFishPendingSession(buildWoodenFishSummary())).toEqual({
sessionId: 'wooden-fish-session-1',
ownerUserId: 'user-1',
status: 'generating',
draft: {
templateId: 'wooden-fish',
templateName: '敲木鱼',
profileId: 'wooden-fish-profile-1',
workTitle: '星灯木鱼',
workDescription: '敲亮星灯。',
themeTags: ['星灯'],
hitObjectPrompt: '',
hitObjectReferenceImageSrc: null,
hitSoundPrompt: null,
floatingWords: ['功德 +1'],
hitObjectAsset: null,
backgroundAsset: null,
backButtonAsset: null,
hitSoundAsset: null,
coverImageSrc: '/wooden-fish-cover.png',
generationStatus: 'generating',
},
createdAt: '2026-06-01T12:00:00.000Z',
updatedAt: '2026-06-01T12:00:00.000Z',
});
});
test('builds wooden fish generating work summary from session and payload', () => {
expect(
buildWoodenFishGeneratingWorkSummary(
buildWoodenFishSession(),
buildWoodenFishCreatePayload(),
),
).toEqual({
runtimeKind: 'wooden-fish',
workId: 'wooden-fish-session-1',
profileId: 'wooden-fish-session-1',
ownerUserId: 'user-1',
sourceSessionId: 'wooden-fish-session-1',
workTitle: '表单星灯木鱼',
workDescription: '表单里敲亮星灯。',
themeTags: ['表单星灯'],
coverImageSrc: '/wooden-fish-cover.png',
publicationStatus: 'draft',
playCount: 0,
updatedAt: '2026-06-01T12:00:00.000Z',
publishedAt: null,
publishReady: false,
generationStatus: 'generating',
});
expect(
buildWoodenFishGeneratingWorkSummary(
buildWoodenFishSession({
draft: null,
createdAt: '2026-06-01T11:59:00.000Z',
}),
null,
),
).toMatchObject({
workTitle: '敲木鱼',
workDescription: '',
themeTags: ['敲木鱼'],
coverImageSrc: null,
updatedAt: '2026-06-01T12:00:00.000Z',
});
});
test('builds wooden fish recovered session with summary, fallback and profile id priority', () => {
expect(
buildWoodenFishSessionFromWorkDetail(
buildWoodenFishWorkProfile({
summary: buildWoodenFishSummary({
sourceSessionId: null,
}),
}),
buildWoodenFishSummary({
sourceSessionId: ' fallback-session ',
}),
),
).toMatchObject({
sessionId: 'fallback-session',
ownerUserId: 'user-1',
status: 'generating',
});
expect(
buildWoodenFishSessionFromWorkDetail(
buildWoodenFishWorkProfile({
summary: buildWoodenFishSummary({
sourceSessionId: null,
}),
}),
null,
).sessionId,
).toBe('wooden-fish-profile-1');
});
});

View File

@@ -0,0 +1,218 @@
import type { JumpHopSessionSnapshotResponse, JumpHopWorkSummaryResponse } from '../../../packages/shared/src/contracts/jumpHop';
import type { PuzzleAgentSessionSnapshot } from '../../../packages/shared/src/contracts/puzzleAgentSession';
import type { PuzzleWorkSummary } from '../../../packages/shared/src/contracts/puzzleWorkSummary';
import type { SquareHoleSessionSnapshot } from '../../../packages/shared/src/contracts/squareHoleAgent';
import type { SquareHoleWorkProfile } from '../../../packages/shared/src/contracts/squareHoleWorks';
import type {
VisualNovelAgentSessionSnapshot,
VisualNovelWorkDetail,
} from '../../../packages/shared/src/contracts/visualNovel';
import type {
WoodenFishSessionSnapshotResponse,
WoodenFishWorkProfileResponse,
WoodenFishWorkspaceCreateRequest,
WoodenFishWorkSummaryResponse,
} from '../../../packages/shared/src/contracts/woodenFish';
import { normalizeCreationUrlValue } from './platformCreationUrlStateModel';
import {
buildPuzzleResultProfileId,
buildPuzzleResultWorkId,
} from './platformPuzzleIdentityModel';
export type PlatformMiniGameSessionOwner = {
userId?: string | null;
displayName?: string | null;
};
export function buildPuzzleRuntimeWorkFromSession(
session: PuzzleAgentSessionSnapshot,
owner: PlatformMiniGameSessionOwner,
): PuzzleWorkSummary | null {
const draft = session.draft;
const profileId =
session.publishedProfileId ?? buildPuzzleResultProfileId(session.sessionId);
if (!draft || !profileId || !draft.coverImageSrc?.trim()) {
return null;
}
return {
workId: buildPuzzleResultWorkId(session.sessionId) ?? profileId,
profileId,
ownerUserId: owner.userId ?? 'current-user',
sourceSessionId: session.sessionId,
authorDisplayName: owner.displayName ?? '玩家',
workTitle: draft.workTitle,
workDescription: draft.workDescription,
levelName: draft.levelName,
summary: draft.summary,
themeTags: draft.themeTags,
coverImageSrc: draft.coverImageSrc,
coverAssetId: draft.coverAssetId,
publicationStatus: 'draft',
updatedAt: session.updatedAt,
publishedAt: null,
playCount: 0,
remixCount: 0,
likeCount: 0,
publishReady: Boolean(session.resultPreview?.publishReady),
levels: draft.levels,
};
}
export function buildSquareHoleProfileFromSession(
session: SquareHoleSessionSnapshot | null,
): SquareHoleWorkProfile | null {
const draft = session?.draft;
if (!session || !draft?.profileId) {
return null;
}
const now = session.updatedAt || new Date().toISOString();
return {
workId: draft.profileId,
profileId: draft.profileId,
ownerUserId: 'current-user',
sourceSessionId: session.sessionId,
gameName: draft.gameName,
themeText: draft.themeText,
twistRule: draft.twistRule,
summary: draft.summary,
tags: draft.tags,
coverImageSrc: draft.coverImageSrc ?? null,
backgroundPrompt: draft.backgroundPrompt,
backgroundImageSrc: draft.backgroundImageSrc ?? null,
shapeOptions: draft.shapeOptions,
holeOptions: draft.holeOptions,
shapeCount: draft.shapeCount,
difficulty: draft.difficulty,
publicationStatus: 'draft',
playCount: 0,
updatedAt: now,
publishedAt: null,
publishReady: Boolean(draft.publishReady),
};
}
export function buildVisualNovelSessionFromWorkDetail(
work: VisualNovelWorkDetail,
): VisualNovelAgentSessionSnapshot {
return {
sessionId: normalizeCreationUrlValue(work.sourceSessionId) ?? work.workId,
ownerUserId: work.summary.ownerUserId,
sourceMode: work.draft.sourceMode,
status: 'ready',
messages: [],
draft: work.draft,
pendingAction: null,
createdAt: work.createdAt,
updatedAt: work.summary.updatedAt,
};
}
export function buildJumpHopPendingSession(
item: JumpHopWorkSummaryResponse,
): JumpHopSessionSnapshotResponse {
const sessionId =
normalizeCreationUrlValue(item.sourceSessionId) ?? item.profileId;
return {
sessionId,
ownerUserId: item.ownerUserId,
status: item.generationStatus,
draft: {
templateId: 'jump-hop',
templateName: '跳一跳',
profileId: item.profileId,
workTitle: item.workTitle,
workDescription: item.workDescription,
themeTags: item.themeTags,
difficulty: item.difficulty,
stylePreset: item.stylePreset,
characterPrompt: '',
tilePrompt: '',
endMoodPrompt: null,
characterAsset: null,
tileAtlasAsset: null,
tileAssets: [],
path: null,
coverComposite: item.coverImageSrc,
generationStatus: item.generationStatus,
},
createdAt: item.updatedAt,
updatedAt: item.updatedAt,
};
}
export function buildWoodenFishSessionFromWorkDetail(
work: WoodenFishWorkProfileResponse,
fallbackItem?: WoodenFishWorkSummaryResponse | null,
): WoodenFishSessionSnapshotResponse {
const sessionId =
normalizeCreationUrlValue(work.summary.sourceSessionId) ??
normalizeCreationUrlValue(fallbackItem?.sourceSessionId) ??
work.summary.profileId;
return {
sessionId,
ownerUserId: work.summary.ownerUserId,
status: work.summary.generationStatus,
draft: work.draft,
createdAt: work.summary.updatedAt,
updatedAt: work.summary.updatedAt,
};
}
export function buildWoodenFishGeneratingWorkSummary(
session: WoodenFishSessionSnapshotResponse,
payload?: WoodenFishWorkspaceCreateRequest | null,
): WoodenFishWorkSummaryResponse {
const updatedAt = session.updatedAt ?? session.createdAt;
return {
runtimeKind: 'wooden-fish',
workId: session.sessionId,
profileId: session.sessionId,
ownerUserId: session.ownerUserId,
sourceSessionId: session.sessionId,
workTitle: payload?.workTitle ?? session.draft?.workTitle ?? '敲木鱼',
workDescription:
payload?.workDescription ?? session.draft?.workDescription ?? '',
themeTags: payload?.themeTags ?? session.draft?.themeTags ?? ['敲木鱼'],
coverImageSrc: session.draft?.coverImageSrc ?? null,
publicationStatus: 'draft',
playCount: 0,
updatedAt,
publishedAt: null,
publishReady: false,
generationStatus: 'generating',
};
}
export function buildWoodenFishPendingSession(
item: WoodenFishWorkSummaryResponse,
): WoodenFishSessionSnapshotResponse {
const sessionId =
normalizeCreationUrlValue(item.sourceSessionId) ?? item.profileId;
return {
sessionId,
ownerUserId: item.ownerUserId,
status: item.generationStatus,
draft: {
templateId: 'wooden-fish',
templateName: '敲木鱼',
profileId: item.profileId,
workTitle: item.workTitle,
workDescription: item.workDescription,
themeTags: item.themeTags,
hitObjectPrompt: '',
hitObjectReferenceImageSrc: null,
hitSoundPrompt: null,
floatingWords: ['功德 +1'],
hitObjectAsset: null,
backgroundAsset: null,
backButtonAsset: null,
hitSoundAsset: null,
coverImageSrc: item.coverImageSrc,
generationStatus: item.generationStatus,
},
createdAt: item.updatedAt,
updatedAt: item.updatedAt,
};
}

View File

@@ -0,0 +1,205 @@
import { describe, expect, test } from 'vitest';
import type { ProfilePlayedWorkSummary } from '../../../packages/shared/src/contracts/runtime';
import { resolvePlatformPlayedWorkOpenIntent } from './platformPlayedWorkOpenModel';
function buildPlayedWork(
overrides: Partial<ProfilePlayedWorkSummary> = {},
): ProfilePlayedWorkSummary {
return {
worldKey: 'custom:world-1',
ownerUserId: 'user-1',
profileId: 'world-1',
worldType: 'CUSTOM',
worldTitle: '潮雾列岛',
worldSubtitle: '旧灯塔与失控航路',
firstPlayedAt: '2026-04-18T12:00:00.000Z',
lastPlayedAt: '2026-04-19T12:00:00.000Z',
lastObservedPlayTimeMs: 12_000,
...overrides,
};
}
describe('platformPlayedWorkOpenModel', () => {
test('opens puzzle played works with profile tab context', () => {
expect(
resolvePlatformPlayedWorkOpenIntent(
buildPlayedWork({
worldType: 'PUZZLE',
profileId: 'puzzle-profile-1',
}),
),
).toEqual({
type: 'open-puzzle',
profileId: 'puzzle-profile-1',
tab: 'profile',
});
});
test('falls back to worldKey prefixes when profile id is absent', () => {
const cases = [
['puzzle:profile-1', 'open-puzzle', 'profile-1'],
['match3d:profile-2', 'open-match3d', 'profile-2'],
['square-hole:profile-3', 'open-square-hole', 'profile-3'],
['jump-hop:profile-4', 'open-jump-hop', 'profile-4'],
['wooden-fish:profile-5', 'open-wooden-fish', 'profile-5'],
] as const;
for (const [worldKey, type, profileId] of cases) {
expect(
resolvePlatformPlayedWorkOpenIntent(
buildPlayedWork({
worldKey,
profileId: null,
worldType: null,
}),
),
).toMatchObject({ type, profileId });
}
});
test('keeps explicit profile id ahead of worldKey fallback', () => {
expect(
resolvePlatformPlayedWorkOpenIntent(
buildPlayedWork({
worldKey: 'jump-hop:key-profile',
profileId: 'explicit-profile',
worldType: null,
}),
),
).toMatchObject({
type: 'open-jump-hop',
profileId: 'explicit-profile',
});
});
test('supports played work type aliases for mini-games', () => {
const cases = [
['match_3d', 'open-match3d'],
['square_hole', 'open-square-hole'],
['jump_hop', 'open-jump-hop'],
['wooden_fish', 'open-wooden-fish'],
] as const;
for (const [worldType, type] of cases) {
expect(
resolvePlatformPlayedWorkOpenIntent(
buildPlayedWork({
worldType,
profileId: `${worldType}-profile`,
}),
),
).toMatchObject({
type,
profileId: `${worldType}-profile`,
});
}
});
test('returns noop when a mini-game target is empty', () => {
expect(
resolvePlatformPlayedWorkOpenIntent(
buildPlayedWork({
worldKey: 'puzzle:key-profile',
profileId: '',
worldType: 'puzzle',
}),
),
).toEqual({
type: 'noop',
reason: 'missing-target',
});
});
test('builds big fish intent and fallback work for gallery misses', () => {
expect(
resolvePlatformPlayedWorkOpenIntent(
buildPlayedWork({
worldKey: 'big-fish:big-fish-session-1',
ownerUserId: null,
profileId: null,
worldType: 'big_fish',
worldTitle: '机械深海',
worldSubtitle: '',
}),
),
).toEqual({
type: 'open-big-fish',
sessionId: 'big-fish-session-1',
fallbackWork: {
workId: 'big-fish:big-fish-session-1',
sourceSessionId: 'big-fish-session-1',
ownerUserId: '',
authorDisplayName: '玩家',
title: '机械深海',
subtitle: '',
summary: '',
coverImageSrc: null,
status: 'published',
updatedAt: '2026-04-19T12:00:00.000Z',
publishReady: true,
levelCount: 0,
levelMainImageReadyCount: 0,
levelMotionReadyCount: 0,
backgroundReady: false,
},
});
});
test('opens unknown played work types as RPG detail when identity is complete', () => {
expect(
resolvePlatformPlayedWorkOpenIntent(
buildPlayedWork({
worldType: 'CUSTOM',
profileId: null,
}),
),
).toEqual({
type: 'open-rpg',
detail: {
ownerUserId: 'user-1',
profileId: 'custom:world-1',
publicWorkCode: null,
authorPublicUserCode: null,
visibility: 'published',
publishedAt: '2026-04-18T12:00:00.000Z',
updatedAt: '2026-04-19T12:00:00.000Z',
authorDisplayName: '旧灯塔与失控航路',
worldName: '潮雾列岛',
subtitle: '旧灯塔与失控航路',
summaryText: '',
coverImageSrc: null,
themeMode: 'martial',
playableNpcCount: 0,
landmarkCount: 0,
playCount: 0,
remixCount: 0,
likeCount: 0,
},
});
});
test('returns noop for RPG fallback when owner or profile is missing', () => {
expect(
resolvePlatformPlayedWorkOpenIntent(
buildPlayedWork({
ownerUserId: null,
}),
),
).toEqual({
type: 'noop',
reason: 'missing-target',
});
expect(
resolvePlatformPlayedWorkOpenIntent(
buildPlayedWork({
worldKey: '',
profileId: null,
}),
),
).toEqual({
type: 'noop',
reason: 'missing-target',
});
});
});

View File

@@ -0,0 +1,212 @@
import type { BigFishWorkSummary } from '../../../packages/shared/src/contracts/bigFishWorkSummary';
import type {
CustomWorldGalleryCard,
ProfilePlayedWorkSummary,
} from '../../../packages/shared/src/contracts/runtime';
export type PlatformPlayedWorkOpenIntent =
| {
type: 'noop';
reason: 'missing-target';
}
| {
type: 'open-puzzle';
profileId: string;
tab: 'profile';
}
| {
type: 'open-match3d';
profileId: string;
}
| {
type: 'open-square-hole';
profileId: string;
}
| {
type: 'open-jump-hop';
profileId: string;
}
| {
type: 'open-wooden-fish';
profileId: string;
}
| {
type: 'open-big-fish';
sessionId: string;
fallbackWork: BigFishWorkSummary;
}
| {
type: 'open-rpg';
detail: CustomWorldGalleryCard;
};
function normalizePlayedWorkWorldType(worldType: string | null) {
return (worldType ?? '').toLowerCase();
}
function resolvePlayedWorkTargetId(
work: ProfilePlayedWorkSummary,
worldKeyPrefix: string,
) {
const prefixedWorldKey = `${worldKeyPrefix}:`;
return (
work.profileId ??
(work.worldKey.startsWith(prefixedWorldKey)
? work.worldKey.slice(prefixedWorldKey.length)
: work.worldKey)
);
}
function resolvePlayedWorkProfileIntent<TIntent extends PlatformPlayedWorkOpenIntent>(
profileId: string,
intent: (profileId: string) => TIntent,
) {
return profileId ? intent(profileId) : buildMissingPlayedWorkTargetIntent();
}
function buildMissingPlayedWorkTargetIntent(): PlatformPlayedWorkOpenIntent {
return {
type: 'noop',
reason: 'missing-target',
};
}
function buildPlayedBigFishFallbackWork(
work: ProfilePlayedWorkSummary,
sessionId: string,
): BigFishWorkSummary {
return {
workId: `big-fish:${sessionId}`,
sourceSessionId: sessionId,
ownerUserId: work.ownerUserId ?? '',
authorDisplayName: work.worldSubtitle || '玩家',
title: work.worldTitle,
subtitle: work.worldSubtitle,
summary: work.worldSubtitle,
coverImageSrc: null,
status: 'published',
updatedAt: work.lastPlayedAt,
publishReady: true,
levelCount: 0,
levelMainImageReadyCount: 0,
levelMotionReadyCount: 0,
backgroundReady: false,
};
}
function buildPlayedRpgDetail(
work: ProfilePlayedWorkSummary,
profileId: string,
ownerUserId: string,
): CustomWorldGalleryCard {
return {
ownerUserId,
profileId,
publicWorkCode: null,
authorPublicUserCode: null,
visibility: 'published',
publishedAt: work.firstPlayedAt,
updatedAt: work.lastPlayedAt,
authorDisplayName: work.worldSubtitle,
worldName: work.worldTitle,
subtitle: work.worldSubtitle,
summaryText: '',
coverImageSrc: null,
themeMode: 'martial',
playableNpcCount: 0,
landmarkCount: 0,
playCount: 0,
remixCount: 0,
likeCount: 0,
};
}
/** 收口个人“玩过作品”点击后的玩法打开意图,壳层只执行副作用。 */
export function resolvePlatformPlayedWorkOpenIntent(
work: ProfilePlayedWorkSummary,
): PlatformPlayedWorkOpenIntent {
const worldType = normalizePlayedWorkWorldType(work.worldType);
if (worldType === 'puzzle' || work.worldKey.startsWith('puzzle:')) {
const profileId = resolvePlayedWorkTargetId(work, 'puzzle');
return resolvePlayedWorkProfileIntent(profileId, (resolvedProfileId) => ({
type: 'open-puzzle',
profileId: resolvedProfileId,
tab: 'profile',
}));
}
if (
worldType === 'match3d' ||
worldType === 'match_3d' ||
work.worldKey.startsWith('match3d:')
) {
const profileId = resolvePlayedWorkTargetId(work, 'match3d');
return resolvePlayedWorkProfileIntent(profileId, (resolvedProfileId) => ({
type: 'open-match3d',
profileId: resolvedProfileId,
}));
}
if (
worldType === 'square-hole' ||
worldType === 'square_hole' ||
work.worldKey.startsWith('square-hole:')
) {
const profileId = resolvePlayedWorkTargetId(work, 'square-hole');
return resolvePlayedWorkProfileIntent(profileId, (resolvedProfileId) => ({
type: 'open-square-hole',
profileId: resolvedProfileId,
}));
}
if (
worldType === 'jump-hop' ||
worldType === 'jump_hop' ||
work.worldKey.startsWith('jump-hop:')
) {
const profileId = resolvePlayedWorkTargetId(work, 'jump-hop');
return resolvePlayedWorkProfileIntent(profileId, (resolvedProfileId) => ({
type: 'open-jump-hop',
profileId: resolvedProfileId,
}));
}
if (
worldType === 'wooden-fish' ||
worldType === 'wooden_fish' ||
work.worldKey.startsWith('wooden-fish:')
) {
const profileId = resolvePlayedWorkTargetId(work, 'wooden-fish');
return resolvePlayedWorkProfileIntent(profileId, (resolvedProfileId) => ({
type: 'open-wooden-fish',
profileId: resolvedProfileId,
}));
}
if (
worldType === 'big_fish' ||
worldType === 'big-fish' ||
work.worldKey.startsWith('big-fish:')
) {
const sessionId = resolvePlayedWorkTargetId(work, 'big-fish');
return sessionId
? {
type: 'open-big-fish',
sessionId,
fallbackWork: buildPlayedBigFishFallbackWork(work, sessionId),
}
: buildMissingPlayedWorkTargetIntent();
}
const profileId = work.profileId ?? work.worldKey;
const ownerUserId = work.ownerUserId;
if (!ownerUserId || !profileId) {
return buildMissingPlayedWorkTargetIntent();
}
return {
type: 'open-rpg',
detail: buildPlayedRpgDetail(work, profileId, ownerUserId),
};
}

View File

@@ -0,0 +1,117 @@
import { afterEach, beforeEach, describe, expect, test, vi } from 'vitest';
import type { ProfileDashboardSummary } from '../../../packages/shared/src/contracts/runtime';
import {
adjustProfileDashboardWalletBalance,
reconcileProfileWalletLocalDeltaWithServerDashboard,
resolveProfileWalletBalance,
} from './platformProfileWalletDeltaModel';
const NOW = Date.parse('2026-06-04T04:30:00.000Z');
function buildDashboard(
overrides: Partial<ProfileDashboardSummary> = {},
): ProfileDashboardSummary {
return {
walletBalance: 100,
totalPlayTimeMs: 0,
playedWorldCount: 0,
updatedAt: '2026-06-01T00:00:00.000Z',
...overrides,
};
}
beforeEach(() => {
vi.useFakeTimers();
vi.setSystemTime(NOW);
});
afterEach(() => {
vi.useRealTimers();
});
describe('platformProfileWalletDeltaModel', () => {
test('normalizes wallet balance to a non-negative integer', () => {
expect(resolveProfileWalletBalance(buildDashboard({ walletBalance: 12.8 }))).toBe(
12,
);
expect(
resolveProfileWalletBalance(buildDashboard({ walletBalance: -4 })),
).toBe(0);
expect(resolveProfileWalletBalance({ walletBalance: Number.NaN })).toBe(0);
expect(resolveProfileWalletBalance(null)).toBe(0);
});
test('applies local delta and refreshes dashboard timestamp', () => {
expect(
adjustProfileDashboardWalletBalance(buildDashboard(), -3.8),
).toMatchObject({
walletBalance: 97,
updatedAt: '2026-06-04T04:30:00.000Z',
});
expect(
adjustProfileDashboardWalletBalance(buildDashboard({ walletBalance: 2 }), -10),
).toMatchObject({
walletBalance: 0,
});
expect(adjustProfileDashboardWalletBalance(null, 5)).toBeNull();
const dashboard = buildDashboard();
expect(adjustProfileDashboardWalletBalance(dashboard, Number.POSITIVE_INFINITY)).toBe(
dashboard,
);
});
test('reconciles debit delta already reflected by latest server dashboard', () => {
const previous = buildDashboard({ walletBalance: 100 });
expect(
reconcileProfileWalletLocalDeltaWithServerDashboard(
previous,
buildDashboard({ walletBalance: 98 }),
-5,
),
).toBe(-3);
expect(
reconcileProfileWalletLocalDeltaWithServerDashboard(
previous,
buildDashboard({ walletBalance: 92 }),
-5,
),
).toBe(0);
});
test('reconciles credit delta already reflected by latest server dashboard', () => {
const previous = buildDashboard({ walletBalance: 100 });
expect(
reconcileProfileWalletLocalDeltaWithServerDashboard(
previous,
buildDashboard({ walletBalance: 103 }),
8,
),
).toBe(5);
expect(
reconcileProfileWalletLocalDeltaWithServerDashboard(
previous,
buildDashboard({ walletBalance: 120 }),
8,
),
).toBe(0);
});
test('does not reconcile when server balance moves against local delta', () => {
const previous = buildDashboard({ walletBalance: 100 });
expect(
reconcileProfileWalletLocalDeltaWithServerDashboard(
previous,
buildDashboard({ walletBalance: 104 }),
-5,
),
).toBe(-5);
expect(
reconcileProfileWalletLocalDeltaWithServerDashboard(
previous,
buildDashboard({ walletBalance: 96 }),
8,
),
).toBe(8);
});
});

View File

@@ -0,0 +1,61 @@
import type { ProfileDashboardSummary } from '../../../packages/shared/src/contracts/runtime';
type ProfileWalletBalanceSource =
| Pick<ProfileDashboardSummary, 'walletBalance'>
| { walletBalance?: number | null }
| null
| undefined;
export function resolveProfileWalletBalance(
dashboard: ProfileWalletBalanceSource,
) {
const walletBalance = dashboard?.walletBalance;
return typeof walletBalance === 'number' && Number.isFinite(walletBalance)
? Math.max(0, Math.floor(walletBalance))
: 0;
}
export function adjustProfileDashboardWalletBalance(
dashboard: ProfileDashboardSummary | null,
delta: number,
): ProfileDashboardSummary | null {
if (!dashboard || !Number.isFinite(delta) || delta === 0) {
return dashboard;
}
return {
...dashboard,
walletBalance: Math.max(
0,
resolveProfileWalletBalance(dashboard) + Math.trunc(delta),
),
updatedAt: new Date().toISOString(),
};
}
export function reconcileProfileWalletLocalDeltaWithServerDashboard(
previousDashboard: ProfileDashboardSummary | null,
latestDashboard: ProfileDashboardSummary | null,
localDelta: number,
) {
if (
!previousDashboard ||
!latestDashboard ||
!Number.isFinite(localDelta) ||
localDelta === 0
) {
return Number.isFinite(localDelta) ? Math.trunc(localDelta) : 0;
}
const previousBalance = resolveProfileWalletBalance(previousDashboard);
const latestBalance = resolveProfileWalletBalance(latestDashboard);
const normalizedDelta = Math.trunc(localDelta);
if (normalizedDelta < 0) {
const reflectedDebit = Math.max(0, previousBalance - latestBalance);
return Math.min(0, normalizedDelta + reflectedDebit);
}
const reflectedCredit = Math.max(0, latestBalance - previousBalance);
return Math.max(0, normalizedDelta - reflectedCredit);
}

View File

@@ -0,0 +1,482 @@
import { describe, expect, test } from 'vitest';
import type { BarkBattleWorkSummary } from '../../../packages/shared/src/contracts/barkBattle';
import type { BigFishWorkSummary } from '../../../packages/shared/src/contracts/bigFishWorkSummary';
import type { BabyObjectMatchDraft } from '../../../packages/shared/src/contracts/edutainmentBabyObject';
import type { JumpHopGalleryCardResponse } from '../../../packages/shared/src/contracts/jumpHop';
import type { Match3DWorkSummary } from '../../../packages/shared/src/contracts/match3dWorks';
import type { PuzzleWorkSummary } from '../../../packages/shared/src/contracts/puzzleWorkSummary';
import type { CustomWorldLibraryEntry } from '../../../packages/shared/src/contracts/runtime';
import type { SquareHoleWorkSummary } from '../../../packages/shared/src/contracts/squareHoleWorks';
import type { VisualNovelWorkSummary } from '../../../packages/shared/src/contracts/visualNovel';
import type { WoodenFishGalleryCardResponse } from '../../../packages/shared/src/contracts/woodenFish';
import {
buildBarkBattlePublicWorkCode,
buildBigFishPublicWorkCode,
buildJumpHopPublicWorkCode,
buildMatch3DPublicWorkCode,
buildPuzzlePublicWorkCode,
buildSquareHolePublicWorkCode,
buildVisualNovelPublicWorkCode,
buildWoodenFishPublicWorkCode,
} from '../../services/publicWorkCode';
import type { CustomWorldProfile } from '../../types';
import {
mapRpgPublicCodeSearchDetailToGalleryCard,
type PlatformPublicCodeSearchStep,
resolveBabyObjectMatchPublicCodeSearchMatch,
resolveBarkBattlePublicCodeSearchMatch,
resolveBigFishPublicCodeSearchMatch,
resolveJumpHopPublicCodeSearchMatch,
resolveMatch3DPublicCodeSearchMatch,
resolvePlatformPublicCodeSearchPlan,
resolvePuzzlePublicCodeSearchMatch,
resolveSquareHolePublicCodeSearchMatch,
resolveVisualNovelPublicCodeSearchMatch,
resolveWoodenFishPublicCodeSearchMatch,
} from './platformPublicCodeSearchModel';
function expectSearchSteps(
keyword: string,
steps: readonly PlatformPublicCodeSearchStep[],
) {
expect(resolvePlatformPublicCodeSearchPlan(keyword)?.steps).toEqual(steps);
}
describe('platformPublicCodeSearchModel', () => {
test('ignores empty public code search input', () => {
expect(resolvePlatformPublicCodeSearchPlan(' ')).toBeNull();
});
test('normalizes public code search keyword before planning', () => {
expect(resolvePlatformPublicCodeSearchPlan(' PZ-00000001 ')).toEqual({
normalizedKeyword: 'PZ-00000001',
steps: ['puzzle-work'],
});
});
test('searches internal user ids directly without work fallback', () => {
expectSearchSteps('user_00000001', ['user-id']);
expectSearchSteps('USER-profile-1', ['user-id']);
});
test('routes known public work prefixes to their play-specific lookup', () => {
const cases: Array<
[keyword: string, step: PlatformPublicCodeSearchStep]
> = [
['PZ-EPUBLIC1', 'puzzle-work'],
['BF-NPUBLIC1', 'big-fish-work'],
['JH-EPUBLIC1', 'jump-hop-work'],
['WF-EPUBLIC1', 'wooden-fish-work'],
['BO-EPUBLIC1', 'baby-object-match-work'],
['M3-EPUBLIC1', 'match3d-work'],
['M3D-LEGACY1', 'match3d-work'],
['SH-EPUBLIC1', 'square-hole-work'],
['VN-EPUBLIC1', 'visual-novel-work'],
['BB-EPUBLIC1', 'bark-battle-work'],
];
for (const [keyword, step] of cases) {
expectSearchSteps(keyword, [step]);
}
});
test('searches RPG public works before public user codes for CW and numeric codes', () => {
expectSearchSteps('CW-00000001', ['rpg-work', 'public-user-code']);
expectSearchSteps('12345678', ['rpg-work', 'public-user-code']);
});
test('keeps legacy user-code-first fallback for SY and ordinary keywords', () => {
const legacyFallbackSteps = [
'public-user-code',
'rpg-work',
'bark-battle-work',
'public-user-code',
] as const;
expectSearchSteps('SY-00000001', legacyFallbackSteps);
expectSearchSteps('月井守望', legacyFallbackSteps);
});
test('maps RPG detail responses to gallery cards with count defaults', () => {
expect(
mapRpgPublicCodeSearchDetailToGalleryCard(
buildRpgDetailEntry({
playCount: undefined,
remixCount: undefined,
likeCount: undefined,
}),
),
).toMatchObject({
profileId: 'rpg-profile-1',
visibility: 'published',
worldName: '潮雾世界',
playCount: 0,
remixCount: 0,
likeCount: 0,
});
});
test('resolves public code matches for every play-specific gallery type', () => {
const puzzle = buildPuzzleWork({ profileId: 'puzzle-profile-12345678' });
const bigFish = buildBigFishWork({
sourceSessionId: 'big-fish-session-12345678',
});
const jumpHop = buildJumpHopCard({ profileId: 'jump-hop-profile-12345678' });
const woodenFish = buildWoodenFishCard({
profileId: 'wooden-fish-profile-12345678',
});
const babyObjectMatch = buildBabyObjectMatchDraft({
profileId: 'baby-object-profile-12345678',
});
const match3d = buildMatch3DWork({ profileId: 'match3d-profile-12345678' });
const squareHole = buildSquareHoleWork({
profileId: 'square-hole-profile-12345678',
});
const visualNovel = buildVisualNovelWork({
profileId: 'visual-novel-profile-12345678',
});
const barkBattle = buildBarkBattleWork({
workId: 'bark-battle-work-12345678',
});
expect(
resolvePuzzlePublicCodeSearchMatch(
[puzzle],
buildPuzzlePublicWorkCode(puzzle.profileId),
)?.detail,
).toMatchObject({ sourceType: 'puzzle' });
expect(
resolveBigFishPublicCodeSearchMatch(
[bigFish],
buildBigFishPublicWorkCode(bigFish.sourceSessionId),
)?.detail,
).toMatchObject({ sourceType: 'big-fish' });
expect(
resolveJumpHopPublicCodeSearchMatch(
[jumpHop],
buildJumpHopPublicWorkCode(jumpHop.profileId),
)?.detail,
).toMatchObject({ sourceType: 'jump-hop' });
expect(
resolveWoodenFishPublicCodeSearchMatch(
[woodenFish],
buildWoodenFishPublicWorkCode(woodenFish.profileId),
)?.detail,
).toMatchObject({ sourceType: 'wooden-fish' });
expect(
resolveBabyObjectMatchPublicCodeSearchMatch(
[babyObjectMatch],
`BO-${babyObjectMatch.profileId.slice(-8)}`,
)?.detail,
).toMatchObject({ sourceType: 'edutainment' });
expect(
resolveMatch3DPublicCodeSearchMatch(
[match3d],
buildMatch3DPublicWorkCode(match3d.profileId),
)?.detail,
).toMatchObject({ sourceType: 'match3d' });
expect(
resolveSquareHolePublicCodeSearchMatch(
[squareHole],
buildSquareHolePublicWorkCode(squareHole.profileId),
)?.detail,
).toMatchObject({ sourceType: 'square-hole' });
expect(
resolveVisualNovelPublicCodeSearchMatch(
[visualNovel],
buildVisualNovelPublicWorkCode(visualNovel.profileId),
)?.detail,
).toMatchObject({ sourceType: 'visual-novel' });
expect(
resolveBarkBattlePublicCodeSearchMatch(
[barkBattle],
buildBarkBattlePublicWorkCode(barkBattle.workId),
)?.detail,
).toMatchObject({ sourceType: 'bark-battle' });
});
test('public code search matchers skip entries hidden by visibility policy', () => {
const hiddenPuzzle = buildPuzzleWork({
profileId: 'hidden-profile-12345678',
});
expect(
resolvePuzzlePublicCodeSearchMatch(
[hiddenPuzzle],
buildPuzzlePublicWorkCode(hiddenPuzzle.profileId),
() => false,
),
).toBeNull();
});
});
function buildRpgDetailEntry(
overrides: Partial<CustomWorldLibraryEntry<CustomWorldProfile>> = {},
): CustomWorldLibraryEntry<CustomWorldProfile> {
return {
ownerUserId: 'rpg-owner-1',
profileId: 'rpg-profile-1',
publicWorkCode: 'CW-00000001',
authorPublicUserCode: 'SY-00000001',
profile: {} as CustomWorldProfile,
visibility: 'published',
publishedAt: '2026-06-04T00:00:00.000Z',
updatedAt: '2026-06-04T00:00:00.000Z',
authorDisplayName: '测试作者',
worldName: '潮雾世界',
subtitle: '潮雾港',
summaryText: '潮雾世界说明。',
coverImageSrc: null,
themeMode: 'tide',
playableNpcCount: 1,
landmarkCount: 1,
playCount: 1,
remixCount: 1,
likeCount: 1,
...overrides,
};
}
function buildPuzzleWork(
overrides: Partial<PuzzleWorkSummary> = {},
): PuzzleWorkSummary {
return {
workId: 'puzzle-work-1',
profileId: 'puzzle-profile-1',
ownerUserId: 'user-1',
sourceSessionId: 'puzzle-session-1',
authorDisplayName: '测试作者',
workTitle: '潮雾拼图',
workDescription: '潮雾拼图说明。',
levelName: '潮雾拼图',
summary: '潮雾拼图说明。',
themeTags: [],
coverImageSrc: null,
coverAssetId: null,
publicationStatus: 'published',
updatedAt: '2026-06-04T00:00:00.000Z',
publishedAt: '2026-06-04T00:00:00.000Z',
playCount: 0,
remixCount: 0,
likeCount: 0,
publishReady: true,
levels: [],
...overrides,
};
}
function buildBigFishWork(
overrides: Partial<BigFishWorkSummary> = {},
): BigFishWorkSummary {
return {
workId: 'big-fish-work-1',
sourceSessionId: 'big-fish-session-1',
ownerUserId: 'user-1',
authorDisplayName: '测试作者',
title: '潮雾大鱼',
subtitle: '潮雾港',
summary: '潮雾大鱼说明。',
coverImageSrc: null,
status: 'published',
updatedAt: '2026-06-04T00:00:00.000Z',
publishedAt: '2026-06-04T00:00:00.000Z',
playCount: 0,
remixCount: 0,
likeCount: 0,
publishReady: true,
levelCount: 1,
levelMainImageReadyCount: 1,
levelMotionReadyCount: 1,
backgroundReady: true,
...overrides,
};
}
function buildJumpHopCard(
overrides: Partial<JumpHopGalleryCardResponse> = {},
): JumpHopGalleryCardResponse {
const profileId = overrides.profileId ?? 'jump-hop-profile-1';
return {
publicWorkCode: buildJumpHopPublicWorkCode(profileId),
workId: 'jump-hop-work-1',
profileId,
ownerUserId: 'user-1',
authorDisplayName: '测试作者',
workTitle: '潮雾跳一跳',
workDescription: '潮雾跳一跳说明。',
coverImageSrc: null,
themeTags: [],
difficulty: 'standard',
stylePreset: 'minimal-blocks',
publicationStatus: 'published',
playCount: 0,
updatedAt: '2026-06-04T00:00:00.000Z',
publishedAt: '2026-06-04T00:00:00.000Z',
generationStatus: 'ready',
...overrides,
};
}
function buildWoodenFishCard(
overrides: Partial<WoodenFishGalleryCardResponse> = {},
): WoodenFishGalleryCardResponse {
const profileId = overrides.profileId ?? 'wooden-fish-profile-1';
return {
publicWorkCode: buildWoodenFishPublicWorkCode(profileId),
workId: 'wooden-fish-work-1',
profileId,
ownerUserId: 'user-1',
authorDisplayName: '测试作者',
workTitle: '潮雾木鱼',
workDescription: '潮雾木鱼说明。',
coverImageSrc: null,
themeTags: ['敲木鱼'],
publicationStatus: 'published',
playCount: 0,
updatedAt: '2026-06-04T00:00:00.000Z',
publishedAt: '2026-06-04T00:00:00.000Z',
generationStatus: 'ready',
...overrides,
};
}
function buildBabyObjectMatchDraft(
overrides: Partial<BabyObjectMatchDraft> = {},
): BabyObjectMatchDraft {
return {
draftId: 'baby-draft-1',
profileId: 'baby-object-profile-1',
templateId: 'baby-object-match',
templateName: '宝贝识物',
workTitle: '潮雾识物',
workDescription: '潮雾识物说明。',
itemNames: ['苹果', '香蕉'],
itemAssets: [
buildBabyObjectMatchItemAsset('item-a', '苹果'),
buildBabyObjectMatchItemAsset('item-b', '香蕉'),
],
visualPackage: null,
themeTags: ['寓教于乐'],
publicationStatus: 'published',
createdAt: '2026-06-04T00:00:00.000Z',
updatedAt: '2026-06-04T00:00:00.000Z',
publishedAt: '2026-06-04T00:00:00.000Z',
...overrides,
};
}
function buildBabyObjectMatchItemAsset(itemId: string, itemName: string) {
return {
itemId,
itemName,
imageSrc: `/media/${itemId}.png`,
assetObjectId: null,
generationProvider: 'placeholder' as const,
prompt: itemName,
};
}
function buildMatch3DWork(
overrides: Partial<Match3DWorkSummary> = {},
): Match3DWorkSummary {
return {
workId: 'match3d-work-1',
profileId: 'match3d-profile-1',
ownerUserId: 'user-1',
sourceSessionId: 'match3d-session-1',
gameName: '潮雾抓大鹅',
themeText: '潮雾港',
summary: '潮雾抓大鹅说明。',
tags: [],
coverImageSrc: null,
referenceImageSrc: null,
clearCount: 0,
difficulty: 1,
publicationStatus: 'published',
playCount: 0,
updatedAt: '2026-06-04T00:00:00.000Z',
publishedAt: '2026-06-04T00:00:00.000Z',
publishReady: true,
generationStatus: 'ready',
generatedItemAssets: [],
...overrides,
};
}
function buildSquareHoleWork(
overrides: Partial<SquareHoleWorkSummary> = {},
): SquareHoleWorkSummary {
return {
workId: 'square-hole-work-1',
profileId: 'square-hole-profile-1',
ownerUserId: 'user-1',
sourceSessionId: 'square-hole-session-1',
gameName: '潮雾方洞',
themeText: '潮雾港',
twistRule: '避开雾门',
summary: '潮雾方洞说明。',
tags: [],
coverImageSrc: null,
backgroundPrompt: '潮雾港',
backgroundImageSrc: null,
shapeOptions: [],
holeOptions: [],
shapeCount: 1,
difficulty: 1,
publicationStatus: 'published',
playCount: 0,
updatedAt: '2026-06-04T00:00:00.000Z',
publishedAt: '2026-06-04T00:00:00.000Z',
publishReady: true,
...overrides,
};
}
function buildVisualNovelWork(
overrides: Partial<VisualNovelWorkSummary> = {},
): VisualNovelWorkSummary {
return {
runtimeKind: 'visual-novel',
profileId: 'visual-novel-profile-1',
ownerUserId: 'user-1',
title: '潮雾视觉小说',
description: '潮雾视觉小说说明。',
coverImageSrc: null,
tags: [],
publishStatus: 'published',
publishReady: true,
playCount: 0,
updatedAt: '2026-06-04T00:00:00.000Z',
publishedAt: '2026-06-04T00:00:00.000Z',
...overrides,
};
}
function buildBarkBattleWork(
overrides: Partial<BarkBattleWorkSummary> = {},
): BarkBattleWorkSummary {
return {
workId: 'bark-battle-work-1',
draftId: 'bark-battle-draft-1',
ownerUserId: 'user-1',
authorDisplayName: '测试作者',
title: '潮雾声浪',
summary: '潮雾声浪说明。',
themeDescription: '潮雾港',
playerImageDescription: '小狗',
opponentImageDescription: '对手',
onomatopoeia: ['汪'],
playerCharacterImageSrc: null,
opponentCharacterImageSrc: null,
uiBackgroundImageSrc: null,
difficultyPreset: 'normal',
status: 'published',
generationStatus: 'ready',
publishReady: true,
playCount: 0,
updatedAt: '2026-06-04T00:00:00.000Z',
publishedAt: '2026-06-04T00:00:00.000Z',
...overrides,
};
}

View File

@@ -0,0 +1,313 @@
import type { BarkBattleWorkSummary } from '../../../packages/shared/src/contracts/barkBattle';
import type { BigFishWorkSummary } from '../../../packages/shared/src/contracts/bigFishWorkSummary';
import type { BabyObjectMatchDraft } from '../../../packages/shared/src/contracts/edutainmentBabyObject';
import type { JumpHopGalleryCardResponse } from '../../../packages/shared/src/contracts/jumpHop';
import type { Match3DWorkSummary } from '../../../packages/shared/src/contracts/match3dWorks';
import type { PuzzleWorkSummary } from '../../../packages/shared/src/contracts/puzzleWorkSummary';
import type {
CustomWorldGalleryCard,
CustomWorldLibraryEntry,
} from '../../../packages/shared/src/contracts/runtime';
import type { SquareHoleWorkSummary } from '../../../packages/shared/src/contracts/squareHoleWorks';
import type { VisualNovelWorkSummary } from '../../../packages/shared/src/contracts/visualNovel';
import type { WoodenFishGalleryCardResponse } from '../../../packages/shared/src/contracts/woodenFish';
import {
isSameBabyObjectMatchPublicWorkCode,
isSameBarkBattlePublicWorkCode,
isSameBigFishPublicWorkCode,
isSameJumpHopPublicWorkCode,
isSameMatch3DPublicWorkCode,
isSamePuzzlePublicWorkCode,
isSameSquareHolePublicWorkCode,
isSameVisualNovelPublicWorkCode,
isSameWoodenFishPublicWorkCode,
} from '../../services/publicWorkCode';
import type { CustomWorldProfile } from '../../types';
import type { PlatformPublicGalleryCard } from '../rpg-entry/rpgEntryWorldPresentation';
import { mapBabyObjectMatchDraftToPlatformGalleryCard } from '../rpg-entry/rpgEntryWorldPresentation';
import { canExposePublicWork } from './platformEdutainmentVisibility';
import { mapMatch3DWorkToPublicWorkDetail } from './platformMatch3DRuntimeProfile';
import {
mapBarkBattleWorkToPublicWorkDetail,
mapBigFishWorkToPublicWorkDetail,
mapJumpHopWorkToPublicWorkDetail,
mapPuzzleWorkToPublicWorkDetail,
mapSquareHoleWorkToPublicWorkDetail,
mapVisualNovelWorkToPublicWorkDetail,
mapWoodenFishWorkToPublicWorkDetail,
} from './platformPublicWorkDetailFlow';
export type PlatformPublicCodeSearchStep =
| 'user-id'
| 'public-user-code'
| 'rpg-work'
| 'puzzle-work'
| 'big-fish-work'
| 'jump-hop-work'
| 'wooden-fish-work'
| 'baby-object-match-work'
| 'match3d-work'
| 'square-hole-work'
| 'visual-novel-work'
| 'bark-battle-work';
export type PlatformPublicCodeSearchPlan = {
normalizedKeyword: string;
steps: readonly PlatformPublicCodeSearchStep[];
};
export type PlatformPublicCodeSearchMatch<TItem> = {
item: TItem;
detail: PlatformPublicGalleryCard;
};
type PlatformPublicCodeSearchMatcherInput<TItem> = {
keyword: string;
entries: readonly TItem[];
mapEntry: (item: TItem) => PlatformPublicGalleryCard;
matchesEntry: (keyword: string, item: TItem) => boolean;
canExposeEntry?: (entry: PlatformPublicGalleryCard) => boolean;
};
const PLATFORM_PUBLIC_USER_ID_PATTERN = /^user[_-][a-z0-9_-]+$/iu;
const PLATFORM_RPG_WORK_NUMERIC_CODE_PATTERN = /^\d{1,8}$/u;
const DIRECT_WORK_PREFIX_STEPS: ReadonlyArray<
readonly [prefix: string, step: PlatformPublicCodeSearchStep]
> = [
['PZ', 'puzzle-work'],
['BF', 'big-fish-work'],
['JH', 'jump-hop-work'],
['WF', 'wooden-fish-work'],
['BO', 'baby-object-match-work'],
['M3', 'match3d-work'],
['SH', 'square-hole-work'],
['VN', 'visual-novel-work'],
['BB', 'bark-battle-work'],
];
/** 收口公开码搜索顺序,壳层只按步骤执行网络读取与打开副作用。 */
export function resolvePlatformPublicCodeSearchPlan(
keyword: string,
): PlatformPublicCodeSearchPlan | null {
const normalizedKeyword = keyword.trim();
if (!normalizedKeyword) {
return null;
}
if (PLATFORM_PUBLIC_USER_ID_PATTERN.test(normalizedKeyword)) {
return {
normalizedKeyword,
steps: ['user-id'],
};
}
const upperKeyword = normalizedKeyword.toUpperCase();
const directWorkStep = DIRECT_WORK_PREFIX_STEPS.find(([prefix]) =>
upperKeyword.startsWith(prefix),
)?.[1];
if (directWorkStep) {
return {
normalizedKeyword,
steps: [directWorkStep],
};
}
if (
upperKeyword.startsWith('CW') ||
PLATFORM_RPG_WORK_NUMERIC_CODE_PATTERN.test(normalizedKeyword)
) {
return {
normalizedKeyword,
steps: ['rpg-work', 'public-user-code'],
};
}
return {
normalizedKeyword,
steps: [
'public-user-code',
'rpg-work',
'bark-battle-work',
'public-user-code',
],
};
}
export function mapRpgPublicCodeSearchDetailToGalleryCard(
entry: CustomWorldLibraryEntry<CustomWorldProfile>,
): CustomWorldGalleryCard {
return {
ownerUserId: entry.ownerUserId,
profileId: entry.profileId,
publicWorkCode: entry.publicWorkCode,
authorPublicUserCode: entry.authorPublicUserCode,
visibility: 'published',
publishedAt: entry.publishedAt,
updatedAt: entry.updatedAt,
authorDisplayName: entry.authorDisplayName,
worldName: entry.worldName,
subtitle: entry.subtitle,
summaryText: entry.summaryText,
coverImageSrc: entry.coverImageSrc,
themeMode: entry.themeMode,
playableNpcCount: entry.playableNpcCount,
landmarkCount: entry.landmarkCount,
playCount: entry.playCount ?? 0,
remixCount: entry.remixCount ?? 0,
likeCount: entry.likeCount ?? 0,
};
}
export function resolvePuzzlePublicCodeSearchMatch(
entries: readonly PuzzleWorkSummary[],
keyword: string,
canExposeEntry = canExposePublicWork,
) {
return resolveMappedPublicCodeSearchMatch({
keyword,
entries,
mapEntry: mapPuzzleWorkToPublicWorkDetail,
matchesEntry: (searchKeyword, item) =>
isSamePuzzlePublicWorkCode(searchKeyword, item.profileId),
canExposeEntry,
});
}
export function resolveBigFishPublicCodeSearchMatch(
entries: readonly BigFishWorkSummary[],
keyword: string,
canExposeEntry = canExposePublicWork,
) {
return resolveMappedPublicCodeSearchMatch({
keyword,
entries,
mapEntry: mapBigFishWorkToPublicWorkDetail,
matchesEntry: (searchKeyword, item) =>
isSameBigFishPublicWorkCode(searchKeyword, item.sourceSessionId),
canExposeEntry,
});
}
export function resolveJumpHopPublicCodeSearchMatch(
entries: readonly JumpHopGalleryCardResponse[],
keyword: string,
canExposeEntry = canExposePublicWork,
) {
return resolveMappedPublicCodeSearchMatch({
keyword,
entries,
mapEntry: mapJumpHopWorkToPublicWorkDetail,
matchesEntry: (searchKeyword, item) =>
isSameJumpHopPublicWorkCode(searchKeyword, item.profileId),
canExposeEntry,
});
}
export function resolveWoodenFishPublicCodeSearchMatch(
entries: readonly WoodenFishGalleryCardResponse[],
keyword: string,
canExposeEntry = canExposePublicWork,
) {
return resolveMappedPublicCodeSearchMatch({
keyword,
entries,
mapEntry: mapWoodenFishWorkToPublicWorkDetail,
matchesEntry: (searchKeyword, item) =>
isSameWoodenFishPublicWorkCode(searchKeyword, item.profileId),
canExposeEntry,
});
}
export function resolveBabyObjectMatchPublicCodeSearchMatch(
entries: readonly BabyObjectMatchDraft[],
keyword: string,
canExposeEntry = canExposePublicWork,
) {
return resolveMappedPublicCodeSearchMatch({
keyword,
entries,
mapEntry: mapBabyObjectMatchDraftToPlatformGalleryCard,
matchesEntry: (searchKeyword, item) =>
isSameBabyObjectMatchPublicWorkCode(searchKeyword, item.profileId),
canExposeEntry,
});
}
export function resolveMatch3DPublicCodeSearchMatch(
entries: readonly Match3DWorkSummary[],
keyword: string,
canExposeEntry = canExposePublicWork,
) {
return resolveMappedPublicCodeSearchMatch({
keyword,
entries,
mapEntry: mapMatch3DWorkToPublicWorkDetail,
matchesEntry: (searchKeyword, item) =>
isSameMatch3DPublicWorkCode(searchKeyword, item.profileId),
canExposeEntry,
});
}
export function resolveSquareHolePublicCodeSearchMatch(
entries: readonly SquareHoleWorkSummary[],
keyword: string,
canExposeEntry = canExposePublicWork,
) {
return resolveMappedPublicCodeSearchMatch({
keyword,
entries,
mapEntry: mapSquareHoleWorkToPublicWorkDetail,
matchesEntry: (searchKeyword, item) =>
isSameSquareHolePublicWorkCode(searchKeyword, item.profileId),
canExposeEntry,
});
}
export function resolveVisualNovelPublicCodeSearchMatch(
entries: readonly VisualNovelWorkSummary[],
keyword: string,
canExposeEntry = canExposePublicWork,
) {
return resolveMappedPublicCodeSearchMatch({
keyword,
entries,
mapEntry: mapVisualNovelWorkToPublicWorkDetail,
matchesEntry: (searchKeyword, item) =>
isSameVisualNovelPublicWorkCode(searchKeyword, item.profileId),
canExposeEntry,
});
}
export function resolveBarkBattlePublicCodeSearchMatch(
entries: readonly BarkBattleWorkSummary[],
keyword: string,
canExposeEntry = canExposePublicWork,
) {
return resolveMappedPublicCodeSearchMatch({
keyword,
entries,
mapEntry: mapBarkBattleWorkToPublicWorkDetail,
matchesEntry: (searchKeyword, item) =>
isSameBarkBattlePublicWorkCode(searchKeyword, item.workId),
canExposeEntry,
});
}
function resolveMappedPublicCodeSearchMatch<TItem>({
keyword,
entries,
mapEntry,
matchesEntry,
canExposeEntry = canExposePublicWork,
}: PlatformPublicCodeSearchMatcherInput<TItem>):
| PlatformPublicCodeSearchMatch<TItem>
| null {
for (const item of entries) {
const detail = mapEntry(item);
if (canExposeEntry(detail) && matchesEntry(keyword, item)) {
return { item, detail };
}
}
return null;
}

View File

@@ -0,0 +1,883 @@
import { expect, test } from 'vitest';
import type { BarkBattleWorkSummary } from '../../../packages/shared/src/contracts/barkBattle';
import type { BigFishWorkSummary } from '../../../packages/shared/src/contracts/bigFishWorkSummary';
import type { BabyObjectMatchDraft } from '../../../packages/shared/src/contracts/edutainmentBabyObject';
import type { JumpHopGalleryCardResponse } from '../../../packages/shared/src/contracts/jumpHop';
import type { Match3DWorkSummary } from '../../../packages/shared/src/contracts/match3dWorks';
import type { PuzzleWorkSummary } from '../../../packages/shared/src/contracts/puzzleWorkSummary';
import type { CustomWorldGalleryCard } from '../../../packages/shared/src/contracts/runtime';
import {
EDUTAINMENT_BABY_OBJECT_MATCH_TEMPLATE_ID,
EDUTAINMENT_BABY_OBJECT_MATCH_TEMPLATE_NAME,
type PlatformPublicGalleryCard,
} from '../rpg-entry/rpgEntryWorldPresentation';
import {
buildPlatformPublicGalleryFeeds,
getPlatformPublicGalleryEntryKey,
getPlatformPublicGalleryEntryTime,
getPlatformRecommendRuntimeKind,
isPlatformRecommendRuntimeReadyForEntry,
isSamePlatformPublicGalleryEntry,
mergePlatformPublicGalleryEntries,
type PlatformRecommendRuntimeStartIntentDeps,
type RecommendRuntimeKind,
resolvePlatformRecommendRuntimeAutoStartDecision,
resolvePlatformRecommendRuntimeStartIntent,
} from './platformPublicGalleryFlow';
import {
mapBarkBattlePublicDetailToWorkSummary,
mapPublicWorkDetailToBigFishWork,
mapPublicWorkDetailToPuzzleWork,
mapPublicWorkDetailToSquareHoleWork,
} from './platformPublicWorkDetailFlow';
type TypedPlatformPublicGalleryCard = Extract<
PlatformPublicGalleryCard,
{ sourceType: string }
>;
type PlatformGallerySourceType = TypedPlatformPublicGalleryCard['sourceType'];
type TypedPlatformPublicGalleryCardOverrides = Partial<
Omit<TypedPlatformPublicGalleryCard, 'sourceType'>
>;
function buildRpgEntry(
overrides: Partial<CustomWorldGalleryCard> = {},
): CustomWorldGalleryCard {
return {
ownerUserId: 'user-1',
profileId: 'rpg-profile',
publicWorkCode: 'CW-RPG',
authorPublicUserCode: null,
visibility: 'published',
publishedAt: '2026-06-01T00:00:00.000Z',
updatedAt: '2026-06-01T01:00:00.000Z',
authorDisplayName: '玩家',
worldName: 'RPG 世界',
subtitle: '公开作品',
summaryText: '公开作品摘要',
coverImageSrc: null,
themeMode: 'martial',
playableNpcCount: 1,
landmarkCount: 1,
...overrides,
};
}
function buildBigFishWork(
overrides: Partial<BigFishWorkSummary> = {},
): BigFishWorkSummary {
return {
workId: 'big-fish-work',
sourceSessionId: 'big-fish-session',
ownerUserId: 'user-1',
authorDisplayName: '玩家',
title: '大鱼吃小鱼',
subtitle: '海湾',
summary: '一路长大。',
coverImageSrc: '/big-fish-cover.png',
status: 'published',
updatedAt: '2026-06-01T02:00:00.000Z',
publishedAt: '2026-06-01T02:00:00.000Z',
publishReady: true,
levelCount: 1,
levelMainImageReadyCount: 1,
levelMotionReadyCount: 1,
backgroundReady: true,
playCount: 1,
...overrides,
};
}
function buildBabyObjectMatchDraft(
overrides: Partial<BabyObjectMatchDraft> = {},
): BabyObjectMatchDraft {
const itemAsset = {
itemId: 'item-a',
itemName: '苹果',
imageSrc: '/apple.png',
assetObjectId: null,
generationProvider: 'placeholder' as const,
prompt: '苹果',
};
return {
draftId: 'baby-draft',
profileId: 'baby-profile',
templateId: EDUTAINMENT_BABY_OBJECT_MATCH_TEMPLATE_ID,
templateName: EDUTAINMENT_BABY_OBJECT_MATCH_TEMPLATE_NAME,
workTitle: '宝贝识物',
workDescription: '认识水果。',
itemNames: ['苹果', '香蕉'],
itemAssets: [itemAsset, { ...itemAsset, itemId: 'item-b', itemName: '香蕉' }],
visualPackage: null,
themeTags: ['寓教于乐'],
publicationStatus: 'published',
createdAt: '2026-06-01T00:00:00.000Z',
updatedAt: '2026-06-01T03:00:00.000Z',
publishedAt: '2026-06-01T03:00:00.000Z',
...overrides,
};
}
function buildJumpHopEntry(
overrides: Partial<JumpHopGalleryCardResponse> = {},
): JumpHopGalleryCardResponse {
return {
publicWorkCode: 'JH-JUMP',
workId: 'jump-hop-work',
profileId: 'jump-hop-profile',
ownerUserId: 'user-1',
authorDisplayName: '玩家',
workTitle: '跳一跳',
workDescription: '一路向前。',
coverImageSrc: '/jump-hop-cover.png',
themeTags: ['跳一跳'],
difficulty: 'standard',
stylePreset: 'minimal-blocks',
publicationStatus: 'published',
playCount: 1,
updatedAt: '2026-06-04T00:00:00.000Z',
publishedAt: '2026-06-04T00:00:00.000Z',
generationStatus: 'ready',
...overrides,
};
}
function buildTypedEntry(
sourceType: PlatformGallerySourceType,
overrides: TypedPlatformPublicGalleryCardOverrides = {},
): PlatformPublicGalleryCard {
const common = {
workId: `${sourceType}-work`,
profileId: `${sourceType}-profile`,
publicWorkCode: `${sourceType}-code`,
ownerUserId: 'user-1',
authorDisplayName: '玩家',
worldName: `${sourceType} 作品`,
subtitle: '公开作品',
summaryText: '公开作品摘要',
coverImageSrc: null,
themeTags: [sourceType],
visibility: 'published' as const,
publishedAt: '2026-06-01T00:00:00.000Z',
updatedAt: '2026-06-01T01:00:00.000Z',
};
switch (sourceType) {
case 'puzzle':
return { ...common, ...overrides, sourceType };
case 'big-fish':
return { ...common, ...overrides, sourceType };
case 'match3d':
return { ...common, ...overrides, sourceType };
case 'square-hole':
return { ...common, ...overrides, sourceType };
case 'visual-novel':
return { ...common, ...overrides, sourceType };
case 'jump-hop':
return { ...common, ...overrides, sourceType };
case 'wooden-fish':
return { ...common, ...overrides, sourceType };
case 'edutainment':
return {
...common,
...overrides,
sourceType,
templateId: EDUTAINMENT_BABY_OBJECT_MATCH_TEMPLATE_ID,
templateName: EDUTAINMENT_BABY_OBJECT_MATCH_TEMPLATE_NAME,
};
case 'bark-battle':
return {
...common,
...overrides,
sourceType,
authorPublicUserCode: null,
coverRenderMode: 'image',
coverCharacterImageSrcs: [],
themeMode: 'martial',
playableNpcCount: 1,
landmarkCount: 1,
};
default: {
const exhaustive: never = sourceType;
return exhaustive;
}
}
}
function buildPuzzleWork(
overrides: Partial<PuzzleWorkSummary> = {},
): PuzzleWorkSummary {
return {
workId: 'puzzle-work',
profileId: 'puzzle-profile',
ownerUserId: 'user-1',
sourceSessionId: 'puzzle-session',
authorDisplayName: '玩家',
levelName: '拼图作品',
summary: '拼图摘要',
themeTags: ['拼图'],
coverImageSrc: '/puzzle-cover.png',
publicationStatus: 'published',
updatedAt: '2026-06-01T01:00:00.000Z',
publishedAt: '2026-06-01T00:00:00.000Z',
playCount: 3,
remixCount: 2,
likeCount: 1,
pointIncentiveTotalHalfPoints: 0,
pointIncentiveClaimedPoints: 0,
pointIncentiveTotalPoints: 0,
pointIncentiveClaimablePoints: 0,
publishReady: true,
...overrides,
};
}
function buildMatch3DWork(
overrides: Partial<Match3DWorkSummary> = {},
): Match3DWorkSummary {
return {
workId: 'match3d-work',
profileId: 'match3d-profile',
ownerUserId: 'user-1',
sourceSessionId: 'match3d-session',
gameName: '抓大鹅作品',
themeText: '经典消除',
summary: '抓大鹅摘要',
tags: ['抓大鹅'],
coverImageSrc: '/match3d-cover.png',
referenceImageSrc: null,
clearCount: 12,
difficulty: 4,
publicationStatus: 'published',
playCount: 10,
updatedAt: '2026-06-01T01:00:00.000Z',
publishedAt: '2026-06-01T00:00:00.000Z',
publishReady: true,
generatedItemAssets: [],
...overrides,
};
}
function buildBarkBattleWork(
overrides: Partial<BarkBattleWorkSummary> = {},
): BarkBattleWorkSummary {
return {
workId: 'bark-battle-work',
draftId: 'bark-battle-draft',
ownerUserId: 'user-1',
authorDisplayName: '玩家',
title: '汪汪声浪作品',
summary: '汪汪摘要',
themeDescription: '森林擂台',
playerImageDescription: '小狗',
opponentImageDescription: '对手',
playerCharacterImageSrc: '/player.png',
opponentCharacterImageSrc: '/opponent.png',
uiBackgroundImageSrc: '/bark-bg.png',
difficultyPreset: 'normal',
status: 'published',
generationStatus: 'ready',
publishReady: true,
playCount: 9,
recentPlayCount7d: 2,
updatedAt: '2026-06-01T01:00:00.000Z',
publishedAt: '2026-06-01T00:00:00.000Z',
...overrides,
};
}
function buildRecommendRuntimeStartDeps(
overrides: Partial<PlatformRecommendRuntimeStartIntentDeps> = {},
): PlatformRecommendRuntimeStartIntentDeps {
return {
selectedPuzzleDetail: null,
barkBattleGalleryEntries: [],
mapMatch3DWork: () => buildMatch3DWork(),
...overrides,
};
}
test('platform public gallery flow resolves stable key and runtime kind for every play kind', () => {
const cases: Array<
[sourceType: PlatformGallerySourceType, keyKind: string, kind: RecommendRuntimeKind]
> = [
['big-fish', 'big-fish', 'big-fish'],
['puzzle', 'puzzle', 'puzzle'],
['jump-hop', 'jump-hop', 'jump-hop'],
['wooden-fish', 'wooden-fish', 'wooden-fish'],
['match3d', 'match3d', 'match3d'],
['square-hole', 'square-hole', 'square-hole'],
['visual-novel', 'visual-novel', 'visual-novel'],
['bark-battle', 'bark-battle', 'bark-battle'],
[
'edutainment',
`edutainment:${EDUTAINMENT_BABY_OBJECT_MATCH_TEMPLATE_ID}`,
'edutainment',
],
];
cases.forEach(([sourceType, keyKind, kind]) => {
const entry = buildTypedEntry(sourceType);
expect(getPlatformPublicGalleryEntryKey(entry)).toBe(
`${keyKind}:user-1:${sourceType}-profile`,
);
expect(getPlatformRecommendRuntimeKind(entry)).toBe(kind);
});
const rpgEntry = buildRpgEntry();
expect(getPlatformPublicGalleryEntryKey(rpgEntry)).toBe(
'rpg:user-1:rpg-profile',
);
expect(getPlatformRecommendRuntimeKind(rpgEntry)).toBe('rpg');
});
test('platform public gallery flow compares entries by resolved identity', () => {
const left = buildTypedEntry('puzzle');
const sameIdentity = buildTypedEntry('puzzle', {
workId: 'other-work',
worldName: '新标题',
});
const otherKind = buildTypedEntry('match3d', {
ownerUserId: left.ownerUserId,
profileId: left.profileId,
});
expect(isSamePlatformPublicGalleryEntry(left, sameIdentity)).toBe(true);
expect(isSamePlatformPublicGalleryEntry(left, otherKind)).toBe(false);
});
test('platform public gallery flow resolves recommend runtime start intent', () => {
const bigFishEntry = buildTypedEntry('big-fish');
expect(
resolvePlatformRecommendRuntimeStartIntent(
bigFishEntry,
buildRecommendRuntimeStartDeps(),
),
).toEqual({
type: 'start-big-fish',
work: mapPublicWorkDetailToBigFishWork(bigFishEntry),
returnStage: 'platform',
embedded: true,
});
const selectedPuzzleDetail = buildPuzzleWork({
profileId: 'puzzle-profile',
});
expect(
resolvePlatformRecommendRuntimeStartIntent(
buildTypedEntry('puzzle'),
buildRecommendRuntimeStartDeps({ selectedPuzzleDetail }),
),
).toEqual({
type: 'start-puzzle',
work: selectedPuzzleDetail,
returnStage: 'platform',
embedded: true,
});
const puzzleEntry = buildTypedEntry('puzzle', {
profileId: 'fallback-puzzle-profile',
});
expect(
resolvePlatformRecommendRuntimeStartIntent(
puzzleEntry,
buildRecommendRuntimeStartDeps({
selectedPuzzleDetail: buildPuzzleWork({ profileId: 'stale-profile' }),
}),
),
).toEqual({
type: 'start-puzzle',
work: mapPublicWorkDetailToPuzzleWork(puzzleEntry),
returnStage: 'platform',
embedded: true,
});
expect(
resolvePlatformRecommendRuntimeStartIntent(
buildTypedEntry('jump-hop'),
buildRecommendRuntimeStartDeps(),
),
).toEqual({
type: 'start-jump-hop',
profileId: 'jump-hop-profile',
returnStage: 'platform',
embedded: true,
});
expect(
resolvePlatformRecommendRuntimeStartIntent(
buildTypedEntry('wooden-fish'),
buildRecommendRuntimeStartDeps(),
),
).toEqual({
type: 'start-wooden-fish',
profileId: 'wooden-fish-profile',
returnStage: 'platform',
embedded: true,
});
expect(
resolvePlatformRecommendRuntimeStartIntent(
buildTypedEntry('visual-novel'),
buildRecommendRuntimeStartDeps(),
),
).toEqual({
type: 'start-visual-novel',
profileId: 'visual-novel-profile',
returnStage: 'platform',
embedded: true,
});
expect(
resolvePlatformRecommendRuntimeStartIntent(
buildTypedEntry('edutainment'),
buildRecommendRuntimeStartDeps(),
),
).toEqual({
type: 'start-edutainment',
entry: buildTypedEntry('edutainment'),
returnStage: 'platform',
embedded: true,
});
expect(
resolvePlatformRecommendRuntimeStartIntent(
buildRpgEntry(),
buildRecommendRuntimeStartDeps(),
),
).toEqual({
type: 'mark-ready',
});
});
test('platform public gallery flow resolves recommend runtime mapper-backed start intent', () => {
const match3DEntry = buildTypedEntry('match3d');
const match3DWork = buildMatch3DWork({ workId: 'mapped-match3d-work' });
expect(
resolvePlatformRecommendRuntimeStartIntent(
match3DEntry,
buildRecommendRuntimeStartDeps({
mapMatch3DWork: (entry) =>
entry === match3DEntry ? match3DWork : null,
}),
),
).toEqual({
type: 'start-match3d',
work: match3DWork,
returnStage: 'work-detail',
embedded: true,
});
expect(
resolvePlatformRecommendRuntimeStartIntent(
match3DEntry,
buildRecommendRuntimeStartDeps({ mapMatch3DWork: () => null }),
),
).toEqual({
type: 'blocked',
errorTarget: 'match3d',
errorMessage: '当前抓大鹅作品信息不完整,暂时无法进入玩法。',
});
const squareHoleEntry = buildTypedEntry('square-hole');
expect(
resolvePlatformRecommendRuntimeStartIntent(
squareHoleEntry,
buildRecommendRuntimeStartDeps(),
),
).toEqual({
type: 'start-square-hole',
work: mapPublicWorkDetailToSquareHoleWork(squareHoleEntry),
returnStage: 'platform',
embedded: true,
});
});
test('platform public gallery flow resolves recommend runtime bark battle priority', () => {
const entry = buildTypedEntry('bark-battle');
const galleryWork = buildBarkBattleWork({
workId: 'bark-battle-work',
title: '推荐缓存',
});
expect(
resolvePlatformRecommendRuntimeStartIntent(
entry,
buildRecommendRuntimeStartDeps({
barkBattleGalleryEntries: [galleryWork],
}),
),
).toEqual({
type: 'start-bark-battle',
work: galleryWork,
returnStage: 'platform',
embedded: true,
});
expect(
resolvePlatformRecommendRuntimeStartIntent(
entry,
buildRecommendRuntimeStartDeps(),
),
).toEqual({
type: 'start-bark-battle',
work: mapBarkBattlePublicDetailToWorkSummary(entry),
returnStage: 'platform',
embedded: true,
});
});
test('platform public gallery flow resolves recommend runtime readiness', () => {
expect(
isPlatformRecommendRuntimeReadyForEntry(buildTypedEntry('big-fish'), {
activeKind: 'puzzle',
hasBigFishRun: true,
}),
).toBe(false);
expect(
isPlatformRecommendRuntimeReadyForEntry(buildTypedEntry('big-fish'), {
activeKind: 'big-fish',
hasBigFishRun: true,
}),
).toBe(true);
expect(
isPlatformRecommendRuntimeReadyForEntry(buildTypedEntry('jump-hop'), {
activeKind: 'jump-hop',
hasJumpHopRun: true,
}),
).toBe(true);
expect(
isPlatformRecommendRuntimeReadyForEntry(buildTypedEntry('wooden-fish'), {
activeKind: 'wooden-fish',
hasWoodenFishRun: true,
}),
).toBe(true);
expect(
isPlatformRecommendRuntimeReadyForEntry(buildTypedEntry('match3d'), {
activeKind: 'match3d',
hasMatch3DRun: true,
}),
).toBe(true);
expect(
isPlatformRecommendRuntimeReadyForEntry(buildTypedEntry('square-hole'), {
activeKind: 'square-hole',
hasSquareHoleRun: true,
}),
).toBe(true);
expect(
isPlatformRecommendRuntimeReadyForEntry(buildTypedEntry('visual-novel'), {
activeKind: 'visual-novel',
hasVisualNovelRun: true,
}),
).toBe(true);
expect(
isPlatformRecommendRuntimeReadyForEntry(buildTypedEntry('bark-battle'), {
activeKind: 'bark-battle',
}),
).toBe(true);
expect(
isPlatformRecommendRuntimeReadyForEntry(buildRpgEntry(), {
activeKind: 'rpg',
}),
).toBe(true);
});
test('platform public gallery flow resolves puzzle and edutainment readiness details', () => {
const puzzleEntry = buildTypedEntry('puzzle', {
profileId: 'puzzle-profile',
});
expect(
isPlatformRecommendRuntimeReadyForEntry(puzzleEntry, {
activeKind: 'puzzle',
puzzleRunEntryProfileId: 'other-profile',
puzzleRunCurrentLevelProfileId: 'puzzle-profile',
}),
).toBe(true);
expect(
isPlatformRecommendRuntimeReadyForEntry(puzzleEntry, {
activeKind: 'puzzle',
puzzleRunEntryProfileId: 'other-profile',
puzzleRunCurrentLevelProfileId: 'another-profile',
}),
).toBe(false);
expect(
isPlatformRecommendRuntimeReadyForEntry(buildTypedEntry('edutainment'), {
activeKind: 'edutainment',
hasBabyObjectMatchDraft: true,
}),
).toBe(true);
expect(
isPlatformRecommendRuntimeReadyForEntry(buildTypedEntry('edutainment'), {
activeKind: 'edutainment',
hasBabyObjectMatchDraft: false,
}),
).toBe(false);
});
test('platform public gallery flow resolves recommend runtime auto-start gates', () => {
const entry = buildTypedEntry('big-fish');
const baseInput: Parameters<
typeof resolvePlatformRecommendRuntimeAutoStartDecision
>[0] = {
isDesktopLayout: false,
selectionStage: 'platform',
platformTab: 'home',
isLoadingPlatform: false,
entries: [entry],
activeEntryKey: null,
isStarting: false,
readyState: { activeKind: null },
};
expect(
resolvePlatformRecommendRuntimeAutoStartDecision({
...baseInput,
isDesktopLayout: true,
}),
).toEqual({ type: 'noop' });
expect(
resolvePlatformRecommendRuntimeAutoStartDecision({
...baseInput,
platformTab: 'discover',
}),
).toEqual({ type: 'noop' });
expect(
resolvePlatformRecommendRuntimeAutoStartDecision({
...baseInput,
entries: [],
}),
).toEqual({ type: 'clear' });
expect(
resolvePlatformRecommendRuntimeAutoStartDecision({
...baseInput,
isStarting: true,
}),
).toEqual({ type: 'noop' });
});
test('platform public gallery flow resolves recommend runtime auto-start target', () => {
const firstEntry = buildTypedEntry('big-fish', {
profileId: 'big-fish-first',
});
const activeEntry = buildTypedEntry('puzzle', {
profileId: 'puzzle-active',
});
const activeEntryKey = getPlatformPublicGalleryEntryKey(activeEntry);
const baseInput: Parameters<
typeof resolvePlatformRecommendRuntimeAutoStartDecision
>[0] = {
isDesktopLayout: false,
selectionStage: 'platform',
platformTab: 'home',
isLoadingPlatform: false,
entries: [firstEntry, activeEntry],
activeEntryKey,
isStarting: false,
readyState: { activeKind: 'puzzle' },
};
expect(
resolvePlatformRecommendRuntimeAutoStartDecision({
...baseInput,
readyState: {
activeKind: 'puzzle',
puzzleRunEntryProfileId: 'puzzle-active',
},
}),
).toEqual({ type: 'noop' });
expect(resolvePlatformRecommendRuntimeAutoStartDecision(baseInput)).toEqual({
type: 'start',
entry: activeEntry,
});
expect(
resolvePlatformRecommendRuntimeAutoStartDecision({
...baseInput,
activeEntryKey: 'missing-entry',
}),
).toEqual({
type: 'start',
entry: firstEntry,
});
});
test('platform public gallery flow merges duplicate identities and sorts newest first', () => {
const staleRpgEntry = buildRpgEntry({
profileId: 'shared-rpg',
worldName: '旧版 RPG',
publishedAt: '2026-06-01T00:00:00.000Z',
});
const freshRpgEntry = buildRpgEntry({
profileId: 'shared-rpg',
worldName: '新版 RPG',
publishedAt: '2026-06-04T00:00:00.000Z',
});
const middleRpgEntry = buildRpgEntry({
profileId: 'middle-rpg',
worldName: '中间 RPG',
publishedAt: '2026-06-02T00:00:00.000Z',
});
const updatedOnlyEntry = buildTypedEntry('big-fish', {
profileId: 'updated-only',
publishedAt: null,
updatedAt: '2026-06-03T00:00:00.000Z',
});
const invalidTimeEntry = buildTypedEntry('puzzle', {
profileId: 'invalid-time',
publishedAt: 'not-a-date',
updatedAt: 'still-not-a-date',
});
const merged = mergePlatformPublicGalleryEntries(
[staleRpgEntry, middleRpgEntry],
[invalidTimeEntry, updatedOnlyEntry, freshRpgEntry],
);
expect(merged).toHaveLength(4);
expect(merged.map((entry) => entry.profileId)).toEqual([
'shared-rpg',
'updated-only',
'middle-rpg',
'invalid-time',
]);
expect(merged[0]?.worldName).toBe('新版 RPG');
expect(getPlatformPublicGalleryEntryTime(invalidTimeEntry)).toBe(0);
});
test('platform public gallery flow builds feeds with visibility gates and bark battle fallback', () => {
const hiddenBigFish = buildBigFishWork({
workId: 'hidden-big-fish',
sourceSessionId: 'hidden-big-fish-session',
});
const hiddenBabyDraft = buildBabyObjectMatchDraft({
profileId: 'hidden-baby',
});
const publishedBarkFallback = buildBarkBattleWork({
workId: 'fallback-bark',
publishedAt: '2026-06-04T00:00:00.000Z',
updatedAt: '2026-06-04T00:00:00.000Z',
});
const draftBarkFallback = buildBarkBattleWork({
workId: 'draft-bark',
status: 'draft',
});
const hiddenFeeds = buildPlatformPublicGalleryFeeds({
rpgEntries: [
buildRpgEntry({
profileId: 'rpg-visible',
publishedAt: '2026-06-01T00:00:00.000Z',
}),
],
bigFishEntries: [hiddenBigFish],
match3dEntries: [],
puzzleEntries: [],
barkBattleGalleryEntries: [],
barkBattleWorks: [draftBarkFallback, publishedBarkFallback],
jumpHopEntries: [],
woodenFishEntries: [],
squareHoleEntries: [],
visualNovelEntries: [],
babyObjectMatchDrafts: [hiddenBabyDraft],
isBigFishCreationVisible: false,
isBabyObjectMatchVisible: false,
isVisualNovelCreationOpen: false,
});
expect(
hiddenFeeds.latestEntries.map((entry) =>
'sourceType' in entry ? entry.sourceType : 'rpg',
),
).toEqual(['bark-battle', 'rpg']);
expect(hiddenFeeds.latestEntries[0]?.profileId).toBe('fallback-bark');
const visibleFeeds = buildPlatformPublicGalleryFeeds({
rpgEntries: [],
bigFishEntries: [hiddenBigFish],
match3dEntries: [],
puzzleEntries: [],
barkBattleGalleryEntries: [
buildBarkBattleWork({
workId: 'gallery-bark',
publishedAt: '2026-06-05T00:00:00.000Z',
updatedAt: '2026-06-05T00:00:00.000Z',
}),
],
barkBattleWorks: [publishedBarkFallback],
jumpHopEntries: [],
woodenFishEntries: [],
squareHoleEntries: [],
visualNovelEntries: [],
babyObjectMatchDrafts: [hiddenBabyDraft],
isBigFishCreationVisible: true,
isBabyObjectMatchVisible: true,
isVisualNovelCreationOpen: false,
});
expect(visibleFeeds.latestEntries.map((entry) => entry.profileId)).toEqual([
'gallery-bark',
'hidden-baby',
'hidden-big-fish-session',
]);
expect(visibleFeeds.featuredEntries).toEqual(
visibleFeeds.latestEntries.slice(0, 6),
);
});
test('platform public gallery flow preserves feed tie order and featured slice', () => {
const sameTime = '2026-06-04T00:00:00.000Z';
const tieFeeds = buildPlatformPublicGalleryFeeds({
rpgEntries: [],
bigFishEntries: [],
match3dEntries: [],
puzzleEntries: [],
barkBattleGalleryEntries: [],
barkBattleWorks: [
buildBarkBattleWork({
workId: 'fallback-bark',
publishedAt: sameTime,
updatedAt: sameTime,
}),
],
jumpHopEntries: [
buildJumpHopEntry({
profileId: 'jump-hop',
publishedAt: sameTime,
updatedAt: sameTime,
}),
],
woodenFishEntries: [],
squareHoleEntries: [],
visualNovelEntries: [],
babyObjectMatchDrafts: [],
isBigFishCreationVisible: false,
isBabyObjectMatchVisible: false,
isVisualNovelCreationOpen: false,
});
expect(tieFeeds.latestEntries.map((entry) => entry.profileId)).toEqual([
'jump-hop',
'fallback-bark',
]);
const sliceFeeds = buildPlatformPublicGalleryFeeds({
rpgEntries: Array.from({ length: 7 }, (_, index) =>
buildRpgEntry({
profileId: `rpg-${index}`,
publishedAt: `2026-06-0${index + 1}T00:00:00.000Z`,
updatedAt: `2026-06-0${index + 1}T00:00:00.000Z`,
}),
),
bigFishEntries: [],
match3dEntries: [],
puzzleEntries: [],
barkBattleGalleryEntries: [],
barkBattleWorks: [],
jumpHopEntries: [],
woodenFishEntries: [],
squareHoleEntries: [],
visualNovelEntries: [],
babyObjectMatchDrafts: [],
isBigFishCreationVisible: false,
isBabyObjectMatchVisible: false,
isVisualNovelCreationOpen: false,
});
expect(sliceFeeds.featuredEntries).toHaveLength(6);
});

View File

@@ -0,0 +1,556 @@
import type { BarkBattleWorkSummary } from '../../../packages/shared/src/contracts/barkBattle';
import type { BigFishWorkSummary } from '../../../packages/shared/src/contracts/bigFishWorkSummary';
import type { BabyObjectMatchDraft } from '../../../packages/shared/src/contracts/edutainmentBabyObject';
import type { JumpHopGalleryCardResponse } from '../../../packages/shared/src/contracts/jumpHop';
import type { Match3DWorkSummary } from '../../../packages/shared/src/contracts/match3dWorks';
import type { PuzzleWorkSummary } from '../../../packages/shared/src/contracts/puzzleWorkSummary';
import type { CustomWorldGalleryCard } from '../../../packages/shared/src/contracts/runtime';
import type { SquareHoleWorkSummary } from '../../../packages/shared/src/contracts/squareHoleWorks';
import type { VisualNovelWorkSummary } from '../../../packages/shared/src/contracts/visualNovel';
import type { WoodenFishGalleryCardResponse } from '../../../packages/shared/src/contracts/woodenFish';
import {
isBarkBattleGalleryEntry,
isBigFishGalleryEntry,
isEdutainmentGalleryEntry,
isJumpHopGalleryEntry,
isMatch3DGalleryEntry,
isPuzzleGalleryEntry,
isSquareHoleGalleryEntry,
isVisualNovelGalleryEntry,
isWoodenFishGalleryEntry,
mapBabyObjectMatchDraftToPlatformGalleryCard,
mapBarkBattleWorkToPlatformGalleryCard,
mapBigFishWorkToPlatformGalleryCard,
mapJumpHopWorkToPlatformGalleryCard,
mapPuzzleWorkToPlatformGalleryCard,
mapSquareHoleWorkToPlatformGalleryCard,
mapVisualNovelWorkToPlatformGalleryCard,
mapWoodenFishWorkToPlatformGalleryCard,
type PlatformPublicGalleryCard,
} from '../rpg-entry/rpgEntryWorldPresentation';
import { mapMatch3DWorkToPublicWorkDetail } from './platformMatch3DRuntimeProfile';
import {
mapBarkBattlePublicDetailToWorkSummary,
mapPublicWorkDetailToBigFishWork,
mapPublicWorkDetailToPuzzleWork,
mapPublicWorkDetailToSquareHoleWork,
} from './platformPublicWorkDetailFlow';
export type RecommendRuntimeKind =
| 'bark-battle'
| 'big-fish'
| 'edutainment'
| 'jump-hop'
| 'match3d'
| 'puzzle'
| 'square-hole'
| 'wooden-fish'
| 'visual-novel'
| 'rpg';
export type PlatformRecommendRuntimeStartErrorTarget =
| 'bark-battle'
| 'big-fish'
| 'match3d'
| 'puzzle'
| 'square-hole';
export type PlatformRecommendRuntimeStartIntent =
| {
type: 'blocked';
errorTarget: PlatformRecommendRuntimeStartErrorTarget;
errorMessage: string;
}
| {
type: 'start-big-fish';
work: BigFishWorkSummary;
returnStage: 'platform';
embedded: true;
}
| {
type: 'start-puzzle';
work: PuzzleWorkSummary;
returnStage: 'platform';
embedded: true;
}
| {
type: 'start-jump-hop';
profileId: string;
returnStage: 'platform';
embedded: true;
}
| {
type: 'start-wooden-fish';
profileId: string;
returnStage: 'platform';
embedded: true;
}
| {
type: 'start-match3d';
work: Match3DWorkSummary;
returnStage: 'work-detail';
embedded: true;
}
| {
type: 'start-square-hole';
work: SquareHoleWorkSummary;
returnStage: 'platform';
embedded: true;
}
| {
type: 'start-visual-novel';
profileId: string;
returnStage: 'platform';
embedded: true;
}
| {
type: 'start-bark-battle';
work: BarkBattleWorkSummary;
returnStage: 'platform';
embedded: true;
}
| {
type: 'start-edutainment';
entry: PlatformPublicGalleryCard;
returnStage: 'platform';
embedded: true;
}
| {
type: 'mark-ready';
};
export type PlatformRecommendRuntimeStartIntentDeps = {
selectedPuzzleDetail?: PuzzleWorkSummary | null;
barkBattleGalleryEntries?: readonly BarkBattleWorkSummary[];
mapMatch3DWork: (
entry: PlatformPublicGalleryCard,
) => Match3DWorkSummary | null;
};
export type PlatformRecommendRuntimeReadyState = {
activeKind: RecommendRuntimeKind | null;
hasBabyObjectMatchDraft?: boolean;
hasBigFishRun?: boolean;
hasJumpHopRun?: boolean;
hasMatch3DRun?: boolean;
hasSquareHoleRun?: boolean;
hasVisualNovelRun?: boolean;
hasWoodenFishRun?: boolean;
puzzleRunEntryProfileId?: string | null;
puzzleRunCurrentLevelProfileId?: string | null;
};
export type PlatformRecommendRuntimeAutoStartDecision =
| { type: 'noop' }
| { type: 'clear' }
| { type: 'start'; entry: PlatformPublicGalleryCard };
export type PlatformRecommendRuntimeAutoStartInput = {
isDesktopLayout: boolean;
selectionStage: string;
platformTab: string;
isLoadingPlatform: boolean;
entries: readonly PlatformPublicGalleryCard[];
activeEntryKey: string | null;
isStarting: boolean;
readyState: PlatformRecommendRuntimeReadyState;
};
export type PlatformPublicGalleryFeedsInput = {
rpgEntries: readonly CustomWorldGalleryCard[];
bigFishEntries: readonly BigFishWorkSummary[];
match3dEntries: readonly Match3DWorkSummary[];
puzzleEntries: readonly PuzzleWorkSummary[];
barkBattleGalleryEntries: readonly BarkBattleWorkSummary[];
barkBattleWorks: readonly BarkBattleWorkSummary[];
jumpHopEntries: readonly JumpHopGalleryCardResponse[];
woodenFishEntries: readonly WoodenFishGalleryCardResponse[];
squareHoleEntries: readonly SquareHoleWorkSummary[];
visualNovelEntries: readonly VisualNovelWorkSummary[];
babyObjectMatchDrafts: readonly BabyObjectMatchDraft[];
isBigFishCreationVisible: boolean;
isBabyObjectMatchVisible: boolean;
isVisualNovelCreationOpen: boolean;
};
export type PlatformPublicGalleryFeeds = {
featuredEntries: PlatformPublicGalleryCard[];
latestEntries: PlatformPublicGalleryCard[];
};
export function getPlatformPublicGalleryEntryTime(
entry: PlatformPublicGalleryCard,
) {
const rawTime = entry.publishedAt ?? entry.updatedAt;
const timestamp = new Date(rawTime).getTime();
return Number.isNaN(timestamp) ? 0 : timestamp;
}
export function getPlatformPublicGalleryEntryKey(
entry: PlatformPublicGalleryCard,
) {
// 同一作品身份由玩法、作者与 profile 共同确定,避免不同玩法共享 profileId 时误合并。
const kind = isBigFishGalleryEntry(entry)
? 'big-fish'
: isPuzzleGalleryEntry(entry)
? 'puzzle'
: isJumpHopGalleryEntry(entry)
? 'jump-hop'
: isWoodenFishGalleryEntry(entry)
? 'wooden-fish'
: isMatch3DGalleryEntry(entry)
? 'match3d'
: isSquareHoleGalleryEntry(entry)
? 'square-hole'
: isVisualNovelGalleryEntry(entry)
? 'visual-novel'
: isBarkBattleGalleryEntry(entry)
? 'bark-battle'
: isEdutainmentGalleryEntry(entry)
? `edutainment:${entry.templateId}`
: 'rpg';
return `${kind}:${entry.ownerUserId}:${entry.profileId}`;
}
export function getPlatformRecommendRuntimeKind(
entry: PlatformPublicGalleryCard,
): RecommendRuntimeKind {
if (isBigFishGalleryEntry(entry)) {
return 'big-fish';
}
if (isPuzzleGalleryEntry(entry)) {
return 'puzzle';
}
if (isJumpHopGalleryEntry(entry)) {
return 'jump-hop';
}
if (isWoodenFishGalleryEntry(entry)) {
return 'wooden-fish';
}
if (isMatch3DGalleryEntry(entry)) {
return 'match3d';
}
if (isSquareHoleGalleryEntry(entry)) {
return 'square-hole';
}
if (isVisualNovelGalleryEntry(entry)) {
return 'visual-novel';
}
if (isBarkBattleGalleryEntry(entry)) {
return 'bark-battle';
}
if (isEdutainmentGalleryEntry(entry)) {
return 'edutainment';
}
return 'rpg';
}
export function resolvePlatformRecommendRuntimeStartIntent(
entry: PlatformPublicGalleryCard,
deps: PlatformRecommendRuntimeStartIntentDeps,
): PlatformRecommendRuntimeStartIntent {
if (isBigFishGalleryEntry(entry)) {
const work = mapPublicWorkDetailToBigFishWork(entry);
if (!work) {
return {
type: 'blocked',
errorTarget: 'big-fish',
errorMessage: '当前作品缺少会话信息,暂时无法进入玩法。',
};
}
return {
type: 'start-big-fish',
work,
returnStage: 'platform',
embedded: true,
};
}
if (isPuzzleGalleryEntry(entry)) {
const work =
deps.selectedPuzzleDetail?.profileId === entry.profileId
? deps.selectedPuzzleDetail
: mapPublicWorkDetailToPuzzleWork(entry);
if (!work) {
return {
type: 'blocked',
errorTarget: 'puzzle',
errorMessage: '当前拼图作品信息不完整,暂时无法进入玩法。',
};
}
return {
type: 'start-puzzle',
work,
returnStage: 'platform',
embedded: true,
};
}
if (isJumpHopGalleryEntry(entry)) {
return {
type: 'start-jump-hop',
profileId: entry.profileId,
returnStage: 'platform',
embedded: true,
};
}
if (isWoodenFishGalleryEntry(entry)) {
return {
type: 'start-wooden-fish',
profileId: entry.profileId,
returnStage: 'platform',
embedded: true,
};
}
if (isMatch3DGalleryEntry(entry)) {
// 中文注释:抓大鹅推荐 runtime 仍接 Match3D Module 的 Adapter避免复制素材归一规则。
const work = deps.mapMatch3DWork(entry);
if (!work) {
return {
type: 'blocked',
errorTarget: 'match3d',
errorMessage: '当前抓大鹅作品信息不完整,暂时无法进入玩法。',
};
}
return {
type: 'start-match3d',
work,
returnStage: 'work-detail',
embedded: true,
};
}
if (isSquareHoleGalleryEntry(entry)) {
const work = mapPublicWorkDetailToSquareHoleWork(entry);
if (!work) {
return {
type: 'blocked',
errorTarget: 'square-hole',
errorMessage: '当前方洞挑战作品信息不完整,暂时无法进入玩法。',
};
}
return {
type: 'start-square-hole',
work,
returnStage: 'platform',
embedded: true,
};
}
if (isVisualNovelGalleryEntry(entry)) {
return {
type: 'start-visual-novel',
profileId: entry.profileId,
returnStage: 'platform',
embedded: true,
};
}
if (isBarkBattleGalleryEntry(entry)) {
const work =
deps.barkBattleGalleryEntries?.find(
(item) => item.workId === entry.workId,
) ?? mapBarkBattlePublicDetailToWorkSummary(entry);
if (!work) {
return {
type: 'blocked',
errorTarget: 'bark-battle',
errorMessage: '当前汪汪声浪作品信息不完整,暂时无法进入玩法。',
};
}
return {
type: 'start-bark-battle',
work,
returnStage: 'platform',
embedded: true,
};
}
if (isEdutainmentGalleryEntry(entry)) {
return {
type: 'start-edutainment',
entry,
returnStage: 'platform',
embedded: true,
};
}
return {
type: 'mark-ready',
};
}
export function isPlatformRecommendRuntimeReadyForEntry(
entry: PlatformPublicGalleryCard,
state: PlatformRecommendRuntimeReadyState,
) {
const expectedKind = getPlatformRecommendRuntimeKind(entry);
if (state.activeKind !== expectedKind) {
return false;
}
if (expectedKind === 'big-fish') {
return Boolean(state.hasBigFishRun);
}
if (expectedKind === 'jump-hop') {
return Boolean(state.hasJumpHopRun);
}
if (expectedKind === 'wooden-fish') {
return Boolean(state.hasWoodenFishRun);
}
if (expectedKind === 'match3d') {
return Boolean(state.hasMatch3DRun);
}
if (expectedKind === 'puzzle') {
return (
state.puzzleRunEntryProfileId === entry.profileId ||
state.puzzleRunCurrentLevelProfileId === entry.profileId
);
}
if (expectedKind === 'square-hole') {
return Boolean(state.hasSquareHoleRun);
}
if (expectedKind === 'visual-novel') {
return Boolean(state.hasVisualNovelRun);
}
if (expectedKind === 'bark-battle') {
return true;
}
if (expectedKind === 'edutainment') {
return Boolean(state.hasBabyObjectMatchDraft);
}
return true;
}
export function resolvePlatformRecommendRuntimeAutoStartDecision(
input: PlatformRecommendRuntimeAutoStartInput,
): PlatformRecommendRuntimeAutoStartDecision {
if (
input.isDesktopLayout ||
input.selectionStage !== 'platform' ||
input.platformTab !== 'home' ||
input.isLoadingPlatform
) {
return { type: 'noop' };
}
if (input.entries.length === 0) {
return { type: 'clear' };
}
const activeEntry = input.activeEntryKey
? (input.entries.find(
(entry) =>
getPlatformPublicGalleryEntryKey(entry) === input.activeEntryKey,
) ?? null)
: null;
const isActiveRuntimeReady =
activeEntry !== null &&
isPlatformRecommendRuntimeReadyForEntry(activeEntry, input.readyState);
if ((activeEntry !== null && isActiveRuntimeReady) || input.isStarting) {
return { type: 'noop' };
}
const nextEntry = activeEntry ?? input.entries[0];
return nextEntry ? { type: 'start', entry: nextEntry } : { type: 'clear' };
}
export function isSamePlatformPublicGalleryEntry(
left: PlatformPublicGalleryCard,
right: PlatformPublicGalleryCard,
) {
return (
getPlatformPublicGalleryEntryKey(left) ===
getPlatformPublicGalleryEntryKey(right)
);
}
export function mergePlatformPublicGalleryEntries(
rpgEntries: readonly CustomWorldGalleryCard[],
puzzleEntries: readonly PlatformPublicGalleryCard[],
) {
const entryMap = new Map<string, PlatformPublicGalleryCard>();
[...rpgEntries, ...puzzleEntries].forEach((entry) => {
entryMap.set(getPlatformPublicGalleryEntryKey(entry), entry);
});
return Array.from(entryMap.values()).sort(
(left, right) =>
getPlatformPublicGalleryEntryTime(right) -
getPlatformPublicGalleryEntryTime(left),
);
}
export function buildPlatformPublicGalleryFeeds(
input: PlatformPublicGalleryFeedsInput,
): PlatformPublicGalleryFeeds {
const bigFishEntries = input.isBigFishCreationVisible
? input.bigFishEntries.map(mapBigFishWorkToPlatformGalleryCard)
: [];
const babyObjectMatchEntries = input.isBabyObjectMatchVisible
? input.babyObjectMatchDrafts
.filter((draft) => draft.publicationStatus === 'published')
.map(mapBabyObjectMatchDraftToPlatformGalleryCard)
: [];
const barkBattleGalleryEntries = input.barkBattleGalleryEntries.map(
mapBarkBattleWorkToPlatformGalleryCard,
);
const barkBattleFallbackEntries =
input.barkBattleGalleryEntries.length === 0
? input.barkBattleWorks
.filter((work) => work.status === 'published')
.map(mapBarkBattleWorkToPlatformGalleryCard)
: [];
const visualNovelEntries = input.isVisualNovelCreationOpen
? input.visualNovelEntries.map(mapVisualNovelWorkToPlatformGalleryCard)
: [];
const latestEntries = mergePlatformPublicGalleryEntries(input.rpgEntries, [
...bigFishEntries,
...input.match3dEntries.map(mapMatch3DWorkToPublicWorkDetail),
...input.puzzleEntries.map(mapPuzzleWorkToPlatformGalleryCard),
...barkBattleGalleryEntries,
...input.jumpHopEntries.map(mapJumpHopWorkToPlatformGalleryCard),
...barkBattleFallbackEntries,
...input.woodenFishEntries.map(mapWoodenFishWorkToPlatformGalleryCard),
...input.squareHoleEntries.map(mapSquareHoleWorkToPlatformGalleryCard),
...visualNovelEntries,
...babyObjectMatchEntries,
]);
const featuredEntries = mergePlatformPublicGalleryEntries(input.rpgEntries, [
...bigFishEntries,
...input.match3dEntries.map(mapMatch3DWorkToPublicWorkDetail),
...input.puzzleEntries.map(mapPuzzleWorkToPlatformGalleryCard),
...(barkBattleGalleryEntries.length > 0
? barkBattleGalleryEntries
: barkBattleFallbackEntries),
...input.squareHoleEntries.map(mapSquareHoleWorkToPlatformGalleryCard),
...input.jumpHopEntries.map(mapJumpHopWorkToPlatformGalleryCard),
...input.woodenFishEntries.map(mapWoodenFishWorkToPlatformGalleryCard),
...visualNovelEntries,
...babyObjectMatchEntries,
]).slice(0, 6);
return {
featuredEntries,
latestEntries,
};
}

File diff suppressed because it is too large Load Diff

File diff suppressed because it is too large Load Diff

View File

@@ -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> = {},
): PuzzleGeneratedImageCandidate {
return {
candidateId: 'candidate-1',
imageSrc: '/candidate-cover.png',
assetId: 'asset-candidate-cover',
prompt: '星桥机关',
sourceType: 'generated',
selected: true,
...overrides,
};
}
function buildLevel(overrides: Partial<PuzzleDraftLevel> = {}): PuzzleDraftLevel {
return {
levelId: 'level-1',
levelName: '星桥机关',
pictureDescription: '星桥机关画面',
candidates: [buildCandidate()],
selectedCandidateId: null,
coverImageSrc: null,
coverAssetId: null,
generationStatus: 'generating',
...overrides,
};
}
function buildDraft(overrides: Partial<PuzzleResultDraft> = {}): 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> = {},
): 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> = {},
): 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);
});
});

View File

@@ -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,
),
},
};
}

View File

@@ -0,0 +1,31 @@
import { describe, expect, test } from 'vitest';
import {
buildPuzzleResultProfileId,
buildPuzzleResultWorkId,
buildPuzzleSessionIdFromProfileId,
} from './platformPuzzleIdentityModel';
describe('platformPuzzleIdentityModel', () => {
test('builds stable puzzle result identities from a session id', () => {
expect(buildPuzzleResultProfileId(' puzzle-session-ocean ')).toBe(
'puzzle-profile-ocean',
);
expect(buildPuzzleResultWorkId('puzzle-session-ocean')).toBe(
'puzzle-work-ocean',
);
});
test('keeps legacy suffix inputs usable', () => {
expect(buildPuzzleResultProfileId('ocean')).toBe('puzzle-profile-ocean');
expect(buildPuzzleResultWorkId('ocean')).toBe('puzzle-work-ocean');
});
test('builds draft runtime session ids from profile ids', () => {
expect(buildPuzzleSessionIdFromProfileId(' puzzle-profile-ocean ')).toBe(
'puzzle-session-ocean',
);
expect(buildPuzzleSessionIdFromProfileId('puzzle-work-ocean')).toBeNull();
expect(buildPuzzleSessionIdFromProfileId('puzzle-profile-')).toBeNull();
});
});

View File

@@ -0,0 +1,36 @@
/** 收口拼图草稿在 session/profile/work 之间的稳定身份互推规则。 */
export function buildPuzzleResultProfileId(
sessionId: string | null | undefined,
) {
const stableSuffix = resolvePuzzleSessionStableSuffix(sessionId);
return stableSuffix ? `puzzle-profile-${stableSuffix}` : null;
}
export function buildPuzzleResultWorkId(sessionId: string | null | undefined) {
const stableSuffix = resolvePuzzleSessionStableSuffix(sessionId);
return stableSuffix ? `puzzle-work-${stableSuffix}` : null;
}
export function buildPuzzleSessionIdFromProfileId(
profileId: string | null | undefined,
) {
const normalizedProfileId = profileId?.trim();
if (!normalizedProfileId?.startsWith('puzzle-profile-')) {
return null;
}
const stableSuffix = normalizedProfileId.slice('puzzle-profile-'.length);
return stableSuffix ? `puzzle-session-${stableSuffix}` : null;
}
function resolvePuzzleSessionStableSuffix(
sessionId: string | null | undefined,
) {
const normalizedSessionId = sessionId?.trim();
if (!normalizedSessionId) {
return null;
}
return normalizedSessionId.startsWith('puzzle-session-')
? normalizedSessionId.slice('puzzle-session-'.length)
: normalizedSessionId;
}

View File

@@ -0,0 +1,197 @@
import { describe, expect, test } from 'vitest';
import type {
PuzzleLeaderboardEntry,
PuzzleRunSnapshot,
PuzzleRuntimeLevelSnapshot,
} from '../../../packages/shared/src/contracts/puzzleRuntimeSession';
import { mergePuzzleServiceRuntimeState } from './platformPuzzleRuntimeStateModel';
const currentLeaderboard: PuzzleLeaderboardEntry[] = [
{
rank: 1,
nickname: '本地玩家',
elapsedMs: 12000,
isCurrentPlayer: true,
},
];
const serviceLevelLeaderboard: PuzzleLeaderboardEntry[] = [
{
rank: 1,
nickname: '服务端玩家',
elapsedMs: 9000,
},
];
const serviceRunLeaderboard: PuzzleLeaderboardEntry[] = [
{
rank: 2,
nickname: '全局玩家',
elapsedMs: 15000,
},
];
function buildPuzzleLevel(
overrides: Partial<PuzzleRuntimeLevelSnapshot> = {},
): PuzzleRuntimeLevelSnapshot {
return {
runId: 'run-current',
levelIndex: 0,
levelId: 'level-1',
gridSize: 3,
profileId: 'puzzle-profile-current',
levelName: '星桥机关',
authorDisplayName: '玩家',
themeTags: ['星桥'],
coverImageSrc: '/cover.png',
board: {
rows: 3,
cols: 3,
pieces: [],
mergedGroups: [],
selectedPieceId: null,
allTilesResolved: true,
},
status: 'cleared',
startedAtMs: 1000,
clearedAtMs: 13000,
elapsedMs: 12000,
timeLimitMs: 120000,
remainingMs: 108000,
pausedAccumulatedMs: 0,
pauseStartedAtMs: null,
freezeAccumulatedMs: 0,
freezeStartedAtMs: null,
freezeUntilMs: null,
leaderboardEntries: currentLeaderboard,
...overrides,
};
}
function buildPuzzleRun(
overrides: Partial<PuzzleRunSnapshot> = {},
): PuzzleRunSnapshot {
return {
runId: 'run-current',
entryProfileId: 'puzzle-profile-current',
clearedLevelCount: 1,
currentLevelIndex: 0,
currentGridSize: 3,
playedProfileIds: ['puzzle-profile-current'],
previousLevelTags: ['星桥'],
currentLevel: buildPuzzleLevel(),
recommendedNextProfileId: null,
nextLevelMode: 'sameWork',
nextLevelProfileId: null,
nextLevelId: null,
recommendedNextWorks: [],
leaderboardEntries: currentLeaderboard,
...overrides,
};
}
describe('platformPuzzleRuntimeStateModel', () => {
test('keeps current run when either current level is missing', () => {
const currentRun = buildPuzzleRun({ currentLevel: null });
expect(
mergePuzzleServiceRuntimeState(currentRun, buildPuzzleRun()),
).toBe(currentRun);
const serviceRun = buildPuzzleRun({ currentLevel: null });
const playableCurrentRun = buildPuzzleRun();
expect(
mergePuzzleServiceRuntimeState(playableCurrentRun, serviceRun),
).toBe(playableCurrentRun);
});
test('merges service leaderboard and next-level handoff without replacing local level state', () => {
const currentRun = buildPuzzleRun({
clearedLevelCount: 2,
currentLevel: buildPuzzleLevel({
runId: 'run-current',
status: 'cleared',
board: {
rows: 3,
cols: 3,
pieces: [
{
pieceId: 'piece-local',
correctRow: 0,
correctCol: 0,
currentRow: 0,
currentCol: 0,
mergedGroupId: null,
},
],
mergedGroups: [],
selectedPieceId: 'piece-local',
allTilesResolved: true,
},
}),
});
const serviceRun = buildPuzzleRun({
runId: 'run-service',
entryProfileId: 'puzzle-profile-service',
clearedLevelCount: 1,
recommendedNextProfileId: 'next-recommended',
nextLevelMode: 'similarWorks',
nextLevelProfileId: 'next-profile',
nextLevelId: 'next-level',
recommendedNextWorks: [
{
profileId: 'next-profile',
levelName: '月桥机关',
authorDisplayName: '推荐作者',
themeTags: ['月桥'],
coverImageSrc: '/next-cover.png',
similarityScore: 0.91,
},
],
currentLevel: buildPuzzleLevel({
runId: 'run-service-level',
status: 'playing',
leaderboardEntries: serviceLevelLeaderboard,
}),
});
const merged = mergePuzzleServiceRuntimeState(currentRun, serviceRun);
expect(merged.runId).toBe('run-service');
expect(merged.entryProfileId).toBe('puzzle-profile-service');
expect(merged.clearedLevelCount).toBe(2);
expect(merged.recommendedNextProfileId).toBe('next-recommended');
expect(merged.nextLevelMode).toBe('similarWorks');
expect(merged.nextLevelProfileId).toBe('next-profile');
expect(merged.nextLevelId).toBe('next-level');
expect(merged.recommendedNextWorks).toEqual(serviceRun.recommendedNextWorks);
expect(merged.leaderboardEntries).toEqual(serviceLevelLeaderboard);
expect(merged.currentLevel?.status).toBe('cleared');
expect(merged.currentLevel?.board.pieces).toEqual(
currentRun.currentLevel?.board.pieces,
);
expect(merged.currentLevel?.leaderboardEntries).toEqual(
serviceLevelLeaderboard,
);
});
test('falls back to service run leaderboard, then current level leaderboard', () => {
const currentRun = buildPuzzleRun();
const serviceRun = buildPuzzleRun({
currentLevel: buildPuzzleLevel({ leaderboardEntries: [] }),
leaderboardEntries: serviceRunLeaderboard,
});
expect(
mergePuzzleServiceRuntimeState(currentRun, serviceRun).currentLevel
?.leaderboardEntries,
).toEqual(serviceRunLeaderboard);
expect(
mergePuzzleServiceRuntimeState(currentRun, {
...serviceRun,
leaderboardEntries: [],
}).currentLevel?.leaderboardEntries,
).toEqual(currentLeaderboard);
});
});

View File

@@ -0,0 +1,40 @@
import type { PuzzleRunSnapshot } from '../../../packages/shared/src/contracts/puzzleRuntimeSession';
export function mergePuzzleServiceRuntimeState(
currentRun: PuzzleRunSnapshot,
serviceRun: PuzzleRunSnapshot,
): PuzzleRunSnapshot {
if (!currentRun.currentLevel || !serviceRun.currentLevel) {
return currentRun;
}
const serviceLevel = serviceRun.currentLevel;
const leaderboardEntries =
serviceLevel.leaderboardEntries.length > 0
? serviceLevel.leaderboardEntries
: serviceRun.leaderboardEntries;
// 中文注释:拼块布局和通关状态由前端即时裁决;后端快照只合并榜单与下一关 handoff。
return {
...currentRun,
runId: serviceRun.runId,
entryProfileId: serviceRun.entryProfileId,
clearedLevelCount: Math.max(
currentRun.clearedLevelCount,
serviceRun.clearedLevelCount,
),
recommendedNextProfileId: serviceRun.recommendedNextProfileId,
nextLevelMode: serviceRun.nextLevelMode,
nextLevelProfileId: serviceRun.nextLevelProfileId,
nextLevelId: serviceRun.nextLevelId,
recommendedNextWorks: serviceRun.recommendedNextWorks,
leaderboardEntries,
currentLevel: {
...currentRun.currentLevel,
leaderboardEntries:
leaderboardEntries.length > 0
? leaderboardEntries
: currentRun.currentLevel.leaderboardEntries,
},
};
}

View File

@@ -0,0 +1,96 @@
import { expect, test } from 'vitest';
import {
resolvePlatformRecommendRuntimeAuthPlan,
shouldUsePlatformRecommendRuntimeGuestAuth,
} from './platformRecommendRuntimeAuthModel';
test('uses runtime guest auth for anonymous embedded recommendation runtime', () => {
expect(
resolvePlatformRecommendRuntimeAuthPlan({
embedded: true,
authUserId: null,
hasStoredAccessToken: false,
}),
).toEqual({
requestKind: 'runtime-guest',
puzzleRuntimeAuthMode: 'isolated',
});
});
test('uses background auth for signed-in embedded recommendation runtime', () => {
expect(
resolvePlatformRecommendRuntimeAuthPlan({
embedded: true,
authUserId: 'user-1',
hasStoredAccessToken: false,
}),
).toEqual({
requestKind: 'background',
puzzleRuntimeAuthMode: 'default',
});
});
test('uses background auth when embedded runtime has only a stored access token', () => {
expect(
resolvePlatformRecommendRuntimeAuthPlan({
embedded: true,
authUserId: null,
hasStoredAccessToken: true,
}),
).toEqual({
requestKind: 'background',
puzzleRuntimeAuthMode: 'default',
});
});
test('does not alter auth for non-embedded runtime launches by default', () => {
expect(
resolvePlatformRecommendRuntimeAuthPlan({
embedded: false,
authUserId: null,
hasStoredAccessToken: false,
}),
).toEqual({
requestKind: 'none',
puzzleRuntimeAuthMode: 'default',
});
});
test('uses isolated guest auth for anonymous puzzle isolated launch', () => {
expect(
resolvePlatformRecommendRuntimeAuthPlan({
embedded: false,
allowRuntimeGuestAuth: true,
authUserId: null,
hasStoredAccessToken: false,
}),
).toEqual({
requestKind: 'runtime-guest',
puzzleRuntimeAuthMode: 'isolated',
});
});
test('falls back to default puzzle auth when isolated launch has account auth', () => {
expect(
resolvePlatformRecommendRuntimeAuthPlan({
embedded: false,
allowRuntimeGuestAuth: true,
authUserId: 'user-1',
hasStoredAccessToken: false,
}),
).toEqual({
requestKind: 'none',
puzzleRuntimeAuthMode: 'default',
});
});
test('guest auth decision trims user id before treating account as signed in', () => {
expect(
shouldUsePlatformRecommendRuntimeGuestAuth({
allowRuntimeGuestAuth: true,
authUserId: ' ',
hasStoredAccessToken: false,
}),
).toBe(true);
});

View File

@@ -0,0 +1,58 @@
export type PlatformRecommendRuntimeRequestKind =
| 'none'
| 'background'
| 'runtime-guest';
export type PlatformPuzzleRuntimeAuthMode = 'default' | 'isolated';
export type PlatformRecommendRuntimeAuthPlan = {
requestKind: PlatformRecommendRuntimeRequestKind;
puzzleRuntimeAuthMode: PlatformPuzzleRuntimeAuthMode;
};
export type PlatformRecommendRuntimeAuthInput = {
embedded?: boolean;
allowRuntimeGuestAuth?: boolean;
authUserId?: string | null;
hasStoredAccessToken?: boolean;
};
function hasAccountAuth(input: {
authUserId?: string | null;
hasStoredAccessToken?: boolean;
}) {
return Boolean(input.authUserId?.trim() || input.hasStoredAccessToken);
}
export function shouldUsePlatformRecommendRuntimeGuestAuth(
input: Pick<
PlatformRecommendRuntimeAuthInput,
'allowRuntimeGuestAuth' | 'authUserId' | 'hasStoredAccessToken'
>,
) {
return Boolean(input.allowRuntimeGuestAuth) && !hasAccountAuth(input);
}
export function resolvePlatformRecommendRuntimeAuthPlan(
input: PlatformRecommendRuntimeAuthInput,
): PlatformRecommendRuntimeAuthPlan {
const embedded = Boolean(input.embedded);
const allowRuntimeGuestAuth = input.allowRuntimeGuestAuth ?? embedded;
const useRuntimeGuestAuth = shouldUsePlatformRecommendRuntimeGuestAuth({
allowRuntimeGuestAuth,
authUserId: input.authUserId,
hasStoredAccessToken: input.hasStoredAccessToken,
});
if (useRuntimeGuestAuth) {
return {
requestKind: 'runtime-guest',
puzzleRuntimeAuthMode: 'isolated',
};
}
return {
requestKind: embedded ? 'background' : 'none',
puzzleRuntimeAuthMode: 'default',
};
}

View File

@@ -0,0 +1,168 @@
import { describe, expect, test } from 'vitest';
import { createRpgCreationPublishedProfileFixture } from '../../../packages/shared/src/contracts/rpgCreationFixtures';
import type { CustomWorldProfile } from '../../types';
import {
buildPlatformRpgAgentResultPublishGateView,
type PlatformRpgAgentResultBlockerView,
resolvePlatformRpgAgentResultPreviewSourceLabel,
} from './platformRpgAgentResultPreviewModel';
function buildProfile(
overrides: Record<string, unknown> = {},
): CustomWorldProfile {
return {
...createRpgCreationPublishedProfileFixture(),
worldHook: '潮雾列岛旧灯塔重新点亮。',
playerPremise: '玩家从回潮旧灯塔切入沉船旧案。',
coreConflicts: ['守灯会与沉船旧案的冲突'],
sceneChapterBlueprints: [
{
id: 'chapter-1',
acts: [
{
id: 'act-1',
},
],
},
],
...overrides,
} as unknown as CustomWorldProfile;
}
const missingWorldHookBlocker: PlatformRpgAgentResultBlockerView = {
code: 'publish_missing_world_hook',
message: '缺少世界钩子',
};
const missingPlayerPremiseBlocker: PlatformRpgAgentResultBlockerView = {
code: 'publish_missing_player_premise',
message: '缺少玩家前提',
};
const missingCoreConflictBlocker: PlatformRpgAgentResultBlockerView = {
code: 'publish_missing_core_conflict',
message: '缺少核心冲突',
};
const missingMainChapterBlocker: PlatformRpgAgentResultBlockerView = {
code: 'publish_missing_main_chapter',
message: '缺少主章节',
};
const missingFirstActBlocker: PlatformRpgAgentResultBlockerView = {
code: 'publish_missing_first_act',
message: '缺少首幕',
};
const structuralBlockers: PlatformRpgAgentResultBlockerView[] = [
missingWorldHookBlocker,
missingPlayerPremiseBlocker,
missingCoreConflictBlocker,
missingMainChapterBlocker,
missingFirstActBlocker,
];
describe('platformRpgAgentResultPreviewModel', () => {
test('uses fallback blockers and publish readiness without a profile', () => {
expect(
buildPlatformRpgAgentResultPublishGateView(
null,
structuralBlockers.slice(0, 2),
false,
),
).toEqual({
blockers: ['缺少世界钩子', '缺少玩家前提'],
publishReady: false,
});
});
test('filters structural blockers already satisfied by the profile', () => {
expect(
buildPlatformRpgAgentResultPublishGateView(
buildProfile(),
[
...structuralBlockers,
{
code: 'future_blocker',
message: '未知服务端阻断',
},
],
false,
),
).toEqual({
blockers: ['未知服务端阻断'],
publishReady: false,
});
});
test('keeps unresolved structural blockers when profile fields are empty', () => {
expect(
buildPlatformRpgAgentResultPublishGateView(
buildProfile({
worldHook: '',
playerPremise: '',
settingText: '',
creatorIntent: null,
anchorContent: null,
coreConflicts: [],
chapters: [],
sceneChapterBlueprints: [],
sceneChapters: [],
}),
structuralBlockers,
true,
),
).toEqual({
blockers: structuralBlockers.map((entry) => entry.message),
publishReady: false,
});
});
test('resolves structural blockers from nested profile compatibility fields', () => {
expect(
buildPlatformRpgAgentResultPublishGateView(
buildProfile({
worldHook: '',
playerPremise: '',
settingText: '',
creatorIntent: {
worldHook: '旧灯塔潮路重新开启。',
},
anchorContent: {
playerEntryPoint: {
openingProblem: '玩家被卷入沉船旧案。',
},
},
coreConflicts: [''],
chapters: [],
sceneChapterBlueprints: null,
sceneChapters: [
{
acts: [{}],
},
],
}),
[
missingWorldHookBlocker,
missingPlayerPremiseBlocker,
missingFirstActBlocker,
],
false,
),
).toEqual({
blockers: [],
publishReady: true,
});
});
test('maps preview source to result label', () => {
expect(resolvePlatformRpgAgentResultPreviewSourceLabel(null)).toBeNull();
expect(
resolvePlatformRpgAgentResultPreviewSourceLabel('published_profile'),
).toBe('已发布世界');
expect(
resolvePlatformRpgAgentResultPreviewSourceLabel('session_preview'),
).toBe('会话预览');
expect(
resolvePlatformRpgAgentResultPreviewSourceLabel('future_source'),
).toBe(
'服务端预览',
);
});
});

View File

@@ -0,0 +1,159 @@
import type { RpgCreationPreviewSource } from '../../../packages/shared/src/contracts/rpgCreationPreview';
import type { CustomWorldProfile } from '../../types';
export type PlatformRpgAgentResultBlockerView = {
code?: string | null;
message: string;
};
export type PlatformRpgAgentResultPublishGateView = {
blockers: string[];
publishReady: boolean;
};
const AGENT_RESULT_STRUCTURAL_BLOCKER_CODES = new Set([
'publish_missing_world_hook',
'publish_missing_player_premise',
'publish_missing_core_conflict',
'publish_missing_main_chapter',
'publish_missing_first_act',
]);
function readProfileTextField(
profile: CustomWorldProfile | null,
paths: string[],
) {
for (const path of paths) {
let current: unknown = profile;
for (const segment of path.split('.')) {
if (!current || typeof current !== 'object') {
current = null;
break;
}
current = (current as Record<string, unknown>)[segment];
}
if (typeof current === 'string' && current.trim()) {
return current.trim();
}
}
return null;
}
function hasProfileTextArray(profile: CustomWorldProfile | null, key: string) {
const value = profile
? (profile as unknown as Record<string, unknown>)[key]
: null;
return Array.isArray(value)
? value.some((entry) => typeof entry === 'string' && entry.trim())
: false;
}
function hasProfileArray(profile: CustomWorldProfile | null, key: string) {
const value = profile
? (profile as unknown as Record<string, unknown>)[key]
: null;
return Array.isArray(value) && value.length > 0;
}
function hasSceneAct(profile: CustomWorldProfile | null) {
const rawProfile = profile as unknown as Record<string, unknown> | null;
const chapters =
rawProfile &&
(Array.isArray(rawProfile.sceneChapterBlueprints)
? rawProfile.sceneChapterBlueprints
: Array.isArray(rawProfile.sceneChapters)
? rawProfile.sceneChapters
: []);
return Array.isArray(chapters)
? chapters.some((chapter) => {
const acts =
chapter && typeof chapter === 'object'
? (chapter as Record<string, unknown>).acts
: null;
return Array.isArray(acts) && acts.length > 0;
})
: false;
}
function isAgentResultStructuralBlockerResolved(
profile: CustomWorldProfile,
code: string | null | undefined,
) {
if (!code || !AGENT_RESULT_STRUCTURAL_BLOCKER_CODES.has(code)) {
return false;
}
if (code === 'publish_missing_world_hook') {
return Boolean(
readProfileTextField(profile, [
'worldHook',
'creatorIntent.worldHook',
'anchorContent.worldPromise',
'anchorContent.worldPromise.hook',
'settingText',
]),
);
}
if (code === 'publish_missing_player_premise') {
return Boolean(
readProfileTextField(profile, [
'playerPremise',
'creatorIntent.playerPremise',
'anchorContent.playerEntryPoint',
'anchorContent.playerEntryPoint.openingIdentity',
'anchorContent.playerEntryPoint.openingProblem',
'anchorContent.playerEntryPoint.entryMotivation',
]),
);
}
if (code === 'publish_missing_core_conflict') {
return hasProfileTextArray(profile, 'coreConflicts');
}
if (code === 'publish_missing_main_chapter') {
return (
hasProfileArray(profile, 'chapters') ||
hasProfileArray(profile, 'sceneChapterBlueprints') ||
hasProfileArray(profile, 'sceneChapters')
);
}
return hasSceneAct(profile);
}
export function buildPlatformRpgAgentResultPublishGateView(
profile: CustomWorldProfile | null,
fallbackBlockers: PlatformRpgAgentResultBlockerView[],
fallbackPublishReady: boolean,
): PlatformRpgAgentResultPublishGateView {
if (!profile) {
return {
blockers: fallbackBlockers.map((entry) => entry.message),
publishReady: fallbackPublishReady,
};
}
const blockers = fallbackBlockers
.filter(
(entry) => !isAgentResultStructuralBlockerResolved(profile, entry.code),
)
.map((entry) => entry.message);
return {
blockers,
publishReady: blockers.length === 0,
};
}
export function resolvePlatformRpgAgentResultPreviewSourceLabel(
source: RpgCreationPreviewSource | string | null | undefined,
) {
if (!source) {
return null;
}
if (source === 'published_profile') {
return '已发布世界';
}
if (source === 'session_preview') {
return '会话预览';
}
return '服务端预览';
}

View File

@@ -0,0 +1,278 @@
import { describe, expect, test } from 'vitest';
import type { SelectionStage } from './platformEntryTypes';
import {
type MissingCreationStateParams,
resolveSelectionStageAfterMissingCreationState,
resolveSelectionStageAfterProtectedDataLoss,
} from './platformSelectionStageModel';
describe('platformSelectionStageModel', () => {
test('keeps public and workspace stages after protected data loss', () => {
const stableStages: SelectionStage[] = [
'platform',
'work-detail',
'detail',
'agent-workspace',
'big-fish-agent-workspace',
'match3d-agent-workspace',
'square-hole-agent-workspace',
'jump-hop-workspace',
'wooden-fish-workspace',
'puzzle-agent-workspace',
'bark-battle-workspace',
'visual-novel-agent-workspace',
'baby-object-match-workspace',
'creative-agent-workspace',
'puzzle-gallery-detail',
];
stableStages.forEach((stage) => {
expect(resolveSelectionStageAfterProtectedDataLoss(stage)).toBe(stage);
});
});
test('resets private result, generating, runtime and profile stages to platform', () => {
const resetStages: SelectionStage[] = [
'profile-feedback',
'big-fish-generating',
'big-fish-result',
'big-fish-runtime',
'match3d-generating',
'match3d-result',
'match3d-runtime',
'square-hole-generating',
'square-hole-result',
'square-hole-runtime',
'jump-hop-generating',
'jump-hop-result',
'jump-hop-runtime',
'jump-hop-gallery-detail',
'wooden-fish-generating',
'wooden-fish-result',
'wooden-fish-runtime',
'visual-novel-generating',
'visual-novel-result',
'visual-novel-gallery-detail',
'visual-novel-runtime',
'baby-object-match-generating',
'baby-object-match-result',
'baby-object-match-runtime',
'baby-love-drawing-runtime',
'puzzle-generating',
'puzzle-onboarding',
'puzzle-result',
'puzzle-runtime',
'custom-world-generating',
'custom-world-result',
'bark-battle-generating',
'bark-battle-result',
'bark-battle-runtime',
];
resetStages.forEach((stage) => {
expect(resolveSelectionStageAfterProtectedDataLoss(stage)).toBe(
'platform',
);
});
});
test('resolves missing session draft result stages', () => {
expect(
resolveSelectionStageAfterMissingCreationState(
buildMissingCreationStateParams({
stage: 'big-fish-result',
bigFish: { hasSession: true, hasSessionDraft: false, hasRun: false },
}),
),
).toBe('big-fish-agent-workspace');
expect(
resolveSelectionStageAfterMissingCreationState(
buildMissingCreationStateParams({
stage: 'big-fish-result',
bigFish: { hasSession: false, hasSessionDraft: false, hasRun: false },
}),
),
).toBe('platform');
expect(
resolveSelectionStageAfterMissingCreationState(
buildMissingCreationStateParams({
stage: 'match3d-result',
match3d: { hasSession: true, hasSessionDraft: false, hasRun: false },
}),
),
).toBe('match3d-agent-workspace');
expect(
resolveSelectionStageAfterMissingCreationState(
buildMissingCreationStateParams({
stage: 'square-hole-result',
squareHole: {
hasSession: true,
hasSessionDraft: false,
hasRun: false,
},
}),
),
).toBe('square-hole-agent-workspace');
});
test('resolves missing session run stages', () => {
expect(
resolveSelectionStageAfterMissingCreationState(
buildMissingCreationStateParams({
stage: 'big-fish-runtime',
bigFish: { hasSession: true, hasSessionDraft: true, hasRun: false },
}),
),
).toBe('big-fish-result');
expect(
resolveSelectionStageAfterMissingCreationState(
buildMissingCreationStateParams({
stage: 'big-fish-runtime',
bigFish: { hasSession: true, hasSessionDraft: false, hasRun: false },
}),
),
).toBe('platform');
expect(
resolveSelectionStageAfterMissingCreationState(
buildMissingCreationStateParams({
stage: 'match3d-runtime',
match3d: { hasSession: true, hasSessionDraft: true, hasRun: false },
}),
),
).toBe('match3d-result');
expect(
resolveSelectionStageAfterMissingCreationState(
buildMissingCreationStateParams({
stage: 'square-hole-runtime',
squareHole: {
hasSession: true,
hasSessionDraft: true,
hasRun: false,
},
}),
),
).toBe('square-hole-result');
});
test('resolves visual novel and baby object missing state stages', () => {
expect(
resolveSelectionStageAfterMissingCreationState(
buildMissingCreationStateParams({
stage: 'visual-novel-result',
visualNovel: {
hasSession: true,
hasSessionDraft: false,
hasWork: false,
hasWorkDraft: false,
hasRun: false,
},
}),
),
).toBe('visual-novel-agent-workspace');
expect(
resolveSelectionStageAfterMissingCreationState(
buildMissingCreationStateParams({
stage: 'visual-novel-runtime',
visualNovel: {
hasSession: true,
hasSessionDraft: false,
hasWork: true,
hasWorkDraft: true,
hasRun: false,
},
}),
),
).toBe('visual-novel-result');
expect(
resolveSelectionStageAfterMissingCreationState(
buildMissingCreationStateParams({
stage: 'visual-novel-gallery-detail',
visualNovel: {
hasSession: false,
hasSessionDraft: false,
hasWork: false,
hasWorkDraft: false,
hasRun: false,
},
}),
),
).toBe('platform');
expect(
resolveSelectionStageAfterMissingCreationState(
buildMissingCreationStateParams({
stage: 'baby-object-match-result',
babyObjectMatch: { hasDraft: false, hasFormPayload: true },
}),
),
).toBe('baby-object-match-workspace');
expect(
resolveSelectionStageAfterMissingCreationState(
buildMissingCreationStateParams({
stage: 'baby-object-match-runtime',
babyObjectMatch: { hasDraft: false, hasFormPayload: true },
}),
),
).toBe('platform');
});
test('keeps stages when required creation state exists', () => {
expect(
resolveSelectionStageAfterMissingCreationState(
buildMissingCreationStateParams({
stage: 'big-fish-result',
bigFish: { hasSession: true, hasSessionDraft: true, hasRun: false },
}),
),
).toBe('big-fish-result');
expect(
resolveSelectionStageAfterMissingCreationState(
buildMissingCreationStateParams({
stage: 'big-fish-runtime',
bigFish: { hasSession: true, hasSessionDraft: true, hasRun: true },
}),
),
).toBe('big-fish-runtime');
expect(
resolveSelectionStageAfterMissingCreationState(
buildMissingCreationStateParams({
stage: 'visual-novel-gallery-detail',
visualNovel: {
hasSession: false,
hasSessionDraft: false,
hasWork: true,
hasWorkDraft: false,
hasRun: false,
},
}),
),
).toBe('visual-novel-gallery-detail');
expect(
resolveSelectionStageAfterMissingCreationState(
buildMissingCreationStateParams({
stage: 'platform',
}),
),
).toBe('platform');
});
});
function buildMissingCreationStateParams(
overrides: Partial<MissingCreationStateParams> = {},
): MissingCreationStateParams {
return {
stage: 'platform',
bigFish: { hasSession: false, hasSessionDraft: false, hasRun: false },
match3d: { hasSession: false, hasSessionDraft: false, hasRun: false },
squareHole: { hasSession: false, hasSessionDraft: false, hasRun: false },
visualNovel: {
hasSession: false,
hasSessionDraft: false,
hasWork: false,
hasWorkDraft: false,
hasRun: false,
},
babyObjectMatch: { hasDraft: false, hasFormPayload: false },
...overrides,
};
}

View File

@@ -0,0 +1,153 @@
import type { SelectionStage } from './platformEntryTypes';
const PROTECTED_DATA_LOSS_STABLE_STAGE_BY_STAGE = {
platform: true,
'profile-feedback': false,
'work-detail': true,
detail: true,
'agent-workspace': true,
'big-fish-agent-workspace': true,
'big-fish-generating': false,
'big-fish-result': false,
'big-fish-runtime': false,
'match3d-agent-workspace': true,
'match3d-generating': false,
'match3d-result': false,
'match3d-runtime': false,
'square-hole-agent-workspace': true,
'square-hole-generating': false,
'square-hole-result': false,
'square-hole-runtime': false,
'jump-hop-workspace': true,
'jump-hop-generating': false,
'jump-hop-result': false,
'jump-hop-runtime': false,
'jump-hop-gallery-detail': false,
'bark-battle-workspace': true,
'bark-battle-generating': false,
'bark-battle-result': false,
'bark-battle-runtime': false,
'wooden-fish-workspace': true,
'wooden-fish-generating': false,
'wooden-fish-result': false,
'wooden-fish-runtime': false,
'creative-agent-workspace': true,
'visual-novel-agent-workspace': true,
'visual-novel-generating': false,
'visual-novel-result': false,
'visual-novel-gallery-detail': false,
'visual-novel-runtime': false,
'baby-object-match-workspace': true,
'baby-object-match-generating': false,
'baby-object-match-result': false,
'baby-object-match-runtime': false,
'baby-love-drawing-runtime': false,
'puzzle-agent-workspace': true,
'puzzle-generating': false,
'puzzle-onboarding': false,
'puzzle-result': false,
'puzzle-gallery-detail': true,
'puzzle-runtime': false,
'custom-world-generating': false,
'custom-world-result': false,
} as const satisfies Record<SelectionStage, boolean>;
export function resolveSelectionStageAfterProtectedDataLoss(
stage: SelectionStage,
): SelectionStage {
return PROTECTED_DATA_LOSS_STABLE_STAGE_BY_STAGE[stage] ? stage : 'platform';
}
type SessionDraftRunState = {
hasSession: boolean;
hasSessionDraft: boolean;
hasRun: boolean;
};
type VisualNovelCreationState = {
hasSession: boolean;
hasSessionDraft: boolean;
hasWork: boolean;
hasWorkDraft: boolean;
hasRun: boolean;
};
type BabyObjectMatchCreationState = {
hasDraft: boolean;
hasFormPayload: boolean;
};
export type MissingCreationStateParams = {
stage: SelectionStage;
bigFish: SessionDraftRunState;
match3d: SessionDraftRunState;
squareHole: SessionDraftRunState;
visualNovel: VisualNovelCreationState;
babyObjectMatch: BabyObjectMatchCreationState;
};
export function resolveSelectionStageAfterMissingCreationState(
params: MissingCreationStateParams,
): SelectionStage {
const { stage } = params;
if (stage === 'big-fish-result' && !params.bigFish.hasSessionDraft) {
return params.bigFish.hasSession ? 'big-fish-agent-workspace' : 'platform';
}
if (stage === 'big-fish-runtime' && !params.bigFish.hasRun) {
return params.bigFish.hasSessionDraft ? 'big-fish-result' : 'platform';
}
if (stage === 'match3d-result' && !params.match3d.hasSessionDraft) {
return params.match3d.hasSession ? 'match3d-agent-workspace' : 'platform';
}
if (stage === 'match3d-runtime' && !params.match3d.hasRun) {
return params.match3d.hasSessionDraft ? 'match3d-result' : 'platform';
}
if (stage === 'square-hole-result' && !params.squareHole.hasSessionDraft) {
return params.squareHole.hasSession
? 'square-hole-agent-workspace'
: 'platform';
}
if (stage === 'square-hole-runtime' && !params.squareHole.hasRun) {
return params.squareHole.hasSessionDraft
? 'square-hole-result'
: 'platform';
}
if (
stage === 'visual-novel-result' &&
!params.visualNovel.hasSessionDraft &&
!params.visualNovel.hasWorkDraft
) {
return params.visualNovel.hasSession
? 'visual-novel-agent-workspace'
: 'platform';
}
if (stage === 'visual-novel-runtime' && !params.visualNovel.hasRun) {
return params.visualNovel.hasSessionDraft || params.visualNovel.hasWorkDraft
? 'visual-novel-result'
: 'platform';
}
if (stage === 'visual-novel-gallery-detail' && !params.visualNovel.hasWork) {
return 'platform';
}
if (
stage === 'baby-object-match-result' &&
!params.babyObjectMatch.hasDraft
) {
return params.babyObjectMatch.hasFormPayload
? 'baby-object-match-workspace'
: 'platform';
}
if (
stage === 'baby-object-match-runtime' &&
!params.babyObjectMatch.hasDraft
) {
return 'platform';
}
return stage;
}

View File

@@ -307,12 +307,21 @@ const {
amountDelta: -1,
balanceAfter: 29,
sourceType: 'asset_operation_consume',
createdAt: '2026-05-03T08:00:00Z',
},
{
id: 'ledger-2',
amountDelta: 30,
balanceAfter: 30,
sourceType: 'invite_invitee_reward',
createdAt: '2026-05-03T09:00:00Z',
},
{
id: 'ledger-3',
amountDelta: 5,
balanceAfter: 35,
sourceType: 'puzzle_author_incentive_claim',
createdAt: '2026-05-03T10:00:00Z',
},
],
})),
@@ -1222,6 +1231,7 @@ test('opens wallet ledger modal from narrative coin card', async () => {
expect(screen.getByText('-1')).toBeTruthy();
expect(screen.getByText('填写邀请码奖励')).toBeTruthy();
expect(screen.getByText('+30')).toBeTruthy();
expect(screen.getByText('拼图作者奖励')).toBeTruthy();
});
test('profile recharge modal shows native qr code on desktop web by default', async () => {

File diff suppressed because it is too large Load Diff

View File

@@ -0,0 +1,89 @@
import { expect, test } from 'vitest';
import type {
ProfileDashboardSummary,
ProfilePlayedWorkSummary,
} from '../../../packages/shared/src/contracts/runtime';
import {
buildProfileDashboardPresentation,
formatCompactPlayTime,
formatDashboardCount,
formatPlayedWorkId,
formatPlayedWorkType,
formatTotalPlayTimeHours,
} from './rpgEntryProfileDashboardPresentation';
function buildDashboard(
overrides: Partial<ProfileDashboardSummary> = {},
): ProfileDashboardSummary {
return {
walletBalance: 12345,
totalPlayTimeMs: 3_780_000,
playedWorldCount: 7,
updatedAt: '2026-06-03T00:00:00.000Z',
...overrides,
};
}
function buildPlayedWork(
overrides: Partial<ProfilePlayedWorkSummary> = {},
): ProfilePlayedWorkSummary {
return {
worldKey: 'rpg:world-1',
ownerUserId: 'user-1',
profileId: 'profile-1',
worldType: 'custom-world',
worldTitle: '星桥',
worldSubtitle: '',
firstPlayedAt: '2026-06-03T00:00:00.000Z',
lastPlayedAt: '2026-06-03T01:00:00.000Z',
lastObservedPlayTimeMs: 60_000,
...overrides,
};
}
test('profile dashboard presentation formats compact counts', () => {
expect(formatDashboardCount(-1)).toBe('0');
expect(formatDashboardCount(9999.4)).toBe('9,999');
expect(formatDashboardCount(12000)).toBe('1.2万');
expect(formatDashboardCount(230000000)).toBe('2.3亿');
});
test('profile dashboard presentation formats play time for cards and modal rows', () => {
expect(formatTotalPlayTimeHours(0)).toBe('0小时');
expect(formatTotalPlayTimeHours(3_780_000)).toBe('1.1小时');
expect(formatCompactPlayTime(59_000)).toBe('0分');
expect(formatCompactPlayTime(3_600_000)).toBe('1.0小时');
expect(formatCompactPlayTime(3 * 24 * 60 * 60 * 1000)).toBe('3天');
expect(formatCompactPlayTime(12 * 24 * 60 * 60 * 1000)).toBe('12天');
});
test('profile dashboard presentation normalizes played work labels and ids', () => {
expect(formatPlayedWorkType('match_3d')).toBe('抓鹅');
expect(formatPlayedWorkType('square-hole')).toBe('方洞');
expect(formatPlayedWorkType('big_fish')).toBe('大鱼');
expect(formatPlayedWorkType('unknown')).toBe('RPG');
expect(formatPlayedWorkId(buildPlayedWork({ profileId: ' ' }))).toBe(
'rpg:world-1',
);
});
test('profile dashboard presentation builds stat labels from dashboard summary', () => {
expect(buildProfileDashboardPresentation(buildDashboard())).toEqual({
playedWorkCount: 7,
playedWorkCountLabel: '7个',
totalPlayTimeLabel: '1.1小时',
walletBalance: 12345,
walletBalanceLabel: '1.2万',
walletBalanceWithUnitLabel: '1.2万泥点',
});
expect(buildProfileDashboardPresentation(null)).toEqual({
playedWorkCount: 0,
playedWorkCountLabel: '0个',
totalPlayTimeLabel: '0小时',
walletBalance: 0,
walletBalanceLabel: '0',
walletBalanceWithUnitLabel: '0泥点',
});
});

View File

@@ -0,0 +1,95 @@
import type {
ProfileDashboardSummary,
ProfilePlayedWorkSummary,
} from '../../../packages/shared/src/contracts/runtime';
export type ProfileDashboardPresentation = {
playedWorkCount: number;
playedWorkCountLabel: string;
totalPlayTimeLabel: string;
walletBalance: number;
walletBalanceLabel: string;
walletBalanceWithUnitLabel: string;
};
export function formatCompactPlayTime(playTimeMs: number) {
const totalMinutes = Math.max(0, Math.floor(playTimeMs / 60000));
const days = totalMinutes / 1440;
if (days >= 10) {
return `${Math.floor(days)}`;
}
if (days >= 1) {
return `${days.toFixed(days >= 3 ? 0 : 1)}`;
}
const hours = totalMinutes / 60;
if (hours >= 1) {
return `${hours.toFixed(hours >= 10 ? 0 : 1)}小时`;
}
return `${Math.max(0, totalMinutes)}`;
}
// “累计游戏时长”卡片固定用小时口径,避免卡片在分钟 / 天之间跳变。
export function formatTotalPlayTimeHours(playTimeMs: number) {
const roundedHours = Math.max(0, Math.round(playTimeMs / 360000) / 10);
return `${roundedHours.toLocaleString('zh-CN', {
maximumFractionDigits: 1,
})}小时`;
}
export function formatDashboardCount(value: number) {
const normalizedValue = Math.max(0, Math.round(value));
if (normalizedValue >= 100000000) {
return `${(normalizedValue / 100000000).toFixed(1)}亿`;
}
if (normalizedValue >= 10000) {
return `${(normalizedValue / 10000).toFixed(1)}`;
}
return normalizedValue.toLocaleString('zh-CN');
}
// 玩法标签沿用首页既有外显口径,未知类型暂归入 RPG。
export function formatPlayedWorkType(value: string | null | undefined) {
const normalizedValue = (value ?? '').toLowerCase();
if (normalizedValue === 'puzzle') {
return '拼图';
}
if (normalizedValue === 'match3d' || normalizedValue === 'match_3d') {
return '抓鹅';
}
if (normalizedValue === 'square-hole' || normalizedValue === 'square_hole') {
return '方洞';
}
if (normalizedValue === 'big_fish' || normalizedValue === 'big-fish') {
return '大鱼';
}
return 'RPG';
}
// 现有契约尚未下发公开作品码,“玩过”列表先沿用 profileId再兜底 worldKey。
export function formatPlayedWorkId(work: ProfilePlayedWorkSummary) {
return work.profileId?.trim() || work.worldKey;
}
export function buildProfileDashboardPresentation(
dashboard: ProfileDashboardSummary | null,
): ProfileDashboardPresentation {
const walletBalance = dashboard?.walletBalance ?? 0;
const walletBalanceLabel = formatDashboardCount(walletBalance);
const playedWorkCount = dashboard?.playedWorldCount ?? 0;
return {
playedWorkCount,
playedWorkCountLabel: `${formatDashboardCount(playedWorkCount)}`,
totalPlayTimeLabel: formatTotalPlayTimeHours(
dashboard?.totalPlayTimeMs ?? 0,
),
walletBalance,
walletBalanceLabel,
walletBalanceWithUnitLabel: `${walletBalanceLabel}泥点`,
};
}

Some files were not shown because too many files have changed in this diff Show More