diff --git a/AGENTS.md b/AGENTS.md index a974a818..9ddf2f74 100644 --- a/AGENTS.md +++ b/AGENTS.md @@ -92,7 +92,7 @@ docs/ │ ├─ AI_NATIVE_UNIFIED_ROLE_ATTRIBUTE_SYSTEM_PRD_2026-04-02.md │ ├─ BUILD_SYSTEM_ATTRIBUTE_SIMILARITY_PRD_2026-04-02.md │ └─ RUNTIME_ITEM_GENERATION_CURRENT_SYSTEM_DESIGN.md -├─ reference/[QwenSpriteSheetTool.tsx](src/tools/QwenSpriteSheetTool.tsx) +├─ reference/ │ ├─ README.md │ └─ FUNCTION_SCRIPT_CATALOG_2026-04-04.md └─ technical/ diff --git a/README.md b/README.md index 98cd591f..c9b219e4 100644 --- a/README.md +++ b/README.md @@ -10,7 +10,7 @@ - NPC 交易、送礼、求助、招募 - 宝藏交互 - 同伴跟随与战斗 -- 预设编辑器 / NPC 视觉编辑器 / 行为编辑器 +- 游戏主流程内嵌的角色资产工坊、自定义世界实体编辑与角色形象编辑 - 自动存档与继续游戏 ## 运行 @@ -99,11 +99,11 @@ npm run check:content - [src/hooks/useCombatFlow.ts](/E:/Repos/Genarrative/src/hooks/useCombatFlow.ts) - [src/hooks/useStoryGeneration.ts](/E:/Repos/Genarrative/src/hooks/useStoryGeneration.ts) -编辑器: +主流程内嵌编辑能力: -- [src/components/PresetEditor.tsx](/E:/Repos/Genarrative/src/components/PresetEditor.tsx) -- [src/components/NpcVisualEditor.tsx](/E:/Repos/Genarrative/src/components/NpcVisualEditor.tsx) -- [src/components/StateFunctionEditor.tsx](/E:/Repos/Genarrative/src/components/StateFunctionEditor.tsx) +- [src/components/CustomWorldEntityEditorModal.tsx](/E:/Repos/Genarrative/src/components/CustomWorldEntityEditorModal.tsx) +- [src/components/CustomWorldNpcVisualEditor.tsx](/E:/Repos/Genarrative/src/components/CustomWorldNpcVisualEditor.tsx) +- [src/components/CustomWorldRoleAssetStudioModal.tsx](/E:/Repos/Genarrative/src/components/CustomWorldRoleAssetStudioModal.tsx) 核心数据: diff --git a/docs/audits/AGENT_TO_DRAFT_TO_WORLD_PIPELINE_AUDIT_2026-04-20.md b/docs/audits/AGENT_TO_DRAFT_TO_WORLD_PIPELINE_AUDIT_2026-04-20.md index d6aa8108..303fdc11 100644 --- a/docs/audits/AGENT_TO_DRAFT_TO_WORLD_PIPELINE_AUDIT_2026-04-20.md +++ b/docs/audits/AGENT_TO_DRAFT_TO_WORLD_PIPELINE_AUDIT_2026-04-20.md @@ -251,8 +251,8 @@ custom-world-library | `anchorContent / creatorIntent / anchorPack / lockState` 被直接塞进 legacy profile | 创作态元数据 | 会跟随自动保存一起写进作品库 profile,但 runtime 并不以这些字段为正式运行时输入 | 当前更像创作态元数据泄漏进运行时 profile | | `qualityFindings` | session / contract 字段 | contract、session store、测试里存在,但没形成生成、渲染、发布阻断闭环 | 当前主链悬空 | | `checkpoints` | session 字段 | session store 会记录,但主工作区和结果页没有真实展示入口 | 当前主链悬空 | -| `suggestedActions` | session 字段 | session 会生成,但主工作区没有接 `QuickActions` 等面板 | 当前主链悬空 | -| `pendingClarifications` | session 字段 | session 有数据,但澄清面板未接入主工作区 | 当前主链悬空 | +| `suggestedActions` | session 字段 | session 会生成,但当前正式工作区没有对应消费面;旧 `QuickActions` 面板已在 `2026-04-21` 判定退出当前版本主链并物理删除 | 当前主链悬空 | +| `pendingClarifications` | session 字段 | session 有数据,但当前正式工作区没有对应消费面;旧澄清面板已在 `2026-04-21` 判定退出当前版本主链并物理删除 | 当前主链悬空 | | `operations` 历史 | session 字段 | 主工作区只展示当前 `activeOperation` 横幅,不展示完整历史 | 当前主链弱消费 | | `roleAssetSummaryLabel / cover* / counts` 等 works 字段 | works 聚合字段 | 后端能返回,但主平台 create tab 没走 `works` 入口 | 当前主链弱消费 | @@ -266,11 +266,11 @@ custom-world-library | --- | --- | --- | | 结果页直接生成 playable/story/landmark | `CustomWorldResultView.tsx` 仍可直接调用 AI 生成 | 与 Agent 对象精修链重复,且不会同步回 session | | 结果页直接编辑 `CustomWorldProfile` | `CustomWorldEntityEditorModal` 仍挂在结果页 | 把结果页继续维持成旧编辑器,而不是 Agent 流程的收口层 | -| 旧 `custom-world/sessions` 世界生成 | `aiService.generateCustomWorldProfile()` 仍完整可用 | 与 Agent 八锚点世界创建重复 | +| 旧 `custom-world/sessions` 世界生成 | `2026-04-20` 审计时仍完整可用;`2026-04-21` 已完成物理删除 | 与 Agent 八锚点世界创建重复;当前遗留问题已转为文档口径清理 | | 作品库 `publish/unpublish` 与 Agent `publish_world` | 两套“发布”概念并行 | 一套作用于 library profile,一套想作用于 Agent session,但后者还未打通 | | 结果页自动保存 | `generatedCustomWorldProfile` 变化时自动 `upsertCustomWorldProfile()` | 让“草稿保存”“作品库存档”“正式发布”语义混在一起 | -## 7.2 冗余或未接线组件 +## 7.2 冗余或已退出当前版本主链的组件 `src/components/custom-world-agent/CustomWorldAgentWorkspace.tsx` 当前只真正接了: @@ -280,7 +280,7 @@ custom-world-library 4. `CustomWorldAgentThread` 5. `CustomWorldAgentComposer` -但同目录下已经存在且主工作区未接线的组件包括: +在 `2026-04-21` 清理前,同目录下还存在一组未接线旧组件: 1. `CustomWorldAgentLockBar.tsx` 2. `CustomWorldAgentDraftDrawer.tsx` @@ -291,6 +291,25 @@ custom-world-library 7. `CustomWorldAgentClarificationPanel.tsx` 8. `CustomWorldGenerateEntityModal.tsx` +其中: + +1. `CustomWorldAgentDraftDrawer.tsx` 已在批次 A 清理中删除 +2. `CustomWorldAgentLockBar.tsx` +3. `CustomWorldAgentDraftDetailPanel.tsx` +4. `CustomWorldAgentQuickActions.tsx` +5. `CustomWorldAgentSummaryPanel.tsx` +6. `CustomWorldAgentIntentSummaryPanel.tsx` +7. `CustomWorldAgentClarificationPanel.tsx` +8. `CustomWorldGenerateEntityModal.tsx` + +已在批次 D 清理中判定为退出当前版本主链,并完成物理删除。 + +因此这里的审计结论需要更新为: + +1. 它们不再属于“待接线组件” +2. 它们属于已确认退场的旧副面板链 +3. 当前版本如果还要补 `suggestedActions / pendingClarifications / draftCards` 的消费面,应基于新的主链设计重新定义,而不是默认把旧面板接回来 + 另外,`src/components/custom-world-home/CustomWorldCreationHub.tsx` 也已存在,但平台 `create` tab 还没有把它接成主入口。 --- @@ -345,16 +364,16 @@ Agent session 2. 只有 `publish_world` 成功后,才产出正式 `CustomWorldProfile` 并允许主入口进入世界。 3. `qualityFindings / blocker` 必须在 foundation draft 完成、资产写回后、publish 前持续重跑。 -## P1:决定旧 world session 流程的命运 +## P1:继续做旧 world session 链的文档收口 -当前最容易继续制造重复复杂度的是旧 `custom-world/sessions` 链。 +`2026-04-21` 更新: -建议二选一: +旧 `custom-world/sessions` 链已经完成物理删除。 -1. 明确保留为“快速世界生成兼容模式”,但从主入口降级。 -2. 明确进入淘汰路径,逐步下线 `generateCustomWorldProfile()` 这条旧链。 +因此这里不再是“保留还是淘汰”的开放问题,而是: -不建议继续让它和 Agent 八锚点链同时作为主入口长期并存。 +1. 继续清理由这条旧链残留在审计、PRD、知识图谱中的过时口径 +2. 把当前正式主链与仍保留的兼容层边界写清楚 ## P1:把 works 创作中心接回主平台 @@ -365,16 +384,23 @@ Agent session 3. 已发布 profile 通过“进入世界”或“查看详情”进入。 4. `myEntries` 退回为作品库子集,而不是 create tab 的唯一数据源。 -## P1:补齐 Agent workspace 的最小闭环 +## P1:为悬空 session 字段重新定义最小闭环 -建议优先接上: +`2026-04-21` 更新: -1. `CustomWorldAgentQuickActions` -2. `CustomWorldAgentDraftDrawer` -3. `CustomWorldAgentDraftDetailPanel` -4. `CustomWorldAgentClarificationPanel` +原文这里建议把旧 `QuickActions / DraftDrawer / DraftDetailPanel / ClarificationPanel` 接回主工作区。 -如果这几个面板不接上,`suggestedActions / pendingClarifications / draftCards` 这些 session 字段会长期处于悬空状态。 +但这些旧副面板已经在当前版本收口判断中被明确认定为: + +1. 不属于现行主链 +2. 不再作为当前版本默认待落地项 +3. 已完成物理删除 + +因此当前更准确的建议应该是: + +1. 如果 `suggestedActions / pendingClarifications / draftCards` 仍要进入正式主流程,需要先重新定义符合当前极简工作区的消费方式 +2. 不应再以“把旧副面板接回来”作为默认方案 +3. 在没有新主链设计前,这些字段继续标记为“主链悬空” ## P2:等主链收口后再清桥接字段 diff --git a/docs/audits/engineering/ENGINEERING_DEAD_CODE_CLEANUP_BATCH_A_2026-04-21.md b/docs/audits/engineering/ENGINEERING_DEAD_CODE_CLEANUP_BATCH_A_2026-04-21.md new file mode 100644 index 00000000..26b2b81a --- /dev/null +++ b/docs/audits/engineering/ENGINEERING_DEAD_CODE_CLEANUP_BATCH_A_2026-04-21.md @@ -0,0 +1,141 @@ +# 工程死分支清理执行记录 A(2026-04-21) + +更新时间:`2026-04-21` + +## 0. 本批次目标 + +这份记录对应: + +- `docs/planning/ENGINEERING_DEAD_CODE_AND_HIDDEN_BRANCH_CLEANUP_PLAN_2026-04-21.md` +- 其中的 `P0 + 批次 A` + +本批次只做一件事: + +**先清理高置信度、低耦合、无正式入口的小型孤岛与残留壳子。** + +这批对象有一个共同特征: + +1. 当前没有正式运行时引用 +2. 没有当前主链计划要接回 +3. 删除后有明确替代路径,或者本身只是历史占位 + +因此这批次不碰运行时真相链、不碰鉴权链、不碰任务物品主链,只先做低风险去噪。 + +--- + +## 1. 本批次已处理对象 + +## 1.1 已删除文件 + +| 文件 | 判定 | 删除原因 | 替代路径 / 当前真相源 | 验证口径 | +| --- | --- | --- | --- | --- | +| `src/services/customWorldPresentation.stub.ts` | 无引用占位 stub | 文件本身就是占位实现,且正式逻辑已由 `customWorldPresentation.ts` 承接 | `src/services/customWorldPresentation.ts` | 符号级检索确认正式调用方都指向正式实现 | +| `src/services/typewriter.ts` | 无引用 helper 残留 | 独立 helper 已失效,正式链路已在 `storyPresentation.ts` / `storyRenderingHelpers.ts` 等处内联或迁移 | `src/hooks/story/storyPresentation.ts`、`src/hooks/story/storyRenderingHelpers.ts` | `getTypewriterDelay` 调用点未指向该文件 | +| `src/prompts/customWorldOrchestratorPrompts.ts` | 前端孤岛 prompt 壳 | 当前无正式 import,正式主编排 prompt 已收口到后端 prompt 目录,前端 `ai.ts` 也保留自己的现行实现 | `server-node/src/prompts/customWorldOrchestratorPrompts.ts`、`src/services/ai.ts` | 全仓检索仅剩文档引用,无代码消费 | +| `src/prompts/storyOrchestratorPrompts.ts` | 前端孤岛 prompt 壳 | 当前无正式 import,剧情语言修复 prompt 已由后端 prompt 目录承接,前端当前执行路径不依赖该文件 | `server-node/src/prompts/storyOrchestratorPrompts.ts`、`src/services/ai.ts` | 全仓检索仅剩文档引用,无代码消费 | +| `src/components/custom-world-home/CustomWorldCreationLauncherModal.tsx` | 无入口 UI 壳层 | 最近两轮工程审计都确认无运行时引用,当前平台主流程未接这条入口 | 当前平台正式入口链 | 文件级检索确认无组件 import | +| `src/components/custom-world-agent/CustomWorldAgentLauncherModal.tsx` | 无入口 UI 壳层 | Agent 创作主流程已切到当前工作区链路,这个旧 modal 没有接线价值 | 当前 Agent 工作区主链 | 文件级检索确认无组件 import | +| `src/components/custom-world-agent/CustomWorldAgentDraftDrawer.tsx` | 无入口 UI 壳层 | 只有孤立 UI 实现,没有正式调用链,也不在当前结果页 / 工作区主链中 | 当前 Agent 工作区与结果页正式链 | 文件级检索确认无组件 import | + +--- + +## 2. 本批次为什么先删这 7 个 + +这批文件适合先处理,不是因为它们最大,而是因为它们最清晰: + +1. **没有正式入口。** + 本轮检索没有发现主工程 import。 +2. **删除后不会形成职责空洞。** + 要么已有正式替代路径,要么本身只是历史占位。 +3. **不会误伤当前重点链路。** + 这批不涉及运行时快照、鉴权、任务、物品、AI 正式编排主链。 +4. **可以最快降低目录噪音。** + 先把真假并存的壳子删掉,后面做批次 B/C/D 时判断成本会更低。 + +--- + +## 3. 本批次暂不处理对象 + +以下对象虽然已进入首轮台账,但本批次暂不删除: + +1. `src/components/GameShell.tsx` +2. `src/components/custom-world-home/CustomWorldCreationHub.tsx` +3. `src/hooks/story/storyBootstrap.ts` +4. `src/hooks/useEquipmentFlow.ts` +5. `src/hooks/useForgeFlow.ts` +6. `src/hooks/useInventoryFlow.ts` +7. `src/data/buildTagSimilarity.generated.ts` + +暂缓原因分别是: + +1. 仍属于旧主流程 / 旧 flow 级别对象,删除前要先核对更多历史依赖和替代路径 +2. 部分对象仍有测试引用或更大的上下文耦合 +3. `buildTagSimilarity.generated.ts` 虽无正式业务 import,但属于生成产物,处理前还要确认脚本链与文档链 + +这批对象更适合进入: + +1. `批次 B:旧 flow / 旧 shell / 旧 hook` +2. 或独立的数据产物复核批次 + +--- + +## 4. 本批次同步更新的文档 + +本批次除了删文件,还同步做了文档回填: + +1. 新增本执行记录,说明本批删了什么、为什么删、哪些对象暂缓 +2. 更新 `docs/audits/engineering/README.md`,把这份执行记录加入当前审计入口 + +这样做的目的,是避免再次出现: + +1. 代码删了 +2. 但审计入口还是旧状态 +3. 后续开发又从旧清单里重复判断一遍 + +--- + +## 5. 验证方式 + +本批次验证采用两层口径: + +## 5.1 删除前验证 + +1. 文件级检索确认无正式 import +2. 符号级检索确认关键导出没有被主链消费 +3. 结合 `2026-04-20` 工程审计交叉确认这些对象已被标记为高置信度孤岛 + +## 5.2 删除后验证 + +建议至少执行: + +1. `npm run check:encoding` +2. `npm run build` + +说明: + +- 当前仓库已知 `typecheck` 与 `lint` 仍处于红线阶段,因此本批不把它们作为“由本批引入的新失败”判断口径 +- 本批主要验证目标是:删除小残留后,不产生新的导入断裂和构建断裂 + +--- + +## 6. 本批次结果判断 + +本批次完成后,工程至少获得了 3 个直接收益: + +1. `src/prompts/`、`src/services/`、`src/components/custom-world-*` 中少了一批无入口孤岛 +2. 当前目录里“看起来像正式入口,其实已经废弃”的误导性对象减少 +3. 后续可以把精力集中到真正高价值的批次 B/C/D,而不是继续被小残留分散判断成本 + +--- + +## 7. 下一批建议 + +建议严格按计划继续往下推进: + +1. 批次 B:`GameShell`、`storyBootstrap`、`useEquipmentFlow`、`useForgeFlow`、`useInventoryFlow` +2. 批次 C:`runtimeStoryCoordinator`、`runtimeStoryService`、`apiClient` +3. 批次 D:`npcEncounterActions`、`questDirector`、`runtimeItemAiDirector`、`ai.ts` + +一句话总结本批次: + +**先把最确定的死分支和占位壳子清掉,让主工程少一些假入口、假主源、假能力,再进入更重的主链收口。** diff --git a/docs/audits/engineering/ENGINEERING_DEAD_CODE_CLEANUP_BATCH_B_2026-04-21.md b/docs/audits/engineering/ENGINEERING_DEAD_CODE_CLEANUP_BATCH_B_2026-04-21.md new file mode 100644 index 00000000..f4bb8093 --- /dev/null +++ b/docs/audits/engineering/ENGINEERING_DEAD_CODE_CLEANUP_BATCH_B_2026-04-21.md @@ -0,0 +1,145 @@ +# 工程死分支清理执行记录 B(2026-04-21) + +更新时间:`2026-04-21` + +## 0. 本批次目标 + +这份记录对应清洗计划中的: + +- `批次 B:旧 flow / 旧 shell / 旧 hook` + +本批次聚焦的不是小型 stub,而是: + +**已经退出正式主流程、但仍占着高辨识度命名和旧职责心智的壳层与流程 Hook。** + +这类文件如果继续留在仓库里,问题比小 helper 更大,因为它们会持续制造误判: + +1. 新人会以为它们还是正式入口 +2. 后续开发会误判“应该往这里接逻辑” +3. review 时会多出一层“旧主链是不是还活着”的判断成本 + +--- + +## 1. 本批次已处理对象 + +## 1.1 已删除文件 + +| 文件 | 判定 | 删除原因 | 替代路径 / 当前真相源 | 验证口径 | +| --- | --- | --- | --- | --- | +| `src/components/GameShell.tsx` | 旧主流程壳层残留 | 当前正式壳层已由 `src/components/game-shell/GameShellRuntime.tsx` 承接,旧文件无正式 import | `src/components/game-shell/GameShellRuntime.tsx`、`src/hooks/useGameShellRuntime.ts` | 全仓检索未发现对旧 `GameShell` 组件的正式消费 | +| `src/hooks/story/storyBootstrap.ts` | 旧启动流程 Hook 残留 | 当前主剧情启动链已不再调用该 Hook,继续保留只会误导人以为它还是故事初始化入口 | 当前 story runtime / coordinator 链 | 全仓检索未发现 `useStoryBootstrap` 消费方 | +| `src/hooks/useEquipmentFlow.ts` | 旧装备流程 Hook 残留 | 当前正式背包与装备链未消费该 Hook,属于旧流程实现残留 | 当前 inventory / runtime 正式链 | 符号级检索仅命中定义文件自身 | +| `src/hooks/useForgeFlow.ts` | 旧锻造流程 Hook 残留 | 当前正式锻造入口未通过该 Hook 进入主链,保留会制造旧流程错觉 | 当前 inventory / runtime 正式链 | 符号级检索仅命中定义文件自身 | +| `src/hooks/useInventoryFlow.ts` | 旧背包使用流程 Hook 残留 | 当前主流程未消费该 Hook,属于旧状态推进实现残留 | 当前 inventory / runtime 正式链 | 符号级检索仅命中定义文件自身 | + +--- + +## 2. 为什么这批要紧跟批次 A 处理 + +批次 A 清掉的是“小型假入口”。 + +批次 B 清掉的是“高辨识度旧主链”。 + +这批必须紧跟着做,原因是: + +1. 它们虽然比 stub 更大,但引用关系同样清楚 +2. 它们的误导性比小残留更强 +3. 不先处理这批,后面做批次 C/D 时,很容易继续有人拿旧 flow Hook 当候选接线点 + +一句话讲: + +**批次 A 是去噪,批次 B 是拔掉旧路牌。** + +--- + +## 3. 本批次删除后的结构变化 + +本批次完成后,仓库里的流程心智会更清楚: + +1. 游戏壳层正式入口继续收敛到 `src/components/game-shell/**` +2. 旧 `GameShell.tsx` 不再和 `GameShellRuntime.tsx` 并存 +3. 旧的装备 / 锻造 / 背包单独 flow Hook 不再伪装成还在生效的正式实现 +4. 旧 `storyBootstrap` 不再和当前 story runtime 链并存 + +这会直接减少两类误判: + +1. “是不是还有旧主流程没迁完” +2. “我是不是应该把新逻辑继续补进这些旧 Hook” + +--- + +## 4. 本批次暂不处理对象 + +虽然批次 B 已经处理了旧 shell / old flow / old bootstrap,但以下对象仍暂缓: + +1. `src/components/custom-world-home/CustomWorldCreationHub.tsx` +2. `src/data/buildTagSimilarity.generated.ts` +3. 批次 C 的运行时真相链: + - `src/hooks/story/runtimeStoryCoordinator.ts` + - `src/services/runtimeStoryService.ts` + - `src/services/apiClient.ts` +4. 批次 D 的混合执行层: + - `src/hooks/story/npcEncounterActions.ts` + - `src/services/questDirector.ts` + - `src/services/runtimeItemAiDirector.ts` + - `src/services/ai.ts` + +暂缓原因很明确: + +1. 这些对象要么仍在当前正式链上 +2. 要么涉及运行时真相与鉴权边界 +3. 不能按“无引用旧壳”同一口径直接删除 + +--- + +## 5. 本批次验证方式 + +## 5.1 删除前验证 + +1. 全仓检索 `GameShell` 旧组件消费方,确认当前正式壳层已切到 `game-shell/` 目录 +2. 全仓检索 `useStoryBootstrap` +3. 全仓检索旧装备 / 锻造 / 背包 flow Hook 导出的 handler 名称 +4. 交叉确认当前正式主链入口已存在替代实现 + +## 5.2 删除后验证 + +建议至少执行: + +1. `npm run check:encoding` +2. `npm run build` + +如果这两项通过,说明: + +1. 删除没有引入新的导入断裂 +2. 主工程构建链仍然成立 + +--- + +## 6. 本批次结果判断 + +本批次完成后,工程获得的直接收益是: + +1. 旧主流程壳层不再和现行壳层并存 +2. 旧流程 Hook 不再占据 `src/hooks/` 的主路径注意力 +3. 当前正式入口和历史残留的边界更清楚 +4. 后续开发更不容易把新逻辑接回旧流程壳子 + +--- + +## 7. 下一批建议 + +建议下一步进入真正有结构价值的收口: + +1. `批次 C:运行时真相收口` + - `runtimeStoryCoordinator` + - `runtimeStoryService` + - `apiClient` +2. `批次 D:任务 / 物品 / AI 混合执行层收口` + - `npcEncounterActions` + - `questDirector` + - `runtimeItemAiDirector` + - `ai.ts` + +一句话总结本批次: + +**这一步不是在“删几个没用 Hook”,而是在把已经退场的旧主流程壳层和旧 flow 路牌从主工程里真正拔掉,让现行架构不再和历史壳子并排站着。** diff --git a/docs/audits/engineering/ENGINEERING_DEAD_CODE_CLEANUP_BATCH_C_2026-04-21.md b/docs/audits/engineering/ENGINEERING_DEAD_CODE_CLEANUP_BATCH_C_2026-04-21.md new file mode 100644 index 00000000..9997b5f1 --- /dev/null +++ b/docs/audits/engineering/ENGINEERING_DEAD_CODE_CLEANUP_BATCH_C_2026-04-21.md @@ -0,0 +1,202 @@ +# 工程死分支清理执行记录 C(2026-04-21) + +更新时间:`2026-04-21` + +## 0. 本批次目标 + +这份记录对应清洗计划中的: + +- `批次 C:运行时真相收口` + +但这次不是“一口气把运行时真相链全删干净”,而是先做其中最明确、风险最低、最不该继续拖的那一段: + +1. **收掉前端本地自动登录用户名 / 密码真相** +2. **把登录恢复改成优先依赖服务端 session / refresh** + +同时,这一批也明确记录了一件事: + +**运行时快照前置写入链当前还不能直接砍。** + +原因不是“不想动”,而是服务端当前 `runtime story` 动作入口仍然以远端快照作为执行基线。 +在后端 contract 没先改好之前,前端不能假装自己已经退出这条链。 + +--- + +## 1. 本批次已处理对象 + +## 1.1 已收口的鉴权链 + +| 文件 | 处理动作 | 本批结论 | +| --- | --- | --- | +| `src/services/apiClient.ts` | 删除本地自动登录用户名 / 密码存取逻辑 | 前端不再保存 auto auth 账号密码 | +| `src/services/authService.ts` | 去掉对本地游客凭证的读写依赖 | 自动游客登录改为仅本次生成凭证,不再长期落本地 | +| `src/components/auth/AuthGate.tsx` | 去掉“必须先有本地 access token 才尝试恢复”的前置假设 | 登录恢复改为优先尝试服务端 `getCurrentAuthUser()` / refresh session | +| `src/services/authService.test.ts` | 改写游客自动登录相关断言 | 验证改为“生成临时凭证并完成登录”,而不是“落本地账号密码” | +| `src/components/auth/AuthGate.test.tsx` | 改写登录恢复 mock | 验证改为“先尝试服务端会话恢复,再决定是否走游客兜底” | + +--- + +## 2. 本批次为什么先做这段 + +这批优先级高,是因为它同时满足 4 条: + +1. **风险明确。** + 浏览器保存自动登录用户名 / 密码,本身就不符合“前端只做表现、后端负责鉴权真相”的方向。 +2. **替代路径已经存在。** + 后端已经有 refresh session cookie 与 `getCurrentAuthUser()`,不是没有可替代能力。 +3. **改动边界清楚。** + 这一段主要落在前端鉴权恢复逻辑和测试,不会直接波及运行时战斗、任务、物品、剧情主链。 +4. **收益直接。** + 一旦收掉,前端就少了一份最不该长期保留的高风险真相。 + +一句话讲: + +**这一步先把“浏览器记住游客账号密码再重登”这条假真相链拔掉。** + +--- + +## 3. 本批次明确没做的事 + +## 3.1 没有直接删除 `runtimeStoryCoordinator.ts` 里的前置 `putSaveSnapshot(...)` + +这不是漏做,而是明确暂缓。 + +当前复核结果是: + +1. `server-node/src/modules/story/storyActionService.ts` +2. `server-node/src/routes/runtimeRoutes.ts` +3. `server-node/src/repositories/runtimeRepository.ts` + +这条后端链当前仍然通过远端快照读取运行时状态,再执行: + +1. `getRuntimeStoryState` +2. `resolveRuntimeStoryAction` + +也就是说,当前真实情况不是“前端多写了一份完全没用的镜像”,而是: + +**前端在提交动作前先把当前状态写回远端快照,后端再基于这份快照执行业务动作。** + +在这个 contract 没先升级为“前端只发 action,后端自己持有完整 session 真相”之前,前端不能直接把这一步砍掉。 + +否则会出现: + +1. 动作请求仍在走 +2. 但服务端读取到的执行基线不完整 +3. 最后不是收口真相,而是把主链打断 + +## 3.2 没有删除 `runtimeStoryService.ts` / `runtimeStoryCoordinator.ts` 的快照再水合逻辑 + +这一步本轮也做了复核,结论是: + +1. 我曾尝试把 `runtimeStoryCoordinator.ts` 中对服务端返回快照的重复再水合去掉 +2. 但对应的 `runtimeStoryCoordinator` 测试立即暴露出:当前后端返回的快照在部分战斗场景下还不是完整水合态 +3. 说明前端当前这层再水合仍然有现实职责,不是纯多余代码 + +所以这一步本批明确结论是: + +**暂不删除,等后端快照 contract 先补完整后再做。** + +--- + +## 4. 本批次验证结果 + +本批次已完成的定向验证: + +1. `npx vitest run src/services/authService.test.ts` +2. `npx vitest run src/components/auth/AuthGate.test.tsx` +3. `npx vitest run src/hooks/story/runtimeStoryCoordinator.test.ts` +4. `npm run check:encoding` + +结果: + +1. `authService` 测试通过 +2. `AuthGate` 测试通过 +3. `runtimeStoryCoordinator` 测试通过 +4. 编码检查通过 + +另外执行了: + +1. `npm run build` + +结果: + +构建产物生成成功,但 `build-gate` 仍因主包 chunk warning 拦截失败。 +当前失败点仍是已知的主包体积问题: + +- `AuthenticatedApp-*.js` 超过当前 warning 门槛 + +这属于仓库当前既有工程问题,不是本批次引入的新断裂。 + +## 4.1 2026-04-21 补充修正:会话探测 401 自触发循环 + +在这批收口完成后,前端又暴露出一条更细的鉴权恢复回路问题: + +1. `AuthGate` 启动时会调用 `getCurrentAuthUser()` 探测现有会话 +2. `/api/auth/me` 返回 `401` 时,`apiClient.ts` 会默认广播一次 `AUTH_STATE_EVENT` +3. `AuthGate` 自己又监听这个事件并重新 `hydrate()` +4. 最终形成 `hydrate -> /auth/me 401 -> emit -> hydrate` 的自触发循环 + +这条链的问题不在“是否允许 401”,而在: + +**会话探测请求把“未登录态探测”错误地当成了“全局登录态变更”。** + +因此这里补了一条更细粒度的约束: + +1. `apiClient.ts` 新增 `notifyAuthStateChange` 选项,默认仍保持原有广播行为 +2. `getCurrentAuthUser()` 作为会话探测请求,显式关闭这类 401 广播 +3. 真实登录、登出、刷新成功后,仍保留全局鉴权变更通知 + +这样修完后: + +1. `AuthGate` 仍会优先尝试服务端会话恢复 +2. 无会话时会正常落回未登录分支 +3. 不会因为探测型 401 把自己重新唤醒并刷爆控制台 + +--- + +## 5. 本批次完成后的实际收益 + +这一步完成后,工程在鉴权边界上有了两个明确改善: + +1. **前端不再保存自动登录用户名 / 密码。** + 浏览器只保留 access token,本地高风险游客凭证真相已经收掉。 +2. **登录恢复逻辑更接近服务端为真相源。** + `AuthGate` 不再假设“没有本地 token 就一定还没登录”,而是优先尝试服务端会话恢复。 + +这意味着前端鉴权链已经从: + +```text +本地用户名/密码 -> 再次 entry -> 拿 token +``` + +进一步收到了: + +```text +refresh session / 当前会话 -> 恢复用户 +兜底时才创建一次游客凭证 +``` + +--- + +## 6. 本批次后续建议 + +要继续完成批次 C,下一步不该直接在前端硬删,而应该先补后端 contract: + +1. 让 `runtime story` 动作链逐步摆脱“前端先写远端快照”的依赖 +2. 让服务端自己持有更完整的运行时 session 真相 +3. 等后端返回快照已经稳定水合后,再删前端的重复再水合 + +换句话说,批次 C 的后半段应该拆成: + +1. **C-1:鉴权真相收口** + 本批已完成 +2. **C-2:运行时快照 contract 后端化** + 需要先改后端 +3. **C-3:前端镜像写入与重复水合退场** + 依赖 C-2 + +--- + +## 7. 一句话总结 + +**批次 C 这一轮已经先把“浏览器长期保存游客账号密码”这条最不该存在的鉴权假真相链收掉了;而运行时快照前置写入这条链经过复核确认仍受后端 contract 约束,不能在服务端未先补齐前硬砍。** diff --git a/docs/audits/engineering/ENGINEERING_DEAD_CODE_CLEANUP_BATCH_D_2026-04-21.md b/docs/audits/engineering/ENGINEERING_DEAD_CODE_CLEANUP_BATCH_D_2026-04-21.md new file mode 100644 index 00000000..89315af5 --- /dev/null +++ b/docs/audits/engineering/ENGINEERING_DEAD_CODE_CLEANUP_BATCH_D_2026-04-21.md @@ -0,0 +1,56 @@ +# 工程死分支清理执行记录 D(2026-04-21) + +更新时间:`2026-04-21` + +## 0. 本批次目标 + +本批次继续清理上一轮复核后剩余的低风险数据产物与测试占位: + +1. 未接入业务的生成产物 +2. 只为测试替换真实实现的空 stub +3. 支撑这些残留的配置与脚本 + +--- + +## 1. 已删除对象 + +| 文件 | 判定 | 删除原因 | 替代路径 / 当前真相源 | +| --- | --- | --- | --- | +| `src/data/buildTagSimilarity.generated.ts` | 未接入业务的生成产物 | 运行时代码不 import;Build 相似度当前由 `buildTags.ts` 中的属性亲和度逻辑计算 | `src/data/buildTags.ts` | +| `scripts/generate-build-tag-similarity.py` | 已无输出目标的生成脚本 | 只负责生成已删除的矩阵文件,继续保留会误导后续开发恢复旧主源 | `src/data/buildTags.ts` 的手工审表逻辑 | +| `src/data/customWorldCharacterLoadout.stub.ts` | 测试专用空 stub | 只通过 `vitest.config.ts` alias 替换真实实现;真实实现已经稳定存在 | `src/data/customWorldCharacterLoadout.ts` | + +--- + +## 2. 同步更新 + +本批次同步移除了: + +1. `vitest.config.ts` 中指向 `customWorldCharacterLoadout.stub.ts` 的 alias +2. `BUILD_SYSTEM_ATTRIBUTE_SIMILARITY_PRD_2026-04-02.md` 中把旧 generated 矩阵描述为当前文件的表述 +3. 清理计划里对 `buildTagSimilarity.generated.ts` 的未处理状态说明 + +--- + +## 3. 验证口径 + +删除前已确认: + +1. `buildTagSimilarity.generated.ts` 无运行时代码引用 +2. `customWorldCharacterLoadout.stub.ts` 只被 `vitest.config.ts` alias 引用 +3. 真实 `customWorldCharacterLoadout.ts` 仍被 `characterPresets.ts` 与 `npcInteractions.ts` 使用,不能删除 + +删除后建议验证: + +1. `npm run check:encoding` +2. 与自定义世界开局物品相关的测试 + +--- + +## 4. 当前结论 + +本批次完成后,剩余清理对象已经不再适合按“无引用直接删”推进。后续如果继续清,需要先改 contract 或主链职责: + +1. 运行时快照真相链 +2. 任务 / 物品 / AI 混合执行层 +3. 大型主流程组件继续拆分,而不是直接删除 diff --git a/docs/audits/engineering/ENGINEERING_DEAD_CODE_CLEANUP_BATCH_E_2026-04-21.md b/docs/audits/engineering/ENGINEERING_DEAD_CODE_CLEANUP_BATCH_E_2026-04-21.md new file mode 100644 index 00000000..01c2ff5f --- /dev/null +++ b/docs/audits/engineering/ENGINEERING_DEAD_CODE_CLEANUP_BATCH_E_2026-04-21.md @@ -0,0 +1,117 @@ +# 工程死分支清理执行记录 E(2026-04-21) + +更新时间:`2026-04-21` + +## 0. 本批次目标 + +本批次承接批次 D,继续清掉已经退出 RPG 游戏创作主流程、RPG 运行时玩法主流程、平台基本功能主流程的历史壳层。 + +本批次不处理仍需后端 contract 先收口的对象,例如: + +1. `src/services/questDirector.ts` +2. `src/services/runtimeItemAiDirector.ts` +3. `src/hooks/rpg-runtime-story/runtimeStoryCoordinator.ts` +4. `src/services/apiClient.ts` + +这些对象仍属于“前端越界逻辑继续后端化”的后续批次,不按无引用文件直接删除。 + +--- + +## 1. 删除判定口径 + +本批只删除满足下面条件之一的对象: + +1. 无运行时入口、无脚本入口、无当前路由挂载。 +2. 已有现行正式实现,旧文件只剩 re-export / facade / 兼容命名。 +3. 只被测试验证旧壳自身,且该测试不再服务当前主流程门禁。 +4. 文档已明确该对象处于“后续只允许收缩、不再接新逻辑”的兼容残留状态。 + +--- + +## 2. 本批次已处理对象 + +| 文件 | 判定 | 删除原因 | 替代路径 / 当前真相源 | +| --- | --- | --- | --- | +| `server-node/src/routes/rpgCreationAgentRoutes.ts` | 旧命名 re-export | 当前后端正式路由直接使用 `customWorldAgent.ts` | `server-node/src/routes/customWorldAgent.ts` | +| `server-node/src/routes/rpgWorldGalleryRoutes.ts` | 空路由骨架 | 世界广场实际列表和详情已经进入世界库路由 | `server-node/src/routes/rpg-entry/rpgWorldLibraryRoutes.ts` | +| `server-node/src/services/RpgAgentOrchestrator.ts` | 旧命名 re-export | 当前正式上下文直接使用 `CustomWorldAgentOrchestrator` | `server-node/src/services/customWorldAgentOrchestrator.ts` | +| `server-node/src/services/RpgAgentSessionStore.ts` | 旧命名 re-export | 当前正式上下文直接使用 `CustomWorldAgentSessionStore` | `server-node/src/services/customWorldAgentSessionStore.ts` | +| `server-node/src/services/customWorldWorkSummaryService.ts` | 旧兼容入口 | 测试和路由已改为直接使用 RPG 命名服务 | `server-node/src/services/RpgWorldWorkSummaryService.ts` | +| `server-node/src/services/customWorldAgentPublishGateService.ts` | 旧发布门禁实现 | 当前 action executor 与作品库发布链已统一走 PublishingService | `server-node/src/services/customWorldAgentPublishingService.ts` | +| `server-node/src/services/customWorldAgentPublishService.ts` | 旧发布实现 | 当前发布链不再编译旧 legacy result profile | `server-node/src/services/customWorldAgentPublishingService.ts` | +| `server-node/src/modules/custom-world/runtime-profile/runtimeProfileCompiler.ts` | 旧 facade | runtime profile 已拆到目录模块并由 `index.ts` / `runtimeProfile.ts` 承接 | `server-node/src/modules/custom-world/runtime-profile/index.ts` | +| `server-node/src/bridges/legacyBuildRuntimeBridge.ts` | 无引用旧桥 | 后端 runtime build / equipment 已直接在正式模块内使用 | `server-node/src/modules/runtime/**` | +| `server-node/src/bridges/legacyRuntimeItemResolutionBridge.ts` | 旧桥 | runtime item 解析服务一并删除,正式运行时使用 `runtimeItemModule.ts` | `server-node/src/modules/runtime-item/runtimeItemModule.ts` | +| `server-node/src/modules/runtime-item/runtimeItemResolutionService.ts` | 无正式入口 wrapper | 只被 barrel 和自身测试引用,未挂入 Express 运行时主链 | `server-node/src/modules/runtime-item/runtimeItemModule.ts` | +| `server-node/src/modules/**/index.ts` | 无引用 barrel | 这些 barrel 没有被当前后端入口消费,反而制造“公共模块入口仍存在”的错觉 | 直接 import 具体正式模块 | +| `server-node/src/routes/rpg-*/index.ts` | 无引用 barrel | 当前 Express app 直接 import 具体 route 文件 | `server-node/src/app.ts` 中的具体路由 | +| `server-node/src/repositories/rpg-*/index.ts` | 无引用 barrel | 当前上下文直接 import 具体 repository | `server-node/src/server.ts` 中的具体仓储 | +| `src/components/DeveloperTeamModal.tsx` | 无入口 UI | 平台主流程没有打开该弹窗的入口 | 无替代 UI,删除历史壳 | +| `src/components/LazySkillEffectPreview.tsx` | 无入口 lazy 壳 | 正式技能预览直接使用 `SkillEffectPreview` | `src/components/SkillEffectPreview.tsx` | +| `src/components/npcVisualEditorModel.ts` | 旧 NPC 形象写回模型 | 当前 RPG 创作编辑器使用 `CustomWorldNpcVisualEditor` 与结果页新入口 | `src/components/CustomWorldNpcVisualEditor.tsx`、`src/components/rpg-creation-editor/**` | +| `src/components/npcVisualEditorPersistence.ts` | 旧 NPC 形象写回持久层 | 只被旧持久化测试引用,正式编辑入口已迁移 | `src/components/rpg-creation-editor/**` | +| `src/components/rpg-creation-*/index.ts` | 无引用 barrel | 当前入口直接 import 具体 facade 文件,barrel 没有主流程消费 | 直接 import `RpgCreation*` 具体文件 | +| `src/components/rpg-creation-editor/CustomWorldSceneChapterEditorSection.tsx` | 旧 facade | 当前编辑器 section 直接在 `RpgCreationEntityEditorShared.tsx` 中分发 | `src/components/rpg-creation-editor/RpgCreationEntityEditorShared.tsx` | +| `src/data/editorValidation.ts` | 旧预设编辑器校验 | 当前主流程和内容门禁不再调用 | `scripts/validate-overrides.ts`、后端 editor API | +| `src/editor/shared/EditorNotice.tsx` | 无入口共享 UI | 只被同批删除的 FormFields 使用 | 无替代 UI,删除历史编辑器壳 | +| `src/editor/shared/FormFields.tsx` | 无入口共享 UI | 旧编辑器共享表单未接主流程 | 当前 RPG 编辑器组件内聚在 `rpg-creation-editor/**` | +| `src/editor/shared/SectionCard.tsx` | 无入口共享 UI | 旧编辑器卡片未接主流程 | 当前 RPG 编辑器组件内聚在 `rpg-creation-editor/**` | +| `src/hooks/rpg-runtime-story/npcEncounterActions.ts` | 旧 wrapper | 正式实现已在 `useRpgRuntimeNpcInteraction.ts`,测试已改到正式文件 | `src/hooks/rpg-runtime-story/useRpgRuntimeNpcInteraction.ts` | +| `src/hooks/rpg-runtime-story/openingAdventure.ts` | 旧前端开局特殊流程 | 开局营地对白已由后端 `RpgRuntimeStoryActionDomain` 和当前 story context 承接 | `server-node/src/modules/rpg-runtime-story/RpgRuntimeStoryActionDomain.ts` | +| `src/hooks/rpg-runtime-story/storyCampCompanion.ts` | 旧前端营地同伴 helper | 只剩旧开局流程和自身测试引用,正式开局上下文已迁到当前 runtime story 链 | 后端 runtime story action domain 与 `storyContextBuilder.ts` | +| `src/hooks/rpg-runtime-story/storyRenderingHelpers.ts` | 无入口旧渲染 helper | 当前正式 story presentation 不再 import | `src/hooks/rpg-runtime-story/storyPresentation.ts` | +| `src/prompts/questPrompts.ts` | 前端 prompt 残留 | Quest prompt 真相已迁到后端 | `server-node/src/prompts/questPrompts.ts` | +| `src/prompts/runtimeItemPrompts.ts` | 前端 prompt 残留 | Runtime item prompt 真相已迁到后端 | `server-node/src/prompts/runtimeItemPrompts.ts` | +| `src/services/questPrompt.ts` | 前端 prompt re-export | 只指向同批删除的前端 prompt | `server-node/src/prompts/questPrompts.ts` | +| `src/services/runtimeItemAiPrompt.ts` | 前端 prompt re-export | 只指向同批删除的前端 prompt | `server-node/src/prompts/runtimeItemPrompts.ts` | +| `src/services/storyEngine/contentDependencyGraph.ts` | 实验性孤岛 | 只被自身测试引用,没有主流程消费 | 后续如需要重新设计到后端 story graph 服务 | + +--- + +## 3. 同步调整 + +1. `customWorldAgentPhase2/3/4` 测试改为直接实例化 `RpgWorldWorkSummaryService`。 +2. `customWorldWorkSummaryService.integration.test.ts` 改为直接覆盖 `RpgWorldWorkSummaryService`。 +3. `npcEncounterActions.test.ts` 改为直接覆盖 `useRpgRuntimeNpcInteraction.ts`,不再通过旧 wrapper。 +4. `story_opening_camp_dialogue` 的 function catalog 执行路径改为后端 runtime action domain,不再指向已删除旧前端文件。 +5. NPC function catalog 中 `npc_chat / npc_help / npc_leave / npc_fight / npc_spar / npc_preview_talk` 的 executor 路牌改到现行 `useRpgRuntimeNpcInteraction.ts`。 + +--- + +## 4. 本批次暂缓对象 + +以下对象仍然保留,原因是它们不是“无引用死代码”,而是需要下一轮按 contract 或主链职责迁移: + +1. `src/services/questDirector.ts` +2. `src/services/runtimeItemAiDirector.ts` +3. `src/services/ai.ts` +4. `src/data/sceneObservation.ts` +5. `server-node/ecosystem.config.cjs` +6. `server-node/src/scripts/syncCustomWorldSavedProfileAssets.ts` + +其中 `ecosystem.config.cjs` 被部署脚本直接使用;`sceneObservation.ts` 被内容 smoke 脚本验证;`syncCustomWorldSavedProfileAssets.ts` 是一次性运维脚本,后续要单独按运维脚本治理口径确认是否归档。 + +--- + +## 5. 验证口径 + +本批删除后建议验证: + +1. `npm run check:encoding` +2. `npx tsx --test server-node/src/services/customWorldWorkSummaryService.integration.test.ts` +3. `npx vitest run src/hooks/rpg-runtime-story/npcEncounterActions.test.ts` +4. `npm run server-node:build` +5. `npm run build` + +如果 `npm run build` 仍被既有 chunk warning 拦截,需要单独记录为既有门禁问题,不归因到本批删除。 + +--- + +## 6. 当前结论 + +本批次进一步删除了“旧命名入口、旧 facade、旧 prompt 前端镜像、无入口编辑器壳层”这批容易误导后续开发的文件。 + +后续清理不应继续按“静态无引用”直接推进,而应进入两类工作: + +1. 运行时 / 任务 / 物品 / AI 的后端 contract 收口。 +2. RPG 创作编辑器与运行时热点文件的职责拆分。 diff --git a/docs/audits/engineering/ENGINEERING_DEAD_CODE_CLEANUP_BATCH_F_2026-04-21.md b/docs/audits/engineering/ENGINEERING_DEAD_CODE_CLEANUP_BATCH_F_2026-04-21.md new file mode 100644 index 00000000..5c26b54e --- /dev/null +++ b/docs/audits/engineering/ENGINEERING_DEAD_CODE_CLEANUP_BATCH_F_2026-04-21.md @@ -0,0 +1,91 @@ +# 工程死分支清理执行记录 F(2026-04-21) + +更新时间:`2026-04-21` + +## 0. 本批次目标 + +本批次承接批次 E 的验证结果,继续处理删除后暴露出的最后一组高置信残留: + +1. 已经没有任何代码入口引用的前端任务生成 director。 +2. 只被内容 smoke 牵住、但不再是正式运行时入口的旧观察文案 helper。 +3. 带有固定用户、固定 session、固定 profile 的一次性历史同步脚本。 +4. 清理后暴露出的 function catalog 契约覆盖缺口。 + +本批次仍然不按文件名直接删除 `legacy` 命名对象。经核对,`server-node/src/bridges/legacyInventoryRuntimeBridge.ts`、`legacyNpcTask6Bridge.ts`、`legacyQuestProgressBridge.ts`、`legacyQuestRuntimeBridge.ts`、`legacyRuntimeItemBridge.ts`、`legacyTreasureRuntimeBridge.ts` 仍被后端战斗、背包、任务、宝藏主链直接引用,不能按历史命名硬删。 + +--- + +## 1. 删除判定口径 + +本批删除对象必须同时满足: + +1. 修正 `.js -> .ts` 后端源码解析、前端懒加载入口解析后,仍不可从正式入口到达。 +2. 全仓库代码引用扫描没有正式入口引用。 +3. 如只被 smoke 或测试牵住,先把 smoke / 测试改到当前正式主链,再删除旧对象。 +4. 删除后通过对应门禁验证,没有新增悬空 import。 + +--- + +## 2. 本批次已处理对象 + +| 文件 | 判定 | 删除 / 调整原因 | 替代路径 / 当前真相源 | +| --- | --- | --- | --- | +| `src/services/questDirector.ts` | 无代码入口残留 | 正式 quest 生成已由后端 `/api/runtime/quests/generate` 与 `questService.ts` 承接,前端当前没有任何 import | `server-node/src/services/questService.ts`、`server-node/src/modules/quest/runtimeQuestModule.ts` | +| `src/data/sceneObservation.ts` | 旧观察文案 helper | 只被 `scripts/smoke-content.ts` 引用,正式观察动作已走 `idle_observe_signs` function 与运行时 story continuation | `src/data/functionCatalog/state/idleObserveSigns.ts`、`src/hooks/rpg-runtime-story/storyChoiceContinuation.ts` | +| `server-node/src/scripts/syncCustomWorldSavedProfileAssets.ts` | 一次性硬编码运维脚本 | 脚本内固定用户、session、profile,只服务历史补丁,没有 CLI 参数和当前运维入口 | 无替代;如未来需要,按参数化运维脚本重新设计 | + +--- + +## 3. 同步调整 + +1. `scripts/smoke-content.ts` 不再 import 旧 `sceneObservation.ts`,改为通过 `resolveFunctionOption('idle_observe_signs', ...)` 验证当前正式 function 目录。 +2. `packages/shared/src/contracts/rpgRuntimeContracts.test.ts` 不再验证已移除的旧 `story` façade,改为直接验证当前拆分契约。 +3. `src/data/functionCatalog/` 补齐仍在后端运行时契约中的 function 文档: + - `battle_attack_basic` + - `battle_use_skill` + - `npc_chat_quest_offer_view` + - `npc_chat_quest_offer_replace` + - `npc_chat_quest_offer_abandon` +4. `battle_attack_basic` 与 `battle_use_skill` 只作为后端契约文档登记,不进入 `STATE_FUNCTION_DEFINITIONS`,避免前端本地候选池生成缺少 `runtimePayload.skillId` 的假技能 option。 + +--- + +## 4. 本批次暂缓对象 + +以下对象经本批复核后继续保留: + +1. `server-node/src/services/customWorldAgentRepositoryTestHelpers.ts` +2. `server-node/src/services/customWorldAgentTestHelpers.ts` +3. `server-node/src/testFixtures/runtimeCharacter.ts` +4. `server-node/src/testHttp.ts` + +这些文件不属于正式运行时入口,但当前被后端测试、smoke 与路由边界门禁使用。它们不是 RPG 创作 / 运行时玩法主流程代码,但仍是平台基本质量门禁的一部分,不能在“删除冗余业务代码”批次里直接硬删。 + +另保留: + +1. `src/services/runtimeItemAiDirector.ts` +2. `src/services/ai.ts` +3. `src/services/apiClient.ts` + +这些文件仍被当前主链或前端 SDK 入口引用,后续如继续压缩,必须先完成对应 contract / SDK 拆分,不按无引用规则删除。 + +--- + +## 5. 验证结果 + +本批已通过: + +1. `npx vitest run src/data/functionCatalog/functionCatalog.test.ts packages/shared/src/contracts/rpgRuntimeContracts.test.ts` +2. `npx tsx scripts/smoke-content.ts` +3. `npm run check:encoding` + +并额外确认: + +1. 全仓库代码中不再引用 `sceneObservation`、`questDirector`、`syncCustomWorldSavedProfileAssets`。 +2. `buildStateFunctionDefinitions()` 中不会出现 `battle_attack_basic` / `battle_use_skill`,这两个 function 只由后端运行时 option 池下发。 + +--- + +## 6. 当前结论 + +本批次后,静态入口扫描中剩余的高置信“不可达源码”已经收敛为测试辅助、测试夹具和 smoke helper。继续删除前需要先重构测试基础设施或迁移剩余前端 SDK,而不应再按文件名或历史命名直接硬删。 diff --git a/docs/audits/engineering/FRONTEND_LOGIC_BACKEND_MIGRATION_AUDIT_2026-04-21.md b/docs/audits/engineering/FRONTEND_LOGIC_BACKEND_MIGRATION_AUDIT_2026-04-21.md new file mode 100644 index 00000000..63b5b541 --- /dev/null +++ b/docs/audits/engineering/FRONTEND_LOGIC_BACKEND_MIGRATION_AUDIT_2026-04-21.md @@ -0,0 +1,553 @@ +# 前端应迁后端逻辑审计(2026-04-21) + +更新时间:`2026-04-21` + +## 0. 审计目标 + +这份文档只回答一个问题: + +**当前前端代码里,哪些逻辑已经明显越过“前端只做表现,Express 后端负责逻辑、数据与存储”的边界,应该继续迁到后端。** + +本轮不改业务代码,只做: + +1. 基于当前仓库状态给出高置信度候选点 +2. 标明代码证据 +3. 给出迁移优先级 +4. 说明迁移后前端应该保留什么、移走什么 + +--- + +## 1. 结论先行 + +结合当前代码与已有边界文档,前端里仍有 7 类逻辑应该继续后移: + +1. **运行时快照前置写入与本地镜像解释** +2. **鉴权 token 的浏览器本地真相** +3. **平台浏览历史的本地真相与迁移状态** +4. **NPC 待接委托“换单”仍由前端直接触发正式生成** +5. **quest/runtime item 的双环境混合编排** +6. **浏览器侧大型 AI orchestration 与 prompt/repair/fallback 主链** +7. **NPC 招募对白之后的正式结算链路** + +一句话判断: + +**当前前端已经不是最早那种“大量主算”的状态,但仍然保留了运行时镜像、生成编排和部分正式真相。后端边界还需要再收一轮,前端才算真正退回表现层。** + +--- + +## 2. 审计依据 + +### 2.1 文档依据 + +1. `docs/experience/PROJECT_WORK_EXPERIENCE_PLAYBOOK.md` +2. `docs/planning/EXPRESS_BACKEND_REFACTOR_PLAN_2026-04-08.md` +3. `docs/technical/RUNTIME_STORY_BACKEND_BOUNDARY_MIGRATION_2026-04-19.md` +4. `docs/audits/engineering/ENGINEERING_CLEANUP_AND_BACKEND_BOUNDARY_AUDIT_2026-04-20.md` +5. `docs/audits/engineering/CURRENT_ENGINEERING_OPTIMIZATION_OPPORTUNITIES_2026-04-20.md` + +### 2.2 当前代码依据 + +1. `src/hooks/story/runtimeStoryCoordinator.ts` +2. `src/services/apiClient.ts` +3. `src/services/platformBrowseHistory.ts` +4. `src/components/game-shell/PreGameSelectionFlow.tsx` +5. `src/hooks/story/npcEncounterActions.ts` +6. `src/services/questDirector.ts` +7. `src/services/runtimeItemAiDirector.ts` +8. `src/services/ai.ts` + +--- + +## 3. 当前高置信度应后移逻辑 + +## 3.0 本轮已完成后移 + +以下链路已在本轮或上一轮连续落地中完成后移,不再属于“仍残留在前端”的正式主链: + +1. access token 浏览器本地真相 +2. browse history 本地真相 +3. runtime story 前置 `PUT /runtime/save/snapshot` +4. NPC 待接委托 `replace / abandon / accept` +5. custom world profile 正式浏览器入口 +6. `questDirector` / `runtimeItemAiDirector` 浏览器正式编排 +7. NPC 招募正式结算 + +其中 NPC 招募已从“前端本地改 companions / roster / npcStates / storyHistory”收回到后端 runtime action。 + +## 3.1 运行时快照前置写入仍在前端 + +### 代码证据 + +`src/hooks/story/runtimeStoryCoordinator.ts` 当前仍存在以下链路: + +1. `syncRuntimeSnapshot(...)` +2. `syncRuntimeSnapshot(...)` 内部直接调用 `putSaveSnapshot(...)` +3. `loadServerRuntimeOptionCatalog(...)` 在请求 `getRuntimeStoryState(...)` 之前先写本地快照 +4. `resolveServerRuntimeChoice(...)` 在请求 `resolveRuntimeStoryAction(...)` 之前先写本地快照 + +对应位置: + +1. `src/hooks/story/runtimeStoryCoordinator.ts:21` +2. `src/hooks/story/runtimeStoryCoordinator.ts:25` +3. `src/hooks/story/runtimeStoryCoordinator.ts:36` +4. `src/hooks/story/runtimeStoryCoordinator.ts:99` + +### 当前问题 + +这意味着运行时正式动作发起前,前端仍会先落一份自己的快照真相,再去请求后端。 + +这条链的问题不是“有没有缓存”,而是: + +1. 前端仍在承担正式提交前的状态镜像 +2. 快照解释权没有完全收回到后端 +3. 运行时主链仍处于“本地镜像 + 服务端会话”并存状态 + +### 迁移建议 + +后端继续承接: + +1. 运行时快照写入 +2. 快照版本解释 +3. 动作提交前的状态一致性校验 + +前端只保留: + +1. 当前展示用的 view model +2. 可选的只读恢复缓存 +3. 纯表现态的 loading / transition / animation state + +### 优先级 + +`P0` + +--- + +## 3.2 鉴权 token 仍由前端 localStorage 持有真相 + +### 代码证据 + +`src/services/apiClient.ts` 当前仍直接访问 `window.localStorage` 保存 access token: + +1. `getStoredAccessToken()` +2. `setStoredAccessToken(...)` +3. `clearStoredAccessToken(...)` +4. `withAuthorizationHeaders(...)` 直接从本地 token 组装请求头 + +对应位置: + +1. `src/services/apiClient.ts:333` +2. `src/services/apiClient.ts:341` +3. `src/services/apiClient.ts:362` +4. `src/services/apiClient.ts:382` + +### 当前问题 + +第三批清理已经收掉了“自动登录用户名/密码”本地真相,但 access token 仍然由浏览器长期持有。 + +这在当前项目边界下仍有两个问题: + +1. 正式鉴权真相仍没有完全收回后端 session 边界 +2. 前端 SDK 仍然负担 token 生命周期的关键部分 + +### 迁移建议 + +后端继续承接: + +1. session / refresh / cookie 真相 +2. 鉴权状态续期 +3. token 更新与失效策略 + +前端只保留: + +1. 当前是否已登录的展示态 +2. 统一的请求封装 +3. 401 后的 UI 响应 + +### 优先级 + +`P0` + +--- + +## 3.3 平台浏览历史仍是“前端本地历史 + 后端回填”的双真相 + +### 代码证据 + +`src/services/platformBrowseHistory.ts` 当前仍维护一整套本地历史真相: + +1. `readPlatformBrowseHistory(...)` +2. `writePlatformBrowseHistory(...)` +3. `hasPendingPlatformBrowseHistoryMigration(...)` +4. `markPlatformBrowseHistoryMigrated(...)` + +对应位置: + +1. `src/services/platformBrowseHistory.ts:77` +2. `src/services/platformBrowseHistory.ts:103` +3. `src/services/platformBrowseHistory.ts:151` +4. `src/services/platformBrowseHistory.ts:164` + +`src/components/game-shell/PreGameSelectionFlow.tsx` 当前仍显式做: + +1. 先 `writePlatformBrowseHistory(...)` +2. 再调用 `upsertProfileBrowseHistory(...)` +3. 同步成功后 `markPlatformBrowseHistoryMigrated(...)` +4. 启动阶段读取 `readPlatformBrowseHistory(...)` +5. 根据 `hasPendingPlatformBrowseHistoryMigration(...)` 决定是否补同步 + +对应位置: + +1. `src/components/game-shell/PreGameSelectionFlow.tsx:383` +2. `src/components/game-shell/PreGameSelectionFlow.tsx:392` +3. `src/components/game-shell/PreGameSelectionFlow.tsx:394` +4. `src/components/game-shell/PreGameSelectionFlow.tsx:433` +5. `src/components/game-shell/PreGameSelectionFlow.tsx:466` + +### 当前问题 + +这条链已经不是单纯缓存,而是: + +1. 本地历史存储 +2. 本地同步标记 +3. 后端历史持久化 + +三套状态同时存在。 + +### 迁移建议 + +后端继续承接: + +1. 浏览历史唯一持久化真相 +2. 历史去重、排序、截断 +3. 迁移完成标记 + +前端只保留: + +1. 展示缓存 +2. 弱网下的临时 optimistic UI +3. 刷新后重新拉取远端结果 + +### 优先级 + +`P1` + +--- + +## 3.4 NPC 待接委托“换单”仍由前端直接发起正式生成 + +### 代码证据 + +`src/hooks/story/npcEncounterActions.ts` 当前仍保留: + +1. `replacePendingNpcQuestOffer = async () => { ... }` +2. 内部直接调用 `generateQuestForNpcEncounter(...)` + +对应位置: + +1. `src/hooks/story/npcEncounterActions.ts:1561` +2. `src/hooks/story/npcEncounterActions.ts:1595` + +### 当前问题 + +聊天后是否挂出待接委托已经后移,但“换一份委托”这条分支仍然是: + +1. 前端组装上下文 +2. 前端决定调用生成 +3. 前端直接把结果写回当前 story UI + +这仍属于正式运行时任务编排没有收干净。 + +### 迁移建议 + +后端继续承接: + +1. NPC 待接委托换单决策 +2. 是否允许换单 +3. 换单后的任务草案生成 +4. 对应聊天态快照回填 + +前端只保留: + +1. 点击“换一份委托” +2. loading / error 展示 +3. 消费后端返回的新 pending quest offer + +### 优先级 + +`P0` + +--- + +## 3.5 questDirector 仍是前端 SDK 与生成编排混合体 + +### 代码证据 + +`src/services/questDirector.ts` 当前同时承担: + +1. `generateQuestForNpcEncounter(...)` +2. 浏览器路径 `requestJson('/api/runtime/quests/generate')` +3. 非浏览器路径 `requestChatMessageContent(...)` +4. 本地 `compileQuestIntentToQuest(...)` fallback + +对应位置: + +1. `src/services/questDirector.ts:213` +2. `src/services/questDirector.ts:242` +3. `src/services/questDirector.ts:267` +4. `src/services/questDirector.ts:256` +5. `src/services/questDirector.ts:281` +6. `src/services/questDirector.ts:293` + +### 当前问题 + +这类文件虽然浏览器正式路径已经优先走后端,但职责仍混在一起: + +1. 前端 SDK +2. Quest prompt 编排 +3. Quest intent 解析 +4. deterministic fallback compile + +这会导致边界长期模糊,也让前端仍像“半个服务端”。 + +### 迁移建议 + +后端继续承接: + +1. quest intent 生成 +2. prompt 组装 +3. JSON 解析 +4. fallback compile + +前端只保留: + +1. `requestGenerateQuest(...)` 这类轻量 SDK +2. 请求参数组装 +3. 结果消费 + +### 优先级 + +`P1` + +--- + +## 3.6 runtimeItemAiDirector 仍是前端 SDK 与意图生成混合体 + +### 代码证据 + +`src/services/runtimeItemAiDirector.ts` 当前同时承担: + +1. `generateRuntimeItemAiIntents(...)` +2. 浏览器路径 `requestJson('/api/runtime/items/runtime-intent')` +3. 非浏览器路径 `requestChatMessageContent(...)` +4. 本地 `buildRuntimeItemAiIntent(...)` fallback + +对应位置: + +1. `src/services/runtimeItemAiDirector.ts:84` +2. `src/services/runtimeItemAiDirector.ts:94` +3. `src/services/runtimeItemAiDirector.ts:118` + +### 当前问题 + +它和 `questDirector` 是同类问题: + +1. 正式浏览器路径已经走后端 +2. 但前端文件仍然承担完整生成逻辑认知 +3. 文件职责仍然是双环境混合 + +### 迁移建议 + +后端继续承接: + +1. runtime item intent prompt +2. 模型调用 +3. 结果解析与 fallback + +前端只保留: + +1. 轻量请求 SDK +2. 结果到 UI 的映射 + +### 优先级 + +`P1` + +--- + +## 3.7 `src/services/ai.ts` 仍是浏览器侧正式 AI orchestration 热点 + +### 代码证据 + +当前 `src/services/ai.ts` 仍直接承担以下正式链路: + +1. `requestChatMessageContent(...)` +2. `requestPlainTextCompletionFromClient(...)` +3. `streamPlainTextCompletionFromClient(...)` +4. `generateCustomWorldProfile(...)` +5. `generateInitialStory(...)` +6. `generateNextStep(...)` +7. `streamNpcChatDialogue(...)` +8. `streamNpcRecruitDialogue(...)` + +对应位置: + +1. `src/services/ai.ts:1732` +2. `src/services/ai.ts:1868` +3. `src/services/ai.ts:2038` +4. `src/services/ai.ts:2339` +5. `src/services/ai.ts:2447` +6. `src/services/ai.ts:2487` +7. `src/services/ai.ts:2529` +8. `src/services/ai.ts:2570` + +并且文件内仍保留: + +1. JSON repair +2. prompt 组装 +3. response normalize +4. fallback/offline 响应 +5. 角色聊天建议与摘要生成 + +### 当前问题 + +这说明浏览器端并不只是“请求一个后端接口”,而是还在承担: + +1. prompt source +2. 生成策略 +3. 错误修复 +4. fallback 编排 +5. 多类业务场景的正式 AI 出口 + +这与“前端只做表现”存在明确冲突。 + +### 迁移建议 + +后端继续承接: + +1. story / npc / recruit / custom-world 的 prompt 编排 +2. JSON repair +3. fallback 策略 +4. streaming orchestration +5. 模型调用与日志 + +前端只保留: + +1. 轻量 AI SDK +2. SSE 文本流展示 +3. UI fallback 呈现 + +### 优先级 + +`P0` + +--- + +## 3.8 NPC 招募对白之后的正式结算链路已完成后移 + +### 本轮前状态 + +迁移前,`src/hooks/story/npcInteraction.ts` 中的 `buildRecruitmentOutcome / executeRecruitment / startRecruitmentSequence` 仍在前端本地正式结算: + +1. 改 `npcStates` +2. 改 `companions` +3. 改 `roster` +4. 清 `currentEncounter / inBattle / sceneHostileNpcs` +5. 直接写 `storyHistory` +6. 再触发后续剧情推进 + +这与“前端只做表现,所有正式逻辑、数据都放到 Express 后端”直接冲突。 + +### 本轮后状态 + +本轮已完成: + +1. `server-node/src/modules/story/runtimeSession.ts` + - 正式承接完整 `companions` + - 正式承接 `roster` +2. `server-node/src/modules/npc/npcInteractionService.ts` + - `npc_recruit` 已支持正常入队 + - `npc_recruit` 已支持满员换队招募 +3. `src/hooks/story/npcInteraction.ts` + - 前端只保留招募对白流式展示 + - 正式招募结算改为调用后端 runtime action + +### 当前判断 + +这一项已不再属于前端残留正式逻辑。 + +--- + +## 4. 可以暂时保留在前端的部分 + +下面这些内容即使和上述模块同文件出现,也不属于必须后移的对象: + +1. 面板开关、loading、error、streaming 文本展示 +2. 动画时间线、过场状态、临时 UI 回显 +3. 表单草稿、筛选词、排序选项 +4. 只影响表现、不影响正式真相的 view model 拼接 + +迁移时要注意: + +**不是把所有前端代码都往后端搬,而是把“正式状态解释、规则裁决、生成编排、持久化真相”搬走。** + +--- + +## 5. 推荐迁移顺序 + +## 5.1 第一阶段 + +先收最危险的正式真相: + +1. `runtimeStoryCoordinator.ts` +2. `apiClient.ts` +3. `npcEncounterActions.ts` 里的 quest replace 分支 + +原因: + +1. 这三处最直接影响运行时真相和动作主链 +2. 不先收这些,前端仍然不是纯表现层 + +## 5.2 第二阶段 + +再拆双环境混合服务: + +1. `questDirector.ts` +2. `runtimeItemAiDirector.ts` +3. `platformBrowseHistory.ts` + +原因: + +1. 这几处已经有后端承接基础 +2. 迁移成本相对可控 + +## 5.3 第三阶段 + +最后继续压缩浏览器 AI orchestration: + +1. `src/services/ai.ts` +2. 相关 prompt builder / repair helper / offline fallback + +原因: + +1. 这部分体量大 +2. 链路多 +3. 更适合在前两阶段把 contract 稳住后集中拆 + +--- + +## 6. 建议产出物 + +如果后续按这份文档继续落地,建议每一批都至少同步产出: + +1. 一份落地文档,说明迁移了哪条链 +2. 一组 contract/route 变更说明 +3. 一组前端 SDK 收缩说明 +4. 一组防回退测试 + +--- + +## 7. 一句话结论 + +当前前端最需要继续后移的,不是零散小工具,而是: + +**运行时快照前置写入、鉴权 token、本地浏览历史真相、NPC 委托换单、quest/runtime item 双环境混合编排,以及 `src/services/ai.ts` 里仍然留在浏览器的正式 AI orchestration。** diff --git a/docs/audits/engineering/README.md b/docs/audits/engineering/README.md index e3ae7bcf..0fd8f240 100644 --- a/docs/audits/engineering/README.md +++ b/docs/audits/engineering/README.md @@ -4,21 +4,42 @@ ## 当前推荐入口 -1. [CURRENT_ENGINEERING_OPTIMIZATION_OPPORTUNITIES_2026-04-20.md](./CURRENT_ENGINEERING_OPTIMIZATION_OPPORTUNITIES_2026-04-20.md) +1. [ENGINEERING_DEAD_CODE_CLEANUP_BATCH_F_2026-04-21.md](./ENGINEERING_DEAD_CODE_CLEANUP_BATCH_F_2026-04-21.md) + 这一版是第六批落地记录,聚焦删除无入口 `questDirector`、旧观察文案 helper、一次性硬编码同步脚本,并补齐后端运行时 function catalog 契约覆盖。 +2. [ENGINEERING_DEAD_CODE_CLEANUP_BATCH_E_2026-04-21.md](./ENGINEERING_DEAD_CODE_CLEANUP_BATCH_E_2026-04-21.md) + 这一版是第五批落地记录,聚焦旧命名 re-export、空路由骨架、旧发布服务、前端 prompt 镜像与无入口编辑器壳层的物理删除。 +3. [FRONTEND_LOGIC_BACKEND_MIGRATION_AUDIT_2026-04-21.md](./FRONTEND_LOGIC_BACKEND_MIGRATION_AUDIT_2026-04-21.md) + 这一版是本轮前端越界逻辑专项审计,专门汇总当前仍应继续迁到 Express 后端的运行时、鉴权、生成编排与本地真相残留。 +4. [ENGINEERING_DEAD_CODE_CLEANUP_BATCH_D_2026-04-21.md](./ENGINEERING_DEAD_CODE_CLEANUP_BATCH_D_2026-04-21.md) + 这一版是第四批落地记录,聚焦未接入业务的数据生成产物、测试专用 stub 与对应配置残留出清。 +5. [ENGINEERING_DEAD_CODE_CLEANUP_BATCH_C_2026-04-21.md](./ENGINEERING_DEAD_CODE_CLEANUP_BATCH_C_2026-04-21.md) + 这一版是第三批落地记录,聚焦鉴权真相收口,先移除前端保存自动登录用户名/密码的本地真相,并明确运行时快照前置写入为什么当前还不能硬砍。 +6. [ENGINEERING_DEAD_CODE_CLEANUP_BATCH_B_2026-04-21.md](./ENGINEERING_DEAD_CODE_CLEANUP_BATCH_B_2026-04-21.md) + 这一版是第二批落地记录,聚焦旧主流程壳层、旧 bootstrap 和旧 inventory / forge / equipment flow Hook 的正式出清。 +7. [ENGINEERING_DEAD_CODE_CLEANUP_BATCH_A_2026-04-21.md](./ENGINEERING_DEAD_CODE_CLEANUP_BATCH_A_2026-04-21.md) + 这一版是第一批落地记录,聚焦高置信度小型孤岛、prompt 壳子、stub 和无入口 modal 的首轮清理。 +8. [CURRENT_ENGINEERING_OPTIMIZATION_OPPORTUNITIES_2026-04-20.md](./CURRENT_ENGINEERING_OPTIMIZATION_OPPORTUNITIES_2026-04-20.md) 这一版是面向当前仓库状态的优化点盘点,适合直接拿来排优先级和拆执行批次。 -2. [ENGINEERING_CLEANUP_AND_BACKEND_BOUNDARY_AUDIT_2026-04-20.md](./ENGINEERING_CLEANUP_AND_BACKEND_BOUNDARY_AUDIT_2026-04-20.md) +9. [ENGINEERING_CLEANUP_AND_BACKEND_BOUNDARY_AUDIT_2026-04-20.md](./ENGINEERING_CLEANUP_AND_BACKEND_BOUNDARY_AUDIT_2026-04-20.md) 这一版是对 `2026-04-19` 基线的当前仓库复核,明确哪些问题已经处理、哪些表述需要纠正、热点又迁移到了哪里。 -3. [ENGINEERING_CLEANUP_AND_BACKEND_BOUNDARY_AUDIT_2026-04-19.md](./ENGINEERING_CLEANUP_AND_BACKEND_BOUNDARY_AUDIT_2026-04-19.md) +10. [ENGINEERING_CLEANUP_AND_BACKEND_BOUNDARY_AUDIT_2026-04-19.md](./ENGINEERING_CLEANUP_AND_BACKEND_BOUNDARY_AUDIT_2026-04-19.md) 这一版保留原始问题快照和执行回填,适合回看“为什么会有这轮清理与边界收口”。 -4. [ENGINEERING_OPTIMIZATION_REVIEW_2026-04-01.md](./ENGINEERING_OPTIMIZATION_REVIEW_2026-04-01.md) +11. [ENGINEERING_OPTIMIZATION_REVIEW_2026-04-01.md](./ENGINEERING_OPTIMIZATION_REVIEW_2026-04-01.md) 这一版最适合作为当前工程基线,重点从“是否真正绿色”“门禁有没有覆盖真实风险”来判断仓库状态。 -5. [ENGINEERING_OPTIMIZATION_REVIEW_2026-03-30.md](./ENGINEERING_OPTIMIZATION_REVIEW_2026-03-30.md) +12. [ENGINEERING_OPTIMIZATION_REVIEW_2026-03-30.md](./ENGINEERING_OPTIMIZATION_REVIEW_2026-03-30.md) 适合回看运行时主链路、Story/Combat 边界、分层过渡期问题。 -6. [ENGINEERING_OPTIMIZATION_REVIEW_2026-03-29.md](./ENGINEERING_OPTIMIZATION_REVIEW_2026-03-29.md) +13. [ENGINEERING_OPTIMIZATION_REVIEW_2026-03-29.md](./ENGINEERING_OPTIMIZATION_REVIEW_2026-03-29.md) 适合看第一轮系统性工程扫描,了解最早的问题基线。 ## 融合结论 +- 最新专项审计已经把“前端哪些逻辑还该后移到后端”收敛到 6 类:运行时快照、本地 token、本地浏览历史、NPC 委托换单、quest/runtime item 混合编排、浏览器 AI orchestration。 +- 工程大清洗已经开始进入实际执行阶段,首批高置信度小型孤岛和残留壳子已开始清理。 +- 第二批已经开始清理旧主流程壳层与旧 flow Hook,当前主工程的“现行入口”和“历史入口”边界正在变得更清楚。 +- 第三批已经先完成鉴权真相收口的一段,前端不再保存自动登录用户名/密码;运行时快照链仍需先补后端 contract,再继续往前删。 +- 第四批已经继续收掉未接入业务的数据生成产物、测试专用 stub 与对应脚本/配置残留,主工程里的“假数据主源”进一步减少。 +- 第五批已经继续收掉旧命名 re-export、空路由骨架、旧发布 service、前端 prompt 镜像与无入口编辑器壳层,主工程里的“假入口”和“假 prompt 主源”进一步减少。 +- 第六批已经继续收掉无入口 `questDirector`、旧观察文案 helper、一次性硬编码同步脚本,并修复 function catalog 对后端运行时契约的覆盖缺口。 - 当前仓库已经完成“旧 dev 插件链路删除、根目录噪音清理、`server-node -> src/**` 反向依赖切断”这批第一阶段任务。 - 当前如果想直接判断“今天先优化什么”,优先看 `CURRENT_ENGINEERING_OPTIMIZATION_OPPORTUNITIES_2026-04-20.md`。 - 当前的新重点已经进一步收敛到三类:未接线孤岛模块、前端残留的运行时/鉴权真相、热点向 prompt/runtime profile/平台入口壳层迁移。 diff --git a/docs/experience/AGENT_UI_CHANGELOG.md b/docs/experience/AGENT_UI_CHANGELOG.md index 80c40c9f..16a87f01 100644 --- a/docs/experience/AGENT_UI_CHANGELOG.md +++ b/docs/experience/AGENT_UI_CHANGELOG.md @@ -163,4 +163,13 @@ --- +## 13. 2026-04-21 创作中心失效草稿恢复兜底 + +- `src/components/rpg-entry/useRpgEntryLibraryDetail.ts` 现在会识别 `custom-world agent session` 的 `404 NOT_FOUND` 读取失败,不再把这类错误直接冒泡成未捕获 Promise。 +- 当用户在创作中心点击“继续创作”命中失效草稿时,前端会主动清空 `customWorldSessionId` 恢复参数,并刷新一次 works 列表,避免刷新页面后反复尝试恢复同一个坏会话。 +- 当前交互已收口成平台内可见提示:用户会停留在创作中心,并看到“这份共创草稿已失效,已为你返回创作中心,请重新开始创作。”,而不是卡在空白工作区或只在控制台看到英文异常。 +- 这次兜底只处理失效会话恢复,不改变正常草稿继续创作、结果页恢复和已发布作品进入世界的主链。 + +--- + _文档目的:交接给下一个 Agent 时,优先读本文件 + `UI_CODING_STANDARD.md`,再改 `uiAssets.ts` / `App.tsx` / `index.css`。_ diff --git a/docs/experience/CODEX_PAST_WORK_EXPERIENCE_SUMMARY.md b/docs/experience/CODEX_PAST_WORK_EXPERIENCE_SUMMARY.md index 99166c90..5a15a1ab 100644 --- a/docs/experience/CODEX_PAST_WORK_EXPERIENCE_SUMMARY.md +++ b/docs/experience/CODEX_PAST_WORK_EXPERIENCE_SUMMARY.md @@ -22,20 +22,20 @@ ### 2.1 编辑器入口与页签整理 -- 保留 `/preset-editor`、`/npc-editor`、`/function-editor` -- 新增 `/behavior-editor` 作为“选项行为”编辑页别名 +- 当时曾保留 `/preset-editor`、`/npc-editor`、`/function-editor` +- 当时还新增过 `/behavior-editor` 作为“选项行为”编辑页别名 - 将原先单独的 `NPC 视觉` 标签并回 `NPC` 编辑页 - 将 `Function` 页签改名为 `选项行为` 结论: -- 路由层要尽量兼容旧入口,避免历史链接失效 +- 独立编辑器入口如果没有继续接入主流程,应及时物理删除,不要长期保留兼容壳 - 页签命名要贴近创作者语言,而不是内部实现命名 ### 2.2 NPC 视觉模块并入 NPC 编辑 完成内容: -- 将 [NpcVisualEditor](/E:/Repos/Genarrative/src/components/NpcVisualEditor.tsx) 嵌入 [PresetEditor](/E:/Repos/Genarrative/src/components/PresetEditor.tsx) 的 NPC 编辑页 +- 当时曾将 `NpcVisualEditor` 嵌入 `PresetEditor` 的 NPC 编辑页 - 让 NPC 文本字段与视觉字段围绕同一个当前选中 NPC 联动 - 保留视觉覆盖保存与全局布局保存能力 diff --git a/docs/planning/CURRENT_AGENT_CREATION_FLOW_OPTIMIZATION_PLAN_2026-04-20.md b/docs/planning/CURRENT_AGENT_CREATION_FLOW_OPTIMIZATION_PLAN_2026-04-20.md index 68fe5eff..16424b14 100644 --- a/docs/planning/CURRENT_AGENT_CREATION_FLOW_OPTIMIZATION_PLAN_2026-04-20.md +++ b/docs/planning/CURRENT_AGENT_CREATION_FLOW_OPTIMIZATION_PLAN_2026-04-20.md @@ -86,6 +86,22 @@ 2. 不为了“文档里写过”就把所有没接线面板都接进来 3. 不把当前工作区重新改造成一个更复杂的大后台 +补充更新(`2026-04-21`): + +上一轮审计里提到的一组旧 Agent 副面板,已经在工程清理中被明确判定为退出当前版本主链并物理删除,包括: + +1. `CustomWorldAgentLockBar.tsx` +2. `CustomWorldAgentQuickActions.tsx` +3. `CustomWorldAgentSummaryPanel.tsx` +4. `CustomWorldAgentIntentSummaryPanel.tsx` +5. `CustomWorldAgentClarificationPanel.tsx` +6. `CustomWorldAgentDraftDetailPanel.tsx` +7. `CustomWorldDraftCardDetailModal.tsx` +8. `CustomWorldDraftEditPanel.tsx` +9. `CustomWorldGenerateEntityModal.tsx` + +所以这里的“不为了文档里写过就全接进来”,现在不只是态度提醒,而是已经执行过的现实边界。 + ### 3.3 不把结果页继续当旧编辑器扩写 这轮明确不再鼓励: @@ -244,6 +260,15 @@ 尤其是旧 `custom-world/sessions` 这条链,如果还要保留,也只能是兼容入口,不能再和 Agent 主链平起平坐。 +补充更新(`2026-04-21`): + +这条旧 `custom-world/sessions` 世界生成链已经完成物理删除。 + +因此阶段三在当前仓库里的剩余任务,不再是“决定要不要保留这条旧链”,而是: + +1. 继续收掉结果页 legacy profile 直改能力的误导性职责 +2. 继续清理文档里把旧链当成现行选项的表述 + --- ## 4.5 第五件事:把文稿里那些“这轮不做”的未完成项从主叙事里移掉 @@ -323,6 +348,12 @@ 2. 把“未来也许做”从“这轮要做”里拆开 3. 让所有当前规划只服务当前版本 +就当前状态补一句最重要的执行口径: + +1. 已经物理删除的旧链和旧副面板,不再作为“本轮待落地项” +2. 历史 PRD 可以保留实现设想,但必须和“当前版本执行规划”分开 +3. 当前版本规划只保留仍对正式主链有现实约束的事项 + 这一阶段的目标是: **让接下来所有开发都围绕同一套现实目标执行。** diff --git a/docs/planning/ENGINEERING_DEAD_CODE_AND_HIDDEN_BRANCH_CLEANUP_PLAN_2026-04-21.md b/docs/planning/ENGINEERING_DEAD_CODE_AND_HIDDEN_BRANCH_CLEANUP_PLAN_2026-04-21.md new file mode 100644 index 00000000..e6c36e6b --- /dev/null +++ b/docs/planning/ENGINEERING_DEAD_CODE_AND_HIDDEN_BRANCH_CLEANUP_PLAN_2026-04-21.md @@ -0,0 +1,579 @@ +# 工程无用分支、历史代码与隐形多链路大清洗执行计划(2026-04-21) + +更新时间:`2026-04-21` + +## 0. 文档目标 + +这份文档只解决一件事: + +**对当前工程发起一轮“不是继续加功能,而是系统性减负、删重、收口、归档”的大清洗。** + +这轮重点不是做表面上的“代码变少”,而是把下面 3 类长期拉低可读性和可维护性的东西真正处理掉: + +1. 无用历史代码 +2. 隐形的多数据链路 / 多真相链路乱代码 +3. 实现到一半但长期挂在主工程里的半成品代码 + +本文目标不是重复现有审计,而是把已有结论整理成: + +1. 可执行的清洗范围 +2. 明确的判定标准 +3. 分阶段的推进顺序 +4. 每阶段的交付物 +5. 可以落地的验收与回滚口径 + +--- + +## 1. 先把“清什么”说清楚 + +这次文档里说的“无用分支”,优先指的是: + +1. 代码逻辑分支 +2. 数据链路分支 +3. 兼容实现分支 +4. 遗留入口分支 + +**不是先把 Git 分支清空。** + +Git 分支治理可以后置做,但不能和首轮工程清洗混在一起,否则很容易把“代码归因”“入口归因”“历史责任归因”一起搅乱。 + +--- + +## 2. 三类清洗对象的定义 + +## 2.1 无用历史代码 + +满足以下任一特征,即进入“无用历史代码候选”: + +1. 没有正式运行时入口,也没有当前规划要接回入口 +2. 只被测试或历史兼容层引用,但主流程已经不再依赖 +3. 与当前正式实现功能重复,但不是唯一真相源 +4. 只剩 stub、占位、迁移残骸、旧 prompt 壳子、旧 helper 壳子 +5. 生成产物仍留在主仓库,但已不再被正式流程消费 + +这类代码的处理目标是: + +**删除、归档、降级标记三选一,不再长期以“也许以后要用”为理由挂在主路径里。** + +## 2.2 隐形多数据链路乱代码 + +满足以下任一特征,即进入“隐形多链路问题候选”: + +1. 同一份运行时状态同时由前端本地镜像和后端会话共同解释 +2. 同一类任务、物品、剧情、鉴权逻辑在前后端或多模块里各维护一份 +3. 同一份数据在“提交前本地写一份、提交后服务端再回填一份” +4. 同一功能表面只有一个按钮,背后却有两到三条实现路径 +5. 正式链路和 fallback 链路长期并存,且没有退场时间 + +这类问题的处理目标是: + +**把每条正式能力收敛成单一主链、单一真相源、单一编排出口。** + +## 2.3 实现到一半的半成品代码 + +满足以下任一特征,即进入“半成品候选”: + +1. UI、Hook、Service 已存在,但没有正式入口 +2. 文档写了概念,代码只落了一半,后续也没有继续接完 +3. 只有局部测试或局部 mock 在用,真实流程不用 +4. 仍保留 TODO / stub / draft / launcher / modal,但未纳入当前主线 +5. 用户看不到、主流程不调用、团队也没有当前阶段交付计划 + +这类代码的处理目标是: + +**要么纳入当前主线尽快补完,要么明确归档,不允许继续以“半活状态”污染主工程。** + +--- + +## 3. 这轮清洗后的目标状态 + +本轮完成后,工程应至少达到下面 7 个状态: + +1. 同一领域只保留一条正式主链,不再出现前后端双真相或多桥接链路并存 +2. 无入口孤岛、旧兼容壳子、旧 prompt 壳子、旧 stub 文件有明确去留结果 +3. “实现到一半”的模块不再伪装成正式能力挂在主工程中 +4. 前端继续回到“表现层”,正式运行时逻辑、鉴权真相、任务物品编排继续向后端收口 +5. 热点大文件不再同时背负历史残留、兼容残留和新逻辑堆叠 +6. 文档与代码状态一致,不再让旧规划长期误导当前执行方向 +7. `lint + typecheck + test + build + check:content` 重新成为可信基线 + +--- + +## 4. 执行原则 + +## 4.1 不做大爆炸整仓改写 + +本轮只允许“小批次、可回归、可解释”的连续清洗,不做一次性整仓推翻。 + +## 4.2 先建台账,再动删除 + +任何删除、归档、重定向动作前,必须先确认: + +1. 当前入口关系 +2. 当前依赖关系 +3. 当前替代路径 +4. 删除后的验收路径 + +没有台账,不做大规模删改。 + +## 4.3 先收真相源,再谈瘦身 + +如果同一领域仍有多条真相链路并存,优先收口真相源,而不是只删表面代码量。 + +## 4.4 文档和代码同步收口 + +只要本轮确认某条旧链降级、冻结、归档,相关文档必须同步更新,不能让旧文档继续把团队往废链路上拉。 + +## 4.5 每批清洗必须可回归 + +每一批完成后至少要求: + +1. 入口可解释 +2. 回归路径明确 +3. 门禁可跑 +4. 回滚点存在 + +--- + +## 5. 当前已知问题基础 + +本计划基于现有文档已经确认的结论推进,重点参考: + +1. `docs/audits/engineering/ENGINEERING_OPTIMIZATION_REVIEW_2026-04-01.md` +2. `docs/audits/engineering/ENGINEERING_CLEANUP_AND_BACKEND_BOUNDARY_AUDIT_2026-04-20.md` +3. `docs/audits/engineering/CURRENT_ENGINEERING_OPTIMIZATION_OPPORTUNITIES_2026-04-20.md` +4. `docs/planning/EXPRESS_BACKEND_REFACTOR_PLAN_2026-04-08.md` +5. `docs/experience/PROJECT_WORK_EXPERIENCE_PLAYBOOK.md` + +按当前审计结果,首轮就应重点关注下面 3 组对象。 + +## 5.1 当前高置信度“无入口 / 孤岛 / 残留”候选 + +以下对象已经在最近审计中被点名,默认进入首轮复核台账: + +1. `src/components/GameShell.tsx` +2. `src/components/custom-world-home/CustomWorldCreationHub.tsx` +3. `src/components/custom-world-home/CustomWorldCreationLauncherModal.tsx` +4. `src/components/custom-world-agent/CustomWorldAgentLauncherModal.tsx` +5. `src/components/custom-world-agent/CustomWorldAgentDraftDrawer.tsx` +6. `src/hooks/story/storyBootstrap.ts` +7. `src/hooks/useEquipmentFlow.ts` +8. `src/hooks/useForgeFlow.ts` +9. `src/hooks/useInventoryFlow.ts` +10. `src/services/customWorldPresentation.stub.ts` +11. `src/services/typewriter.ts` +12. `src/prompts/customWorldOrchestratorPrompts.ts` +13. `src/prompts/storyOrchestratorPrompts.ts` +14. `src/data/buildTagSimilarity.generated.ts`(已在后续清理批次中删除) + +这些文件不能直接判死刑,但必须进入“保留 / 接回 / 归档 / 删除”四选一清单。 + +## 5.2 当前高置信度“隐形多链路 / 双真相”候选 + +以下对象应进入首轮主链收口清单: + +1. `src/hooks/story/runtimeStoryCoordinator.ts` +2. `src/services/runtimeStoryService.ts` +3. `src/services/apiClient.ts` +4. `src/hooks/story/npcEncounterActions.ts` +5. `src/services/questDirector.ts` +6. `src/services/runtimeItemAiDirector.ts` +7. `src/services/ai.ts` + +当前这批问题的共同特征是: + +1. 前端仍保留本地镜像、自动登录凭证或双环境编排残留 +2. NPC 任务换单、任务生成、运行时物品生成仍有前端发起和混合执行痕迹 +3. 浏览器侧大型 AI orchestration 仍未完全退出主工程 + +## 5.3 当前“新热点继续吸纳历史复杂度”候选 + +以下对象不一定是垃圾代码,但很容易继续成为历史残留的新容器: + +1. `src/components/CustomWorldEntityEditorModal.tsx` +2. `server-node/src/modules/assets/characterAssetRoutes.ts` +3. `src/prompts/storyPromptBuilders.ts` +4. `server-node/src/modules/custom-world/runtimeProfile.ts` +5. `src/components/game-shell/PreGameSelectionFlow.tsx` +6. `src/components/game-shell/PlatformHomeView.tsx` + +这批文件必须在本轮中被视为“禁止继续裸堆新逻辑”的重点区域。 + +--- + +## 6. 清洗判定表 + +每个候选对象进入清理台账后,只允许落到下面 4 类结果之一: + +| 结果类型 | 适用场景 | 处理动作 | +| --- | --- | --- | +| 删除 | 无入口、无当前规划、无兼容价值 | 直接删文件、删引用、补回归 | +| 归档 | 暂不继续,但保留历史价值 | 移出主路径、在文档中标明冻结状态 | +| 扶正 | 当前主线确实需要,只是入口丢失或命名混乱 | 接回正式入口、补测试、补文档 | +| 拆分收口 | 不是废代码,但混合了历史残留和正式逻辑 | 先拆职责,再删除残留分支 | + +禁止出现第 5 种状态: + +**“先留着,以后再说,但继续挂在主工程里。”** + +--- + +## 7. 分阶段执行计划 + +## P0:冻结新增污染,先建立清洗台账 + +### 目标 + +先把“哪些东西要清、为什么清、怎么判定是否能清”讲清楚,停止继续往旧热点和疑似废链上加逻辑。 + +### 主要动作 + +1. 建立 3 份清单: + - 无入口孤岛清单 + - 多真相链路清单 + - 半成品能力清单 +2. 为每个对象补 5 个字段: + - 当前入口 + - 当前调用方 + - 当前替代路径 + - 建议结论 + - 回归验证点 +3. 约束新增开发: + - 不再向疑似废链补功能 + - 不再向热点大文件直接叠逻辑 + - 新需求优先接到当前正式主链 +4. 明确本轮清洗后的唯一方向: + - 前端只做表现 + - 后端持有正式运行时真相 + - 旧兼容链不能继续膨胀 + +### 交付物 + +1. 清洗对象总台账 +2. 首轮批次拆分表 +3. 每批回归清单 + +### 完成标准 + +不是“开始删文件”才算开始。 + +只要台账、批次、判定口径和冻结规则明确,这一阶段就算完成。 + +--- + +## P1:先清无入口孤岛和明显历史残留 + +### 目标 + +先把最容易污染阅读体验、又不需要大规模业务改造的对象清掉,快速降低仓库噪音。 + +### 优先清理对象 + +1. 无运行时入口组件 +2. 只被测试引用的旧壳层 +3. 已迁移后留下的 stub / prompt 壳 / helper 壳 +4. 已不进入正式链路的 generated 文件 +5. 旧 launcher / draft / modal 壳层 + +### 处理顺序建议 + +1. 先处理 `prompt / stub / helper / launcher` 级别的小残留 +2. 再处理 `旧 hook / 旧 flow / 旧 shell` 级别的流程残留 +3. 最后处理“可能有历史价值但暂不接回”的 UI 大块头 + +### 本阶段输出结果 + +每个对象必须给出明确结果: + +1. 删除 +2. 归档 +3. 扶正接回 + +### 验收标准 + +1. 主工程中“没有正式入口的文件”显著减少 +2. 新人看目录时,不再大量遇到真假难辨的旧入口 +3. 相关引用、测试、文档同步更新 + +--- + +## P2:收单一真相源,清掉隐形多数据链路 + +### 目标 + +这阶段不以“删多少文件”为核心,而是以“同一件事最终只走一条正式链”作为核心。 + +### 第一优先级链路 + +1. 运行时快照链 +2. 鉴权与自动登录链 +3. NPC 任务生成 / 换单链 +4. 运行时物品生成链 +5. 浏览器端 AI orchestration 链 + +### 重点动作 + +1. 收掉前端“提交前先写本地真相,再等服务端回填”的链路 +2. 收掉本地存储中的自动登录用户名 / 密码真相 +3. 把 NPC 委托换单动作继续迁回后端运行时主链 +4. 将 `questDirector`、`runtimeItemAiDirector` 拆成: + - 前端 SDK 层 + - 后端正式执行层 +5. 继续压缩浏览器端 `src/services/ai.ts` 的正式职责 + +### 这阶段最重要的判断标准 + +不是“文件还在不在”,而是下面 4 条是否成立: + +1. 玩家一次动作只提交一个正式 action,而不是两边各写一遍状态 +2. 前端不再持有正式运行时镜像真相 +3. 前端不再长期持有自动登录账号密码 +4. 同一类生成能力不再同时存在“浏览器正式版”和“后端正式版” + +### 验收标准 + +1. 正式运行时状态解释权明确以后端为准 +2. 鉴权边界不再依赖浏览器保存高风险凭证 +3. NPC 任务、物品、剧情编排链路的职责边界清楚 + +--- + +## P3:集中处理实现到一半的半成品能力 + +### 目标 + +把“看起来像功能、实际上不是当前正式能力”的对象清出主路径。 + +### 清理规则 + +半成品对象统一按下面规则处理: + +1. 30 天内明确要接回主线的,进入补完批次 +2. 当前阶段不做的,降级为归档或实验稿 +3. 没有继续计划、也没有正式入口价值的,直接删除 + +### 本阶段重点对象 + +1. 只有 modal / launcher / draft 壳层,但没有正式调用链的 UI +2. 只有部分 hook / service 实现,但没有主链消费的流程模块 +3. 只剩“概念占位”的 prompt、adapter、presentation、stub 文件 +4. 文档里反复提到、代码里却长期不接线的能力块 + +### 必须同步做的事 + +1. 更新对应规划文档 +2. 从当前主叙事中移除本轮明确不做的项 +3. 给保留实验稿加清晰标签,避免被误读成正式能力 + +### 验收标准 + +1. 主工程里不再混着大量“像功能但不是正式功能”的对象 +2. 文档不再持续推动团队回头补本轮已冻结能力 +3. 目录层级和入口关系显著更清楚 + +--- + +## P4:在减负后的基础上拆热点,恢复可读性 + +### 目标 + +前 3 阶段做完后,再进入“真正让工程重新好读”的结构优化。 + +### 重点对象 + +1. `src/components/CustomWorldEntityEditorModal.tsx` +2. `server-node/src/modules/assets/characterAssetRoutes.ts` +3. `src/prompts/storyPromptBuilders.ts` +4. `server-node/src/modules/custom-world/runtimeProfile.ts` +5. `src/components/game-shell/PreGameSelectionFlow.tsx` +6. `src/components/game-shell/PlatformHomeView.tsx` + +### 拆分原则 + +1. 先按职责拆,不按文件长度拆 +2. 先把历史残留和兼容分支移走,再做正式模块化 +3. 拆完之后必须更清晰地回答: + - 谁负责 UI + - 谁负责数据准备 + - 谁负责正式规则 + - 谁负责调用后端 + +### 验收标准 + +1. 热点文件不再同时吞 UI、规则、编排、兼容残留 +2. 新功能不需要再跨四五层历史壳子一起改 +3. 后续 review 能更快定位责任边界 + +--- + +## 8. 批次拆分建议 + +为了避免清理动作过大失控,建议按下面粒度推进: + +## 批次 A:小型孤岛与残留壳子 + +处理对象: + +1. stub +2. prompt 壳 +3. 无入口 helper +4. 无入口 launcher / modal + +目标: + +快速去噪,降低目录误导性。 + +## 批次 B:旧 flow / 旧 shell / 旧 hook + +处理对象: + +1. `GameShell` +2. `storyBootstrap` +3. `useEquipmentFlow` +4. `useForgeFlow` +5. `useInventoryFlow` + +目标: + +清旧主流程壳层和旧流程残留。 + +## 批次 C:运行时真相收口 + +处理对象: + +1. `runtimeStoryCoordinator` +2. `runtimeStoryService` +3. `apiClient` + +目标: + +去掉本地镜像真相与本地鉴权真相。 + +## 批次 D:任务 / 物品 / AI 混合执行层收口 + +处理对象: + +1. `npcEncounterActions` +2. `questDirector` +3. `runtimeItemAiDirector` +4. `ai.ts` + +目标: + +消灭混合执行和双环境正式链。 + +## 批次 E:热点大文件拆分 + +处理对象: + +1. custom world +2. assets +3. game shell platform +4. prompt builder +5. runtime profile + +目标: + +在主链已收口后恢复可读性。 + +--- + +## 9. 每批必须产出的内容 + +每一批都必须带着下面 5 类产出结束: + +1. 代码改动 +2. 文档回填 +3. 去留说明 +4. 验收记录 +5. 回滚点说明 + +如果一个批次只能产出“删了几个文件”,但说不清: + +1. 删除后谁接手 +2. 主链是否更清楚 +3. 文档是否同步 + +那么这个批次不算完成。 + +--- + +## 10. 统一验收口径 + +本轮建议至少用下面 10 条作为统一验收口径: + +1. `npm run lint` +2. `npm run test` +3. `npm run build` +4. `npm run check:content` +5. 目录中高置信度孤岛数量下降 +6. 旧兼容链不再继续接收新逻辑 +7. 前端不再保存自动登录用户名 / 密码 +8. 运行时主状态不再由前端本地镜像优先解释 +9. 当前正式能力的入口关系能在文档中说清楚 +10. 新人阅读主目录和主流程文件时,不再频繁遇到真假并存入口 + +--- + +## 11. 风险与控制点 + +## 11.1 最大风险不是“删多了”,而是“边删边继续加废链” + +如果没有冻结规则,这轮会一边清旧,一边又把新逻辑接回旧壳子里,最后只会重复劳动。 + +## 11.2 不能把“兼容”当永久借口 + +兼容链可以短期存在,但必须写清: + +1. 为什么保留 +2. 保留到什么时候 +3. 谁负责后续移除 + +## 11.3 不能只删代码,不收文档 + +如果代码删了,旧文档不改,团队还是会持续把需求往旧链上接。 + +## 11.4 不能只盯文件大小,不盯真相链 + +有些文件很大但确实是正式主链。 +有些文件很小,却是双真相和多链路的根源。 + +本轮必须优先盯后者。 + +--- + +## 12. 当前不建议优先做的事 + +1. 不建议在清洗期间继续横向扩功能 +2. 不建议直接对热点文件做“纯格式化式拆分” +3. 不建议在未确认入口关系前整片删除可疑模块 +4. 不建议让前端继续补正式运行时逻辑作为短期兜底 +5. 不建议保留“也许以后有用”的主工程残留 + +原因很简单: + +**当前最需要恢复的不是功能宽度,而是工程的干净边界、单一主链和可读体验。** + +--- + +## 13. 推荐推进顺序 + +建议严格按下面顺序推进: + +1. 先做 P0:建台账、冻结污染 +2. 再做 P1:清无入口孤岛和小残留 +3. 再做 P2:收运行时、鉴权、任务物品的单一主链 +4. 再做 P3:处理半成品能力与文档冻结项 +5. 最后做 P4:拆热点、补结构可读性 + +不建议倒过来先拆热点。 + +因为如果历史残留和双真相还在,大文件拆完以后,复杂度只是换地方继续长。 + +--- + +## 14. 一句话结论 + +这轮工程大清洗的核心,不是“删旧代码看起来更清爽”,而是: + +**用一轮有台账、有判定、有阶段、有验收的大清理,把无用历史代码、隐形多链路乱代码和半成品能力从主工程里真正清出去,让项目重新回到单一主链、单一真相源、目录可读、职责清楚的健康状态。** diff --git a/docs/planning/README.md b/docs/planning/README.md index 0903a157..65201388 100644 --- a/docs/planning/README.md +++ b/docs/planning/README.md @@ -2,6 +2,7 @@ ## 当前入口 +- [ENGINEERING_DEAD_CODE_AND_HIDDEN_BRANCH_CLEANUP_PLAN_2026-04-21.md](./ENGINEERING_DEAD_CODE_AND_HIDDEN_BRANCH_CLEANUP_PLAN_2026-04-21.md):面向无用历史代码、隐形多数据链路和半成品实现的一轮工程大清洗执行计划,强调先建台账、再删重收口、最后恢复主工程可读性。 - [CURRENT_GAME_ITERATION_PRIORITIES_2026-04-03.md](./CURRENT_GAME_ITERATION_PRIORITIES_2026-04-03.md):当前阶段最值得优先做什么、为什么,以及它和审计/PRD 的对应关系。 - [CURRENT_AGENT_CREATION_FLOW_OPTIMIZATION_PLAN_2026-04-20.md](./CURRENT_AGENT_CREATION_FLOW_OPTIMIZATION_PLAN_2026-04-20.md):在不新增前端创作流程的前提下,围绕当前 Agent 创作动线做收口、删重、补通和文档收束的大白话执行规划。 - [EXPRESS_BACKEND_REFACTOR_PLAN_2026-04-08.md](./EXPRESS_BACKEND_REFACTOR_PLAN_2026-04-08.md):基于“前端只做表现、逻辑与数据全部后端化”的工程重构规划。 diff --git a/docs/prd/AI_NATIVE_AGENT_FIRST_CUSTOM_WORLD_CREATOR_PHASE1_IMPLEMENTATION_PLAN_2026-04-13.md b/docs/prd/AI_NATIVE_AGENT_FIRST_CUSTOM_WORLD_CREATOR_PHASE1_IMPLEMENTATION_PLAN_2026-04-13.md index 7f73a297..52c13fd7 100644 --- a/docs/prd/AI_NATIVE_AGENT_FIRST_CUSTOM_WORLD_CREATOR_PHASE1_IMPLEMENTATION_PLAN_2026-04-13.md +++ b/docs/prd/AI_NATIVE_AGENT_FIRST_CUSTOM_WORLD_CREATOR_PHASE1_IMPLEMENTATION_PLAN_2026-04-13.md @@ -2,6 +2,16 @@ 更新时间:`2026-04-13` +## 0.1 当前状态说明(2026-04-21) + +这份文档保留为第一阶段历史落地方案。 + +补充边界: + +1. 文中出现的旧 Agent 副面板、旧世界生成链,不代表它们仍是当前版本待落地项 +2. 已被工程清理判定退出主链并删除的对象,应以最新清理记录为准,不再按这里的历史计划继续接回 +3. 当前版本执行优先级,应回到 `docs/planning/CURRENT_AGENT_CREATION_FLOW_OPTIMIZATION_PLAN_2026-04-20.md` + ## 0. 文档目的 这份文档用于把以下两份 PRD 收束成可直接开工的第一阶段实现方案: @@ -1056,6 +1066,16 @@ onSubmit({ ## 12. 落地文件清单 +### 当前状态补充(2026-04-21) + +下面这份文件清单是第一阶段编写当日的历史落地清单。 + +需要特别注意: + +1. 其中列出的 `CustomWorldAgentLockBar.tsx`、`CustomWorldAgentQuickActions.tsx` 等旧副面板,已经在当前版本清理中退出主链并物理删除 +2. 因此这里不能再被当作“当前版本仍要补齐的现行文件清单” +3. 当前仍然有效的执行边界,应以最新优化规划和清理记录为准 + ## 12.1 shared 必须新增: diff --git a/docs/prd/AI_NATIVE_AGENT_FIRST_CUSTOM_WORLD_CREATOR_PHASE2_IMPLEMENTATION_PLAN_2026-04-13.md b/docs/prd/AI_NATIVE_AGENT_FIRST_CUSTOM_WORLD_CREATOR_PHASE2_IMPLEMENTATION_PLAN_2026-04-13.md index b1ae0d71..9f3ae993 100644 --- a/docs/prd/AI_NATIVE_AGENT_FIRST_CUSTOM_WORLD_CREATOR_PHASE2_IMPLEMENTATION_PLAN_2026-04-13.md +++ b/docs/prd/AI_NATIVE_AGENT_FIRST_CUSTOM_WORLD_CREATOR_PHASE2_IMPLEMENTATION_PLAN_2026-04-13.md @@ -2,6 +2,15 @@ 更新时间:`2026-04-13` +## 0.1 当前状态说明(2026-04-21) + +这份文档保留为第二阶段历史落地方案。 + +补充边界: + +1. 文中提到的 `CustomWorldAgentIntentSummaryPanel`、`CustomWorldAgentClarificationPanel`、`CustomWorldAgentLockBar`、`CustomWorldAgentQuickActions` 等旧副面板,已经在当前版本收口判断中退出主链并物理删除 +2. 这些内容现在只能作为历史设计参考,不再作为当前版本默认待接线项 +3. 当前如果要重新设计这些能力的消费方式,应基于现行主链重新定义 ## 0. 文档目的 这份文档用于把以下两份文档进一步收束成第二阶段实现方案: @@ -548,6 +557,21 @@ buildPendingClarifications(intent, readiness) ## 10. 前端实现方案 +### 当前状态补充(2026-04-21) + +这一节描述的是第二阶段编写当时的右侧副面板方案。 + +但当前版本已经明确: + +1. `CustomWorldAgentIntentSummaryPanel` +2. `CustomWorldAgentClarificationPanel` +3. `CustomWorldAgentLockBar` +4. `CustomWorldAgentQuickActions` + +这组旧副面板不再作为当前版本默认待接线方向,其中对应已落地但退出主链的文件也已物理删除。 + +因此本节以下内容应视为历史设计稿,不再直接代表当前版本执行方案。 + ## 10.1 修改 `CustomWorldAgentWorkspace.tsx` 第二阶段它不再只是空壳工作区,而要新增: @@ -679,6 +703,12 @@ buildPendingClarifications(intent, readiness) ## 13. 落地文件清单 +### 当前状态补充(2026-04-21) + +本节列的是第二阶段历史文件清单。 + +其中涉及旧副面板的部分,现在只保留历史参考价值,不再等同于当前版本待落地项。 + ## 13.1 shared 必须修改: diff --git a/docs/prd/AI_NATIVE_AGENT_FIRST_CUSTOM_WORLD_CREATOR_PHASE3_IMPLEMENTATION_PLAN_2026-04-14.md b/docs/prd/AI_NATIVE_AGENT_FIRST_CUSTOM_WORLD_CREATOR_PHASE3_IMPLEMENTATION_PLAN_2026-04-14.md index 6aef83af..a4f1ddac 100644 --- a/docs/prd/AI_NATIVE_AGENT_FIRST_CUSTOM_WORLD_CREATOR_PHASE3_IMPLEMENTATION_PLAN_2026-04-14.md +++ b/docs/prd/AI_NATIVE_AGENT_FIRST_CUSTOM_WORLD_CREATOR_PHASE3_IMPLEMENTATION_PLAN_2026-04-14.md @@ -2,6 +2,15 @@ 更新时间:`2026-04-14` +## 0.1 当前状态说明(2026-04-21) + +这份文档保留为第三阶段历史落地方案。 + +补充边界: + +1. 文中涉及的 `CustomWorldAgentQuickActions`、`CustomWorldAgentDraftDetailPanel`、`CustomWorldDraftCardDetailModal` 等旧副面板,已在当前版本清理中退出主链并物理删除 +2. 因此这份文档里的对应实现章节,不能再直接视为当前版本待继续补完的目标 +3. 当前版本只保留对正式主链仍有现实约束的能力项 ## 0. 文档目的 这份文档用于把以下几份文档进一步收束成第三阶段实现方案: @@ -721,6 +730,21 @@ object_refining ## 11. 前端实现方案 +### 当前状态补充(2026-04-21) + +这一节描述的是第三阶段编写当时的草稿抽屉与详情侧栏方案。 + +但当前版本已经明确: + +1. `CustomWorldAgentDraftDrawer.tsx` 已在 earlier cleanup 中删除 +2. `CustomWorldAgentDraftDetailPanel.tsx` +3. `CustomWorldDraftCardDetailModal.tsx` +4. `CustomWorldAgentQuickActions.tsx` + +已在当前版本收口判断中退出主链并物理删除。 + +因此本节以下内容应视为历史设计稿,而不是当前版本默认待落地方案。 + ## 11.1 修改 `CustomWorldAgentQuickActions.tsx` 第三阶段必须让: @@ -918,6 +942,12 @@ creatorIntentReadiness.isReady === false ## 14. 落地文件清单 +### 当前状态补充(2026-04-21) + +本节列的是第三阶段历史文件清单。 + +其中涉及旧抽屉、旧详情面板、旧快捷动作面板的部分,当前仅保留历史参考意义。 + ## 14.1 shared 必须修改: diff --git a/docs/prd/AI_NATIVE_AGENT_FIRST_CUSTOM_WORLD_CREATOR_PHASE4_IMPLEMENTATION_PLAN_2026-04-14.md b/docs/prd/AI_NATIVE_AGENT_FIRST_CUSTOM_WORLD_CREATOR_PHASE4_IMPLEMENTATION_PLAN_2026-04-14.md index cca30769..a936102d 100644 --- a/docs/prd/AI_NATIVE_AGENT_FIRST_CUSTOM_WORLD_CREATOR_PHASE4_IMPLEMENTATION_PLAN_2026-04-14.md +++ b/docs/prd/AI_NATIVE_AGENT_FIRST_CUSTOM_WORLD_CREATOR_PHASE4_IMPLEMENTATION_PLAN_2026-04-14.md @@ -2,6 +2,15 @@ 更新时间:`2026-04-14` +## 0.1 当前状态说明(2026-04-21) + +这份文档保留为第四阶段历史落地方案。 + +补充边界: + +1. 文中聚焦的草稿详情编辑链与实体生成弹窗链,在当前版本收口判断中已经退出主链 +2. 对应的 `CustomWorldAgentDraftDetailPanel`、`CustomWorldDraftEditPanel`、`CustomWorldGenerateEntityModal`、`CustomWorldAgentQuickActions` 等文件已物理删除 +3. 当前版本不再把这套旧副面板链作为默认待接线方向 ## 0. 文档目的 这份文档用于把以下几份文档进一步收束成第四阶段实现方案: @@ -665,6 +674,21 @@ generateAdditionalLandmarks(params) ## 10. 前端实现方案 +### 当前状态补充(2026-04-21) + +这一节描述的是第四阶段编写当时的草稿详情编辑链与实体生成弹窗链。 + +但当前版本已经明确: + +1. `CustomWorldAgentDraftDetailPanel.tsx` +2. `CustomWorldDraftEditPanel.tsx` +3. `CustomWorldGenerateEntityModal.tsx` +4. `CustomWorldAgentQuickActions.tsx` + +已在当前版本收口判断中退出主链并物理删除。 + +因此本节以下内容只保留历史设计参考价值,不再直接代表当前版本执行方向。 + ## 10.1 修改 `CustomWorldAgentDraftDetailPanel.tsx` 第四阶段它要从只读详情升级成: @@ -851,6 +875,12 @@ editableSectionIds: [] ## 13. 落地文件清单 +### 当前状态补充(2026-04-21) + +本节列的是第四阶段历史文件清单。 + +其中涉及草稿详情编辑链与实体生成弹窗链的部分,不再等同于当前版本待落地清单。 + ## 13.1 shared 必须修改: diff --git a/docs/prd/AI_NATIVE_AGENT_FIRST_CUSTOM_WORLD_CREATOR_PHASE5_IMPLEMENTATION_PLAN_2026-04-14.md b/docs/prd/AI_NATIVE_AGENT_FIRST_CUSTOM_WORLD_CREATOR_PHASE5_IMPLEMENTATION_PLAN_2026-04-14.md index 6e9c4215..0e0cd627 100644 --- a/docs/prd/AI_NATIVE_AGENT_FIRST_CUSTOM_WORLD_CREATOR_PHASE5_IMPLEMENTATION_PLAN_2026-04-14.md +++ b/docs/prd/AI_NATIVE_AGENT_FIRST_CUSTOM_WORLD_CREATOR_PHASE5_IMPLEMENTATION_PLAN_2026-04-14.md @@ -2,6 +2,15 @@ 更新时间:`2026-04-14` +## 0.1 当前状态说明(2026-04-21) + +这份文档保留为第五阶段历史落地方案。 + +补充边界: + +1. 文中继续沿用的 `CustomWorldAgentDraftDetailPanel`、`CustomWorldAgentQuickActions` 等旧副面板,在当前版本已经退出主链并物理删除 +2. 因此这份文档里的实现安排,不应再被直接视为当前版本执行清单 +3. 当前版本是否重引入相关能力,必须基于新的主链设计重新判断 ## 0. 文档目的 这份文档用于把以下几份文档进一步收束成第五阶段实现方案: @@ -195,6 +204,19 @@ ## 7.2 入口位置 +### 当前状态补充(2026-04-21) + +这一节以下描述依赖当时仍被视为现行方案的旧详情面板链与旧快捷动作链。 + +但当前版本已经明确: + +1. `CustomWorldAgentDraftDetailPanel.tsx` +2. `CustomWorldAgentQuickActions.tsx` + +已退出主链并物理删除。 + +因此这里关于入口位置的说明,现在只能作为历史资产工坊设计参考,不再代表当前版本 UI 入口。 + ### 角色卡详情入口 在 `CustomWorldAgentDraftDetailPanel` 中,当当前卡类型为: @@ -548,6 +570,12 @@ mergeRoleAssetIntoDraftProfile(draftProfile, payload); ## 11. 前端实现方案 +### 当前状态补充(2026-04-21) + +本节以下内容依赖旧详情面板链与旧快捷动作面板。 + +这些文件当前已经退出主链并删除,所以这里不再是当前版本的直接执行清单。 + ## 11.1 修改 `CustomWorldAgentDraftDetailPanel.tsx` 当卡片类型为 `character` 时,新增: diff --git a/docs/prd/AI_NATIVE_AGENT_FIRST_CUSTOM_WORLD_CREATOR_PHASE6_IMPLEMENTATION_PLAN_2026-04-14.md b/docs/prd/AI_NATIVE_AGENT_FIRST_CUSTOM_WORLD_CREATOR_PHASE6_IMPLEMENTATION_PLAN_2026-04-14.md index dd64840a..bb642bd1 100644 --- a/docs/prd/AI_NATIVE_AGENT_FIRST_CUSTOM_WORLD_CREATOR_PHASE6_IMPLEMENTATION_PLAN_2026-04-14.md +++ b/docs/prd/AI_NATIVE_AGENT_FIRST_CUSTOM_WORLD_CREATOR_PHASE6_IMPLEMENTATION_PLAN_2026-04-14.md @@ -2,6 +2,15 @@ 更新时间:`2026-04-14` +## 0.1 当前状态说明(2026-04-21) + +这份文档保留为第六阶段历史落地方案。 + +补充边界: + +1. 文中继续引用的 `CustomWorldAgentDraftDetailPanel`、`CustomWorldAgentQuickActions` 等旧副面板,在当前版本已经退出主链并物理删除 +2. 这份文档可用于回看当时的资产工坊设想,但不代表当前版本仍按这里逐项补齐 +3. 当前执行边界以最新优化规划和阶段四清理边界文档为准 ## 0. 文档目的 这份文档用于把以下几份文档进一步收束成第六阶段实现方案: @@ -190,6 +199,19 @@ ## 7.2 入口位置 +### 当前状态补充(2026-04-21) + +这一节以下描述依赖当时仍被视为现行方案的旧详情面板链与旧快捷动作链。 + +但当前版本已经明确: + +1. `CustomWorldAgentDraftDetailPanel.tsx` +2. `CustomWorldAgentQuickActions.tsx` + +已退出主链并物理删除。 + +因此这里关于入口位置的说明,现在只保留历史场景资产工坊设计参考价值。 + ### 地点卡详情入口 在 `CustomWorldAgentDraftDetailPanel` 中,当当前卡类型为: diff --git a/docs/prd/AI_NATIVE_AGENT_FIRST_CUSTOM_WORLD_CREATOR_PRD_2026-04-12.md b/docs/prd/AI_NATIVE_AGENT_FIRST_CUSTOM_WORLD_CREATOR_PRD_2026-04-12.md index c4cf9d55..4f6a457a 100644 --- a/docs/prd/AI_NATIVE_AGENT_FIRST_CUSTOM_WORLD_CREATOR_PRD_2026-04-12.md +++ b/docs/prd/AI_NATIVE_AGENT_FIRST_CUSTOM_WORLD_CREATOR_PRD_2026-04-12.md @@ -2,6 +2,16 @@ 更新时间:`2026-04-12` +## 0.1 当前状态说明(2026-04-21) + +这份文档保留为历史 PRD 基线,用于回看当时的完整目标设计。 + +但需要特别注意: + +1. 文中涉及的一部分旧 Agent 副面板与旧世界生成链,已经在 `2026-04-21` 的工程清理中判定退出当前版本主链并完成物理删除 +2. 当前版本的真实执行边界,以 `docs/planning/CURRENT_AGENT_CREATION_FLOW_OPTIMIZATION_PLAN_2026-04-20.md`、`docs/technical/CURRENT_AGENT_CREATION_FLOW_STAGE4_CLEANUP_CHECK_2026-04-21.md` 和最新工程清理批次记录为准 +3. 阅读这份文档时,应把它视为“历史完整设计稿”,而不是“当前版本仍要全部继续落地的执行清单” + ## 0. 文档目的 这份 PRD 用于把以下几份分析文档收束成一份可直接指导编码落地的新创作工具产品需求文档: diff --git a/docs/prd/BUILD_SYSTEM_ATTRIBUTE_SIMILARITY_PRD_2026-04-02.md b/docs/prd/BUILD_SYSTEM_ATTRIBUTE_SIMILARITY_PRD_2026-04-02.md index 69dd8516..79f35c3c 100644 --- a/docs/prd/BUILD_SYSTEM_ATTRIBUTE_SIMILARITY_PRD_2026-04-02.md +++ b/docs/prd/BUILD_SYSTEM_ATTRIBUTE_SIMILARITY_PRD_2026-04-02.md @@ -8,7 +8,6 @@ - `src/data/buildDamage.ts` - `src/data/buildTags.ts` -- `src/data/buildTagSimilarity.generated.ts` 现状不是“标签各自独立生效”,而是: @@ -263,7 +262,7 @@ type BuildDamageBreakdown = { ## 4.3 相似度来源 -当前仓库已有 `src/data/buildTagSimilarity.generated.ts`,但新方案不再以“标签-标签相似度矩阵”为主数据源。 +旧版曾生成过标签-标签相似度矩阵,但新方案不再以“标签-标签相似度矩阵”为主数据源。 建议改为新增: @@ -401,13 +400,13 @@ finalDamage = ### 风险 3:旧数据迁移成本 问题: -现有 `buildTagSimilarity.generated.ts` 将弱化甚至失去主要用途。 +旧标签相似度矩阵已经不再作为主数据源。 对策: -1. 本期不强制删除旧文件。 -2. 新逻辑只依赖新 affinity 表。 -3. 等新系统稳定后,再清理旧相似度矩阵和旧展示逻辑。 +1. 新逻辑只依赖标签定义中的属性亲和度。 +2. 旧相似度矩阵生成产物已从主工程移除。 +3. 后续若需要重新引入自动建议,也应输出为审表辅助数据,而不是运行时真相源。 ## 11. 一句话结论 diff --git a/docs/reference/BUSINESS_PROMPT_INVENTORY_2026-04-19.md b/docs/reference/BUSINESS_PROMPT_INVENTORY_2026-04-19.md index eac71f06..35d88d63 100644 --- a/docs/reference/BUSINESS_PROMPT_INVENTORY_2026-04-19.md +++ b/docs/reference/BUSINESS_PROMPT_INVENTORY_2026-04-19.md @@ -74,7 +74,6 @@ | `src/prompts/storyOrchestratorPrompts.ts` | 剧情中文修复 | `STORY_LANGUAGE_REPAIR_SYSTEM_PROMPT` | | `src/prompts/customWorldRolePromptDefaults.ts` | 角色资产工作台默认词唯一主源 | `buildDefaultRolePromptBundle` | | `src/prompts/customWorldEntityActionPrompts.ts` | 编辑器技能动作词 | `buildSkillActionPrompt` | -| `src/prompts/qwenSpriteSheetToolPrompts.ts` | Qwen 精灵图工具 prompt 模型 | 主 prompt / sheet prompt / repair prompt / negative prompt 系列 | ### 3.3 共享层 @@ -91,7 +90,6 @@ - `src/services/questPrompt.ts` - `src/services/runtimeItemAiPrompt.ts` - `server-node/src/services/eightAnchorPromptBuilder.ts` -- `src/tools/qwenSpriteSheetToolModel.ts` - `src/components/asset-studio/customWorldRolePromptDefaults.ts` - `packages/shared/src/assets/qwenSprite.ts` @@ -110,14 +108,12 @@ | --- | --- | --- | | `server-node/src/prompts/characterAssetPrompts.ts` | 正式角色资产生成 prompt | 后端角色主图、动作试片、角色场景词主源 | | `packages/shared/src/prompts/qwenSprite.ts` | 共享角色主 prompt 模板 | 共享给后端资产链使用的基础模板 | -| `src/prompts/qwenSpriteSheetToolPrompts.ts` | 工具链 prompt 模型 | Qwen 精灵图工具主词、分镜词、修帧词、负面词 | | `src/prompts/customWorldRolePromptDefaults.ts` | 工作台默认词种子 | 角色视觉词、动画词、场景词默认值 | | `src/prompts/customWorldEntityActionPrompts.ts` | 编辑器动作词 | 技能动作描述 prompt builder | 当前调用关系: - `server-node/src/modules/assets/characterAssetRoutes.ts` 调用 `server-node/src/prompts/characterAssetPrompts.ts` -- `src/tools/QwenSpriteSheetTool.tsx` 通过兼容层消费 `src/prompts/qwenSpriteSheetToolPrompts.ts` - `src/components/CustomWorldRoleAssetStudioModal.tsx` 通过兼容层消费 `src/prompts/customWorldRolePromptDefaults.ts` - `src/components/CustomWorldEntityEditorModal.tsx` 直接调用 `src/prompts/customWorldEntityActionPrompts.ts` @@ -145,7 +141,7 @@ - `src/services/customWorld.ts` 中的自定义世界分阶段 prompt 与场景背景图 prompt - `src/services/ai.ts` 中的世界修复 / 语言修复 / JSON only system prompt - `src/services/prompt.ts`、`characterChatPrompt.ts`、`questPrompt.ts`、`runtimeItemAiPrompt.ts` 这批前端 prompt 脚本 -- `src/tools/qwenSpriteSheetToolModel.ts`、`src/components/asset-studio/customWorldRolePromptDefaults.ts`、`src/components/CustomWorldEntityEditorModal.tsx` 里的工具 / 编辑器 prompt 散点 +- `src/components/asset-studio/customWorldRolePromptDefaults.ts`、`src/components/CustomWorldEntityEditorModal.tsx` 里的工具 / 编辑器 prompt 散点 ## 8. 当前仍在非 Prompt 目录中的相关文件 diff --git a/docs/reference/FUNCTION_SCRIPT_CATALOG_2026-04-04.md b/docs/reference/FUNCTION_SCRIPT_CATALOG_2026-04-04.md index 6e99aaf6..e422d142 100644 --- a/docs/reference/FUNCTION_SCRIPT_CATALOG_2026-04-04.md +++ b/docs/reference/FUNCTION_SCRIPT_CATALOG_2026-04-04.md @@ -32,6 +32,14 @@ ## 基础状态 Function +- `battle_attack_basic` + 脚本:`src/data/functionCatalog/state/battleAttackBasic.ts` + 说明:后端单行为战斗模型中的普通攻击 function。它由后端战斗 option 池下发,前端只透传 functionId,不进入前端本地 `STATE_FUNCTION_DEFINITIONS` 候选池。 + +- `battle_use_skill` + 脚本:`src/data/functionCatalog/state/battleUseSkill.ts` + 说明:后端单行为战斗模型中的技能释放 function。每个技能 option 必须携带 `runtimePayload.skillId`,因此只登记文档和契约,不作为前端本地泛用 state function 生成。 + - `battle_all_in_crush` 脚本:`src/data/functionCatalog/state/battleAllInCrush.ts` 说明:战斗中的正面强压动作,只在 `battle` 状态且有存活敌人时进入候选池。它会提高伤害与终结/爆发技能权重,同时抬高承伤,适合收头、压血和赌一波换血抢节奏。 @@ -110,6 +118,18 @@ 脚本:`src/data/functionCatalog/npc/npcChat.ts` 说明:围绕当前话题与 NPC 继续交谈的 function。它会先生成对话正文,再把真正的新选项延迟到 `story_continue_adventure` 之后展示。 +- `npc_chat_quest_offer_view` + 脚本:`src/data/functionCatalog/npc/npcChatQuestOffer.ts` + 说明:聊天内待领取委托的查看入口,只查看 pending quest offer,不立即写入正式任务日志。 + +- `npc_chat_quest_offer_replace` + 脚本:`src/data/functionCatalog/npc/npcChatQuestOffer.ts` + 说明:聊天内待领取委托的更换入口,重新走任务生成链替换当前 pending quest offer。 + +- `npc_chat_quest_offer_abandon` + 脚本:`src/data/functionCatalog/npc/npcChatQuestOffer.ts` + 说明:聊天内待领取委托的放弃入口,只清空 pending quest offer,不影响已接任务。 + - `npc_gift` 脚本:`src/data/functionCatalog/npc/npcGift.ts` 说明:向 NPC 送礼的入口 function。第一次点击通常只打开礼物面板,确认礼物后才结算好感变化并继续剧情。 @@ -186,6 +206,7 @@ ## 当前实现约定 -- `src/data/stateFunctions.ts` 现在只负责基础 state function 的聚合、override 合并、运行时过滤和 option 解析。 +- `src/data/stateFunctions.ts` 现在只负责前端本地基础 state function 的聚合、override 合并、运行时过滤和 option 解析。 +- `battle_attack_basic` / `battle_use_skill` 虽然属于后端运行时契约中的战斗 function,但不进入 `STATE_FUNCTION_DEFINITIONS`。它们由后端 runtime story / combat option 池生成,避免前端本地生成缺少 `runtimePayload` 的假选项。 - 非 state function 目前仍由各自原有流程模块执行,但它们的 `id`、标题和详细说明已经统一收口到 `functionCatalog/`。 - 后续新增 function 时,建议先补独立脚本,再把运行时调用接进来,最后同步这份目录文档。 diff --git a/docs/technical/AGENT_DIALOG_AND_RESULT_REFINEMENT_BOUNDARY_2026-04-21.md b/docs/technical/AGENT_DIALOG_AND_RESULT_REFINEMENT_BOUNDARY_2026-04-21.md new file mode 100644 index 00000000..31baf4db --- /dev/null +++ b/docs/technical/AGENT_DIALOG_AND_RESULT_REFINEMENT_BOUNDARY_2026-04-21.md @@ -0,0 +1,99 @@ +# Agent 对话框与结果页精修职责边界修正 + +更新时间:`2026-04-21` + +## 1. 结论 + +本次修正把“Agent 对话框”和“结果页精修”重新拆清楚: + +1. `CustomWorldAgentWorkspace` 只负责八锚点信息收集、八锚点进度展示、八锚点完成后的“整理世界底稿”动作。 +2. “精修”不是 Agent 对话框里的概念,不再通过 Agent 建议动作进入角色、地点、世界总卡的局部修整。 +3. 已经生成底稿的草稿,从创作中心点击后进入结果页继续完善。 +4. 尚未生成底稿的草稿,从创作中心点击后才恢复 Agent 对话框继续补齐八锚点。 +5. 结果页负责成稿后的编辑、补全、进入世界前确认和自动保存,并通过 `sync_result_profile` 回写到当前 Agent session。 + +一句话: + +**Agent 收八锚点,结果页做精修。** + +--- + +## 2. 为什么要修正 + +旧实现把 `object_refining` 草稿卡片显示成“继续精修”,但点击后直接恢复 Agent 工作区。 + +这个行为会让用户产生两个误解: + +1. 以为精修是 Agent 对话框里的下一阶段。 +2. 以为 Agent 对话框不仅负责收集八锚点,还负责后续对象级编辑。 + +这和当前产品边界不一致。Agent 对话框应该保持轻量,只用于拿到足够稳定的八锚点输入;对象、场景、封面、世界档案的修整都应该在结果页完成。 + +--- + +## 3. 当前落地规则 + +### 3.1 创作中心草稿点击分流 + +`custom-world/works` 返回 `agent_session` 草稿后,前端按草稿是否已有底稿内容分流: + +1. `playableNpcCount <= 0 && landmarkCount <= 0` + - 视为八锚点仍未整理成底稿。 + - 点击进入 `agent-workspace`。 +2. `playableNpcCount > 0 || landmarkCount > 0` + - 视为已有可编辑底稿。 + - 点击读取对应 Agent session,编译为 `CustomWorldProfile`,进入 `custom-world-result`。 + +### 3.2 Agent 对话框动作边界 + +Agent 会话建议动作只保留: + +1. 总结当前设定 / 总结当前世界底稿。 +2. 八锚点准备完成后的“整理一版世界底稿”。 + +不再在 Agent 会话快照里继续生成或兼容展示: + +1. `refine_focus_target` +2. “精修角色” +3. “继续补地点” +4. “先看世界总卡” + +旧 session 快照如果仍带有 `refine_focus_target`,服务端兼容层会过滤掉,避免旧数据把精修入口重新塞回 Agent 对话框。 + +### 3.3 结果页精修边界 + +Agent 来源结果页不再是冻结预览态。 + +当前允许在结果页继续进行成稿精修,包括: + +1. 编辑世界信息。 +2. 编辑角色、场景、封面等对象档案。 +3. 删除或调整已有对象。 +4. 自动保存到作品草稿。 +5. 进入世界前通过 `sync_result_profile` 写回 Agent session。 + +为了保持主链简洁,Agent 来源结果页仍不重新打开“通过 Agent 对话精修对象”的入口。 + +--- + +## 4. 对历史文档口径的覆盖 + +这份文档覆盖 [AGENT_RESULT_PROFILE_SYNC_PHASE3_2026-04-20.md](./AGENT_RESULT_PROFILE_SYNC_PHASE3_2026-04-20.md) 中“Agent 来源结果页冻结为预览态”的阶段性口径。 + +新的主口径是: + +1. Agent 来源结果页可以编辑,因为精修本来就应该发生在结果页。 +2. 需要收紧的是 Agent 对话框,不是结果页。 +3. 结果页编辑后仍必须同步回 Agent session,保持进入世界前的数据真相源一致。 + +--- + +## 5. 验收标准 + +本次修正完成后应满足: + +1. 创作中心已有底稿草稿按钮文案为“继续完善”,点击进入结果页。 +2. 创作中心未成稿草稿按钮仍为“继续创作”,点击进入 Agent 对话框。 +3. Agent 对话框不出现“精修角色 / 补地点 / 看世界总卡”类对象精修入口。 +4. Agent 来源结果页可以打开编辑弹窗进行精修。 +5. 返回创作从结果页回到创作中心,不回到 Agent 对话框。 diff --git a/docs/technical/AGENT_DRAFT_RESULT_ASSET_MERGE_FIX_2026-04-21.md b/docs/technical/AGENT_DRAFT_RESULT_ASSET_MERGE_FIX_2026-04-21.md new file mode 100644 index 00000000..ac183414 --- /dev/null +++ b/docs/technical/AGENT_DRAFT_RESULT_ASSET_MERGE_FIX_2026-04-21.md @@ -0,0 +1,119 @@ +# Agent 草稿结果页资产合并修复 2026-04-21 + +更新时间:`2026-04-21` + +## 1. 问题现象 + +当前创作流程里,用户在“生成草稿”后反馈: + +1. 角色主图没有稳定出现在结果页 +2. 场景背景图有时可见,有时角色图缺失 +3. 自动保存后的作品库条目里,分幕图可能已经存在,但场景角色主图仍为空 + +## 2. 本次真实排查结论 + +本轮不是单一的“没写数据库”问题,而是 `agent draft -> result profile` 桥接层存在一类更隐蔽的集合漂移问题。 + +排查后确认: + +1. 最新 `custom_world_sessions.payload_json` 里的 `draftProfile.storyNpcs[].imageSrc` 已经存在 +2. 最新 `draftProfile.sceneChapters[].acts[].backgroundImageSrc` 也已经存在 +3. 对应图片文件也真实存在于仓库根 `public/` 下 +4. 最新 `custom_world_profiles.payload_json` 里,分幕图通常已保存成功 +5. 但场景角色主图可能仍为空 + +根因在于: + +1. 结果页桥接层在 `draftProfile.legacyResultProfile` 存在时,仍把 `legacyResultProfile` 视为主列表 +2. 旧逻辑只会按 `id` 把 `draftProfile` 里的图片字段回贴到 `legacyResultProfile` +3. 一旦后续草稿精修导致 `draftProfile` 的角色集合、角色 id 或角色命名发生漂移 +4. 旧 `legacyResultProfile` 就会继续主导结果页和自动保存对象列表 +5. 最新角色主图虽然已在 `draftProfile` 里生成完成,但会因为匹配失败而被整批吞掉 + +这类问题在场景角色上最明显,因为角色集合最容易在后续精修中替换。 + +## 3. 修复策略 + +本轮在: + +- `src/services/customWorldAgentDraftResult.ts` + +调整桥接规则: + +1. `legacyResultProfile` 仍保留,继续提供运行时富字段 +2. 但角色、场景、分幕等对象集合不再默认由 `legacyResultProfile` 主导 +3. 最新 `draftProfile` 成为结果页对象列表的主来源 +4. `legacyResultProfile` 只负责给命中的对象补运行时富字段 +5. 匹配优先级为: + - 先按 `id` + - 再按名称兜底 + +具体规则: + +1. `playableNpcs`:以最新 draft 集合为主,legacy 只补富字段与旧运行时字段 +2. `storyNpcs`:同上,避免旧角色列表吞掉新角色主图 +3. `sceneChapterBlueprints`:以最新 draft 幕列表为主,legacy 只补章节/幕已有运行时字段 +4. `landmarks`:优先更新最新 draft 命中的场景对象,但保留 legacy 中未被命中的剩余运行时场景,避免丢连接与残留信息 +5. `camp`:保留 legacy 基础信息,但优先取 draft 最新图片字段 + +## 4. 修复后的链路意义 + +修复后: + +1. 草稿自动资产服务生成的角色主图不会再因为旧 `legacyResultProfile` 的角色集合过时而丢失 +2. 分幕图继续可以稳定进入结果页与自动保存 +3. 作品库自动保存时,结果页编译出的 profile 更接近“当前草稿真实快照”,而不是历史 legacy 快照 + +## 5. 新增验证 + +本轮补了前端桥接测试: + +- `src/services/customWorldAgentDraftResult.test.ts` + +新增验证点: + +1. 当 `draftProfile.storyNpcs` 与 `legacyResultProfile.storyNpcs` 集合漂移时 +2. 结果页仍应优先展示最新 draft 角色 +3. 最新角色主图与最新分幕图不能被旧 legacy 快照吞掉 + +## 6. 当前状态 + +本轮修复后,本地已验证: + +1. `src/services/customWorldAgentDraftResult.test.ts` +2. `src/components/CustomWorldResultView.test.tsx` +3. `src/components/game-shell/PreGameSelectionFlow.agent.interaction.test.tsx` +4. `npm run check:encoding` + +均通过。 + +## 7. 后续建议 + +这次问题再次说明: + +1. `legacyResultProfile` 继续长期作为结果页主列表来源风险很高 +2. 只做“按 id 回贴图片字段”在对象集合会漂移的链路里不够稳 +3. 后续如果继续推进后端单一真相源,应优先把结果页对象集合完全改为服务端最新 compile/preview 结果,而不是继续让前端桥接层承担最终裁决 + +--- + +## 8. 2026-04-21 补充:新建共创会话 500 根因 + +后续联调里又发现一条与资产合并无关、但会直接阻断创作入口的后端问题: + +1. 点击“创建新 RPG 游戏”时,`POST /api/runtime/custom-world/agent/sessions` 返回 `500` +2. 表面上前端只看到“服务器内部错误”,实际根因在路由层 + +本次补查确认: + +1. `server-node/src/routes/customWorldAgent.ts` 这组路由内部直接使用 `request.userId!` +2. 但路由文件本身在 `2026-04-21` 修复前没有挂 `requireJwtAuth(...)` +3. 结果是 HTTP 请求虽然带了登录 token,但 `request.userId` 并不会被注入 +4. 后端继续拿 `undefined` 作为 `userId` 创建 / 读取 agent session,最终在仓储写库阶段触发 `500` + +修复方式: + +1. 在 `createCustomWorldAgentRoutes(...)` 顶部统一补上 `router.use(requireJwtAuth(context.config, context.userRepository))` +2. 让 session 创建、读取、发消息、执行 action、读取 operation / card 详情全部走同一层鉴权注入 + +这次补丁的意义不是新增功能,而是把 `custom-world agent` 路由和其他 `rpg-entry / rpg-profile / rpg-runtime` 受保护接口重新对齐,避免再出现“路由里依赖 `request.userId`,但入口没挂鉴权”的低层断线问题。 diff --git a/docs/technical/AGENT_DRAFT_RESULT_AUTOSAVE_DB_CHECK_2026-04-21.md b/docs/technical/AGENT_DRAFT_RESULT_AUTOSAVE_DB_CHECK_2026-04-21.md new file mode 100644 index 00000000..4aa0786f --- /dev/null +++ b/docs/technical/AGENT_DRAFT_RESULT_AUTOSAVE_DB_CHECK_2026-04-21.md @@ -0,0 +1,119 @@ +# 创作流程草稿/图片/动作自动保存数据库检查 2026-04-21 + +更新时间:`2026-04-21` + +## 1. 本次检查范围 + +本次检查只聚焦当前创作流程里下面这条链路: + +`结果页前端编辑 -> 自动保存 -> Agent session 主链同步 -> 作品库落库` + +重点核对三类内容: + +1. 草稿文本类修改 +2. 生成后的角色图片、地点图片、分幕图 +3. 角色动作相关资产字段 + +## 2. 当前实际自动保存链路 + +当前前端主入口在: + +- `src/components/game-shell/PreGameSelectionFlow.tsx` + +实际行为如下: + +1. 结果页编辑统一通过 `onProfileChange` 更新 `generatedCustomWorldProfile` +2. 当结果页停留在 `custom-world-result` 阶段时,前端会对 profile 做防抖自动保存 +3. 如果当前结果页来源是 `agent-draft`,自动保存前会先执行 `sync_result_profile` +4. `sync_result_profile` 完成后,前端不直接保存旧内存 profile,而是优先保存“从最新 session 重编译出的 profile” +5. 作品库保存最终走 `PUT /api/runtime/custom-world-library/:profileId` +6. Express 后端通过 `runtimeRepository.upsertCustomWorldProfile(...)` 把 profile 写入 `custom_world_profiles.payload_json` + +所以数据库层本身是有正常落库能力的。 + +## 3. 本次检查前确认成立的部分 + +以下能力在本次检查前已经成立: + +1. 结果页普通草稿字段编辑会触发自动保存 +2. 自动保存会真正调用后端作品库接口并更新数据库 +3. 返回创作、进入世界两条路径也会优先同步 Agent session +4. `legacyResultProfile` 已作为阶段一桥接快照保留在 session 中 + +## 4. 本次发现的真实风险 + +风险不在数据库写入本身,而在: + +`sync_result_profile -> session 重编译结果页 profile` + +此前 `sync_result_profile` 只回写: + +1. 基础摘要字段 +2. `legacyResultProfile` + +但没有把结果页里已经确认过的资产字段同步回 foundation draft 对应节点。 + +这会导致一个阶段性风险: + +1. 用户在结果页换了新的角色图 +2. 或者结果页里刚确认了新的动作资产字段 +3. 或者结果页里刚确认了新的地点图、分幕图 +4. 自动保存前前端先做一次 session 同步 +5. 同步完成后又从 session 重编译结果页 profile +6. 重编译过程会把 draft 层旧资产字段再次并入结果 profile + +这样就可能出现: + +**数据库自动保存成功了,但保存进去的是“被旧 draft 资产字段回退过的版本”,不是用户刚在结果页看到的最新图/动作。** + +## 5. 本轮修复 + +本轮在: + +- `server-node/src/services/customWorldAgentOrchestrator.ts` + +补了一个收窄修复: + +1. `sync_result_profile` 仍然保持阶段一边界,不做整套 runtime -> foundation draft 反解 +2. 但会按相同 id,把结果页里已确认的资产字段同步回 draft 层已有对象 +3. 同步范围包括: + - 角色 `imageSrc` + - 角色 `generatedVisualAssetId` + - 角色 `generatedAnimationSetId` + - 角色 `animationMap` + - 地点 `imageSrc` + - 分幕 `backgroundImageSrc` + - 分幕 `backgroundAssetId` + +这样后续再从 session 重编译结果页 profile 时,最新资产字段不会再被旧 draft 值回退。 + +## 6. 验证补充 + +本轮补了服务端测试: + +- `server-node/src/services/customWorldAgentPhase4.test.ts` + +新增验证点: + +1. `sync_result_profile` 后,最新角色主图会写回 draft +2. 最新角色动作资产字段会写回 draft +3. 最新地点图会写回 draft +4. 最新分幕图会写回 draft + +## 7. 结论 + +截至本轮修复后,当前创作流程里: + +1. 草稿文本修改可以自动保存到数据库 +2. 结果页中确认后的角色图、地点图、分幕图可以随自动保存稳定进入数据库 +3. 角色动作相关资产字段可以随 session 同步和自动保存稳定保留 + +但仍需注意: + +1. 当前仍是阶段一兼容链路,核心桥接字段仍然是 `legacyResultProfile` +2. 正式发布链 `publish_world` 还没有在当前阶段打通 +3. 前端仍依赖 `buildCustomWorldProfileFromAgentDraft()` 作为 session -> 结果页兼容编译层 + +因此本轮结论是: + +**当前“前端修改 -> 自动保存 -> 数据库”主链可用;本次已补上图片与动作资产在 session 重编译阶段的回退风险。** diff --git a/docs/technical/AGENT_RESULT_PROFILE_SYNC_PHASE1_2026-04-20.md b/docs/technical/AGENT_RESULT_PROFILE_SYNC_PHASE1_2026-04-20.md new file mode 100644 index 00000000..b006e0f0 --- /dev/null +++ b/docs/technical/AGENT_RESULT_PROFILE_SYNC_PHASE1_2026-04-20.md @@ -0,0 +1,212 @@ +# Agent 结果页深度编辑回写主链方案(阶段一) + +更新时间:`2026-04-20` + +## 1. 这次阶段一先改什么 + +这次阶段一不做结果页只读化。 + +结果页继续保留当前已经可用、而且用户已经满意的这些能力: + +1. 结果页继续允许深度编辑世界设定 +2. 结果页继续允许编辑角色、场景、营地、封面 +3. 结果页继续允许直接新增角色与地点 +4. 结果页继续保留当前已有的浏览、自动保存、进入世界体验 + +这次真正要补的是: + +**把结果页里产出的完整 `CustomWorldProfile`,同步回 `Agent session`,让结果页编辑不再游离在主链之外。** + +--- + +## 2. 当前真正的问题 + +当前链路里,结果页虽然还能深度编辑,但数据职责是分裂的: + +```text +Agent session +-> 前端 buildCustomWorldProfileFromAgentDraft() +-> 结果页本地 profile +-> 结果页继续深度编辑 +-> 自动保存到 custom-world-library +-> 进入世界 +``` + +这里最大的问题不是“结果页能编辑”,而是: + +1. 结果页编辑后的最新世界结构,没有稳定回写到 `Agent session` +2. 用户从结果页返回 Agent 工作区后,session 侧仍可能停留在较旧的草稿状态 +3. “结果页当前看到的世界”“Agent session 当前保存的草稿”“作品库里自动保存的 profile”可能不是同一份东西 +4. 进入世界时如果直接吃当前前端内存态,也会继续放大这个分叉 + +所以阶段一要解决的是: + +**结果页仍然是深度编辑器,但它编辑的是 Agent 主链里的当前结果快照,不是脱链的本地副本。** + +--- + +## 3. 阶段一目标状态 + +阶段一把链路先收成下面这样: + +```text +Agent session +-> 前端 buildCustomWorldProfileFromAgentDraft() 生成结果页初始 profile +-> 用户在结果页继续深度编辑 profile +-> 前端调用新的 Agent action,把完整结果 profile 同步回 session +-> session 保留: + - 当前 foundation draft + - 当前 legacyResultProfile 结果快照 + - 重编译后的 draftCards / assetCoverage / suggestedActions +-> 自动保存与进入世界都优先基于已同步的 session 结果快照执行 +``` + +这一步仍然是过渡态,不是最终态。 + +因为: + +1. 阶段一还不打通 `publish_world` +2. 阶段一也不把结果页改造成完全原生的 draft 编辑器 +3. 阶段一允许继续保留 `draftProfile.legacyResultProfile` 作为兼容桥接字段 + +但至少要做到: + +**结果页的深度编辑,必须进入 Agent session 的单一主链。** + +--- + +## 4. 阶段一具体实现边界 + +## 4.1 新增 Agent action:`sync_result_profile` + +阶段一新增一个面向结果页的 Agent action: + +```ts +{ action: 'sync_result_profile'; profile: CustomWorldProfileRecord } +``` + +用途只有一个: + +把结果页当前完整 `CustomWorldProfile` 快照同步回 `CustomWorldAgentSessionRecord`。 + +它不是发布动作,也不是世界编译动作。 +它只是把结果页当前编辑结果认回主链。 + +--- + +## 4.2 服务端写回策略 + +服务端接到 `sync_result_profile` 后,按下面规则处理: + +1. 读取当前 session +2. 取当前 `draftProfile` +3. 保留当前 draft 层已有的结构化字段: + - `playableNpcs / storyNpcs / landmarks / camp` + - `factions / threads / chapters / sceneChapters` + - `worldHook / playerPremise / openingSituation / iconicElements` + - 以及现有资产、scene chapter 等字段 +4. 把结果页传来的完整 `CustomWorldProfile` 写入 `draftProfile.legacyResultProfile` +5. 对于 draft 层里本来就和结果页一一对应、且结果页已经改动的字段,同步覆盖基础摘要字段: + - `name` + - `subtitle` + - `summary` + - `tone` + - `playerGoal` + - `majorFactions` + - `coreConflicts` +6. 重新编译 `draftCards` +7. 重建 `assetCoverage` +8. 刷新 `suggestedActions` +9. 写入 action result message 和 checkpoint + +这里故意不在阶段一做“把完整 runtime profile 反解成一整套全量 foundation draft 结构”的大重构。 + +原因是: + +1. 结果页当前已经支持很多深度编辑字段 +2. 如果现在硬做全量反编译,最容易把场景章节、多幕、资产字段写坏 +3. 阶段一应该先保证“结果页编辑不脱链”,而不是一次性重做所有模型映射 + +--- + +## 4.3 前端触发策略 + +前端只在 `customWorldResultViewSource === 'agent-draft'` 时走这条同步链。 + +具体规则: + +1. 结果页 profile 每次发生变化时,继续允许本地即时更新 +2. 但在自动保存前,先把 profile 通过 `sync_result_profile` 同步到 Agent session +3. 返回创作时,如果要重新读 Agent 草稿,也应优先以最新 session 为准 +4. 点击“进入世界”时,先拉取最新 session,再重新 `buildCustomWorldProfileFromAgentDraft()`,避免吃到旧的前端缓存 profile + +这样阶段一就能做到: + +1. 结果页编辑体验不变 +2. Agent session 成为结果页编辑后的可恢复真相源 +3. 自动保存、返回创作、进入世界三条路都围绕同一份 session-backed 结果快照 + +--- + +## 5. 阶段一明确不做什么 + +这次阶段一明确不做: + +1. 不关闭结果页当前已有的编辑器能力 +2. 不删除结果页当前已有的 AI 新增角色/地点能力 +3. 不打通 `publish_world` +4. 不把 `legacyResultProfile` 直接删掉 +5. 不把结果页整个改写成只操作 draft card 的新系统 +6. 不把旧 `custom-world/sessions` 链在本阶段直接物理移除 + +--- + +## 6. 验收标准 + +阶段一做完后,至少要满足下面这些结果: + +1. Agent 草稿结果页继续保持当前深度编辑体验不变 +2. 结果页发生编辑后,Agent session 中能看到同步后的最新结果快照 +3. 从结果页返回创作后,不会明显回退到较旧的草稿态 +4. 点击“进入世界”时,会优先使用最新 session 重新编译结果,而不是只依赖前端旧内存态 +5. 自动保存到作品库的 profile 与当前 session 结果快照保持一致 + +--- + +## 7. 一句话结论 + +阶段一不是收掉结果页,而是把结果页继续保留为深度编辑器,同时补上一条正式的 session 回写链,让它不再游离在 Agent 主链之外。 + +--- + +## 8. 2026-04-20 实际落地结果 + +本轮已经按阶段一目标完成下面这些收口: + +1. 前端结果页自动保存时,若当前来源是 `agent-draft`,会先执行 `sync_result_profile` +2. `sync_result_profile` 完成后,自动保存不再直接写旧的前端内存 profile,而是优先保存从最新 session 重新 `buildCustomWorldProfileFromAgentDraft()` 得到的结果快照 +3. 点击“进入世界”时,仍会先同步 session,再基于最新 session 重编译 profile 后进入世界 +4. 点击“返回创作”时,也会先做一次结果页到 session 的同步兜底,再返回 Agent 工作区 +5. 为避免用户刚从结果页返回工作区又被自动重开逻辑顶回结果页,前端补了一层显式返回抑制标记 +6. 服务端 `sync_result_profile` 现已按阶段一边界收窄为“保留 foundation draft 结构,只更新基础摘要字段和 `legacyResultProfile`”,没有提前做整套 runtime -> draft 反解 + +这意味着阶段一当前已经把下面三条路径收回到同一条 session 主链: + +1. 自动保存到作品库 +2. 返回 Agent 工作区继续创作 +3. 从结果页直接进入世界 + +## 9. 本轮仍然保留的阶段性边界 + +这次落地后,仍然保留文档原先约定的过渡边界: + +1. 结果页深度编辑能力不做收缩 +2. `draftProfile.legacyResultProfile` 继续作为兼容桥接字段保留 +3. `publish_world` 仍未在这一轮打通 +4. 前端仍然使用 `buildCustomWorldProfileFromAgentDraft()` 作为 session -> 结果页的兼容编译层 + +所以下一阶段如果要继续推进,重点应转向: + +1. 降低前端对 legacy profile 编译桥接的依赖 +2. 继续把发布链路收口到 Agent session / service 侧 +3. 逐步缩减结果页直改 legacy profile 的历史职责 diff --git a/docs/technical/AGENT_RESULT_PROFILE_SYNC_PHASE2_2026-04-20.md b/docs/technical/AGENT_RESULT_PROFILE_SYNC_PHASE2_2026-04-20.md new file mode 100644 index 00000000..ab56054c --- /dev/null +++ b/docs/technical/AGENT_RESULT_PROFILE_SYNC_PHASE2_2026-04-20.md @@ -0,0 +1,74 @@ +# Agent 结果页与平台入口收口方案(阶段二) + +更新时间:`2026-04-20` + +## 1. 阶段二目标 + +阶段一已经把 Agent 结果页编辑快照同步回 session 主链。阶段二不继续扩大结果页编辑能力,而是把入口和职责继续收紧: + +1. 平台“创作”入口统一读取 `custom-world/works` 聚合列表 +2. Agent 草稿和已保存作品在同一个入口里展示 +3. 草稿点击后恢复 Agent session,已保存作品点击后进入作品详情 +4. Agent 结果页不再暴露“继续在结果页补世界结构”的新增入口 + +一句话目标: + +**让用户从平台创作入口能稳定找回草稿和作品,同时让结果页更像收口预览,而不是另一套编辑器。** + +--- + +## 2. 本阶段不做什么 + +阶段二明确不做: + +1. 不物理删除旧 `custom-world/sessions` 链 +2. 不打通 `publish_world` +3. 不重做结果页 UI +4. 不删除已保存作品的继续编辑入口 +5. 不把结果页整体改成只读 + +这些事项留给后续阶段继续拆。 + +--- + +## 3. 平台入口落地规则 + +平台“创作”Tab 改为优先展示 `listCustomWorldWorks()` 的聚合结果: + +1. `agent_session` 类型展示为草稿,可点击恢复 Agent 工作区 +2. `published_profile` 类型展示为作品,可点击进入作品详情 +3. 聚合接口失败时保留现有作品库 `myEntries` 兜底 +4. 不新增平行页面,复用已有 `CustomWorldCreationHub` + +这样用户不再需要依赖隐藏 sessionId 或旧作品库入口才能找回创作。 + +--- + +## 4. 结果页职责收口规则 + +Agent 来源结果页继续保留: + +1. 浏览世界、角色、场景 +2. 自动保存 +3. 返回 Agent 工作区 +4. 进入世界 + +Agent 来源结果页本阶段收紧: + +1. 不再显示直接新增可扮演角色、场景角色、场景的入口 +2. 不再把“去 Agent 调整设定”设计成结果页内部继续补世界结构 +3. 如需继续调整,返回 Agent 工作区 + +已保存作品的结果页仍保持现有编辑能力,避免破坏作品库已有体验。 + +--- + +## 5. 验收标准 + +阶段二完成后应满足: + +1. 平台“创作”Tab 能看到 Agent 草稿和已保存作品的统一列表 +2. 点击 Agent 草稿能恢复对应 Agent 工作区 +3. 点击已保存作品能进入原有作品详情 +4. Agent 结果页不再显示直接新增角色/地点的入口 +5. 已保存作品的结果页编辑能力不受影响 diff --git a/docs/technical/AGENT_RESULT_PROFILE_SYNC_PHASE3_2026-04-20.md b/docs/technical/AGENT_RESULT_PROFILE_SYNC_PHASE3_2026-04-20.md new file mode 100644 index 00000000..93548f78 --- /dev/null +++ b/docs/technical/AGENT_RESULT_PROFILE_SYNC_PHASE3_2026-04-20.md @@ -0,0 +1,148 @@ +# Agent 结果页旧链降级与预览冻结方案(阶段三) + +更新时间:`2026-04-20` + +## 1. 阶段三目标 + +阶段一已经把结果页编辑同步回 Agent session 主链。 + +阶段二已经把平台“创作”入口统一到 `custom-world/works` 聚合列表,并收紧了 Agent 结果页里的新增入口。 + +阶段三不继续扩功能,而是继续做两件事: + +1. 让旧 pipeline 在主入口里进一步降级,不再和 Agent 主链抢“草稿”职责 +2. 让 Agent 来源结果页进一步冻结为“预览/收口层”,不再继续承担 legacy profile 直改编辑器职责 + +一句话目标: + +**把还在和 Agent 主链并行的旧职责继续降级,避免系统自己和自己打架。** + +--- + +## 2. 当前剩余问题 + +虽然阶段一、二已经把主链收紧了不少,但当前还保留两个明显的并行口: + +### 2.1 创作中心里旧 library 草稿仍可能继续冒充主草稿 + +当前 `listCustomWorldWorkSummaries()` 会把 runtime library 里的所有 profile 都折成 `published_profile` 类型返回。 + +这意味着: + +1. `visibility = 'draft'` 的 library 草稿仍会继续出现在创作中心 +2. 创作中心里同时存在: + - Agent session 草稿 + - library 草稿 + - 已发布作品 +3. 用户看到的“草稿”概念仍然可能混成两套 + +阶段三需要明确: + +**创作中心主入口只认 Agent session 草稿 和 已发布作品,不再继续把 library draft 当主草稿展示。** + +--- + +### 2.2 Agent 结果页仍能继续打开旧 legacy 编辑器 + +当前 Agent 来源结果页虽然已经不再暴露“新增角色/新增地点”入口,但仍然保留下面这些旧编辑链: + +1. 点击世界概述/基本设定仍能打开 legacy world editor +2. 点击角色、场景、封面仍能继续进入旧 profile 编辑弹窗 +3. 这些编辑器本质上仍然是在改 legacy `CustomWorldProfile` + +这会带来两个问题: + +1. Agent 结果页继续像一套“旧编辑器” +2. “去 Agent 调整设定”和“结果页直接改 legacy profile”两条路仍然并行存在 + +阶段三需要明确: + +**Agent 来源结果页继续保留浏览、自动保存、返回创作、进入世界,但不再继续承担 legacy profile 深编辑职责。** + +--- + +## 3. 阶段三落地规则 + +## 3.1 创作中心只展示两类主入口内容 + +`custom-world/works` 在阶段三只保留下面两类条目: + +1. `agent_session` + - 统一视为草稿 + - 点击后恢复 Agent 工作区 +2. `published_profile` + - 统一视为已发布作品 + - 点击后进入现有作品详情 + +明确不再把下面这类内容继续塞进创作中心主入口: + +1. library 中 `visibility = 'draft'` 的兼容草稿 + +这些兼容草稿仍然保留在作品库/详情链路里,不在本阶段物理删除,但不再继续占创作中心“草稿主入口”。 + +--- + +## 3.2 Agent 来源结果页冻结为预览态 + +当 `customWorldResultViewSource === 'agent-draft'` 时,结果页阶段三继续保留: + +1. 浏览世界信息 +2. 浏览角色、地点、场景结构 +3. 自动保存 +4. 返回 Agent 工作区 +5. 进入世界 + +同时阶段三进一步收紧: + +1. 不再打开世界/角色/场景/封面的 legacy 编辑弹窗 +2. 不再提供删除角色、删除场景等旧 profile 直改入口 +3. Agent 来源结果页上的对象卡统一作为“查看详情”预览卡使用 + +已保存作品的结果页编辑能力继续保留,不在本阶段收缩,避免破坏已有作品库编辑体验。 + +--- + +## 3.3 结果页同步动作只在真的发生差异时执行 + +阶段一补的 `sync_result_profile` 仍然保留,但阶段三补一个行为约束: + +1. 如果当前 Agent 结果页 profile 和最新 session 重编译结果签名一致 +2. 那么返回创作、进入世界、自动保存前不再重复触发一次 `sync_result_profile` + +目的不是省接口,而是明确: + +**结果页同步是“有改动才回写”的主链动作,不是每次离开页面都机械重放。** + +--- + +## 4. 阶段三明确不做什么 + +这次阶段三明确不做: + +1. 不物理删除旧 `custom-world/sessions` 相关服务与兼容代码 +2. 不打通 `publish_world` +3. 不把前端 `buildCustomWorldProfileFromAgentDraft()` 兼容编译层移除 +4. 不删除 `draftProfile.legacyResultProfile` +5. 不收缩已保存作品的 legacy 编辑器能力 + +阶段三只做主入口降级与 Agent 结果页职责冻结,不做更大的模型替换。 + +--- + +## 5. 验收标准 + +阶段三完成后应满足: + +1. 创作中心不再把 library draft 兼容作品继续显示为“草稿主入口” +2. 创作中心里只保留 Agent 草稿和已发布作品两类主入口内容 +3. Agent 来源结果页不再能继续打开 legacy 世界/角色/场景编辑弹窗 +4. 已保存作品结果页编辑能力不受影响 +5. Agent 结果页在未发生改动时,返回创作/进入世界/自动保存不会重复触发无意义的 `sync_result_profile` + +--- + +## 6. 一句话结论 + +阶段三不是删除兼容层,而是把它们继续降级到不会抢主流程职责的位置上: + +**创作中心只认 Agent 草稿和已发布作品,Agent 结果页只负责预览与收口,不再继续充当旧编辑器。** diff --git a/docs/technical/CREATION_FLOW_CHAIN_REFACTOR_EXECUTION_PLAN_2026-04-21.md b/docs/technical/CREATION_FLOW_CHAIN_REFACTOR_EXECUTION_PLAN_2026-04-21.md new file mode 100644 index 00000000..131bac39 --- /dev/null +++ b/docs/technical/CREATION_FLOW_CHAIN_REFACTOR_EXECUTION_PLAN_2026-04-21.md @@ -0,0 +1,1501 @@ +# 当前创作流程链路前后端脚本重构执行方案 + +更新时间:`2026-04-21` + +## 0. 文档目标 + +本文件只服务一件事: + +**把当前“创作入口 -> Agent 会话 -> 世界底稿 -> 结果页编辑 -> 自动保存 -> 作品库 -> 进入世界”这条链路上的前后端脚本,整理成一份可以直接指导后续编码拆分的执行方案。** + +本轮不直接改业务逻辑,只明确: + +1. 当前链路上的真实脚本地图 +2. 当前可读性差、可扩展性差的结构性问题 +3. 目标分层与真相源边界 +4. 文件级拆分建议 +5. 分阶段落地计划与验收标准 + +--- + +## 1. 范围与依据 + +### 1.1 本文覆盖的创作链路 + +```text +平台创作入口 +-> Agent session 创建 / 恢复 +-> Agent 对话与 action 执行 +-> foundation draft 生成 +-> 角色图 / 地点图 / 分幕图 / 动作资产生成与同步 +-> 结果页编辑 +-> 自动保存到作品库 +-> works 聚合展示 / 恢复创作 +-> 进入游戏世界 +``` + +### 1.2 本文主要依据 + +1. `docs/audits/AGENT_TO_DRAFT_TO_WORLD_PIPELINE_AUDIT_2026-04-20.md` +2. `docs/technical/AGENT_RESULT_PROFILE_SYNC_PHASE1_2026-04-20.md` +3. `docs/technical/AGENT_RESULT_PROFILE_SYNC_PHASE2_2026-04-20.md` +4. `docs/technical/AGENT_RESULT_PROFILE_SYNC_PHASE3_2026-04-20.md` +5. `docs/audits/engineering/FRONTEND_LOGIC_BACKEND_MIGRATION_AUDIT_2026-04-21.md` +6. `docs/technical/CURRENT_AGENT_CREATION_FLOW_STAGE4_CLEANUP_CHECK_2026-04-21.md` +7. `docs/technical/AGENT_DRAFT_RESULT_AUTOSAVE_DB_CHECK_2026-04-21.md` + +--- + +## 2. 当前链路脚本地图 + +## 2.1 前端主链脚本 + +| 文件 | 当前职责 | 当前问题 | +| --- | --- | --- | +| `src/components/game-shell/PreGameSelectionFlow.tsx` | 平台 tab、详情页、创作入口、Agent session 创建与恢复、operation 轮询、结果页自动保存、session 同步、进入世界前同步、works/library/gallery/history/save 拉取 | 单文件承载过多流程编排,页面壳层、状态机、网络请求、自动保存和世界进入逻辑混在一起,是当前前端最大热点 | +| `src/components/custom-world-home/CustomWorldCreationHub.tsx` | 创作中心 works 展示、继续创作、草稿与已发布作品入口 | 读模型已经存在,但与平台壳层仍有状态耦合,入口职责还没有完全收口 | +| `src/components/CustomWorldResultView.tsx` | 结果页预览、结果页内生成角色/地点、结果页内触发编辑、资产调试面板 | 结果页同时是预览层、编辑层、生成层,仍保留 legacy profile 直改能力 | +| `src/components/CustomWorldEntityEditorModal.tsx` | 世界、封面、营地、角色、地点等多种对象编辑,以及部分资产与运行时预览能力 | 单文件过大,编辑表单、资产工作流、运行时预览混合,后续很难局部扩展 | +| `src/components/CustomWorldRoleAssetStudioModal.tsx` | 角色主图候选、动作生成、动作发布、缓存读写 | 视觉生成、动作生成、缓存、发布四类职责耦合在一个模态层里 | +| `src/services/customWorldAgentDraftResult.ts` | `Agent session draftProfile -> legacy CustomWorldProfile` 的桥接编译与资产合并 | 前端承担了结构化编译责任,是“前端只做表现”边界下最应继续收缩的兼容层 | +| `src/services/aiService.ts` | Agent session、消息流、operation、旧 custom world 相关请求、若干结果页生成动作 | custom world 相关 API 与 story/chat/legacy AI 接口混放,不利于链路收口 | +| `src/services/storageService.ts` | works、library、gallery、browse history、save archive、profile dashboard | custom world 作品链 API 与通用 runtime 存储 API 混放,边界不清晰 | + +## 2.2 后端主链脚本 + +| 文件 | 当前职责 | 当前问题 | +| --- | --- | --- | +| `packages/shared/src/contracts/customWorldAgent.ts` | session、message、draft、asset、works、action、operation 契约 | 契约体量过大,action 定义与后端真实执行能力存在漂移 | +| `server-node/src/routes/customWorldAgent.ts` | Agent session 与 action 路由入口 | 应继续保持薄路由,但当前下游编排层过重,路由可读性受限 | +| `server-node/src/routes/runtimeRoutes.ts` | works、library、gallery、runtime 相关通用路由 | custom world 作品链路仍混在 runtime 大路由里,不利于独立演进 | +| `server-node/src/services/customWorldAgentOrchestrator.ts` | session 生命周期、消息处理、action 分发、result sync、派生状态拼装、suggested actions、质量状态拼装 | 当前后端最大热点之一,承担了过多业务分支和字段同步细节 | +| `server-node/src/services/customWorldAgentSessionStore.ts` | session 创建、读写、兼容旧结构补齐、snapshot 输出 | store、兼容转换、session factory 三类职责还没有拆开 | +| `server-node/src/services/customWorldAgentFoundationDraftService.ts` | eight-anchor / intent -> foundation draft;内部依赖 runtime profile 编译再转回 draft | 存在“先编 legacy runtime profile,再转回 foundation draft”的双重编译 | +| `server-node/src/modules/custom-world/runtimeProfile.ts` | custom world runtime profile 规范化、构建、编译、属性 schema、场景章节处理 | 文件过大,normalize/build/schema/scene/role 等职责全部堆在一起 | +| `server-node/src/repositories/runtimeRepository.ts` | save/settings/custom world profiles/custom world sessions/browse history 等仓储 | 仓储按技术分组,不按领域分组;custom world 相关方法和通用 runtime 方法耦合 | +| `server-node/src/services/customWorldWorkSummaryService.ts` | 聚合 Agent 草稿与已发布 profile,生成 works 读模型 | 汇总逻辑、展示语义、metadata 回退混在一起,适合作为独立 read model 层继续收口 | + +## 2.3 当前链路上的次级执行模块 + +以下脚本不是主入口,但属于链路中的重要执行点,后续重构不能绕开: + +1. `server-node/src/services/customWorldAgentEntityGenerationService.ts` +2. `server-node/src/services/customWorldAgentAutoAssetService.ts` +3. `server-node/src/services/customWorldAgentAssetBridgeService.ts` +4. `server-node/src/services/customWorldAgentDraftCompiler.ts` +5. `server-node/src/services/customWorldAgentRoleAssetStateService.ts` + +这些模块后续要继续明确边界: + +1. 生成型 service 只负责生成结果 +2. bridge / sync 型 service 只负责把已确认结果写回 session +3. snapshot / read model 型 service 只负责组织前端展示数据 + +--- + +## 3. 当前结构性问题 + +## 3.1 前端壳层承担了过多编排责任 + +`PreGameSelectionFlow.tsx` 当前既是平台页面壳层,又是创作流程控制器,还同时负责: + +1. Agent session 创建与恢复 +2. operation 轮询 +3. works、gallery、history、save、dashboard 拉取 +4. 结果页自动保存 +5. 结果页改动同步回 session +6. 进入世界前同步 + +这会导致: + +1. 页面组件一改就容易碰到主链数据流 +2. 自动保存与页面切换耦合过紧 +3. 后续任何新增 action 都会继续堆进壳层文件 + +## 3.2 前端仍在承担结果 profile 编译责任 + +`buildCustomWorldProfileFromAgentDraft()` 仍是当前 session draft -> result profile 的关键桥接点。 + +这意味着: + +1. Agent session 不是最终唯一真相源 +2. 前端在裁决字段取舍、默认值、资产合并 +3. 自动保存和进入世界依赖的是前端重编译结果,而不是服务端正式输出 + +这与“前端只做表现,逻辑和数据收回 Express 后端”的项目约束不一致。 + +## 3.3 结果页仍是 legacy 编辑器兼容工作台 + +`CustomWorldResultView.tsx`、`CustomWorldEntityEditorModal.tsx`、`CustomWorldRoleAssetStudioModal.tsx` 当前仍然存在: + +1. 直接改 legacy `CustomWorldProfile` +2. 直接调用 legacy 生成接口补角色、地点 +3. 资产工坊直接处理缓存、候选、发布 + +结果页就不只是“预览 / 发布前收口层”,而是独立并行编辑器。 + +## 3.4 后端编排层和编译层都过重 + +`customWorldAgentOrchestrator.ts` 与 `runtimeProfile.ts` 是当前两个最明显的大文件热点: + +1. 一个同时承担 message orchestration、action dispatch、result sync、snapshot 拼装 +2. 一个同时承担 runtime normalize、schema build、scene chapter compile、role build + +结果是: + +1. 新增 action 成本高 +2. 新增字段时容易遗漏多个分支 +3. 单测很难精准覆盖局部职责 + +## 3.5 works、library、publish、enter world 仍没有完全收成单一路径 + +当前仍然是: + +1. Agent session 草稿链 +2. 已保存 profile 链 +3. 结果页 legacy profile 直改链 +4. works 聚合读模型链 + +多条 pipeline 并存会继续放大桥接层复杂度。 + +--- + +## 4. 目标分层架构 + +## 4.1 目标原则 + +后续重构必须统一遵守 5 条原则: + +1. **Agent session 是创作态唯一真相源。** +2. **服务端编译结果预览是结果页唯一数据来源。** +3. **published profile 是进入世界与作品库持久化的正式真相。** +4. **前端只保留展示状态、交互状态、表单草稿态,不再承担结构化编译。** +5. **action 能力由后端注册表统一声明,前端不再假设 contract 中定义的 action 一定真实可用。** + +## 4.2 目标链路 + +```text +前端平台壳层 +-> custom world 专属 client +-> Agent route / works route / library route +-> session application service +-> action registry +-> foundation / entity / asset / publish domain services +-> session store / custom world repository +-> 服务端 result preview compiler +-> 前端结果页展示与编辑 +-> 服务端 autosave / publish / enter-world gate +``` + +## 4.3 三类真相源 + +后续必须严格区分三类数据: + +| 数据 | 真相位置 | 用途 | +| --- | --- | --- | +| 创作态 session | Agent session store | 对话、草稿、锁定、suggested actions、asset 覆盖率、阶段状态 | +| 结果页预览态 result preview | 服务端 preview compiler 输出 | 结果页展示、结果页局部编辑回填、自动保存前比对 | +| 已发布世界 profile | custom world profile repository | 作品库、发布态、进入世界、对外展示 | + +前端不再把 `draftProfile -> runtime profile` 编译结果视为正式真相,只能把它视为临时兼容输出,且这条兼容层要持续收缩。 + +--- + +## 4.4 RPG 创作流程脚本命名规范 + +这套创作流程只服务 **RPG 类型游戏**,后续命名不能继续沿用过于泛化的 `customWorld`、`runtime`、`flow` 混搭口径,而应把“RPG 创作域”显式写进命名里。 + +### 命名目标 + +1. 让人一眼看出这是 **RPG 世界创作链**,不是通用世界编辑器。 +2. 让文件名能直接表达层级:页面壳层、工作流、应用服务、编译器、仓储、读模型。 +3. 避免继续出现“同一文件名里既有业务域又有历史兼容语义”的情况。 + +### 推荐命名根 + +后续新建或重命名文件时,优先使用下面 3 类命名根: + +1. `rpgCreation`:用于前端创作流程壳层、workflow、client、view model +2. `rpgWorld`:用于后端世界草稿、世界预览、世界发布、世界仓储 +3. `rpgAgent`:用于 Agent session、message turn、action executor、snapshot + +### 命名规则 + +1. 前端组件文件使用 `RpgCreation` 前缀。 +2. 前端 hooks / workflow 文件使用 `useRpgCreation` 前缀。 +3. 前端 client / adapter / mapper 文件使用 `rpgCreation` 前缀。 +4. 后端应用服务文件使用 `RpgAgent` 或 `RpgWorld` 前缀。 +5. 后端仓储文件使用 `RpgWorld...Repository` 或 `RpgAgent...Repository`。 +6. 共享契约文件使用 `rpgCreation...` 或 `rpgAgent...` 小驼峰命名。 +7. 禁止再新增过于泛化的 `customWorld*Service.ts`、`customWorld*Flow.tsx` 作为新主命名。 + +### 文件命名示例 + +#### 前端示例 + +1. `src/components/game-shell/rpg-creation-flow/RpgCreationShell.tsx` +2. `src/components/game-shell/rpg-creation-flow/useRpgCreationPlatformBootstrap.ts` +3. `src/components/game-shell/rpg-creation-flow/useRpgCreationSessionController.ts` +4. `src/components/rpg-creation-result/RpgCreationResultView.tsx` +5. `src/components/rpg-creation-editor/RpgCreationRoleEditorSection.tsx` +6. `src/services/rpg-creation/rpgCreationAgentClient.ts` +7. `src/services/rpg-creation/rpgCreationPreviewAdapter.ts` + +#### 后端示例 + +1. `server-node/src/routes/rpgCreationAgentRoutes.ts` +2. `server-node/src/routes/rpgWorldLibraryRoutes.ts` +3. `server-node/src/services/RpgAgentOrchestrator.ts` +4. `server-node/src/services/RpgAgentActionRegistry.ts` +5. `server-node/src/services/RpgWorldPreviewCompiler.ts` +6. `server-node/src/repositories/RpgWorldProfileRepository.ts` +7. `server-node/src/repositories/RpgAgentSessionRepository.ts` + +#### 共享契约示例 + +1. `packages/shared/src/contracts/rpgAgentSession.ts` +2. `packages/shared/src/contracts/rpgAgentActions.ts` +3. `packages/shared/src/contracts/rpgCreationPreview.ts` +4. `packages/shared/src/contracts/rpgCreationWorkSummary.ts` + +### 兼容期命名策略 + +因为当前主链已有大量 `customWorld*` 文件,重构期间采用“两段式迁移”: + +1. 第一阶段先新增按规范命名的新目录和 façade。 +2. 第二阶段再把旧 `customWorld*` 文件逐步迁到 `rpgCreation* / rpgWorld* / rpgAgent*` 命名。 + +### 命名禁忌 + +后续重构中禁止继续出现以下命名问题: + +1. 一个文件名同时表达多个层级,例如 `FlowServiceController`。 +2. 用 `runtime` 指代创作态脚本。 +3. 用 `customWorld` 指代实际上只服务 RPG 创作链的新模块。 +4. 用 `Helper`、`Utils`、`Manager` 作为主业务模块名。 + +--- + +## 5. 前端重构拆分方案 + +## 5.1 `PreGameSelectionFlow.tsx` 拆分方案 + +### 现状问题 + +当前文件同时承担“平台壳层 + 数据加载器 + 创作流程控制器 + 自动保存协调器 + 世界进入协调器”五类职责。 + +### 目标拆分 + +保留 `PreGameSelectionFlow.tsx` 作为页面壳层,只负责: + +1. stage 切换 +2. 组件装配 +3. 视觉级 loading / error 展示 + +从该文件中拆出以下模块: + +1. `src/components/game-shell/custom-world-flow/useCustomWorldPlatformBootstrap.ts` +2. `src/components/game-shell/custom-world-flow/useCustomWorldWorkEntries.ts` +3. `src/components/game-shell/custom-world-flow/useCustomWorldAgentSessionController.ts` +4. `src/components/game-shell/custom-world-flow/useCustomWorldAgentOperationPolling.ts` +5. `src/components/game-shell/custom-world-flow/useCustomWorldResultAutosave.ts` +6. `src/components/game-shell/custom-world-flow/useCustomWorldEnterWorld.ts` +7. `src/components/game-shell/custom-world-flow/useCustomWorldDetailNavigation.ts` + +### 编码要求 + +1. 壳层文件内不再直接拼接 route path。 +2. 壳层文件内不再直接包含自动保存防抖实现。 +3. 壳层文件内不再直接包含 session -> result profile 编译细节。 +4. 壳层文件内不再直接处理 works/library/history/save 的多路请求编排。 + +## 5.2 custom world 专属 client 拆分方案 + +### 现状问题 + +`aiService.ts` 和 `storageService.ts` 中 custom world 相关接口已经比较多,继续堆在通用 service 里会加重跨域耦合。 + +### 目标拆分 + +新增 custom world 专属 client 目录: + +1. `src/services/custom-world/customWorldAgentClient.ts` +2. `src/services/custom-world/customWorldWorkClient.ts` +3. `src/services/custom-world/customWorldLibraryClient.ts` +4. `src/services/custom-world/customWorldAssetClient.ts` + +### 保留边界 + +1. `aiService.ts` 只保留 story/chat/通用 AI 能力。 +2. `storageService.ts` 只保留 save/settings/profile dashboard 等通用 runtime 存储。 +3. custom world 相关请求全部从通用 service 中迁出后,旧导出保留一个阶段的兼容别名,再统一删除。 + +## 5.3 `CustomWorldResultView.tsx` 拆分方案 + +### 现状问题 + +该文件同时承担结果概览、实体目录、结果页动作、局部生成动作和调试面板。 + +### 目标拆分 + +保留 `CustomWorldResultView.tsx` 作为结果页组合壳层,并拆出: + +1. `src/components/custom-world-result/CustomWorldResultHeader.tsx` +2. `src/components/custom-world-result/CustomWorldResultActionBar.tsx` +3. `src/components/custom-world-result/CustomWorldResultEntitySection.tsx` +4. `src/components/custom-world-result/CustomWorldAssetCoveragePanel.tsx` +5. `src/components/custom-world-result/CustomWorldAssetDebugPanel.tsx` +6. `src/components/custom-world-result/useCustomWorldResultActions.ts` + +### 边界要求 + +1. 结果页只调用 custom world 专属 client。 +2. 结果页不再直接依赖 legacy 生成函数作为长期主链能力。 +3. 调试面板与正式结果页逻辑隔离,避免调试代码继续污染主流程组件。 + +## 5.4 `CustomWorldEntityEditorModal.tsx` 拆分方案 + +### 现状问题 + +该文件把 world / cover / camp / role / landmark / chapter 编辑全部放在一个文件里,且还混入运行时预览与资产工作流。 + +### 目标拆分 + +保留 modal 壳层,拆出: + +1. `src/components/custom-world-editor/CustomWorldWorldEditorSection.tsx` +2. `src/components/custom-world-editor/CustomWorldCoverEditorSection.tsx` +3. `src/components/custom-world-editor/CustomWorldCampEditorSection.tsx` +4. `src/components/custom-world-editor/CustomWorldRoleEditorSection.tsx` +5. `src/components/custom-world-editor/CustomWorldLandmarkEditorSection.tsx` +6. `src/components/custom-world-editor/CustomWorldSceneChapterEditorSection.tsx` +7. `src/components/custom-world-editor/customWorldResultFormMapper.ts` + +### 边界要求 + +1. 编辑 section 只负责表单表现。 +2. 提交 patch、差异比较、字段清洗都收口到独立 mapper / mutation 层。 +3. 运行时预览和战斗预览不继续堆在结果编辑主链文件里。 + +## 5.5 `CustomWorldRoleAssetStudioModal.tsx` 拆分方案 + +### 现状问题 + +角色图候选、动作模板、动作生成、缓存恢复、结果发布都在一个文件里,后续加“场景资产工坊”时会继续复制相同问题。 + +### 目标拆分 + +建议拆为: + +1. `src/components/custom-world-asset-studio/useRoleVisualCandidateWorkflow.ts` +2. `src/components/custom-world-asset-studio/useRoleAnimationWorkflow.ts` +3. `src/components/custom-world-asset-studio/roleAssetStudioModel.ts` +4. `src/components/custom-world-asset-studio/roleAssetStudioPublishClient.ts` +5. `src/components/custom-world-asset-studio/CustomWorldRoleAssetStudioModal.tsx` + +### 边界要求 + +1. 缓存模型独立于 UI。 +2. 发布动作只负责把“已确认资产结果”提交给后端。 +3. 动作生成参数模板不要散落在 UI 文件里。 + +## 5.6 `customWorldAgentDraftResult.ts` 收缩方案 + +### 当前定位 + +这是当前最重要的前端兼容桥接层。 + +### 过渡策略 + +阶段一不直接删除,但要改名并收缩定位: + +1. 建议迁为 `src/services/custom-world/customWorldResultPreviewAdapter.ts` +2. 只保留“服务端 result preview -> 前端 view model”的轻量适配 +3. 禁止继续在其中新增业务裁决和字段拼装逻辑 + +### 长期目标 + +服务端提供正式 `result preview` 输出后,前端不再执行 `draftProfile -> CustomWorldProfile` 编译,本文件可以在后续阶段物理删除。 + +--- + +## 6. 后端重构拆分方案 + +## 6.1 route 层收口 + +### 当前问题 + +`customWorldAgent.ts` 与 `runtimeRoutes.ts` 的下游能力边界还不够清晰,custom world 作品链仍混在 runtime 大路由里。 + +### 目标拆分 + +1. `server-node/src/routes/customWorldAgent.ts` 只保留 Agent session / action / operation 路由。 +2. 从 `server-node/src/routes/runtimeRoutes.ts` 中拆出: + - `server-node/src/routes/customWorldWorks.ts` + - `server-node/src/routes/customWorldLibrary.ts` + - `server-node/src/routes/customWorldGallery.ts` + +### 编码要求 + +1. 路由层只做鉴权、请求校验、应用服务调用、响应映射。 +2. 不在路由层拼装 session 派生状态。 +3. 不在路由层做 draft / profile 字段兼容转换。 + +## 6.2 `customWorldAgentOrchestrator.ts` 拆分方案 + +### 当前问题 + +该文件当前同时承担: + +1. message turn 处理 +2. action 分发 +3. session 读写 +4. result profile sync +5. suggested actions 生成 +6. 派生状态与 operation 文案拼装 + +### 目标拆分 + +保留 `customWorldAgentOrchestrator.ts` 作为应用服务 façade,只负责主链事务编排,并拆出: + +1. `server-node/src/services/customWorldAgentMessageTurnService.ts` +2. `server-node/src/services/customWorldAgentActionRegistry.ts` +3. `server-node/src/services/customWorldAgentActionExecutors/` +4. `server-node/src/services/customWorldAgentResultSyncService.ts` +5. `server-node/src/services/customWorldAgentSuggestedActionService.ts` +6. `server-node/src/services/customWorldAgentSnapshotBuilder.ts` +7. `server-node/src/services/customWorldAgentQualityGateService.ts` + +### 关键要求 + +1. `action -> executor` 必须通过注册表映射,不再在 orchestrator 里堆分支。 +2. `publish_world`、`generate_scene_assets`、`expand_long_tail` 等 contract 中存在的 action,要么接入真实 executor,要么明确标记为未开放并返回禁用原因。 +3. `sync_result_profile` 的字段回写细节只允许出现在独立 sync service 中。 + +## 6.3 `customWorldAgentSessionStore.ts` 拆分方案 + +### 当前问题 + +store 当前混合了: + +1. session factory +2. session persistence +3. 旧结构兼容补齐 +4. snapshot 输出 + +### 目标拆分 + +1. `server-node/src/services/customWorldAgentSessionFactory.ts` +2. `server-node/src/services/customWorldAgentSessionCompatibility.ts` +3. `server-node/src/services/customWorldAgentSessionStore.ts` +4. `server-node/src/repositories/customWorldAgentSessionRepository.ts` + +### 关键要求 + +1. session store 只保留高层读写接口。 +2. 兼容 legacy session 的逻辑单独隔离,避免污染新字段演进。 +3. persistence adapter 不再依赖 runtime 仓储大文件直接暴露全部能力。 + +## 6.4 `customWorldAgentFoundationDraftService.ts` 拆分方案 + +### 当前问题 + +当前 foundation draft 生成还带着“runtime profile 双重编译”的历史包袱。 + +### 目标拆分 + +1. `server-node/src/services/customWorldAgentFoundationInputBuilder.ts` +2. `server-node/src/services/customWorldAgentFoundationLlmService.ts` +3. `server-node/src/services/customWorldAgentFoundationNormalizer.ts` +4. `server-node/src/services/customWorldAgentFoundationDraftService.ts` +5. `server-node/src/services/customWorldAgentResultPreviewCompiler.ts` + +### 关键要求 + +1. foundation draft 生成与 runtime preview 编译彻底拆开。 +2. 不再通过“先编 legacy runtime profile,再转回 foundation draft”维持主链。 +3. `legacyResultProfile` 只作为阶段性兼容字段存在,禁止继续扩大依赖面。 + +## 6.5 `runtimeProfile.ts` 拆分方案 + +### 当前问题 + +这是当前 custom world runtime 编译中心,但文件过大,后续继续在原文件上叠加只会放大维护成本。 + +### 目标拆分 + +新增目录: + +1. `server-node/src/modules/custom-world/runtime-profile/index.ts` +2. `server-node/src/modules/custom-world/runtime-profile/normalizeShared.ts` +3. `server-node/src/modules/custom-world/runtime-profile/normalizeRole.ts` +4. `server-node/src/modules/custom-world/runtime-profile/normalizeLandmark.ts` +5. `server-node/src/modules/custom-world/runtime-profile/normalizeSceneChapter.ts` +6. `server-node/src/modules/custom-world/runtime-profile/normalizeCamp.ts` +7. `server-node/src/modules/custom-world/runtime-profile/buildCompiledProfile.ts` +8. `server-node/src/modules/custom-world/runtime-profile/buildAttributeSchema.ts` +9. `server-node/src/modules/custom-world/runtime-profile/creatorIntentBridge.ts` + +### 过渡策略 + +1. 原 `runtimeProfile.ts` 先保留为 façade 导出层。 +2. 新调用逐步改走目录化模块。 +3. 旧导出在阶段性兼容完成后再统一清理。 + +## 6.6 仓储层与 works 聚合层拆分方案 + +### 当前问题 + +`runtimeRepository.ts` 与 `customWorldWorkSummaryService.ts` 目前都承担了过多跨域职责。 + +### 目标拆分 + +建议把 `runtimeRepository.ts` 拆成: + +1. `server-node/src/repositories/runtimeSaveRepository.ts` +2. `server-node/src/repositories/runtimeSettingsRepository.ts` +3. `server-node/src/repositories/customWorldProfileRepository.ts` +4. `server-node/src/repositories/customWorldAgentSessionRepository.ts` +5. `server-node/src/repositories/profileBrowseHistoryRepository.ts` + +建议把 `customWorldWorkSummaryService.ts` 拆成: + +1. `server-node/src/services/customWorldWorkSummaryAssembler.ts` +2. `server-node/src/services/customWorldWorkCoverResolver.ts` +3. `server-node/src/services/customWorldWorkSummaryService.ts` + +### 关键要求 + +1. works service 只做读模型聚合,不直接承担 session 兼容修补。 +2. repository 按领域而不是按技术杂糅方式拆开。 +3. works 聚合输出必须稳定支持“继续创作”和“进入世界”两个入口判定。 + +## 6.7 结果预览与发布链统一方案 + +### 当前问题 + +当前“结果页可进入世界”和“正式发布”还不是一条统一主链。 + +### 目标方案 + +后续增加服务端 result preview / publish gate 之后,统一为: + +```text +结果页编辑 +-> 服务端写回 session patch +-> 服务端编译 result preview +-> 自动保存或发布 +-> 发布校验 +-> 生成 published profile +-> 进入世界 +``` + +### 关键要求 + +1. 进入世界前不再绕开 publish gate。 +2. `qualityFindings` 与 blocker 要成为真实 gate,而不是仅展示字段。 +3. 自动保存保存的是“服务端确认后的 preview / profile”,而不是前端私有重编译产物。 + +--- + +## 7. 共享契约重构方案 + +## 7.1 `customWorldAgent.ts` 拆分建议 + +建议拆为以下契约文件,再由 index 统一 re-export: + +1. `packages/shared/src/contracts/customWorldAgentAnchors.ts` +2. `packages/shared/src/contracts/customWorldAgentDraft.ts` +3. `packages/shared/src/contracts/customWorldAgentActions.ts` +4. `packages/shared/src/contracts/customWorldAgentSession.ts` +5. `packages/shared/src/contracts/customWorldWorkSummary.ts` + +## 7.2 契约演进要求 + +后续 contract 调整需要新增两类约束: + +1. `supportedActions` 或等价能力矩阵,由后端真实注册表生成,前端只消费它,不再根据类型字面量自行假设按钮可用。 +2. `resultPreview` 或独立 preview contract,明确区分“session 草稿”和“结果页预览”。 + +## 7.3 兼容字段处理 + +以下字段或能力要被明确标为“兼容过渡态”: + +1. `legacyResultProfile` +2. 前端 `buildCustomWorldProfileFromAgentDraft()` +3. 结果页直接调用 legacy 生成函数补实体 + +所有新功能禁止再建立对这些兼容字段的新增依赖。 + +--- + +## 8. 测试与文档同步方案 + +## 8.1 测试分层 + +后续重构至少补齐 4 层测试: + +1. **unit tests**:针对 result sync、action registry、foundation normalizer、runtime preview compiler +2. **contract tests**:针对 session snapshot、works summary、preview contract +3. **integration tests**:覆盖“创建 session -> 发消息 -> draft foundation -> 编辑结果 -> 自动保存 -> 继续创作” +4. **regression tests**:覆盖角色图、地点图、分幕图、动作资产字段不会在 session 重编译中回退 + +## 8.2 fixture 要求 + +建议补充固定 fixture: + +1. 最小 eight-anchor session fixture +2. foundation draft fixture +3. result preview fixture +4. published profile fixture + +这样 runtime compiler、autosave、works 聚合三条链可以共享相同样本。 + +## 8.3 文档同步要求 + +每完成一个阶段,至少同步更新: + +1. 本执行方案 +2. `docs/technical/README.md` +3. 与本阶段对应的阶段性技术文档 +4. 若边界发生变化,还要同步相关 PRD / 审计文档口径 + +--- + +## 9. 可并行重构工作包 + +本次执行计划需要拆成多个可同时推进的工作部分,避免所有人都集中修改同一个热点文件。 + +并行原则如下: + +1. 每个工作包只负责一组清晰模块。 +2. 每个工作包必须有明确写入边界。 +3. 同一阶段允许并行,但禁止多人同时大改同一核心文件。 +4. 先做 façade 和新目录,再做调用迁移,最后做旧层清理。 + +## 9.1 工作包 A:命名规范与目录骨架 + +### 目标 + +先建立 RPG 创作域的新命名与目录骨架,给后续并行迁移提供统一落点。 + +### 负责范围 + +1. 前端 `src/components/game-shell/rpg-creation-flow/` +2. 前端 `src/components/rpg-creation-result/` +3. 前端 `src/components/rpg-creation-editor/` +4. 前端 `src/services/rpg-creation/` +5. 后端 `server-node/src/routes/` 下 RPG 创作相关新路由文件 +6. 后端 `server-node/src/services/` 下 RPG 创作相关 façade 文件 +7. 共享契约新文件骨架 + +### 写入边界 + +1. 可以新建目录和 façade 文件。 +2. 可以改文档和导出索引。 +3. 不负责大规模迁移老逻辑。 + +### 前置依赖 + +无,可最先开始。 + +### 当前进展(`2026-04-21`) + +工作包 A 第一轮已完成以下骨架落地: + +1. 已新增前端 `rpg-creation-flow`、`rpg-creation-result`、`rpg-creation-editor`、`rpg-creation` service 目录。 +2. 已新增 `RpgCreationShell`、`RpgCreationResultView`、`RpgCreationEntityEditorModal` 等 façade 入口,当前仍桥接旧实现。 +3. 已新增 `rpgCreationAgentClient`、`rpgCreationWorkClient`、`rpgCreationLibraryClient`、`rpgCreationAssetClient`、`rpgCreationPreviewAdapter`。 +4. 已新增后端 `rpgCreationAgentRoutes`、`rpgWorldWorksRoutes`、`rpgWorldLibraryRoutes`、`rpgWorldGalleryRoutes` 命名骨架。 +5. 已新增 `RpgAgentOrchestrator`、`RpgAgentSessionStore`、`RpgWorldPreviewCompiler`、`RpgWorldWorkSummaryService` façade。 +6. 已新增 `rpgAgent*` 与 `rpgCreation*` 共享契约骨架,并补齐此前遗漏的 `rpgAgentDraft.ts` 与 shared 根导出。 + +本轮刻意未做: + +1. 没有迁移 `runtimeRoutes.ts` 的真实 works/library/gallery 实现。 +2. 没有拆分 `PreGameSelectionFlow.tsx`、`CustomWorldResultView.tsx`、`CustomWorldEntityEditorModal.tsx` 内部逻辑。 +3. 没有改动现有主链行为,只建立后续并行迁移的统一落点。 + +## 9.2 工作包 B:前端平台壳层与流程编排拆分 + +### 目标 + +把 `PreGameSelectionFlow.tsx` 从大编排文件拆成壳层 + hooks。 + +### 负责范围 + +1. `PreGameSelectionFlow.tsx` +2. 平台 bootstrap、session controller、operation polling、detail navigation 相关 hooks +3. 平台侧 works / dashboard / save / history 拉取协调 + +### 写入边界 + +1. 主要修改前端壳层与流程 hooks。 +2. 不直接改后端 route / service 语义。 +3. 不承担结果页编辑器拆分。 + +### 前置依赖 + +最好在工作包 A 的目录骨架准备好后开始。 + +### 当前进展(`2026-04-21`) + +工作包 B 已完成以下落地: + +1. 已把 `PreGameSelectionFlow.tsx` 降级为兼容入口,旧路径继续导出 `PreGameSelectionFlow`、`PreGameSelectionFlowProps`、`SelectionStage`。 +2. 已把 RPG 创作平台壳层的真实实现迁入 `src/components/game-shell/rpg-creation-flow/RpgCreationShellImpl.tsx`,并把该文件收口成“hooks 组合 + stage 视图装配 + 视觉级 loading/error”的壳层。 +3. `RpgCreationShell.tsx` 已直接桥接 `RpgCreationShellImpl.tsx`,新目录开始承接真实入口。 +4. 已新增 `rpgCreationFlowTypes.ts`、`rpgCreationFlowShared.ts`,把壳层类型与共享 helper 从旧入口文件中收出独立落点。 +5. 已接入 `useRpgCreationPlatformBootstrap.ts`、`useRpgCreationSessionController.ts`、`useRpgCreationAgentOperationPolling.ts`、`useRpgCreationDetailNavigation.ts`、`useRpgCreationResultAutosave.ts`、`useRpgCreationEnterWorld.ts`。 +6. 平台侧 works / library / gallery / history / save / dashboard 拉取、session 恢复、message streaming、action 执行、operation 轮询、detail navigation、结果页自动保存、enter-world 同步已不再直接堆在壳层组件中。 +7. 已完成 `PreGameSelectionFlow.agent.interaction.test.tsx` 的 14 个交互场景回归,以及壳层相关定向 eslint、编码检查。 + +本轮刻意未做: + +1. 还没有物理删除 `PreGameSelectionFlow.tsx` 与其他旧兼容 façade,当前仍保留桥接层以避免影响并行工作包。 +2. 还没有让所有调用方统一显式改走 `RpgCreationShell` 新入口,当前允许旧路径兼容收口到新实现。 +3. 还没有把结果页 preview 数据源从前端兼容 adapter 切到服务端正式 preview contract,这部分仍属于后续工作包 G / H 与 Phase 3 范围。 +4. 还没有清理所有 legacy 兼容导出,本轮优先完成平台壳层编排拆分与主链稳定验证。 + +## 9.3 工作包 C:前端结果页与编辑器拆分 + +### 目标 + +把结果页、实体编辑器、角色资产工坊拆成组合壳层与独立 section / workflow。 + +### 负责范围 + +1. `CustomWorldResultView.tsx` +2. `CustomWorldEntityEditorModal.tsx` +3. `CustomWorldRoleAssetStudioModal.tsx` +4. 结果页 action hooks、表单 mapper、资产 workflow + +### 写入边界 + +1. 主要改结果页与编辑器相关前端组件。 +2. 允许补前端 view model 与 mapper。 +3. 不直接改平台壳层主状态编排。 + +### 前置依赖 + +依赖工作包 A 的命名规范与目录落点,和工作包 B 并行。 + +### 当前进展(`2026-04-21`) + +工作包 C 已完成以下拆分落地: + +1. 已把 `CustomWorldResultView.tsx`、`CustomWorldEntityEditorModal.tsx`、`CustomWorldRoleAssetStudioModal.tsx` 的真实实现迁入 `src/components/rpg-creation-result/`、`src/components/rpg-creation-editor/`、`src/components/rpg-creation-asset-studio/`。 +2. 已把旧文件降级为兼容入口,现有调用仍可继续从旧路径导入,不影响当前主链行为。 +3. 结果页已拆出 `RpgCreationResultHeader`、`RpgCreationResultActionBar`、`RpgCreationAssetDebugPanel`、`useRpgCreationResultActions`,结果页主组件开始退化为组合壳层。 +4. 编辑器已补 `rpgCreationResultFormMapper.ts`,并把 `RpgCreationEntityEditorModalImpl.tsx` 收口成目标分发壳层;`world / cover / camp / playable / story / landmark` 已有稳定 section 入口。 +5. 编辑器当前保留 `RpgCreationEntityEditorShared.tsx` 作为阶段性 shared 实现承载体,避免在同一轮里高风险物理拆散 180KB 级表单细节;后续可在不改壳层接口的前提下继续向各 section 文件迁移。 +6. 角色资产工坊已补 `roleAssetStudioModel.ts`、`roleAssetStudioPublishClient.ts`、`useRoleVisualCandidateWorkflow.ts`、`useRoleAnimationWorkflow.ts`,并进一步拆出 `RpgCreationRoleVisualSection.tsx`、`RpgCreationRoleAnimationSection.tsx`、`RpgCreationRoleAssetStudioFooter.tsx`,当前主模态已退化为组合壳层。 +7. 旧 `CustomWorldResultView.tsx`、`CustomWorldEntityEditorModal.tsx`、`CustomWorldRoleAssetStudioModal.tsx` 兼容入口已统一桥接到 RPG 创作域 façade,而不是继续直连内部 `Impl` 文件。 + +本轮刻意未做: + +1. 还没有把 `RpgCreationEntityEditorShared.tsx` 内部的全部表单实现物理拆成独立文件,当前先以“壳层 + section 入口 + shared 实现”完成工作包 C 收口。 +2. 还没有改平台壳层 `PreGameSelectionFlow.tsx` 的任何主状态编排,仍严格遵守工作包 C 的写入边界。 +3. 还没有把结果页从 legacy preview 兼容数据源切到服务端正式 preview contract,这部分属于后续工作包 D / G / H 的协作范围。 + +## 9.4 工作包 D:前端 custom world client 收口 + +### 目标 + +把 custom world 专属接口从 `aiService.ts`、`storageService.ts` 中迁出。 + +### 负责范围 + +1. `aiService.ts` +2. `storageService.ts` +3. 新增 `rpgCreation*Client` 文件 +4. 调整前端调用导入路径 + +### 写入边界 + +1. 只负责 API client 与请求封装。 +2. 不负责结果页 UI 拆分。 +3. 不负责后端业务实现重构。 + +### 前置依赖 + +依赖工作包 A 的命名和目录约束;可与 B、C 并行。 + +### 当前进展(`2026-04-21`) + +工作包 D 第一轮已完成以下落地: + +1. 已新增 `src/services/rpg-creation/rpgCreationRuntimeClient.ts` 与 `src/services/rpg-creation/rpgCreationRequestHelpers.ts`,把 RPG 创作域的 runtime 请求重试策略、POST JSON 与 SSE 请求辅助能力收口到新目录。 +2. `rpgCreationAgentClient.ts`、`rpgCreationWorkClient.ts`、`rpgCreationLibraryClient.ts`、`rpgCreationAssetClient.ts`、`rpgCreationGenerationClient.ts` 已从 façade 透传升级为真实请求实现,不再继续把主链请求代码堆在 `aiService.ts`、`storageService.ts` 中。 +3. `generateCustomWorldProfile()` 已正式迁入 `rpgCreationGenerationClient.ts`,世界生成入口也已纳入 RPG 创作域 client 边界。 +4. `aiService.ts` 中已迁出的 Agent / works / 世界生成 / 结果页实体生成接口已退化为兼容导出;`storageService.ts` 中 works / library / gallery / publish 链路也已退化为兼容导出。 +5. `PreGameSelectionFlow.tsx` 已开始直接从 `src/services/rpg-creation/` 消费 Agent / works / library / gallery / publish 请求,不再从旧 service 入口拿主链接口。 +6. `RpgCreationEntityEditorShared.tsx` 已把场景图生成请求切到 `rpgCreationAssetClient`,结果页与编辑器相关测试也已改为优先 mock 新的 RPG 创作域 client。 +7. 已完成 `rpgCreationGenerationClient.test.ts`、`storageService.test.ts`、`CustomWorldEntityEditorModal.test.tsx`、`CustomWorldResultView.test.tsx`、`PreGameSelectionFlow.agent.interaction.test.tsx` 的定向回归,以及编码检查。 + +本轮刻意未做: + +1. 还没有物理删除 `aiService.ts`、`storageService.ts` 中的旧命名兼容导出,本轮优先保证调用迁移可平稳过渡。 +2. 还没有改平台壳层的内部流程编排与 hook 结构,这部分仍属于工作包 B。 +3. 还没有把结果页从 legacy preview 兼容数据源切到服务端正式 preview contract,这部分仍属于后续工作包 G / H 的协作范围。 + +## 9.5 工作包 E:后端 Agent 编排拆分 + +### 目标 + +拆解 `customWorldAgentOrchestrator.ts`,引入 registry、snapshot builder、result sync service。 + +### 负责范围 + +1. `customWorldAgentOrchestrator.ts` +2. action registry +3. action executors +4. result sync service +5. snapshot builder +6. quality gate service + +### 写入边界 + +1. 主改后端应用服务层。 +2. 不负责 runtime profile 编译模块的目录化拆分。 +3. 不负责前端壳层迁移。 + +### 前置依赖 + +建议在工作包 A 后开始;可与 B、C、D 并行。 + +### 当前进展(`2026-04-21`) + +工作包 E 当前已完成 3 轮落地,真实状态如下: + +1. `customWorldAgentOrchestrator.ts` 已退化为后端应用服务 façade,只保留 session/message/action 主入口、operation 创建和下游服务委托;消息轮转、action 分发与派生状态重建已从热点文件中拆出。 +2. `CustomWorldAgentActionRegistry` 已正式接管 action 可用性校验、payload normalize、operation type 映射与 `supportedActions` 主链接线;前端不再需要按 action 字面量猜测按钮是否可点。 +3. `customWorldAgentActionExecutors/` 已补齐并接管以下真实执行链: + - `draft_foundation` + - `update_draft_card` + - `sync_result_profile` + - `generate_characters` + - `generate_landmarks` + - `generate_role_assets` + - `sync_role_assets` + - `generate_scene_assets` + - `sync_scene_assets` + - `expand_long_tail` + - `publish_world` + - `revert_checkpoint` +4. `CustomWorldAgentMessageTurnService`、`CustomWorldAgentSnapshotBuilder`、`CustomWorldAgentResultSyncService`、`CustomWorldAgentQualityGateService`、`CustomWorldAgentSuggestedActionService` 已形成稳定协作边界: + - message turn 负责会话轮转 + - snapshot builder 负责派生状态重建 + - result sync service 负责结果页回写 + - quality gate service 负责 `qualityFindings` + - suggested action service 负责建议动作 +5. 发布链已经统一切到 `CustomWorldAgentPublishingService`: + - orchestrator、executor map、publish executor、server 注入口径已经一致 + - 发布 readiness 与正式写库走同一服务 + - 作者展示名优先走 `resolveAuthorDisplayName`,同时保留 `userRepository` 兼容兜底 + - 发布产物 `profileId` 固定优先沿用 legacy 结果页 ID,否则回退为 `agent-draft-${sessionId}` +6. `sync_scene_assets` 已形成完整闭环: + - 营地/地点正式场景图会写回 draft profile + - 对应 `sceneChapters[].acts` 的 `backgroundImageSrc / backgroundAssetId` 会同步刷新 + - `rebuildRoleAssetCoverage()` 已补 camp/landmark 正式场景资产 fallback 汇总,确保 snapshot 重建、works 读模型与 checkpoint 回放都能保住场景资产覆盖状态 +7. checkpoint 已收口为“可恢复真快照”: + - `buildCheckpointSnapshot()` 已接入关键 executor + - `revert_checkpoint` 现在依赖真实 checkpoint snapshot 与 `restoreCheckpoint()` 主链完成回滚,不再是只开放入口的空动作 +8. `CustomWorldAgentActionRegistry` 已重新收口阶段策略: + - `sync_result_profile`、`generate_scene_assets`、`sync_scene_assets` 等精修动作仅允许 `object_refining / visual_refining` + - `expand_long_tail`、`publish_world`、`revert_checkpoint` 单独允许 `long_tail_review / ready_to_publish` +9. 已完成以下验证: + - `npm --prefix server-node run build` + - `npm --prefix server-node run test -- customWorldAgentPhase3.test.ts customWorldAgentActionRegistry.test.ts customWorldAgentPhase5.test.ts` + 当前 `server-node` 定向回归共 `208` 项通过,已覆盖工作包 E 第三轮的发布链、场景资产、长尾扩展与 checkpoint 回滚主链。 + +本轮刻意未做: + +1. 还没有进入 Phase 4 的“进入世界统一走发布态 gate”收口,当前只完成 `publish_world` 本身的后端闭环。 +2. 还没有改 `customWorldAgentSessionStore.ts` 与 repository 边界,这部分仍属于工作包 F。 +3. 还没有让前端结果页正式消费服务端 `resultPreview` 主链字段,这部分仍需要与工作包 G / H 协作。 +4. 旧 `customWorldAgentPublishGateService.ts`、`customWorldAgentPublishService.ts` 仍作为历史兼容文件保留,尚未进入物理删除阶段。 + +## 9.6 工作包 F:后端 session/store/repository 拆分 + +### 目标 + +拆出 session factory、compatibility、repository adapter,并把 custom world 仓储从 runtime 大仓储中分离。 + +### 负责范围 + +1. `customWorldAgentSessionStore.ts` +2. `runtimeRepository.ts` +3. `customWorldWorkSummaryService.ts` +4. 新 session repository / profile repository / work summary assembler + +### 写入边界 + +1. 主改后端持久化与读模型层。 +2. 不负责 action executor 细节。 +3. 不负责前端调用改造。 + +### 前置依赖 + +与工作包 E 有接口协作关系,但可以并行推进,最终通过 façade 汇合。 + +### 当前进展(`2026-04-21`) + +工作包 F 已完成以下拆分落地: + +1. 已新增 `server-node/src/services/rpg-agent-session-store/`,把 session record、compatibility、factory、repository adapter 从 `customWorldAgentSessionStore.ts` 中物理拆出。 +2. `customWorldAgentSessionStore.ts` 已退化为兼容 façade,保留原类名、原方法签名,并正式改为依赖 `RpgAgentSessionRepositoryPort`。 +3. 已新增 `server-node/src/repositories/RpgAgentSessionRepository.ts`、`server-node/src/repositories/RpgWorldProfileRepository.ts`、`server-node/src/repositories/rpgWorldRepositoryShared.ts`。 +4. `runtimeRepository.ts` 中的 custom world session/profile/gallery 读写已改成委托新仓储,runtime 大仓储开始向“通用 runtime façade”收口。 +5. 已新增 `server-node/src/services/RpgWorldWorkCoverResolver.ts`、`server-node/src/services/RpgWorldWorkSummaryAssembler.ts`、`server-node/src/services/RpgWorldWorkSummaryService.ts`,把 works 读模型的封面解析、条目组装与服务入口从 `customWorldWorkSummaryService.ts` 中拆出。 +6. `context.ts`、`server.ts`、`runtimeRoutes.ts`、`syncCustomWorldSavedProfileAssets.ts` 已切到直接注入和使用 `RpgAgentSessionRepository`、`RpgWorldProfileRepository`、`RpgWorldWorkSummaryService`。 +7. `customWorldAgentPhase2~5` 与 `customWorldWorkSummaryService.integration.test.ts` 已切到新的 session/profile 内存仓储端口,定向回归 21 项全部通过。 + +本轮刻意未做: + +1. `RuntimeRepositoryPort` 仍保留兼容 façade 与 custom world 相关旧方法,现阶段先稳住 story/runtime 其他调用方。 +2. `RuntimeRepository` 中的 runtime 快照同步编排还没有继续下沉,当前先完成 custom world 持久化与 works 读模型边界拆分。 +3. `customWorldAgentSessionStore.ts`、`customWorldWorkSummaryService.ts` 等旧命名 façade 仍保留,等待后续统一命名和兼容层清理阶段再删除。 + +## 9.7 工作包 G:后端 preview compiler 与 runtime profile 目录化 + +### 目标 + +把 `runtimeProfile.ts` 拆成目录化模块,并引入服务端 result preview compiler。 + +### 负责范围 + +1. `runtimeProfile.ts` +2. `runtime-profile/` 新目录 +3. result preview compiler +4. foundation draft 与 preview 编译的边界收口 + +### 写入边界 + +1. 只负责编译层、normalize 层和 preview 输出。 +2. 不直接重构路由层。 +3. 不直接迁前端组件。 + +### 前置依赖 + +与工作包 E、F 并行,但在主链接入前需要先和 E 对齐 preview contract。 + +### 当前进展(`2026-04-21`) + +工作包 G 已完成以下落地: + +1. 已新增 `server-node/src/modules/custom-world/runtime-profile/` 目录入口,并把原 `runtimeProfile.ts` 退化为兼容 façade。 +2. 已把 runtime profile 进一步物理拆分到: + - `normalizeShared.ts` + - `normalizeRole.ts` + - `normalizeLandmark.ts` + - `normalizeSceneChapter.ts` + - `normalizeCamp.ts` + - `buildCompiledProfile.ts` + - `buildAttributeSchema.ts` + - `creatorIntentBridge.ts` +3. `runtimeProfileCompiler.ts` 已退化为兼容 façade,不再承载主实现。 +4. `RpgWorldPreviewCompiler.ts` 已从简单别名升级为服务端 preview compiler 入口,新增 preview envelope 输出能力。 +5. `packages/shared/src/contracts/rpgCreationPreview.ts` 已补 `RpgCreationPreviewSource`,并在 Phase 5 后把 session 结果页正式 source 收口为 `session_preview`。 +6. `customWorldAgentFoundationDraftService.ts` 的 LLM foundation draft 主生成链已改成“直接组装 draft 主字段 + 单独保留 `legacyResultProfile` 兼容快照”,不再通过 preview compiler 反解草稿主字段。 +7. 已新增 `server-node/src/services/RpgWorldPreviewCompiler.test.ts`、`server-node/src/services/customWorldAgentFoundationDraftService.test.ts`,并完成编码检查与工作包 G 定向回归验证。 + +本轮刻意未做: + +1. 还没有把 preview contract 从当前 runtime-profile 兼容载体升级成独立 view model。 +2. 还没有让 orchestrator、route、前端结果页正式消费 preview envelope。 +3. `legacyResultProfile` 仍作为结果页兼容快照保留,相关消费链还没有完全脱离 legacy profile 富字段。 +4. 兼容 façade `runtimeProfile.ts` / `runtimeProfileCompiler.ts` 仍保留,等待后续阶段统一清理。 + +## 9.8 工作包 H:共享契约与测试基建 + +### 目标 + +拆分共享契约,补齐 fixture、contract tests、integration tests。 + +### 负责范围 + +1. `packages/shared/src/contracts/` +2. preview / action / session / works summary 契约 +3. fixture +4. unit / contract / integration / regression tests + +### 写入边界 + +1. 契约变更必须同步测试。 +2. 不直接承担业务 UI 拆分。 +3. 不直接承担数据库仓储重构。 + +### 前置依赖 + +可从工作包 A 开始先建骨架,随后跟随 B 到 G 持续补齐。 + +### 当前进展(`2026-04-21`) + +工作包 H 已完成以下落地: + +1. 已把 `rpgAgentAnchors.ts`、`rpgAgentDraft.ts`、`rpgAgentActions.ts`、`rpgAgentSession.ts`、`rpgCreationPreview.ts`、`rpgCreationWorkSummary.ts` 从类型别名骨架推进为真实共享契约定义。 +2. 已把旧 `packages/shared/src/contracts/customWorldAgent.ts` 降级为兼容聚合出口,并补齐: + - `customWorldAgentAnchors.ts` + - `customWorldAgentDraft.ts` + - `customWorldAgentActions.ts` + - `customWorldAgentSession.ts` + - `customWorldResultPreview.ts` + - `customWorldWorkSummary.ts` + 让旧命名导入可以按分域文件渐进迁移,而不是继续依赖单一大文件。 +3. 已新增 `packages/shared/src/contracts/rpgCreationFixtures.ts`,补齐八锚点、foundation draft、session、preview、published profile、library、works 等共享样本,并把 fixture 接入 `packages/shared/src/index.ts` 统一导出。 +4. 已把 shared contract tests 接入 `vitest.config.ts`,并补齐 `packages/shared/src/contracts/rpgContracts.test.ts`,覆盖 session snapshot、preview envelope、published profile、works summary,以及旧命名兼容分文件的类型消费。 +5. 已新增 `server-node/src/services/RpgWorldPreviewCompiler.fixture.test.ts`、`server-node/src/services/RpgWorldWorkSummaryAssembler.fixture.test.ts`、`server-node/src/services/customWorldWorkSummaryService.integration.test.ts`,把 preview compiler、works assembler、works service 对共享 fixture 的消费纳入 unit / integration / regression 回归。 +6. 已补 `server-node/src/services/RpgWorldWorkSummaryService.ts` 兼容实现,确保 works 兼容入口与当前 `rpgWorldProfiles + customWorldAgentSessions` 读模型服务口径一致。 +7. `customWorldAgentOrchestrator.ts` 已新增统一 session snapshot 装配入口,当前普通拉取与 SSE message stream 返回的 session 字段口径开始收口。 +8. 服务端 `RpgWorldPreviewCompiler` 输出已正式接入 session snapshot 的 `resultPreview` 字段,并复用当前 `qualityFindings` 生成 preview `qualityFindings / blockers` 兼容输出。 +9. `rpg-agent-session-store/rpgAgentSessionCompatibility.test.ts` 已覆盖“compatibility 脱离 store 直接单测”的主链能力,Phase 2 的 session 兼容层开始具备独立回归保障。 +10. `src/app.test.ts` 已补“custom world agent stream message returns enriched session payload over sse”回归,session snapshot / resultPreview / supportedActions 的 HTTP 与 SSE 响应口径开始统一验证。 + +本轮刻意未做: + +1. 还没有批量迁移仓库里所有旧 `customWorldAgent.ts` 导入到 `rpgAgent* / rpgCreation*`。 +2. 还没有批量把前端结果页与自动保存链统一切到服务端 `resultPreview`。 +3. 还没有把服务端 preview contract 从 legacy profile 兼容载体升级成独立 view model。 + +## 9.9 并行推进关系 + +推荐并行顺序如下: + +```text +第一批并行: +工作包 A + 工作包 H + +第二批并行: +工作包 B + 工作包 C + 工作包 D + 工作包 E + 工作包 F + 工作包 G + +第三批收口: +把 B~H 的 façade 接回主链 +-> 联调自动保存 / works / publish / enter world +-> 清理旧兼容层 +``` + +## 9.10 并行协作约束 + +为避免多人互相覆盖,本轮建议遵守: + +1. 工作包 B 独占 `PreGameSelectionFlow.tsx`。 +2. 工作包 C 独占 `CustomWorldResultView.tsx`、`CustomWorldEntityEditorModal.tsx`、`CustomWorldRoleAssetStudioModal.tsx`。 +3. 工作包 D 独占 `aiService.ts`、`storageService.ts`。 +4. 工作包 E 独占 `customWorldAgentOrchestrator.ts`。 +5. 工作包 F 独占 `customWorldAgentSessionStore.ts`、`runtimeRepository.ts`、`customWorldWorkSummaryService.ts`。 +6. 工作包 G 独占 `runtimeProfile.ts` 及其新目录。 +7. 工作包 H 独占 shared contracts 主文件和测试 fixture 总目录。 + +--- + +## 10. 分阶段落地计划 + +## Phase 0:冻结口径与清点兼容层 + +### 目标 + +把当前链路拆分前的边界先冻结,避免后续一边拆一边新增同类耦合。 + +### 工作项 + +1. 完成本文档并作为后续施工总口径 +2. 标记兼容层:`customWorldAgentDraftResult.ts`、`legacyResultProfile`、结果页 legacy 生成动作 +3. 列出当前 action contract 与真实 executor 的对照表 + +### 验收标准 + +1. 团队对“session / preview / published profile”三类真相源达成一致 +2. 新需求不再默认往 `PreGameSelectionFlow.tsx` 和 `customWorldAgentOrchestrator.ts` 继续堆逻辑 + +## Phase 1:目录骨架、命名规范与前端拆分并行启动 + +### 目标 + +先建立 RPG 创作域的新目录和命名规范,并把前端热点文件拆成可维护结构,但不改当前主流程行为。 + +### 工作项 + +1. 完成工作包 A +2. 完成工作包 B +3. 完成工作包 C +4. 完成工作包 D + +### 验收标准 + +1. `PreGameSelectionFlow.tsx` 只剩 stage 与组件装配逻辑 +2. 通用 service 中不再继续新增 custom world workflow 接口 +3. 自动保存、session 恢复、进入世界逻辑都有独立 hook / coordinator +4. 新增文件遵循 RPG 创作域命名规范 + +## Phase 2:后端应用服务、仓储、编译层并行拆分 + +### 目标 + +把后端主链从“大 orchestrator + 大 store + 大 compiler”拆成 registry + services + repositories + compiler modules。 + +### 工作项 + +1. 完成工作包 E +2. 完成工作包 F +3. 完成工作包 G +4. 工作包 H 同步补 contract 与测试 + +### 验收标准 + +1. `customWorldAgentOrchestrator.ts` 不再包含具体字段回写实现 +2. action 的启用 / 禁用状态可由后端统一描述 +3. session 兼容逻辑可以脱离 store 单独测试 +4. `runtimeProfile.ts` 已退化为 façade 或兼容导出层 + +## Phase 3:结果预览编译后移到后端 + +### 目标 + +消除前端“本地编译结果 profile”的主链地位。 + +### 工作项 + +1. 新增服务端 result preview compiler +2. 结果页改为消费服务端 preview +3. `customWorldAgentDraftResult.ts` 改为薄适配层 + +### 验收标准 + +1. 前端不再把 `buildCustomWorldProfileFromAgentDraft()` 作为正式编译步骤 +2. 自动保存与 session 同步都基于服务端确认后的 preview +3. 结果页字段回退问题不再依赖前端兼容修补 + +### 当前进展(`2026-04-21`) + +Phase 3 本轮已完成以下主链接线: + +1. 前端 `rpgCreationPreviewAdapter.ts` 已正式改成“优先读取 `session.resultPreview`,本地 `draftProfile -> legacy result profile` 只做 fallback”的薄适配层。 +2. `useRpgCreationSessionController.ts`、`useRpgCreationResultAutosave.ts`、`useRpgEntryLibraryDetail.ts` 所在的 Agent 结果页打开链、自动保存链、继续创作恢复链,已统一通过 `buildPreviewFromSession()` 消费服务端 preview。 +3. `RpgEntryFlowShellImpl.tsx` 当前传给结果页自动保存与创作入口恢复的 `buildDraftResultProfile` 已切到服务端 preview 主链,不再把前端本地编译结果当成正式真相源。 +4. 前端 fallback 编译实现已迁入 `src/services/rpg-creation/rpgCreationPreviewDraftFallback.ts`,旧 `src/services/customWorldAgentDraftResult.ts` 已退化为兼容 re-export,不再继续承载主实现。 +5. 已新增 `src/services/rpg-creation/rpgCreationPreviewAdapter.test.ts`,补齐“服务端 `resultPreview` 优先于本地 draft fallback”的回归断言。 +6. `PreGameSelectionFlow.agent.interaction.test.tsx` 已补充“没有 `legacyResultProfile` 也能凭服务端 preview 打开 Agent 结果页”的交互回归,验证结果页主链已切到后端 preview。 + +本轮刻意未做: + +1. `src/services/customWorldAgentDraftResult.ts` 仍保留,但当前已退化为兼容 re-export;真实 fallback 编译实现已迁到 `src/services/rpg-creation/rpgCreationPreviewDraftFallback.ts`,尚未物理删除该兼容入口。 +2. `legacyResultProfile` 仍保留在 session draft 中参与兼容输出,本轮没有越界清理后端兼容字段。 +3. 结果页 UI 还没有显式消费 `qualityFindings / blockers / preview source` 做额外展示,当前先完成主数据源迁移,不扩大 UI 变更面。 + +## Phase 4:发布链、自动保存链、进入世界链统一 + +### 目标 + +把“可玩”与“已发布”的门槛统一到后端。 + +### 工作项 + +1. 打通 publish gate +2. 把 `qualityFindings` / blocker 接成真实阻断条件 +3. enter world 统一走服务端发布态或明确允许的预览态 + +### 验收标准 + +1. 前端不能绕开 publish / gate 直接进世界 +2. works、library、enter world 三处状态语义一致 +3. 发布失败可以给出明确 blocker 与恢复入口 + +### 当前进展(`2026-04-21`) + +Phase 4 本轮已完成以下主链接线: + +1. 服务端 `customWorldAgentPublishingService.ts` 已补结构化 `evaluatePublishReadiness()`,把 publish blocker 从“只在发布时报错”提升为可供 session preview、结果页和 works 读模型统一消费的后端真相。 +2. `customWorldAgentOrchestrator.ts` 当前输出的 `session.resultPreview` 已补: + - `publishReady` + - `canEnterWorld` + - 基于发布门槛而不是仅 `qualityFindings` 生成的 `blockers` + 让结果页可以直接消费正式 gate 语义。 +3. `RpgWorldWorkSummaryAssembler.ts` 已把 works 读模型进一步收口: + - 已进入 `published` 阶段的 Agent session 不再继续以草稿项出现在 works 创作中心 + - draft works 新增 `blockerCount / publishReady` + - published works 明确输出 `canEnterWorld=true` +4. 前端 Agent 结果页已开始消费服务端 Phase4 状态: + - 结果页在 Agent 草稿未发布时把主 CTA 改成“发布并进入世界” + - 结果页会展示服务端 preview source、publish blockers、warning 数量 + - 有 blocker 时会禁用“发布并进入世界”按钮,不再让前端继续假装可以直接进入世界 +5. `useRpgCreationEnterWorld.ts` 与 `RpgEntryFlowShellImpl.tsx` 已把 Agent 结果页进入世界主链改成: + - 先 `sync_result_profile` + - 再执行后端 `publish_world` + - 发布成功后才进入世界 + 不再允许 Agent 草稿结果页绕开 publish gate 直接起游戏。 +6. `RpgEntryWorldDetailView.tsx` 已把作品详情页草稿态的主按钮改成“请先发布作品”,避免 detail 页继续暗示未发布作品可以直接开始游戏。 +7. 已补回归测试覆盖: + - 服务端 `customWorldAgentPhase4.test.ts` + - 服务端 `customWorldAgentPhase5.test.ts` + - 服务端 `RpgWorldWorkSummaryAssembler.fixture.test.ts` + - 前端 `CustomWorldResultView.test.tsx` + - 前端 `PreGameSelectionFlow.agent.interaction.test.tsx` +8. 作品库 detail 页的“发布到广场”入口已统一复用 Agent Phase4 publish gate: + - `/api/runtime/custom-world-library/:profileId/publish` 在命中 `agent-draft-${sessionId}` 且 session 真实存在时,不再直接绕过 gate 调 `publishOwnProfile()` + - 现在会先复用 `CustomWorldAgentPublishingService` 的 blocker 判断 + - publish 成功后同步把对应 session 推进到 `published` + - detail 页、works、gallery 三处发布态语义已对齐到同一条后端主链 +9. 已补 HTTP 级回归测试覆盖 detail publish 主链: + - 服务端 `app.test.ts` 已新增“agent-backed detail publish 在 blocker 存在时返回明确错误” + - 服务端 `app.test.ts` 已新增“agent-backed detail publish 成功后同步发布 profile 与 session” + +本轮刻意未做: + +1. 旧兼容作品草稿的 detail publish 还没有强行套入 Agent publish gate,当前只在 `agent-draft-${sessionId}` 且 session 真实存在时切换到统一发布链,避免在未补齐兼容映射前误伤历史作品。 +2. 运行态真正的“进入世界解析”仍然是前端把 profile 交给 runtime session bootstrap,本轮先完成 Agent 创作主链的 publish gate 收口与 UI 阻断,不扩大到 runtime 启动协议改造。 + +## Phase 5:兼容层清理 + +### 目标 + +在主链稳定后,物理清理历史桥接层和重复 pipeline。 + +### 工作项 + +1. 删除前端 draft result 编译桥 +2. 删除结果页 legacy 直改链的残余入口 +3. 清理 contract 中已废弃 action / 字段 + +### 验收标准 + +1. 创作主链只剩 session -> preview -> published profile 三层 +2. 不再存在“前端本地编译 profile 才能自动保存”的依赖 +3. 文档、契约、测试口径一致 + +### 当前进展(`2026-04-21`) + +Phase 5 本轮已完成以下主链清理: + +1. 服务端已新增 `server-node/src/services/rpgCreationPreviewProfileBuilder.ts`,把“foundation draft + legacyResultProfile 富字段 + 最新草稿资产”的合并规则正式收回后端,preview 与 publish 开始复用同一套兼容编译口径。 +2. `customWorldAgentOrchestrator.ts` 当前产出的 `session.resultPreview` 已不再依赖前端本地 fallback: + - 预览 profile 改为基于服务端 `rpgCreationPreviewProfileBuilder` 构建 + - preview source 已从兼容期的 `legacy_custom_world_profile` 收口为正式主链值 `session_preview` +3. 前端 `rpgCreationPreviewAdapter.ts` 已改成只消费服务端 `session.resultPreview`,结果页、继续创作、自动保存、发布后进入世界所复用的 `buildPreviewFromSession()` 不再承担本地 `draftProfile -> result profile` 编译职责。 +4. 结果页与编辑器目录内部的旧 façade 依赖已继续收口,当前 RPG 创作目录内部不再通过已删除旧文件反向跳转结果页/编辑器/资产工坊主链。 +5. 前后端测试口径已同步切到 Phase 5: + - 前端 `rpgCreationPreviewAdapter.test.ts`、`PreGameSelectionFlow.agent.interaction.test.tsx` 已统一改为消费 `session_preview` + - 服务端 `RpgWorldPreviewCompiler.test.ts` 已新增“preview builder 保留 legacy 富字段并合并最新草稿资产”的回归 + - 服务端 `customWorldAgentPhase3.test.ts`、`customWorldAgentPhase4.test.ts`、`app.test.ts` 已把 preview source 断言更新为 `session_preview` + +本轮刻意未做: + +1. 后端 `legacyResultProfile` 兼容字段仍保留在 foundation draft / result sync / publishing service 中,当前只是把“如何消费它”统一后移到服务端 preview / publish compiler,而不是继续让前端主链本地重编译。 +2. 旧命名 façade 如 `customWorldAgentSessionStore.ts`、`customWorldWorkSummaryService.ts`、`runtimeProfile.ts` 仍保留,因它们还在后端兼容与模块边界层承担真实职责,不属于本轮必须删除项。 +3. shared contracts 中旧 `customWorld*` 分域兼容导出仍保留,当前只收口真实定义与 preview source 语义,不越界做全仓库导入迁移。 + +--- + +## 11. 本次执行约束 + +后续按本文落地时,需要持续遵守以下约束: + +1. 不做大爆炸式重写,按阶段保留 façade 与兼容层。 +2. 不新开平行系统,优先在现有创作中心、结果页、Agent 工作区上做结构拆分。 +3. 前端新增文件优先按“壳层 / hook / client / section”拆,不把逻辑再塞回组件文件。 +4. 后端新增文件优先按“route / application service / domain service / repository / compiler”拆,不再继续扩大大文件。 +5. 每个阶段完成后同步文档与测试,不允许代码结构已经迁移但文档还停留在旧链路口径。 + +--- + +## 12. 结论 + +当前创作流程的核心问题,不是单点 bug,而是: + +**前端壳层、前端兼容编译层、后端编排层、后端 runtime 编译层同时过重,导致整条创作链处在“多条 pipeline 并存、桥接层过多、职责分层混乱”的过渡态。** + +后续重构的正确方向不是继续在热点文件上补判断,而是按本文把主链收成: + +**session 真相源 -> 服务端 preview 编译 -> published profile 发布态** + +只有这样,当前链路的可读性、可扩展性和后续功能落地稳定性,才会一起提升。 + +--- + +## 13. 2026-04-21 执行核查与老流程清理记录 + +本节用于记录本次按执行方案做的真实完成度核查、测试结果与老流程删除情况,避免“文档宣称已完成”和“代码真实状态”继续漂移。 + +### 13.1 本轮核查口径 + +本轮围绕以下 3 件事执行: + +1. 对照工作包 A / B / D / E / F / G / H 进度文档,核对真实代码入口与引用关系。 +2. 运行创作链相关与全量测试,确认当前主链真实可用范围。 +3. 只删除已经确认不再承载业务逻辑的旧流程桥接入口,不提前删除仍承担兼容编译责任的模块。 + +### 13.2 核查结论 + +当前可以确认: + +1. 工作包 B、D、E、F、G、H 的首轮主体拆分已经真实落地,且对应的新目录、hooks、client、service、repository、compiler 文件已存在。 +2. 工作包 C 的结果页、编辑器、资产工坊拆分也已基本落地到 `rpg-creation-result/`、`rpg-creation-editor/`、`rpg-creation-asset-studio/` 新目录。 +3. Phase 3、Phase 4、Phase 5 的主链接线与兼容层清理现已完成;当前剩余的是后端兼容字段与旧命名 façade 的保留问题,不能再把它们等同于“前端主链仍依赖老流程”。 + +### 13.3 本轮已物理删除的老流程入口 + +本轮已确认以下旧入口仅剩桥接职责,且完成引用迁移后可以安全物理删除: + +1. `src/components/game-shell/PreGameSelectionFlow.tsx` +2. `src/components/CustomWorldResultView.tsx` +3. `src/components/CustomWorldEntityEditorModal.tsx` + +同步完成的调用迁移包括: + +1. `GameShellMainContent.tsx` 已改为直接 lazy import `rpg-creation-flow` 新入口。 +2. `useGameShellViewModel.ts` 已改为直接从 `rpg-creation-flow` 取 `SelectionStage`。 +3. 结果页、编辑器与对应测试已切到 `rpg-creation-result/`、`rpg-creation-editor/` 新入口。 +4. `RpgCreationShellImpl.tsx` 已改为直接 lazy import `RpgCreationResultView` 新入口,不再回退到已删除旧结果页文件。 + +### 13.4 本轮明确不能删除的兼容层 + +以下模块本轮核查后确认仍在主链中承担真实兼容职责,暂时不能物理删除: + +1. `src/services/rpg-creation/rpgCreationPreviewAdapter.ts` +2. `server-node/src/services/customWorldAgentSessionStore.ts` +3. `server-node/src/services/customWorldWorkSummaryService.ts` +4. `server-node/src/services/customWorldAgentOrchestrator.ts` +5. `server-node/src/modules/custom-world/runtimeProfile.ts` + +原因分别是: + +1. `rpgCreationPreviewAdapter.ts` 仍是前端消费服务端 preview 的统一 façade,只是已经不再承担本地 fallback 编译。 +2. 后端仍通过 `legacyResultProfile` 参与阶段性结果回写与兼容输出。 +3. 多个旧命名 façade 仍被 server、context、tests 或 UI 入口直接引用。 + +### 13.5 本轮测试结果 + +已执行并确认结果如下: + +1. `npm run check:encoding` + 结果:通过。 +2. `npm --prefix server-node run test` + 结果:通过,`192` 项测试全部通过。 +3. `npm --prefix server-node run build` + 结果:通过。 +4. `npm --prefix server-node run test -- --test-name-pattern="action registry|phase5 publish_world|phase5 generate_scene_assets|phase5 publish_world blocks incomplete|phase5 revert_checkpoint|phase5 expand_long_tail"` + 结果:通过,`208` 项测试全部通过,已覆盖工作包 E 第三轮发布链、场景资产、长尾扩展与 checkpoint 回滚主链。 +5. `npm run test -- src/components/game-shell/PreGameSelectionFlow.agent.interaction.test.tsx src/components/CustomWorldResultView.test.tsx src/components/CustomWorldEntityEditorModal.test.tsx` + 结果:通过,`34` 项测试全部通过。 +6. `npm run test` + 结果:失败,但失败点与本轮删除旧入口无直接关系;创作链相关定向回归已通过。 +7. `npm run build` + 结果:Vite 构建成功,但 build gate 因 chunk warning 失败,属于既有构建门禁问题。 +8. `npm run typecheck` + 结果:失败,存在 shared contracts、story contracts、runtime data、旧测试断言等既有类型问题,当前不适合作为本轮创作链清理通过口径。 +9. `npm --prefix server-node test -- src/services/customWorldAgentPhase3.test.ts src/services/customWorldAgentPhase4.test.ts src/services/customWorldAgentActionRegistry.test.ts src/services/RpgWorldPreviewCompiler.test.ts` + 结果:本轮新增的 `resultPreview` / `supportedActions` 主链断言已通过,但定向命令仍被一个既有 `customWorldAgentFoundationDraftService.test.ts` 断言失败带停,失败点与本轮 session snapshot 装配改动无直接耦合。 +10. `npm --prefix server-node run build` + 结果:通过。 +11. `npm run test -- src/services/rpg-creation/rpgCreationPreviewAdapter.test.ts src/components/game-shell/PreGameSelectionFlow.agent.interaction.test.tsx` + 结果:通过,`17` 项测试全部通过。 +12. `node --test --import tsx src/services/customWorldAgentActionRegistry.test.ts` + 结果:通过,`5` 项测试全部通过。 +13. `node --test --import tsx src/services/customWorldAgentPhase5.test.ts` + 结果:通过,`7` 项测试全部通过,已覆盖 `publish_world`、`generate_scene_assets`、`sync_scene_assets`、`expand_long_tail`、`revert_checkpoint` 的 Phase 5 主链回归。 +14. `node --test --import tsx src/services/rpg-agent-session-store/rpgAgentSessionCompatibility.test.ts` + 结果:通过,`2` 项测试全部通过。 +15. `node --test --import tsx src/app.test.ts` + 结果:通过,`55` 项测试全部通过,包含 SSE enriched session 回归。 +15. `node --test --import tsx src/services/customWorldAgentPhase3.test.ts src/services/customWorldAgentPhase4.test.ts` + 结果:通过,`11` 项测试全部通过。 +16. `npm --prefix server-node run build` + 结果:通过。 +17. `npm run check:encoding` + 结果:通过,`1877` 个文件编码检查通过。 +18. `npm run test -- src/services/rpg-creation/rpgCreationPreviewAdapter.test.ts src/components/game-shell/PreGameSelectionFlow.agent.interaction.test.tsx` + 结果:通过,`20` 项测试全部通过;已验证前端结果页主链不再依赖本地 preview fallback。 +19. `node --test --test-concurrency=1 --import tsx src/services/customWorldAgentActionRegistry.test.ts src/services/customWorldAgentPhase3.test.ts src/services/customWorldAgentPhase5.test.ts` + 结果:通过,`16` 项测试全部通过;已验证 action registry 契约清理、Phase3 preview source 口径与 Phase5 发布链回归均正常。 + +### 13.6 当前全量阻塞项 + +截至 `2026-04-21` 本轮核查结束时,仓库仍存在以下全量阻塞: + +1. `server-node/src/app.test.ts` 存在未解决合并冲突。 +2. `src/hooks/story/npcEncounterActions.test.ts` 存在未解决合并冲突。 +3. 前端全量 Vitest 仍有 3 个失败用例: + - `src/components/game-shell/useGameShellRuntimeViewModel.test.ts` + - `src/data/functionCatalog/functionCatalog.test.ts` + - `src/hooks/story/npcEncounterActions.test.ts` +4. 前端全量 TypeScript 检查仍有多处既有错误。 +5. 前端 build gate 仍被大 chunk warning 阻断。 + +### 13.7 完成度判断 + +按执行方案分阶段判断,当前更准确的状态是: + +1. Phase 1:主体完成,并已开始物理清理前端旧入口。 +2. Phase 2:后端拆分主体完成,`snapshot / supportedActions / resultPreview / SSE enriched session / session compatibility` 主链都已有定向回归覆盖;但旧命名 façade 兼容层仍保留,且发布链统一语义尚未进入 Phase 4 收口态。 +3. Phase 3:主链接线已完成,前端结果页、自动保存与创作恢复入口已切到服务端 `resultPreview`;但本地 preview fallback 与 `legacyResultProfile` 兼容层仍保留,尚未进入 Phase 5 清理态。 +4. Phase 4:部分完成,`publish_world` 已有真实 executor 与 gate 接线,但 publish gate / enter world / works 状态语义还没有完全统一到后端正式发布态。 +4. Phase 4:主链完成。Agent 结果页、works 聚合、detail publish 与进入世界阻断已统一到后端正式发布态;当前剩余仅是 runtime 启动协议与旧兼容草稿映射,不再属于本阶段必须项。 +5. Phase 5:主链完成。前端本地 preview 编译桥、结果页旧入口影子引用、执行型废弃 action 契约已清理完成;当前剩余仅是后端 `legacyResultProfile` 兼容字段与旧命名 façade 保留,不再阻塞本阶段验收。 + +### 13.8 后续删除顺序建议 + +后续继续删除老流程代码时,应严格按下面顺序推进: + +1. 先完成 `qualityFindings / blockers / preview source` 的结果页与 gate 消费,把 Phase 4 所需阻断语义真正接到 UI 与进入世界链。 +2. 再按后端兼容迁移节奏收缩 `legacyResultProfile` 写回范围,而不是恢复前端本地 preview 编译桥。 +3. 再删除 `customWorldAgentSessionStore.ts`、`customWorldWorkSummaryService.ts`、`runtimeProfile.ts` 等旧命名 façade。 +4. 最后清理 `customWorld*` 旧契约聚合入口与剩余测试旧导入。 + +### 13.9 Phase 4 本轮追加落地(`2026-04-21`) + +本轮围绕 Phase 4 继续补齐了“发布链、自动保存链、进入世界链统一”的剩余断点: + +1. 服务端 `CustomWorldAgentPublishingService` 已新增统一的 publish gate 摘要出口,`resultPreview` 与 works 聚合现在复用同一套 `blockers / publishReady / canEnterWorld` 判断,不再各自重复拼门禁语义。 +2. `RpgWorldWorkSummaryAssembler` 已改为跳过 `stage === published` 的 session 草稿项,避免作品中心在正式发布后同时出现“已发布 profile + 已发布 session 草稿”双份状态。 +3. works 草稿项的 `publishReady / blockerCount` 已从“只看 qualityFindings”切到真实 publish gate 结果,作品中心、结果页与发布执行器开始共享同一套阻断口径。 +4. 前端 Agent 结果页继续沿用服务端 `resultPreview`,并在“发布并进入世界”成功后优先消费发布后的 preview/profile,而不是直接把 preview 原始对象强转成运行时 profile。 +5. 已补 `RpgWorldWorkSummaryAssembler.fixture.test.ts`、`customWorldWorkSummaryService.integration.test.ts` 与 `PreGameSelectionFlow.agent.interaction.test.tsx` 回归,覆盖 works 去重、publish gate 口径一致,以及“先发布再进入世界”主链。 +6. 共享 fixture 已补齐 `generatedSceneAssetId / publishReady / blockerCount / canEnterWorld` 等 Phase 4 口径字段,默认基线样本现在能够真实通过服务端 publish gate,避免 works / preview / 测试断言继续使用“前端自定义假 ready”状态。 +7. 前端“发布并进入世界”交互回归已改为状态驱动 mock:结果页打开前保持 `ready_to_publish`,仅在 `publish_world` 完成后切换为 `published`,从而覆盖 Phase 4 真实的“草稿结果页 -> 发布 -> 进入世界”顺序,而不是直接伪造已发布初始态。 + +本轮仍未完成: + +1. Agent 工作区内还没有独立的“发布世界”快捷入口,当前主入口仍在结果页。 +2. 旧兼容作品草稿的 detail publish 仍保留旧作品库接口,不属于本次 Agent Phase 4 主链统一范围。 + +### 13.10 老脚本依赖删除追加记录(`2026-04-21`) + +本轮按“不要再与老脚本有依赖”的口径继续执行物理清理,完成以下事项: + +1. 前端 RPG 创作主链已切到 `Rpg*` client 命名: + - `src/components/rpg-entry/useRpgCreationSessionController.ts` 直接调用 `createRpgCreationSession / getRpgCreationSession / streamRpgCreationMessage / executeRpgCreationAction` + - `src/components/rpg-entry/useRpgCreationResultAutosave.ts` 直接调用 `executeRpgCreationAction / getRpgCreationOperation / upsertRpgWorldProfile` +2. `src/services/rpg-creation/` 已删除旧命名导出: + - 不再导出 `createCustomWorldAgentSession / executeCustomWorldAgentAction / getCustomWorldAgentSession` + - 不再导出 `listCustomWorldWorks / upsertCustomWorldProfile / listCustomWorldLibrary` + - 不再导出结果页实体生成的 `generateCustomWorldPlayableNpc / generateCustomWorldSceneImage` 等兼容别名 +3. 旧 service 聚合入口已断开: + - `src/services/aiService.ts` 不再 re-export RPG 创作链能力 + - `src/services/storageService.ts` 已删除,运行时存档、设置、作品入口能力已迁入 `rpg-entry / rpg-runtime` 域 client +4. 旧组件入口继续物理删除: + - `src/components/CustomWorldRoleAssetStudioModal.tsx` + - `src/components/CustomWorldResultView.tsx` + - `src/components/CustomWorldEntityEditorModal.tsx` + - `src/components/game-shell/PreGameSelectionFlow.tsx` +5. 新组件入口已删除旧命名导出: + - `RpgCreationResultView.tsx` 只导出 `RpgCreationResultView` + - `RpgCreationEntityEditorModal.tsx` 只导出 `RpgCreationEntityEditorModal / RpgCreationEditorTarget` + - `RpgCreationRoleAssetStudioModal.tsx` 只导出 `RpgCreationRoleAssetStudioModal` +6. 已使用源码级扫描确认 `src / packages / server-node` 中不再存在以下旧主链符号引用: + - `createCustomWorldAgentSession` + - `executeCustomWorldAgentAction` + - `getCustomWorldAgentSession` + - `streamCustomWorldAgentMessage` + - `listCustomWorldWorks` + - `upsertCustomWorldProfile` + - `CustomWorldRoleAssetStudioModal` + - `CustomWorldResultView` + - `CustomWorldEntityEditorModal` +7. 本轮未删除 `CustomWorldProfile` 等历史数据结构类型名,也未删除 `server-node` 侧仍承担真实兼容职责的旧命名 façade;这些属于后端兼容字段与契约命名迁移,不再是前端老脚本依赖。 +8. 本轮验证结果: + - `npm run test -- src/services/rpg-creation/rpgCreationPreviewAdapter.test.ts src/components/CustomWorldResultView.test.tsx src/components/CustomWorldEntityEditorModal.test.tsx src/components/rpg-entry/RpgEntryFlowShell.agent.interaction.test.tsx` + 结果:通过,`42` 项测试全部通过。 + - `npm run test -- src/services/rpg-creation/rpgCreationGenerationClient.test.ts` + 结果:通过,`2` 项测试全部通过。 + - `npm run check:encoding` + 结果:通过,`1929` 个文件编码检查通过。 diff --git a/docs/technical/CREATION_FLOW_CHAIN_REFACTOR_WORK_PACKAGE_A_PROGRESS_2026-04-21.md b/docs/technical/CREATION_FLOW_CHAIN_REFACTOR_WORK_PACKAGE_A_PROGRESS_2026-04-21.md new file mode 100644 index 00000000..087aab57 --- /dev/null +++ b/docs/technical/CREATION_FLOW_CHAIN_REFACTOR_WORK_PACKAGE_A_PROGRESS_2026-04-21.md @@ -0,0 +1,84 @@ +# 创作链路重构工作包 A 落地记录 + +更新时间:`2026-04-21` + +## 1. 本次目标 + +本次只落实 `CREATION_FLOW_CHAIN_REFACTOR_EXECUTION_PLAN_2026-04-21.md` 中的 **工作包 A:命名规范与目录骨架**,约束如下: + +1. 先建立 RPG 创作域的新命名落点。 +2. 先提供 façade 和 barrel,不迁移主流程行为。 +3. 不提前修改工作包 B 到 H 的大块业务逻辑。 + +## 2. 本次已落地内容 + +## 2.1 前端目录骨架 + +已新增以下目录与 façade: + +1. `src/components/game-shell/rpg-creation-flow/` +2. `src/components/rpg-creation-result/` +3. `src/components/rpg-creation-editor/` +4. `src/services/rpg-creation/` + +当前策略: + +1. `RpgCreationShell` 继续桥接旧的 `PreGameSelectionFlow`。 +2. `RpgCreationResultView` 继续桥接旧的 `CustomWorldResultView`。 +3. `RpgCreationEntityEditorModal` 继续桥接旧的 `CustomWorldEntityEditorModal`。 +4. `rpgCreation*Client` 继续桥接 `aiService.ts`、`storageService.ts`、`customWorldCoverAssetService.ts`。 +5. `rpgCreationPreviewAdapter` 继续桥接旧的前端草稿编译函数,明确它只是过渡层。 + +## 2.2 后端目录骨架 + +已新增以下 RPG 创作域 façade: + +1. `server-node/src/routes/rpgCreationAgentRoutes.ts` +2. `server-node/src/routes/rpgWorldWorksRoutes.ts` +3. `server-node/src/routes/rpgWorldLibraryRoutes.ts` +4. `server-node/src/routes/rpgWorldGalleryRoutes.ts` +5. `server-node/src/services/RpgAgentOrchestrator.ts` +6. `server-node/src/services/RpgAgentSessionStore.ts` +7. `server-node/src/services/RpgWorldPreviewCompiler.ts` +8. `server-node/src/services/RpgWorldWorkSummaryService.ts` + +当前策略: + +1. Agent route 与 orchestrator/session store 先用新命名 façade 对齐。 +2. works/library/gallery 路由先建立空骨架和基础 path 常量,避免下一轮迁移继续回落到旧命名。 +3. `RpgWorldPreviewCompiler` 先桥接旧 `runtimeProfile.ts` 编译能力,为工作包 G 的目录化拆分预留落点。 + +## 2.3 共享契约骨架 + +已新增以下共享契约入口: + +1. `packages/shared/src/contracts/rpgAgentAnchors.ts` +2. `packages/shared/src/contracts/rpgAgentDraft.ts` +3. `packages/shared/src/contracts/rpgAgentSession.ts` +4. `packages/shared/src/contracts/rpgAgentActions.ts` +5. `packages/shared/src/contracts/rpgCreationPreview.ts` +6. `packages/shared/src/contracts/rpgCreationWorkSummary.ts` + +当前策略: + +1. 会话、动作、作品摘要先从旧 `customWorldAgent.ts` 做类型级兼容导出。 +2. `rpgAgentDraft.ts` 先把 foundation draft、draft card 等草稿相关类型收口成独立入口,给工作包 H 后续物理拆分预留稳定导入点。 +3. `packages/shared/src/index.ts` 已补上对 RPG 草稿契约骨架的根导出,避免后续工作包继续回退到旧 `customWorldAgent.ts` 取类型。 +4. `rpgCreationPreview.ts` 明确标记当前 preview 仍是 legacy profile 兼容载体,避免误认为 preview contract 已经完成。 + +## 3. 本次没有做的事 + +以下内容仍保持原状,留给后续工作包: + +1. 没有拆 `PreGameSelectionFlow.tsx` 内部编排。 +2. 没有拆 `CustomWorldResultView.tsx`、`CustomWorldEntityEditorModal.tsx`、`CustomWorldRoleAssetStudioModal.tsx` 内部 section。 +3. 没有把 `runtimeRoutes.ts` 中的 works/library/gallery 真正迁出。 +4. 没有改 `customWorldAgentOrchestrator.ts`、`customWorldAgentSessionStore.ts`、`runtimeProfile.ts` 的内部职责。 +5. 没有改变任何线上行为或接口语义。 + +## 4. 对后续工作包的直接收益 + +1. 工作包 B 可以直接把平台壳层 hooks 落到 `src/components/game-shell/rpg-creation-flow/`。 +2. 工作包 C 可以直接把结果页与编辑器 section 落到新目录,而不用先讨论命名。 +3. 工作包 D 可以直接从 `rpgCreation*Client` 开始迁移导入链。 +4. 工作包 E、F、G、H 可以基于 `RpgAgent*`、`RpgWorld*`、`rpg*` 契约骨架继续拆分,而不需要再回头统一首轮命名。 diff --git a/docs/technical/CREATION_FLOW_CHAIN_REFACTOR_WORK_PACKAGE_B_PROGRESS_2026-04-21.md b/docs/technical/CREATION_FLOW_CHAIN_REFACTOR_WORK_PACKAGE_B_PROGRESS_2026-04-21.md new file mode 100644 index 00000000..22c14101 --- /dev/null +++ b/docs/technical/CREATION_FLOW_CHAIN_REFACTOR_WORK_PACKAGE_B_PROGRESS_2026-04-21.md @@ -0,0 +1,106 @@ +# 创作流程链路重构工作包 B 完成记录 + +更新时间:`2026-04-21` + +## 1. 本轮目标 + +工作包 B 聚焦前端平台壳层与流程编排拆分,本轮目标是把平台壳层从“大编排文件”收口成“页面壳层 + 独立 hooks / coordinator”: + +1. `PreGameSelectionFlow.tsx` 退化为兼容入口。 +2. `RpgCreationShellImpl.tsx` 只保留 stage 切换、组件装配、视觉级 loading / error。 +3. 平台 bootstrap、session controller、operation polling、detail navigation、result autosave、enter-world 逻辑全部迁入 `src/components/game-shell/rpg-creation-flow/` 新目录。 +4. 保证现有交互测试继续通过,不引入主链行为回退。 + +--- + +## 2. 已完成内容 + +### 2.1 旧入口已退化为兼容层 + +`src/components/game-shell/PreGameSelectionFlow.tsx` 现在只保留: + +1. 旧类型导出兼容:`PreGameSelectionFlowProps`、`SelectionStage` +2. 旧组件名兼容:`PreGameSelectionFlow` +3. 对新实现 `RpgCreationShellImpl` 的桥接 + +这样现有调用方和测试仍可继续走旧路径,不会因为命名迁移立即破坏主链。 + +### 2.2 新目录已承接真实实现与流程 hooks + +已新增或更新以下文件: + +1. `src/components/game-shell/rpg-creation-flow/RpgCreationShellImpl.tsx` +2. `src/components/game-shell/rpg-creation-flow/RpgCreationShell.tsx` +3. `src/components/game-shell/rpg-creation-flow/index.ts` +4. `src/components/game-shell/rpg-creation-flow/rpgCreationFlowTypes.ts` +5. `src/components/game-shell/rpg-creation-flow/rpgCreationFlowShared.ts` +6. `src/components/game-shell/rpg-creation-flow/useRpgCreationPlatformBootstrap.ts` +7. `src/components/game-shell/rpg-creation-flow/useRpgCreationSessionController.ts` +8. `src/components/game-shell/rpg-creation-flow/useRpgCreationAgentOperationPolling.ts` +9. `src/components/game-shell/rpg-creation-flow/useRpgCreationDetailNavigation.ts` +10. `src/components/game-shell/rpg-creation-flow/useRpgCreationResultAutosave.ts` +11. `src/components/game-shell/rpg-creation-flow/useRpgCreationEnterWorld.ts` + +其中: + +1. `RpgCreationShell.tsx` 已不再桥接旧 `PreGameSelectionFlow`,而是直接桥接 `RpgCreationShellImpl.tsx`。 +2. `index.ts` 已开始从新目录导出 `SelectionStage`,为后续调用迁移准备统一出口。 + +### 2.3 平台编排已全部拆入独立 coordinator +本轮已经把原 `PreGameSelectionFlow` / `RpgCreationShellImpl` 中的主链编排拆到以下 hook: + +1. `useRpgCreationPlatformBootstrap.ts` + - 平台首页 works / library / gallery / history / save / dashboard 拉取 + - 浏览历史写入与存档恢复 +2. `useRpgCreationSessionController.ts` + - Agent session 创建 / 恢复 + - 消息流、action 执行、草稿生成态与结果页自动打开 +3. `useRpgCreationAgentOperationPolling.ts` + - Agent operation 轮询 + - 完成态 session 刷新与失败兜底 +4. `useRpgCreationDetailNavigation.ts` + - 作品详情、创作作品恢复、草稿结果页打开 + - 详情页发布 / 下架 / 删除 +5. `useRpgCreationResultAutosave.ts` + - 结果页自动保存 + - `sync_result_profile` 协调 + - 保存签名去重与延时保存 +6. `useRpgCreationEnterWorld.ts` + - 进入世界前的最终草稿同步 + +当前 `RpgCreationShellImpl.tsx` 只保留: + +1. hooks 组合 +2. stage 级视图切换 +3. 组件 props 装配 +4. 视觉级 loading / error 展示 + +--- + +## 3. 当前状态判断 + +工作包 B 已达到执行方案中的验收口径: + +1. `PreGameSelectionFlow.tsx` 只剩兼容导出与新壳层桥接。 +2. `RpgCreationShellImpl.tsx` 不再直接持有平台请求编排、operation 轮询、自动保存或进入世界同步细节。 +3. 平台侧主链已经切成壳层 + hooks / coordinator。 +4. 现有 `PreGameSelectionFlow.agent.interaction.test.tsx` 的 14 个场景全部通过。 + +--- + +## 4. 本轮刻意未做 + +1. 还没有物理删除 `PreGameSelectionFlow.tsx`,当前继续保留旧入口兼容层,避免影响并行工作包的调用路径。 +2. 还没有让所有调用方统一显式改走 `RpgCreationShell` 新入口,当前仍允许旧入口桥接到新壳层。 +3. 还没有把结果页 preview 数据源从前端兼容 adapter 切到服务端正式 preview contract;当前仍使用 `rpgCreationPreviewAdapter` 作为阶段性兼容层,这属于后续工作包 G / H 与 Phase 3 范围。 +4. 还没有清理所有 legacy 兼容导出与 façade,当前优先稳定主链与测试口径。 + +--- + +## 5. 验证结果 + +1. `npx eslint "src/components/game-shell/PreGameSelectionFlow.tsx" "src/components/game-shell/rpg-creation-flow/*.ts" "src/components/game-shell/rpg-creation-flow/*.tsx"` +2. `npx vitest run src/components/game-shell/PreGameSelectionFlow.agent.interaction.test.tsx` +3. `npm run check:encoding` + +以上检查在本轮修改后均已通过。 diff --git a/docs/technical/CREATION_FLOW_CHAIN_REFACTOR_WORK_PACKAGE_D_PROGRESS_2026-04-21.md b/docs/technical/CREATION_FLOW_CHAIN_REFACTOR_WORK_PACKAGE_D_PROGRESS_2026-04-21.md new file mode 100644 index 00000000..a7e9ac6d --- /dev/null +++ b/docs/technical/CREATION_FLOW_CHAIN_REFACTOR_WORK_PACKAGE_D_PROGRESS_2026-04-21.md @@ -0,0 +1,106 @@ +# 创作链路重构工作包 D 落地记录 + +更新时间:`2026-04-21` + +## 1. 本次目标 + +本次只落实 `CREATION_FLOW_CHAIN_REFACTOR_EXECUTION_PLAN_2026-04-21.md` 中的 **工作包 D:前端 custom world client 收口**,约束如下: + +1. 把创作链主路径依赖的 custom world 请求从 `aiService.ts`、`storageService.ts` 中迁入 `src/services/rpg-creation/`。 +2. 首轮允许旧 service 兼容导出,追加清理轮必须删除已无调用方的旧命名导出。 +3. 不改后端接口语义,不扩写结果页 UI 逻辑,不借机重构工作包 B / C 的内部状态编排。 + +## 2. 本次已落地内容 + +## 2.1 RPG 创作域请求基座已独立 + +已新增以下请求基座文件: + +1. `src/services/rpg-creation/rpgCreationRuntimeClient.ts` +2. `src/services/rpg-creation/rpgCreationRequestHelpers.ts` + +当前策略: + +1. runtime 读写重试策略不再散落在 `storageService.ts` 内部,而是作为 RPG 创作域专属 runtime client 复用。 +2. Agent SSE、POST JSON 请求辅助能力收口到 `rpgCreationRequestHelpers.ts`,避免再把流式解析细节写回通用 service。 + +## 2.2 五类 rpgCreation client 已持有真实请求实现 + +以下 client 已不再桥接旧 service,而是直接持有真实网络实现: + +1. `src/services/rpg-creation/rpgCreationAgentClient.ts` +2. `src/services/rpg-creation/rpgCreationWorkClient.ts` +3. `src/services/rpg-creation/rpgCreationLibraryClient.ts` +4. `src/services/rpg-creation/rpgCreationAssetClient.ts` +5. `src/services/rpg-creation/rpgCreationGenerationClient.ts` + +本轮已完成的具体收口: + +1. Agent session 创建、读取、消息发送、消息流、action 执行、operation 查询、card detail 查询已经正式迁入 `rpgCreationAgentClient.ts`。 +2. works 列表查询已经正式迁入 `rpgCreationWorkClient.ts`。 +3. library / publish / unpublish / gallery / gallery detail 已经正式迁入 `rpgCreationLibraryClient.ts`。 +4. 结果页与编辑器依赖的场景图、场景 NPC、可扮演角色、场景角色、场景生成请求已经正式迁入 `rpgCreationAssetClient.ts`。 +5. `generateCustomWorldProfile()` 已正式迁入 `rpgCreationGenerationClient.ts`,世界生成入口也已进入 RPG 创作域 client。 +6. `src/services/rpg-creation/index.ts` 已收口为 RPG 命名导出,创作主链不再从 barrel 暴露 `createCustomWorldAgentSession / listCustomWorldWorks / upsertCustomWorldProfile` 等旧命名入口。 + +## 2.3 旧 service 兼容导出已删除 + +追加清理轮已完成以下删除: + +1. `src/services/aiService.ts` 不再 re-export RPG 创作 Agent / works / 结果页生成接口,继续只服务 story/chat 等通用 AI 运行时能力。 +2. `src/services/storageService.ts` 已物理删除,运行时存档、设置、资料、浏览历史能力已迁入 `src/services/rpg-entry/` 与 `src/services/rpg-runtime/`。 +3. `rpgCreationAgentClient.ts`、`rpgCreationWorkClient.ts`、`rpgCreationLibraryClient.ts`、`rpgCreationAssetClient.ts` 已删除 `CustomWorld*` 兼容具名导出,只保留 `Rpg*` 主命名。 +4. 源码扫描已确认不再存在 `createCustomWorldAgentSession / executeCustomWorldAgentAction / listCustomWorldWorks / upsertCustomWorldProfile` 等旧主链函数引用。 + +## 2.4 主链调用已开始直接使用 RPG 创作域 client + +本轮已把以下主链入口切到 `src/services/rpg-creation/`: + +1. `src/components/rpg-entry/useRpgCreationSessionController.ts` +2. `src/components/rpg-entry/useRpgCreationResultAutosave.ts` +3. `src/components/rpg-entry/RpgEntryFlowShell.agent.interaction.test.tsx` +4. `src/components/rpg-creation-editor/RpgCreationEntityEditorShared.tsx` +5. 新增世界生成入口 `generateRpgWorldProfile()` 通过 `src/services/rpg-creation/` barrel 暴露,后续新代码不必再从旧 `aiService.ts` 进入。 + +配套收口: + +1. 结果页与编辑器相关测试 mock 已改到 `rpgCreationAssetClient`,不再盯住 `aiService.ts` 的兼容层。 +2. `CustomWorldResultView.test.tsx`、`CustomWorldEntityEditorModal.test.tsx` 已改为直接消费 `RpgCreationResultView / RpgCreationEntityEditorModal` 新入口,不再通过旧组件 façade。 + +## 2.5 本轮验证结果 + +已完成以下针对性验证: + +1. `npm run test -- src/services/rpg-creation/rpgCreationGenerationClient.test.ts src/services/storageService.test.ts src/components/CustomWorldEntityEditorModal.test.tsx src/components/CustomWorldResultView.test.tsx src/components/game-shell/PreGameSelectionFlow.agent.interaction.test.tsx` +2. `npm run check:encoding` + +验证结果: + +1. 上述 5 组定向测试全部通过。 +2. 编码检查通过,未写坏中文文件。 + +追加清理轮已完成以下验证: + +1. `npm run test -- src/services/rpg-creation/rpgCreationPreviewAdapter.test.ts src/components/CustomWorldResultView.test.tsx src/components/CustomWorldEntityEditorModal.test.tsx src/components/rpg-entry/RpgEntryFlowShell.agent.interaction.test.tsx` + 结果:通过,`42` 项测试全部通过。 +2. `npm run test -- src/services/rpg-creation/rpgCreationGenerationClient.test.ts` + 结果:通过,`2` 项测试全部通过。 +3. `npm run check:encoding` + 结果:通过,`1929` 个文件编码检查通过。 +4. 源码扫描确认 `src / packages / server-node` 中不再存在本轮删除的旧主链函数与旧组件入口符号引用。 + +## 3. 本次刻意未做的事 + +以下内容明确留给后续工作包,不在本轮越界处理: + +1. 没有改后端 works/library/gallery/agent route 的语义与 contract。 +2. 没有拆 `PreGameSelectionFlow.tsx` 内部编排;这部分仍属于工作包 B。 +3. 没有继续物理拆散 `RpgCreationEntityEditorShared.tsx`;这部分仍属于工作包 C 后续细拆。 +4. 没有强行重命名历史数据结构类型,例如 `CustomWorldProfile` 与 runtime contract response 名称;这些仍是现有契约类型,不等同于旧脚本依赖。 +5. 没有删除旧 `src/services/ai.ts` 中的 legacy 世界生成实现;它已不在当前 RPG 创作主链 client 上,后续应按独立 dead code 批次评估。 + +## 4. 对后续工作包的直接收益 + +1. 工作包 B 后续拆平台壳层时,可以直接从 `src/services/rpg-creation/` 消费 Agent / works / library / gallery 请求,不必继续回到旧 service 文件找接口。 +2. 工作包 C 后续继续拆结果页和编辑器时,资产生成请求已经有稳定的 RPG 创作域入口。 +3. 后续清理 `aiService.ts`、`storageService.ts` 时,创作链主路径已经完成真实迁出,不会再被“通用 service 同时承载创作域请求”拖住。 diff --git a/docs/technical/CREATION_FLOW_CHAIN_REFACTOR_WORK_PACKAGE_E_PROGRESS_2026-04-21.md b/docs/technical/CREATION_FLOW_CHAIN_REFACTOR_WORK_PACKAGE_E_PROGRESS_2026-04-21.md new file mode 100644 index 00000000..24d4205d --- /dev/null +++ b/docs/technical/CREATION_FLOW_CHAIN_REFACTOR_WORK_PACKAGE_E_PROGRESS_2026-04-21.md @@ -0,0 +1,150 @@ +# 创作链路重构工作包 E 落地记录 + +更新时间:`2026-04-21` + +## 1. 本次目标 + +本次落实 `CREATION_FLOW_CHAIN_REFACTOR_EXECUTION_PLAN_2026-04-21.md` 中的 **工作包 E:后端 Agent 编排拆分**,并严格遵守这一轮的写入边界: + +1. 只改后端应用服务层,不动前端壳层。 +2. 先把 `customWorldAgentOrchestrator.ts` 从“大分支调度 + 派生状态重建 + 结果回写细节”里拆薄。 +3. 补齐 action executor 真实落点与 `supportedActions` 主链字段,但不在这一轮顺手重构 session store 和 runtime compiler。 + +## 2. 本次已落地内容 + +## 2.1 orchestrator 已退化为应用服务 façade + +本轮后,`server-node/src/services/customWorldAgentOrchestrator.ts` 的职责开始收口为: + +1. session 级入口方法保留。 +2. 创建 operation 记录。 +3. 调用 action registry 拿到执行计划。 +4. 把消息轮转、foundation 生成、实体生成、角色资产同步等主链事务串起来。 + +这轮明确移出的内容: + +1. `action -> executor` 的分支校验和分发。 +2. `sync_result_profile` 的字段回写细节。 +3. 多个 action 共用的 draftCards / assetCoverage / suggestedActions / qualityFindings 派生重建逻辑。 + +## 2.2 已新增 action registry 与 executor 目录,并完成真实执行迁移 + +已新增: + +1. `server-node/src/services/customWorldAgentActionRegistry.ts` +2. `server-node/src/services/customWorldAgentActionExecutors/index.ts` +3. `server-node/src/services/customWorldAgentActionExecutors/types.ts` + +本轮收口结果: + +1. registry 统一处理 `draft_foundation`、`update_draft_card`、`sync_result_profile`、`generate_characters`、`generate_landmarks`、`generate_role_assets`、`sync_role_assets` 的可用性校验。 +2. `publish_world`、`generate_scene_assets`、`sync_scene_assets`、`expand_long_tail`、`revert_checkpoint` 已完成真实 executor 装配,不再只是 registry 层面的“已声明但未开放”动作。 +3. `lock_cards`、`unlock_cards`、`regenerate_scope` 仍统一通过 registry 返回禁用原因,不再继续堆在 orchestrator 分支里。 +4. `customWorldAgentActionExecutors/` 已补 `draftFoundationExecutor.ts`、`updateDraftCardExecutor.ts`、`syncResultProfileExecutor.ts`、`generateCharactersExecutor.ts`、`generateLandmarksExecutor.ts`、`generateRoleAssetsExecutor.ts`、`syncRoleAssetsExecutor.ts`、`generateSceneAssetsExecutor.ts`、`syncSceneAssetsExecutor.ts`、`expandLongTailExecutor.ts`、`publishWorldExecutor.ts`、`revertCheckpointExecutor.ts`,真实 action 执行已从 orchestrator 物理迁入目录。 +5. `customWorldAgentActionExecutors/helpers.ts` 与 `executorShared.ts` 已收口 action_result / summary message 构造、operation 更新和 session 读取共用逻辑,避免 executor 间重复堆样板代码。 + +## 2.3 已新增 message turn / suggested action / snapshot / quality gate / result sync service + +已新增: + +1. `server-node/src/services/customWorldAgentMessageTurnService.ts` +1. `server-node/src/services/customWorldAgentSuggestedActionService.ts` +2. `server-node/src/services/customWorldAgentSnapshotBuilder.ts` +3. `server-node/src/services/customWorldAgentQualityGateService.ts` +4. `server-node/src/services/customWorldAgentResultSyncService.ts` + +本轮收口结果: + +1. `CustomWorldAgentMessageTurnService` 已接管 session 初始派生状态与 message turn 的真实执行,`customWorldAgentOrchestrator.ts` 只保留 façade 委托。 +1. `CustomWorldAgentSuggestedActionService` 统一维护 `foundation_review`、`object_refining`、`visual_refining` 的建议动作生成,不再散落在 orchestrator 和 session compatibility。 +2. `CustomWorldAgentSnapshotBuilder` 统一承接 message turn、foundation draft、结果页回写、角色/地点追加、角色资产同步后的派生字段重建。 +3. `CustomWorldAgentQualityGateService` 已形成独立 finding 入口,当前先输出角色缺失、地点缺失、玩家目标缺失、角色资产待补齐、场景资产待补齐等基础 gate finding。 +4. `CustomWorldAgentResultSyncService` 接管了 `sync_result_profile` 的字段回写细节,明确这一轮只允许“摘要 + 资产确认结果 + legacyResultProfile 快照”回写进 draft profile。 + +## 2.4 `supportedActions` 已接入 session snapshot 主链 + +这一轮已把 registry 产出的能力矩阵正式装配到 `CustomWorldAgentSessionSnapshot.supportedActions`: + +1. `createSession`、`getSessionSnapshot`、stream message 完成态、各 action 完成后的 session 拉取都会返回真实 `supportedActions`。 +2. `supportedActions` 的启用状态按 session 当前阶段与草稿可用性计算,不再由前端根据 action 字面量自行猜测。 +3. 具体 payload 校验仍保留在 action 执行阶段,能力矩阵只表达“当前阶段是否允许发起这类动作”。 + +## 2.5 action 主链行为保持不变,但派生状态已开始统一 + +这一轮没有改变现有 action contract,也没有新增前端依赖字段,但已经把以下重复派生逻辑统一改走 snapshot builder: + +1. `draft_foundation` +2. `update_draft_card` +3. `sync_result_profile` +4. `generate_characters` +5. `generate_landmarks` +6. `generate_role_assets` +7. `sync_role_assets` +8. message turn 结束后的 stage / suggested actions / quality findings / asset coverage 重建 + +这意味着: + +1. 后续新增 action 时,不必再复制一整段 `draftCards + assetCoverage + suggestedActions + recommendedReplies` patch 拼装代码。 +2. `qualityFindings` 已开始成为真实后端派生字段,而不只是 session store 中的空占位。 +3. `sync_result_profile` 的边界已经能单独测试和继续收缩。 + +## 2.6 工作包 E 第三轮已补齐的真实闭环 + +本轮把工作包 E 前两轮遗留的 5 个动作补成了真实后端闭环: + +1. `generate_scene_assets` + 已通过 `CustomWorldAgentAssetBridgeService.buildSceneAssetStudioContext()` 打通场景图工坊上下文准备,支持营地与地点单场景进入。 +2. `sync_scene_assets` + 已通过 `applySceneAssetPublishResult()` 写回营地/地点正式场景图,并同步刷新对应 `sceneChapters[].acts` 的背景图与背景资产 ID。 +3. `expand_long_tail` + 已接入实体生成服务与 snapshot builder,能真实追加长尾角色、地点并把阶段推进到 `long_tail_review`。 +4. `publish_world` + 已改为走 `CustomWorldAgentPublishingService + RpgWorldProfileRepository` 主链,正式把 draft session 编译、写入并发布到作品库。 +5. `revert_checkpoint` + 已依赖 checkpoint snapshot 元数据与 `restoreCheckpoint()` 主链完成真实回滚,不再只是开放 action 名称。 + +这一轮同时补齐了 4 个关键收口: + +1. 发布链已经统一改走 `CustomWorldAgentPublishingService`,`customWorldAgentOrchestrator.ts`、`customWorldAgentActionExecutors/index.ts`、`publishWorldExecutor.ts` 与 `server.ts` 的注入口径已经对齐;作者展示名优先走 `resolveAuthorDisplayName`,同时保留 `userRepository` 兼容兜底。 +2. `publish_world` 的 readiness 与正式发布已经收口到同一个服务,`profileId` 固定优先沿用 legacy 结果页 ID,否则回退为 `agent-draft-${sessionId}`,避免发布产物继续使用临时时间戳。 +3. `buildCheckpointSnapshot()` 已接入 `draft_foundation`、`update_draft_card`、`sync_result_profile`、`generate_characters`、`generate_landmarks`、`sync_role_assets`、`sync_scene_assets`、`expand_long_tail`、`publish_world` 等关键 executor,checkpoint 现在保存的是真正可恢复的派生快照,而不是只记一段残缺 patch。 +4. `rebuildRoleAssetCoverage()` 已补营地 / 地点正式场景资产 fallback 汇总,并收口为“只有真实正式场景图已存在时才补 standalone summary”,这样 `sync_scene_assets` 写回后的 camp/landmark asset coverage 在 snapshot 重建、works 读模型与 checkpoint 回放里都不会丢失,也不会误伤 phase3 自动资产回归。 + +## 2.7 本轮验证结果 + +已完成以下验证: + +1. `npm --prefix server-node run build` +2. `npm --prefix server-node run test -- customWorldAgentPhase3.test.ts customWorldAgentActionRegistry.test.ts customWorldAgentPhase5.test.ts` + +本轮重点关注的回归范围: + +1. `customWorldAgentActionRegistry.test.ts` +2. `customWorldAgentPhase3.test.ts` +3. `customWorldAgentPhase5.test.ts` +4. `publish_world` +5. `generate_scene_assets / sync_scene_assets` +6. `expand_long_tail` +7. `revert_checkpoint` + +验证结果: + +1. `server-node` 构建通过。 +2. 定向回归通过,共 `208` 项测试全部通过。 +3. Phase 3 与 Phase 5 已同时确认通过,说明这轮对 `sceneAssets` fallback summary 的收口没有打坏前序自动资产链。 + +## 3. 本次刻意未做的事 + +以下内容明确留给后续工作包或下一轮工作包 E,不在本轮越界处理: + +1. 还没有进入 Phase 4 的“进入世界统一走发布态 gate”收口,当前这轮只完成了发布动作本身的后端闭环。 +2. 还没有改 `customWorldAgentSessionStore.ts` 内部 compatibility / snapshot 输出结构,这部分仍属于工作包 F。 +3. 还没有把 result preview 正式接到 `resultPreview` 主链字段,这部分仍需要和工作包 G / H 协作。 +4. 旧 `customWorldAgentPublishGateService.ts` 与 `customWorldAgentPublishService.ts` 仍作为历史兼容文件保留,但工作包 E 主链已经不再走它们;这一轮没有继续做物理删除与引用清扫,避免越界碰到 Phase 4/Phase 5 之外的兼容入口。 + +## 4. 对后续工作包的直接收益 + +1. 工作包 F 可以在不碰 orchestrator 大分支的前提下,继续拆 session/store/repository。 +2. 工作包 G 可以直接围绕 `CustomWorldAgentResultSyncService` 和 `CustomWorldAgentQualityGateService` 对接服务端 preview compiler 与 publish gate。 +3. 工作包 H 可以基于已落地的 `supportedActions`、action registry 和 quality gate 继续推进 preview contract 与 contract tests。 +4. 后续继续拆 action executor 时,已经有 `customWorldAgentActionExecutors/` 目录和注册表,不需要再回到 orchestrator 里重新铺路。 diff --git a/docs/technical/CREATION_FLOW_CHAIN_REFACTOR_WORK_PACKAGE_F_PROGRESS_2026-04-21.md b/docs/technical/CREATION_FLOW_CHAIN_REFACTOR_WORK_PACKAGE_F_PROGRESS_2026-04-21.md new file mode 100644 index 00000000..072eb922 --- /dev/null +++ b/docs/technical/CREATION_FLOW_CHAIN_REFACTOR_WORK_PACKAGE_F_PROGRESS_2026-04-21.md @@ -0,0 +1,92 @@ +# 创作链路重构工作包 F 落地记录 + +更新时间:`2026-04-21` + +## 1. 本次目标 + +本次落实 `CREATION_FLOW_CHAIN_REFACTOR_EXECUTION_PLAN_2026-04-21.md` 中的 **工作包 F:后端 session/store/repository 拆分**,约束如下: + +1. 不改动现有主链接口与行为语义。 +2. 保留 `customWorldAgentSessionStore.ts`、`runtimeRepository.ts`、`customWorldWorkSummaryService.ts` 作为兼容 façade。 +3. 把 session 兼容补齐、session 持久化、profile 持久化、works 读模型组装从大文件中物理拆出。 + +## 2. 本次已落地内容 + +## 2.1 session store 内部分层 + +已新增以下 RPG Agent session 拆分文件: + +1. `server-node/src/services/rpg-agent-session-store/rpgAgentSessionRecord.ts` +2. `server-node/src/services/rpg-agent-session-store/rpgAgentSessionCompatibility.ts` +3. `server-node/src/services/rpg-agent-session-store/rpgAgentSessionFactory.ts` +4. `server-node/src/services/rpg-agent-session-store/rpgAgentSessionRepositoryAdapter.ts` + +当前策略: + +1. `customWorldAgentSessionStore.ts` 继续保留旧类名和旧方法签名。 +2. sessionId 前缀、snapshot 输出结构、operation/checkpoint 写入语义保持兼容。 +3. 旧 session 的兼容补齐逻辑集中收口到 `rpgAgentSessionCompatibility.ts`,不再继续堆在 store 主文件里。 +4. `customWorldAgentSessionStore.ts` 已正式改为依赖 `RpgAgentSessionRepositoryPort`,phase2~5 与 works 集成测试也已切到新的 session 仓储端口。 + +## 2.2 custom world 仓储从 runtime 大仓储中拆出 + +已新增以下 RPG 世界仓储文件: + +1. `server-node/src/repositories/RpgAgentSessionRepository.ts` +2. `server-node/src/repositories/RpgWorldProfileRepository.ts` +3. `server-node/src/repositories/rpgWorldRepositoryShared.ts` + +当前策略: + +1. `RuntimeRepositoryPort` 继续保留兼容 façade,但 `context.ts`、`server.ts`、`runtimeRoutes.ts`、同步脚本已开始直接注入并使用 `RpgAgentSessionRepository`、`RpgWorldProfileRepository`。 +2. `runtimeRepository.ts` 内的 custom world session/profile/gallery SQL 已改成委托新仓储。 +3. `runtimeRepository.ts` 继续只保留 runtime 快照、设置、浏览历史、档案等通用能力,以及少量尚未迁走的快照同步编排。 + +## 2.3 works 读模型拆分 + +已新增以下 works 读模型相关文件: + +1. `server-node/src/services/RpgWorldWorkCoverResolver.ts` +2. `server-node/src/services/RpgWorldWorkSummaryAssembler.ts` +3. `server-node/src/services/RpgWorldWorkSummaryService.ts` + +并将: + +1. `server-node/src/services/customWorldWorkSummaryService.ts` + +退化为兼容入口,仅负责桥接新 `RpgWorldWorkSummaryService`。 + +当前策略: + +1. works service 只保留服务入口,不再内嵌标题、摘要、封面、资产覆盖率等全部组装细节。 +2. 草稿封面与发布态封面解析统一走 resolver,避免后续重复理解封面规则。 +3. 草稿态与发布态 work summary 的字段语义保持不变,继续支持“继续创作”和“进入世界”入口判定。 +4. `runtimeRoutes.ts` 中的 works/library/gallery 路由已切到 `rpgWorldWorkSummaryService` 与 `rpgWorldProfileRepository` 直接注入,不再经由 `runtimeRepository` 中转 custom world 读模型。 + +## 3. 验证结果 + +本次已完成以下定向回归: + +1. 运行 `node --test --test-concurrency=1 --import tsx src/services/customWorldAgentPhase2.test.ts src/services/customWorldAgentPhase3.test.ts src/services/customWorldAgentPhase4.test.ts src/services/customWorldAgentPhase5.test.ts src/services/customWorldWorkSummaryService.integration.test.ts` +2. 以上 21 个 custom world / agent / works 相关测试全部通过。 + +同时确认: + +1. 全量 `npx tsc -p server-node/tsconfig.json --noEmit` 当前仍被仓库里既有的跨模块类型问题阻塞。 +2. 这些全量类型错误大多与本工作包无关,因此本轮仍以 custom world 定向测试通过作为主验证口径。 +3. 工作包 F 本轮新增的 `RpgWorldWorkSummaryService.ts`、新仓储注入链和测试 helper,未在定向回归中引入新的行为回归。 + +## 4. 当前兼容保留项 + +以下内容属于阶段性兼容保留,不再视为工作包 F 未完成项: + +1. `RuntimeRepositoryPort` 仍保留 custom world 相关兼容方法,避免一次性冲击 story/runtime 其他调用方。 +2. `customWorldAgentSessionStore.ts`、`customWorldWorkSummaryService.ts` 仍保留旧文件名 façade,后续统一命名治理时再清理。 +3. runtime 快照同步与 custom world profile 自动回写的进一步解耦,仍留待后续围绕 `runtimeRepository.ts` 继续收口。 + +## 5. 对后续工作包的直接收益 + +1. 工作包 E 可以在不继续挤压 `customWorldAgentSessionStore.ts` 的情况下,把 orchestrator 的 result sync / snapshot builder 接到更清晰的 session 持久化边界。 +2. 工作包 G 后续若需要让 preview compiler / publish gate 落库,不必再继续往 `runtimeRepository.ts` 堆 custom world SQL。 +3. 工作包 H 已能直接围绕 `rpg-agent-session-store/`、`RpgWorldWorkSummaryAssembler.ts`、`RpgWorldWorkSummaryService.ts` 与新仓储端口补充更细粒度回归,而不必穿透大文件。 +4. 后续若继续拆 route 命名或清理旧 façade,已有 `context -> server -> runtimeRoutes -> script -> tests` 的新仓储注入链可直接复用。 diff --git a/docs/technical/CREATION_FLOW_CHAIN_REFACTOR_WORK_PACKAGE_G_PROGRESS_2026-04-21.md b/docs/technical/CREATION_FLOW_CHAIN_REFACTOR_WORK_PACKAGE_G_PROGRESS_2026-04-21.md new file mode 100644 index 00000000..7fd0f156 --- /dev/null +++ b/docs/technical/CREATION_FLOW_CHAIN_REFACTOR_WORK_PACKAGE_G_PROGRESS_2026-04-21.md @@ -0,0 +1,92 @@ +# 创作链路重构工作包 G 落地记录 + +更新时间:`2026-04-21` + +## 1. 本次目标 + +本次落实 `CREATION_FLOW_CHAIN_REFACTOR_EXECUTION_PLAN_2026-04-21.md` 中的 **工作包 G:后端 preview compiler 与 runtime profile 目录化**,并把目录化拆分推进到文档目标结构: + +1. 先把 `runtimeProfile.ts` 退化成兼容 façade。 +2. 把 `runtime-profile/` 真正拆成 `normalize/build/schema/creatorIntentBridge` 等独立模块。 +3. 把服务端 result preview compiler 从 foundation draft 流程中抽出独立入口。 +4. 不直接改路由层,不直接接前端结果页。 + +## 2. 本次已落地内容 + +## 2.1 runtime profile 已完成目录化完整拆分 + +已完成以下结构调整: + +1. 新增 `server-node/src/modules/custom-world/runtime-profile/index.ts` 作为目录入口。 +2. 原 `server-node/src/modules/custom-world/runtimeProfile.ts` 已退化为兼容 façade,只负责 re-export。 +3. `server-node/src/modules/custom-world/runtime-profile/runtimeProfileCompiler.ts` 已退化为兼容 façade,不再承载主实现。 +4. 已新增并落地以下目标模块: + 1. `normalizeShared.ts` + 2. `normalizeRole.ts` + 3. `normalizeLandmark.ts` + 4. `normalizeSceneChapter.ts` + 5. `normalizeCamp.ts` + 6. `buildCompiledProfile.ts` + 7. `buildAttributeSchema.ts` + 8. `creatorIntentBridge.ts` + +当前策略: + +1. 先保证旧导入路径不失效,避免放大工作包 G 首轮改动范围。 +2. 新代码优先改走 `runtime-profile/` 目录入口。 +3. `runtimeProfile.ts` 与 `runtimeProfileCompiler.ts` 后续只允许继续收缩,不再接受新增主逻辑。 + +## 2.2 服务端 preview compiler 已从 foundation draft 流程中抽出 + +已完成以下收口: + +1. `server-node/src/services/RpgWorldPreviewCompiler.ts` 不再只是别名导出,已提供: + 1. `buildRpgWorldPreviewProfile()` + 2. `normalizeRpgWorldPreviewProfile()` + 3. `buildRpgWorldPreviewEnvelope()` + 4. `normalizeRpgWorldPreviewEnvelope()` +2. `packages/shared/src/contracts/rpgCreationPreview.ts` 已补 `RpgCreationPreviewSource`,把 preview 来源语义显式化。 +3. `customWorldAgentFoundationDraftService.ts` 已把 LLM foundation draft 主生成链改成“直接组装 foundation draft + 单独保留 `legacyResultProfile` 兼容快照”,不再通过 preview compiler 反解草稿主字段。 + +这轮的边界变化是: + +1. foundation draft 主字段已经不再依赖“先编 legacy runtime profile,再转回 draft”的双重编译。 +2. `legacyResultProfile` 仍保留,但只作为结果页兼容快照,不再主导 foundation draft 生成。 +3. “服务端 preview 编译入口”继续独立存在,并在 Phase 5 后补上 `rpgCreationPreviewProfileBuilder.ts`,统一承接 preview 与 publish 的兼容合并规则。 +4. preview source 已在 Phase 5 后正式收口为 `session_preview`,不再继续沿用兼容期的 `legacy_custom_world_profile` 标记。 + +## 2.3 已补最小测试与目录化回归验证 + +本次新增: + +1. `server-node/src/services/RpgWorldPreviewCompiler.test.ts` +2. `server-node/src/services/customWorldAgentFoundationDraftService.test.ts` + +当前覆盖重点: + +1. 验证 preview compiler 可以输出服务端兼容预览 envelope。 +2. 验证 envelope 的 `source` 保持为 `session_preview`。 +3. 验证 preview profile 仍保留 runtime 编译生成的关键字段,例如 `scenarioPackId`、`campaignPackId`。 +4. 验证 Phase 5 新增的 preview builder 可以在服务端保留 `legacyResultProfile` 富字段并合并最新草稿资产。 +5. 验证 foundation draft service 的 LLM 路径已经直接生成 draft 主字段,不再依赖 preview compiler 反解。 +6. 验证 `runtimeProfile.ts` façade 在目录化拆分后仍保持旧调用兼容。 + +本轮额外验证已通过: + +1. `npm run check:encoding` +2. `node --test --test-concurrency=1 --import tsx server-node/src/services/customWorldAgentFoundationDraftService.test.ts server-node/src/modules/custom-world/runtimeProfile.test.ts server-node/src/services/RpgWorldPreviewCompiler.test.ts` + +## 3. 本次刻意没有做的事 + +以下内容仍留给后续阶段: + +1. 还没有让 `RpgWorldPreviewCompiler` 输出真正独立于 legacy profile 的 preview view model。 +2. 还没有把 `RpgWorldPreviewCompiler` 的 preview 载体从当前 runtime-profile 兼容对象升级成真正独立的 preview view model。 +3. `legacyResultProfile` 仍保留为兼容快照,结果页与自动保存链还没有完全脱离 legacy profile 富字段。 +4. 还没有删除 `runtimeProfile.ts`、`runtimeProfileCompiler.ts` 这两个兼容 façade。 + +## 4. 对后续工作包的直接收益 + +1. 工作包 E 可以围绕 `RpgWorldPreviewCompiler` 继续补 result sync / snapshot builder 的 preview 接口。 +2. 工作包 H 可以基于 `RpgCreationPreviewEnvelope` 继续细化正式 preview contract 和 contract tests。 +3. Phase 3 把结果页切到服务端 preview 时,已经有稳定的后端编译入口和目录化 normalize/build 模块,不需要再回头拆 `runtimeProfile.ts` 大文件。 diff --git a/docs/technical/CREATION_FLOW_CHAIN_REFACTOR_WORK_PACKAGE_H_PROGRESS_2026-04-21.md b/docs/technical/CREATION_FLOW_CHAIN_REFACTOR_WORK_PACKAGE_H_PROGRESS_2026-04-21.md new file mode 100644 index 00000000..2f8cd2f2 --- /dev/null +++ b/docs/technical/CREATION_FLOW_CHAIN_REFACTOR_WORK_PACKAGE_H_PROGRESS_2026-04-21.md @@ -0,0 +1,137 @@ +# 创作链路重构工作包 H 落地记录 + +更新时间:`2026-04-21` + +## 1. 本次目标 + +本次落实 `CREATION_FLOW_CHAIN_REFACTOR_EXECUTION_PLAN_2026-04-21.md` 中的 **工作包 H:共享契约与测试基建**,约束如下: + +1. 把 RPG 创作域共享契约从“类型别名骨架”推进到“真实定义 + 兼容出口”。 +2. 补齐可复用的 fixture,避免前后端测试继续各自复制一套假数据。 +3. 补齐 unit / contract / integration / regression 最小闭环,不越界重构 UI、路由和仓储主逻辑。 + +## 2. 本次已落地内容 + +## 2.1 共享契约已完成物理拆分与兼容收口 + +本轮已把以下文件从工作包 A 的骨架态推进为真实定义: + +1. `packages/shared/src/contracts/rpgAgentAnchors.ts` +2. `packages/shared/src/contracts/rpgAgentDraft.ts` +3. `packages/shared/src/contracts/rpgAgentActions.ts` +4. `packages/shared/src/contracts/rpgAgentSession.ts` +5. `packages/shared/src/contracts/rpgCreationPreview.ts` +6. `packages/shared/src/contracts/rpgCreationWorkSummary.ts` + +本轮收口重点: + +1. `rpgAgent*` 与 `rpgCreation*` 文件不再只是从旧 `customWorldAgent.ts` 做类型别名转发,而是承载真实契约定义。 +2. `rpgAgentSession.ts` 已显式加入 `supportedActions?` 与 `resultPreview?` 可选字段,为工作包 E/G 后续正式接入 registry 与服务端 preview compiler 预留稳定契约入口。 +3. `rpgCreationPreview.ts` 已补 `source / generatedAt / qualityFindings / blockers`,把“预览载体”和“预览来源/质量门槛”拆开。 +4. `rpgCreationWorkSummary.ts` 已收口 works 列表稳定字段,明确 `canResume / canEnterWorld` 的读模型语义。 + +## 2.2 旧 `customWorld*` 契约已补齐兼容分文件 + +本轮没有直接删除旧入口,而是把旧命名收口成“聚合出口 + 分文件兼容层”: + +1. 当前旧 `customWorldAgent.ts` 不再承载主定义,而是统一聚合: + - `customWorldAgentAnchors.ts` + - `customWorldAgentDraft.ts` + - `customWorldAgentActions.ts` + - `customWorldAgentSession.ts` + - `customWorldResultPreview.ts` + - `customWorldWorkSummary.ts` +2. 现有前后端直接导入 `customWorldAgent.ts` 的代码不需要在本轮一起大改,避免把工作包 H 扩成全仓导入迁移。 +3. 后续工作包可以逐步把新代码改到 `rpgAgent* / rpgCreation*` 路径;如果暂时仍需旧命名,也可以先切到更细的兼容分文件,而不是继续依赖单一大聚合文件。 + +## 2.3 已补共享 fixture,总线样本开始统一 + +本轮新增: + +1. `packages/shared/src/contracts/rpgCreationFixtures.ts` + +当前已提供并复用的样本包括: + +1. 八锚点 fixture +2. foundation draft fixture +3. session snapshot fixture +4. preview envelope fixture +5. published profile fixture +6. library entry fixture +7. works response fixture + +这些样本的作用是: + +1. 前端 contract test、后端 integration test、后续 preview/compiler 回归可以共用同一批样本。 +2. 避免继续在各测试文件里手写不一致的 session/profile/works 假数据。 +3. 把工作包 H 文档中要求的“最小 eight-anchor / preview / published profile / works 样本”先落成统一入口。 + +## 2.4 已补 unit / contract / integration / regression 最小闭环 + +本轮新增测试: + +1. `packages/shared/src/contracts/rpgContracts.test.ts` +2. `server-node/src/services/customWorldWorkSummaryService.integration.test.ts` +3. `server-node/src/services/RpgWorldPreviewCompiler.fixture.test.ts` +4. `server-node/src/services/RpgWorldWorkSummaryAssembler.fixture.test.ts` +5. `server-node/src/services/customWorldAgentActionRegistry.test.ts` +6. `server-node/src/services/customWorldAgentResultSyncService.test.ts` + +同时补充: + +1. `vitest.config.ts` 已把 `packages/shared/src/**/*.test.ts` 纳入前端 Vitest 测试入口。 +2. shared contract test 当前覆盖: + - session fixture、preview fixture、published profile fixture、works/library fixture 对齐关系 + - `supportedActions` 能力矩阵样本 + - 旧命名兼容分文件的类型消费 + - 角色动作资产、分幕背景、works 门槛字段不会在 fixture 演进时悄悄回退 +3. server unit / regression test 当前覆盖: + - preview compiler 可以直接消费 shared fixture + - works assembler 输出与 shared works fixture 保持一致 + - 角色主图、动作集、分幕背景资产字段在 normalize / assemble 后仍能保留 + - action registry 的 capability enable/disable 与 payload validate/normalize + - result sync service 只回写摘要与匹配资产,不让 runtime-only 结构反向污染 foundation draft +4. server integration test 当前验证共享 fixture 可以被 `customWorldWorkSummaryService` 正常消费,并输出和共享 works 响应样本一致的草稿/发布条目。 + +## 2.5 根导出已补齐 + +本轮已把: + +1. `packages/shared/src/contracts/rpgCreationFixtures.ts` +2. `packages/shared/src/contracts/customWorldAgent.ts` + +接入: + +1. `packages/shared/src/index.ts` + +这样后续前端和后端若要消费共享 fixture 或新契约,不需要再回退到旧单文件入口。 + +## 3. 本次验证结果 + +已完成以下定向验证: + +1. `npm run test -- packages/shared/src/contracts/rpgContracts.test.ts` +2. `node --test --test-concurrency=1 --import tsx src/services/customWorldAgentActionRegistry.test.ts src/services/customWorldAgentResultSyncService.test.ts src/services/customWorldWorkSummaryService.integration.test.ts src/services/RpgWorldPreviewCompiler.fixture.test.ts src/services/RpgWorldWorkSummaryAssembler.fixture.test.ts` +3. `npm run check:encoding` + +验证重点: + +1. shared 契约样本可直接通过 Vitest 执行。 +2. preview compiler、works assembler、works service 三层都可以直接消费 shared fixture,不需要额外复制一套测试数据。 +3. 中文文档与代码文件经过编码检查,没有把文本写坏。 + +## 4. 本次刻意未做的事 + +以下内容明确留给后续工作包或下一轮继续推进: + +1. 还没有把仓库里所有 `customWorldAgent.ts` 旧导入物理迁成 `rpgAgent* / rpgCreation*` 新导入。 +2. 还没有让后端 session snapshot 真正填充 `supportedActions`。 +3. 还没有让服务端 preview compiler 真正把 `resultPreview` 写入主链 snapshot。 +4. 没有改 UI、路由、数据库仓储或 orchestrator 主逻辑,严格控制在 shared contracts 与测试基建写入边界内。 + +## 5. 对后续工作包的直接收益 + +1. 工作包 E 可以直接复用 `supportedActions` 契约入口,把 action registry 的真实能力矩阵接进 session snapshot。 +2. 工作包 G 可以直接复用 `resultPreview` 和 `RpgCreationPreviewEnvelope`,继续把服务端 preview compiler 接回主链。 +3. 后续前后端测试都可以从 shared fixture 取样本,不需要继续维护多套彼此漂移的 session/profile 假数据。 +4. 旧命名导入可以先切到兼容分文件,再逐步替换到 `rpg*` 新契约,迁移路径更平滑。 diff --git a/docs/technical/CREATION_PAGE_MOBILE_UI_FIX_2026-04-21.md b/docs/technical/CREATION_PAGE_MOBILE_UI_FIX_2026-04-21.md new file mode 100644 index 00000000..c20759b5 --- /dev/null +++ b/docs/technical/CREATION_PAGE_MOBILE_UI_FIX_2026-04-21.md @@ -0,0 +1,36 @@ +# 创作页移动端 UI 修复记录 + +日期:`2026-04-21` + +## 问题定位 + +本轮修复只处理创作页表现层,不新增创作流程。 + +当前移动端问题主要来自三处: + +1. 平台页在 `platformTab === 'create'` 时直接渲染 `CustomWorldCreationHub`,绕过了 `PlatformHomeView` 的移动端外壳,导致底部 Tab 栏没有挂载。 +2. 创作中心内部仍混用 `pixel-*` 九宫格样式、`bg-black/*`、`text-white`、`border-white/*` 等暗色 Tailwind 类,亮色主题下会出现深色块和低对比文字。 +3. 创作中心根节点自带 `h-full overflow-y-auto`,放回平台页后容易与平台页主滚动区抢滚动权,手机上会显得布局混乱。 + +## 落地约束 + +1. 创作页仍复用现有平台首页,不新增页面和新系统。 +2. 移动端底部 Tab 必须始终由 `PlatformHomeView` 统一渲染,创作页只作为 `create` Tab 的内容。 +3. 创作中心内部不再使用深色硬编码作为默认底色,普通卡片、筛选 Tab、空状态和按钮统一使用 `platform-*` token。 +4. 创作中心不再自建整页滚动,只把内容交给平台页主滚动区,避免嵌套滚动。 +5. UI 中不增加规则说明类文案,只保留必要入口、状态和作品信息。 + +## 编码方案 + +1. 在 `PlatformHomeView` 增加可选的 `createTabContent`,让当前 Agent 创作中心接回平台页统一外壳。 +2. `PreGameSelectionFlow` 不再在 `platformTab === 'create'` 时绕过 `PlatformHomeView`,而是把 `CustomWorldCreationHub` 作为创作 Tab 内容传入。 +3. `CustomWorldCreationHub` 改为无内部整页滚动的内容容器,标题、返回、计数、错误、加载骨架都使用平台 token。 +4. `CustomWorldCreationStartCard` 与 `CustomWorldWorkCard` 从像素暗色面板切换为平台卡片样式,保留游戏化主视觉但跟随亮暗主题。 +5. `CustomWorldWorkTabs` 改用 `platform-tab`,并保持横向滚动与清晰选中态。 + +## 验收要点 + +1. 手机宽度下进入“创作”后,底部“首页 / 创作 / 存档 / 我的”Tab 始终可见。 +2. 亮色主题下创作页默认卡片不出现大面积黑色底板。 +3. 创作页只有平台页主内容区滚动,底部 Tab 不随作品列表滚走。 +4. 桌面端仍可通过左侧平台导航进入创作页。 diff --git a/docs/technical/CURRENT_AGENT_CREATION_FLOW_STAGE4_CLEANUP_CHECK_2026-04-21.md b/docs/technical/CURRENT_AGENT_CREATION_FLOW_STAGE4_CLEANUP_CHECK_2026-04-21.md new file mode 100644 index 00000000..a9a02faf --- /dev/null +++ b/docs/technical/CURRENT_AGENT_CREATION_FLOW_STAGE4_CLEANUP_CHECK_2026-04-21.md @@ -0,0 +1,115 @@ +# Agent 创作流四阶段收口检查与旧链清理边界 + +更新时间:`2026-04-21` + +补充修正:`2026-04-21` 本文档的“草稿恢复优先回 Agent 工作区”和“Agent 来源结果页冻结为预览收口层”属于阶段性收口口径,已被 [AGENT_DIALOG_AND_RESULT_REFINEMENT_BOUNDARY_2026-04-21.md](./AGENT_DIALOG_AND_RESULT_REFINEMENT_BOUNDARY_2026-04-21.md) 覆盖。当前主口径是:Agent 对话框只收集八锚点,已有底稿的草稿从创作中心进入结果页继续完善。 + +## 1. 结论先行 + +当前这条 Agent 创作流已经完成阶段一到阶段三的主要收口。 + +阶段四中的“文档清理”已经开始做,但还没有形成独立、完整的新主链审计闭环。 + +因此这轮可以执行的清理现在有两类: + +1. 删除已经不再从当前主入口可达的旧 `custom-world/sessions` 世界生成链 +2. 删除已经完全脱离 `CustomWorldAgentWorkspace` 主链、只剩孤立互相引用与自测覆盖的 `custom-world-agent` 旧面板 +3. 保留仍在服务 `Agent session` 主链或已保存作品兼容编辑体验的底层能力 + +这轮不做: + +1. 不删 `Agent session` 的底层持久化能力 +2. 不删已保存作品结果页的 legacy 编辑器兼容能力 +3. 不删 `custom-world/works` 聚合入口 + +--- + +## 2. 阶段完成度 + +### 2.1 阶段一 + +已完成。 + +证据: + +1. 结果页新增了 `sync_result_profile` +2. 结果页编辑后的快照可以回写到 `Agent session` +3. 自动保存、返回创作、进入世界都优先走 session 主链 + +### 2.2 阶段二 + +已完成。 + +证据: + +1. 平台创作入口已切到 `custom-world/works` +2. 草稿恢复优先回 Agent 工作区 +3. Agent 结果页不再继续新增旧编辑入口 + +### 2.3 阶段三 + +已完成。 + +证据: + +1. 创作中心不再把 library draft 当主草稿入口 +2. Agent 来源结果页冻结为预览收口层 +3. 重复同步动作已收敛为有差异才执行 + +### 2.4 阶段四 + +未完全完成。 + +原因: + +1. 文档清理已经开始,但还没有完整收束到单一结论文档 +2. 旧 `custom-world/sessions` 生成链已经完成物理清理,但与之相关的审计/PRD/知识图谱文档仍需继续统一口径 +3. `custom-world-agent` 孤岛面板已经完成第二轮物理清理,但阶段四文档总收口仍未完全覆盖所有历史 PRD 口径 + +--- + +## 3. 本轮允许删除的旧链 + +允许删除: + +1. `src/services/aiService.ts` 里的旧 `custom-world/sessions` 请求函数 +2. `server-node/src/routes/runtimeRoutes.ts` 里的旧 `custom-world/sessions` 路由 +3. `server-node/src/services/customWorldGenerationService.ts` +4. 与这条旧链对应的测试 +5. `server-node/src/services/customWorldSessionStore.ts` +6. `src/components/custom-world-agent/CustomWorldAgentLockBar.tsx` +7. `src/components/custom-world-agent/CustomWorldAgentQuickActions.tsx` +8. `src/components/custom-world-agent/CustomWorldAgentSummaryPanel.tsx` +9. `src/components/custom-world-agent/CustomWorldAgentIntentSummaryPanel.tsx` +10. `src/components/custom-world-agent/CustomWorldAgentClarificationPanel.tsx` +11. `src/components/custom-world-agent/CustomWorldAgentDraftDetailPanel.tsx` +12. `src/components/custom-world-agent/CustomWorldDraftCardDetailModal.tsx` +13. `src/components/custom-world-agent/CustomWorldDraftEditPanel.tsx` +14. `src/components/custom-world-agent/CustomWorldGenerateEntityModal.tsx` +15. 仅为上述孤岛面板存在的对应测试文件 + +不允许删除: + +1. `server-node/src/repositories/runtimeRepository.ts` 中被 Agent session 复用的 session 持久化能力 +2. `src/services/aiService.ts` 里仍在使用的 `generateCustomWorldProfile` 及其现代封装 +3. 已保存作品结果页仍在使用的 legacy 编辑器兼容能力 +4. `src/components/custom-world-agent/CustomWorldAgentWorkspace.tsx` 及其仍在主链上的 5 个子模块: + - `CustomWorldAgentHeader` + - `EightAnchorProgressBar` + - `CustomWorldAgentOperationBanner` + - `CustomWorldAgentThread` + - `CustomWorldAgentComposer` + +--- + +## 4. 删除完成后的判断标准 + +如果旧链清理成功,应满足: + +1. `src/services/aiService.ts` 不再暴露旧 `custom-world/sessions` 请求函数 +2. `server-node/src/routes/runtimeRoutes.ts` 不再挂旧 session 路由 +3. `server-node/src/services/customWorldSessionStore.ts` 与 `server-node/src/services/customWorldGenerationService.ts` 已物理删除 +4. 仓库里不再有主流程可达的旧世界生成入口 +5. `CustomWorldAgentWorkspace.tsx` 只保留当前正式主链需要的 5 个子模块 +6. 与旧 Agent 草稿面板相关的孤岛 UI 与自测不再继续占据正式目录注意力 +7. Agent 主链与已保存作品编辑链仍然可用 diff --git a/docs/technical/EDITOR_ASSET_API_MIGRATION_2026-04-08.md b/docs/technical/EDITOR_ASSET_API_MIGRATION_2026-04-08.md index cd2ba7e6..1768e251 100644 --- a/docs/technical/EDITOR_ASSET_API_MIGRATION_2026-04-08.md +++ b/docs/technical/EDITOR_ASSET_API_MIGRATION_2026-04-08.md @@ -26,10 +26,6 @@ - `GET /api/assets/character-animation/jobs/:taskId` - `POST /api/assets/character-animation/import-video` - `GET /api/assets/character-animation/templates` -- `POST /api/assets/qwen-sprite/master` -- `POST /api/assets/qwen-sprite/sheet` -- `POST /api/assets/qwen-sprite/frame-repair` -- `POST /api/assets/qwen-sprite/save` --- @@ -50,7 +46,6 @@ - 状态行为覆盖保存 - 角色主形象生成、发布与任务查询 - 角色动作生成、导入、发布、模板读取与任务查询 -- Qwen 精灵主图、精灵表、修帧与资产保存 --- diff --git a/docs/technical/EDITOR_ENTRY_CLEANUP_2026-04-21.md b/docs/technical/EDITOR_ENTRY_CLEANUP_2026-04-21.md new file mode 100644 index 00000000..1b2b0f8d --- /dev/null +++ b/docs/technical/EDITOR_ENTRY_CLEANUP_2026-04-21.md @@ -0,0 +1,85 @@ +# 主流程外编辑器入口清理说明(2026-04-21) + +日期:`2026-04-21` + +## 1. 文档目标 + +记录本轮对“挂在主流程路由外的旧编辑器入口”和“仍把这些入口当现役能力的残留说明”做的收口,避免后续开发再次把历史入口误判成正式能力。 + +--- + +## 2. 本轮清理结论 + +本轮确认后,当前前端正式入口只保留游戏主流程: + +- `src/routing/appRoutes.tsx` 仅保留 `game` + +本轮删除或收口的对象: + +- 独立前端工具路由 `qwen-sprite-tool` +- 仅服务该独立入口的前端页面 `src/tools/QwenSpriteSheetTool.tsx` +- 仅服务该独立入口的工具模型与持久化封装 +- 仅服务该独立入口的后端路由 `server-node/src/modules/assets/qwenSpriteRoutes.ts` +- 路由测试里把旧编辑器 / 独立工具入口当作现役分支的断言 +- README、经验文档、类型检查配置中已经失效的旧编辑器文件引用 + +--- + +## 3. 为什么可以删除 + +本轮删除对象满足下面几个条件: + +1. 不在当前玩家主流程中可达 +2. 没有继续嵌入正式创作主链 +3. 当前仓库已有主流程内嵌的替代能力 +4. 保留它们只会继续制造“看起来还能进、实际上已经不走”的假入口 + +其中需要特别区分的是: + +- `src/editor/shared/editorApiClient.ts` +- `server-node/src/modules/editor/editorRoutes.ts` +- `src/components/CustomWorldEntityEditorModal.tsx` +- `src/components/CustomWorldNpcVisualEditor.tsx` +- `src/components/CustomWorldRoleAssetStudioModal.tsx` + +这些仍然服务当前主流程内嵌编辑能力,因此本轮不删除。 + +--- + +## 4. 当前保留的编辑能力边界 + +当前保留的是“嵌入主流程的编辑能力”,不是“独立编辑器站点”: + +- 自定义世界实体编辑 +- 自定义世界角色形象编辑 +- 主流程内的角色资产工坊模态 +- 与这些能力配套的 `/api/editor/*` 与 `/api/assets/character-*` 接口 + +后续如果还要新增编辑能力,应优先: + +1. 先确认是否真的需要独立入口 +2. 默认优先接回主流程模态或正式创作链 +3. 如果只是内部工具,不要长期挂在正式前端路由里 + +--- + +## 5. 本轮同步更新 + +本轮已同步更新: + +- `README.md` +- `docs/experience/CODEX_PAST_WORK_EXPERIENCE_SUMMARY.md` +- `src/routing/appRoutes.tsx` +- `src/routing/appRoutes.test.ts` +- `server-node/src/app.ts` +- `tsconfig.typecheck-guardrails.json` + +--- + +## 6. 后续建议 + +后续继续清理时,优先沿着这条规则推进: + +1. 先识别是否还在主流程可达 +2. 再判断是否仍有正式嵌入点 +3. 若只剩文档、测试、兼容判断或独立路由壳,直接成批收口 diff --git a/docs/technical/FRONTEND_TO_BACKEND_MIGRATION_EXECUTION_PLAN_2026-04-21.md b/docs/technical/FRONTEND_TO_BACKEND_MIGRATION_EXECUTION_PLAN_2026-04-21.md new file mode 100644 index 00000000..28ad44b6 --- /dev/null +++ b/docs/technical/FRONTEND_TO_BACKEND_MIGRATION_EXECUTION_PLAN_2026-04-21.md @@ -0,0 +1,188 @@ +# 前端逻辑后移实施方案(2026-04-21) + +更新时间:`2026-04-21` + +## 1. 目标 + +本方案只回答一件事: + +**怎样把当前仍残留在前端的正式运行时逻辑、正式会话真相与正式生成编排,继续收回到 Express 后端。** + +这份文档不是泛泛而谈的方向说明,而是直接面向本轮与后续几轮编码落地的实施基线。 + +--- + +## 2. 本轮确定的硬边界 + +根据仓库约束与当前审计结果,本轮继续冻结以下边界: + +1. 前端只负责表现、输入采集、临时 UI 状态与服务端结果渲染。 +2. 后端负责正式鉴权、正式会话、正式运行时快照、正式任务生成、正式运行时物品意图生成、正式自定义世界生成。 +3. `codex/backend-rewrite-spacetimedb` 目标分支的鉴权仍以服务端签发 JWT、前端 Bearer token 携带为准;本轮合入不采用 `codex/dev` 的 access cookie 会话方案。 +4. 浏览器内不再把浏览历史作为本地正式真相,不再保留正式 quest / runtime item / custom world 生成编排。 +5. 运行时主链必须继续向“前端提交意图,后端解释快照并返回展示模型”收敛。 + +--- + +## 3. 现状拆分 + +当前残留问题已经收敛为三批: + +### 3.1 第一批:正式真相仍在前端 + +1. `src/services/apiClient.ts` + - 浏览器当前仍保存 access token,并在请求层拼接 `Authorization: Bearer ...` + - 该链路在 `codex/backend-rewrite-spacetimedb` 仍是既定正式实现,不再按 cookie access session 改写 +2. `src/services/authService.ts` + - 登录、微信绑定、回调消费流程都要与 JWT/Bearer 方案保持一致,避免混入 access cookie 分支语义 +3. `src/components/game-shell/PreGameSelectionFlow.tsx` + - 浏览历史仍是本地写入 + 后端回填的双真相 +4. `src/services/platformBrowseHistory.ts` + - 维护浏览历史本地存储、迁移标记与同步状态 + +### 3.2 第二批:运行时主链仍依赖前端预写快照 + +1. `src/hooks/story/runtimeStoryCoordinator.ts` + - 在请求 runtime state / runtime action 前,仍先 `PUT /runtime/save/snapshot` +2. `src/hooks/story/npcEncounterActions.ts` + - 待接委托的“更换任务”“放弃任务”仍由前端正式结算 + +### 3.3 第三批:正式生成编排仍残留在浏览器 + +1. `src/services/questDirector.ts` +2. `src/services/runtimeItemAiDirector.ts` +3. `src/services/aiService.ts` 的 custom world profile 生成入口 +4. `src/services/ai.ts` 中仍保留的浏览器侧 legacy AI orchestration + +--- + +## 4. 分批实施策略 + +## 4.1 第一批:先收正式真相 + +### 鉴权 + +目标状态: + +1. 后端继续通过 JWT 承载 access token,并只从 `Authorization: Bearer ...` 读取当前访问身份。 +2. 前端请求层继续负责保存、刷新和携带 access token;公开请求与静默探测不得误清正式 token。 +3. access cookie 会话方案不进入 `codex/backend-rewrite-spacetimedb`,避免和目标分支已有 JWT 方案并存。 + +本批涉及: + +1. `server-node/src/routes/authRoutes.ts` +2. `server-node/src/middleware/auth.ts` +3. `src/services/apiClient.ts` +4. `src/services/authService.ts` +5. `src/components/auth/AuthGate.tsx` + +### 浏览历史 + +目标状态: + +1. 浏览历史唯一真相在 `runtimeRepository`。 +2. 前端不再保留本地浏览历史、迁移标记、同步标记。 +3. 浏览历史只通过 `storageService` 读取和写入。 + +本批涉及: + +1. `src/components/game-shell/PreGameSelectionFlow.tsx` +2. `src/components/game-shell/PlatformHomeView.tsx` +3. `src/services/storageService.ts` +4. `src/services/platformBrowseHistory.ts` + +## 4.2 第二批:把 runtime story 快照解释权收回后端 + +目标状态: + +1. 前端不再通过单独的 `PUT /runtime/save/snapshot` 预写快照再触发动作。 +2. runtime state / runtime action 允许前端提交当前快照上下文,由后端内部决定是否写入、如何解释、何时持久化。 +3. NPC 待接委托的 replace / abandon / accept 全部走后端 runtime action。 + +建议实施方式: + +1. 扩展 `packages/shared/src/contracts/story.ts` + - `RuntimeStoryActionRequest` 增加可选 `snapshot` + - 新增 `RuntimeStoryStateRequest` +2. 新增 `POST /api/runtime/story/state/resolve` +3. `storyActionService` 内部统一处理“请求携带快照上下文时的服务端同步” +4. 把 `npc_chat_quest_offer_replace` / `npc_chat_quest_offer_abandon` 接到后端 runtime action + +## 4.3 第三批:把正式生成编排收成后端唯一出口 + +目标状态: + +1. `questDirector` 只保留轻量 SDK。 +2. `runtimeItemAiDirector` 只保留轻量 SDK。 +3. custom world profile 正式生成走后端 route。 +4. 浏览器侧 `src/services/ai.ts` 不再承担正式浏览器主链。 + +建议实施方式: + +1. `server-node/src/routes/runtimeRoutes.ts` + - 补 `custom-world/profile` 正式 route +2. `src/services/aiService.ts` + - custom world 入口改走后端 +3. `src/services/questDirector.ts` + - 只请求 `/api/runtime/quests/generate` +4. `src/services/runtimeItemAiDirector.ts` + - 只请求 `/api/runtime/items/runtime-intent` + +--- + +## 5. 本轮落地范围 + +本轮优先完成以下内容: + +1. 鉴权维持 `codex/backend-rewrite-spacetimedb` 既有 JWT/Bearer 方案,不合入 `codex/dev` 的 access cookie 访问认证。 +2. 浏览历史从前端本地真相后移到后端唯一真相。 +3. custom world profile 正式生成入口补齐后端 route,并把前端收成 SDK。 +4. `questDirector` / `runtimeItemAiDirector` 收缩为前端 SDK。 +5. runtime story contract 开始补“随请求提交快照上下文”的后端承接能力,并把 NPC 待接委托 replace / abandon 接到后端。 + +### 5.1 已完成 + +1. `codex/backend-rewrite-spacetimedb` 本轮保留 JWT access token + refresh cookie 组合方案,不合入 access cookie 写入与读取链路。 +2. 浏览历史已收敛为后端唯一真相,前端不再维护正式本地 browse history 链。 +3. runtime story 已支持随请求提交 snapshot,由后端内部解释与持久化。 +4. NPC 待接委托 `replace / abandon / accept` 已以后端 runtime action 为准。 +5. custom world profile 浏览器正式入口已改走后端 route。 +6. `questDirector` / `runtimeItemAiDirector` 已收缩为前端 SDK,不再承担正式浏览器编排。 +7. NPC 招募正式结算已迁到后端: + - 前端只负责招募对白展示与 release 目标选择 + - 后端负责 `npcStates / companions / roster / currentEncounter / storyHistory` 正式结算 + - 满员换队招募已由后端承接 + +### 5.2 剩余未完成 + +1. `src/services/ai.ts` 仍保留 legacy fallback / test 能力,尚未彻底压缩出正式浏览器主链。 +2. 仍需继续审视是否存在其他 NPC / 运行时分支,把正式状态裁决留在前端。 + +--- + +## 6. 验收标准 + +### 第一批验收 + +1. 浏览器继续保存 access token,并由 `fetchWithApiAuth` 稳定拼接 `Authorization: Bearer ...`。 +2. 401 刷新链只在已发送 Bearer token 时触发,并且刷新响应必须返回新的 JWT。 +3. 浏览历史仅通过远端接口读写。 +4. `src/services/platformBrowseHistory.ts` 不再是正式链路依赖。 + +### 第二批验收 + +1. `runtimeStoryCoordinator.ts` 不再在动作前独立 `PUT /runtime/save/snapshot`。 +2. `NPC` 待接委托 replace / abandon / accept 都以后端返回结果为准。 + +### 第三批验收 + +1. `questDirector.ts` 与 `runtimeItemAiDirector.ts` 不再保留正式 fallback orchestration。 +2. custom world profile 的浏览器正式入口不再直接 import legacy `./ai`。 + +--- + +## 7. 一句话结论 + +这轮迁移的重点不是“把几个 helper 挪到 server-node 目录”,而是: + +**把前端里仍然承担正式真相、正式运行时解释和正式生成编排的那一层职责,继续收回到 Express 后端。** diff --git a/docs/technical/NODE_SERVER_KNOWLEDGE_GRAPH_2026-04-08.md b/docs/technical/NODE_SERVER_KNOWLEDGE_GRAPH_2026-04-08.md index 7616c90e..20e1ddee 100644 --- a/docs/technical/NODE_SERVER_KNOWLEDGE_GRAPH_2026-04-08.md +++ b/docs/technical/NODE_SERVER_KNOWLEDGE_GRAPH_2026-04-08.md @@ -150,10 +150,11 @@ JWT 现状: - `POST /api/custom-world/scene-image` - `POST /api/runtime/story/initial` - `POST /api/runtime/story/continue` -- `POST /api/runtime/custom-world/sessions` -- `GET /api/runtime/custom-world/sessions/:sessionId` -- `POST /api/runtime/custom-world/sessions/:sessionId/answers` -- `GET /api/runtime/custom-world/sessions/:sessionId/generate/stream` +- `POST /api/runtime/custom-world/agent/sessions` +- `GET /api/runtime/custom-world/agent/sessions/:sessionId` +- `POST /api/runtime/custom-world/agent/sessions/:sessionId/messages` +- `POST /api/runtime/custom-world/agent/sessions/:sessionId/actions` +- `GET /api/runtime/custom-world/works` - `POST /api/runtime/chat/character/suggestions` - `POST /api/runtime/chat/character/summary` - `POST /api/runtime/chat/character/reply/stream` @@ -183,10 +184,6 @@ JWT 现状: - `GET /api/assets/character-animation/jobs/:taskId` - `POST /api/assets/character-animation/import-video` - `GET /api/assets/character-animation/templates` -- `POST /api/assets/qwen-sprite/master` -- `POST /api/assets/qwen-sprite/sheet` -- `POST /api/assets/qwen-sprite/frame-repair` -- `POST /api/assets/qwen-sprite/save` 编辑器与资产接口门禁: @@ -227,9 +224,7 @@ Custom World: 编辑器与资产工具层: - `src/editor/shared/editorApiClient.ts` -- `src/editor/shared/useJsonSave.ts` - `src/components/preset-editor/characterAssetStudioPersistence.ts` -- `src/tools/qwenSpriteSheetToolPersistence.ts` ## 10. 当前 Vite 角色 diff --git a/docs/technical/PROMPT_DIRECTORY_MANAGEMENT_2026-04-19.md b/docs/technical/PROMPT_DIRECTORY_MANAGEMENT_2026-04-19.md index 1efe4240..beac32a6 100644 --- a/docs/technical/PROMPT_DIRECTORY_MANAGEMENT_2026-04-19.md +++ b/docs/technical/PROMPT_DIRECTORY_MANAGEMENT_2026-04-19.md @@ -11,7 +11,6 @@ - `server-node/src/services/eightAnchorPromptBuilder.ts` - `server-node/src/modules/assets/characterAssetRoutes.ts` - `src/services/**` -- `src/tools/qwenSpriteSheetToolModel.ts` - `src/components/**` 问题主要有三类: @@ -50,7 +49,6 @@ src/prompts/ ├─ customWorldPrompts.ts ├─ customWorldRolePromptDefaults.ts ├─ questPrompts.ts -├─ qwenSpriteSheetToolPrompts.ts ├─ runtimeItemPrompts.ts ├─ storyOrchestratorPrompts.ts └─ storyPromptBuilders.ts @@ -82,8 +80,6 @@ src/prompts/ - 八锚点状态推断、模式规则与正式单轮共创 prompt - `src/prompts/customWorldPrompts.ts` - 自定义世界分阶段生成 prompt 与场景背景图 prompt -- `src/prompts/qwenSpriteSheetToolPrompts.ts` - - 精灵图工具主词 / 分镜词 / 修帧词 / 负面词 - `src/prompts/customWorldRolePromptDefaults.ts` - 角色资产工作台默认 prompt 种子唯一主源 - `src/prompts/customWorldEntityActionPrompts.ts` @@ -127,7 +123,6 @@ src/prompts/ - `src/services/characterChatPrompt.ts` - `src/services/questPrompt.ts` - `src/services/runtimeItemAiPrompt.ts` -- `src/tools/qwenSpriteSheetToolModel.ts` - `src/components/asset-studio/customWorldRolePromptDefaults.ts` - `packages/shared/src/assets/qwenSprite.ts` diff --git a/docs/technical/QWEN_IMAGE_2_PIXELMOTION_REPRODUCTION_WORKFLOW_2026-04-07.md b/docs/technical/QWEN_IMAGE_2_PIXELMOTION_REPRODUCTION_WORKFLOW_2026-04-07.md index 3c124cec..83bd2db4 100644 --- a/docs/technical/QWEN_IMAGE_2_PIXELMOTION_REPRODUCTION_WORKFLOW_2026-04-07.md +++ b/docs/technical/QWEN_IMAGE_2_PIXELMOTION_REPRODUCTION_WORKFLOW_2026-04-07.md @@ -676,7 +676,7 @@ PixelMotion 很关键的一点,不是要求 16 帧都完美,而是允许修 ### 13.4 推荐目录结构 ```text -pixelmotion-qwen/ +pixelmotion-workflow/ refs/ master.png pose_board_run.png diff --git a/docs/technical/README.md b/docs/technical/README.md index c401f4e2..0ddeabcc 100644 --- a/docs/technical/README.md +++ b/docs/technical/README.md @@ -30,11 +30,39 @@ - [M6_OSS_SERVER_UPLOAD_AND_STS_POLICY_2026-04-21.md](./M6_OSS_SERVER_UPLOAD_AND_STS_POLICY_2026-04-21.md):冻结 M6 剩余的 STS 与服务端上传 helper 落地口径,明确当前上传主链为服务器上传 OSS,Web 端只负责签名读下载。 - [AXUM_TO_SPACETIMEDB_ASSET_OBJECT_CONFIRM_CALL_DESIGN_2026-04-21.md](./AXUM_TO_SPACETIMEDB_ASSET_OBJECT_CONFIRM_CALL_DESIGN_2026-04-21.md):冻结 `POST /api/assets/objects/confirm` 从 Axum 通过 Rust SDK 调用 `SpacetimeDB procedure` 的最小落地方案,明确本地 server、数据库名、procedure/reducer 分工与 `spacetime-client` 边界。 - [ASSET_ENTITY_BINDING_REDUCER_DESIGN_2026-04-21.md](./ASSET_ENTITY_BINDING_REDUCER_DESIGN_2026-04-21.md):冻结已确认 `asset_object` 绑定到业务实体槽位的首版 reducer/procedure、通用 `asset_entity_binding` 表与 Axum facade。 +- [FRONTEND_TO_BACKEND_MIGRATION_EXECUTION_PLAN_2026-04-21.md](./FRONTEND_TO_BACKEND_MIGRATION_EXECUTION_PLAN_2026-04-21.md):把鉴权、浏览历史、runtime story 快照、NPC 待接委托与正式生成编排继续后移到 Express 后端的实施方案与验收口径。 - [REPO_NOISE_CLEANUP_BASELINE_2026-04-19.md](./REPO_NOISE_CLEANUP_BASELINE_2026-04-19.md):落实工程清理审计第一阶段后的仓库噪音清理范围、忽略规则闭合点与后续约束。 - [PROMPT_DIRECTORY_MANAGEMENT_2026-04-19.md](./PROMPT_DIRECTORY_MANAGEMENT_2026-04-19.md):后端提示词收口到 `server-node/src/prompts/` 的目录方案、兼容策略与后续新增规则。 - [CUSTOM_WORLD_DRAFT_GENERATION_FAILURE_ANALYSIS_AND_FIX_2026-04-20.md](./CUSTOM_WORLD_DRAFT_GENERATION_FAILURE_ANALYSIS_AND_FIX_2026-04-20.md):世界草稿生成失败后等待页误显示为“卡在编译草稿卡”的根因拆解、主链与增强链路边界,以及本次修复策略。 - [CUSTOM_WORLD_AUTO_ASSET_VISIBILITY_FIX_2026-04-20.md](./CUSTOM_WORLD_AUTO_ASSET_VISIBILITY_FIX_2026-04-20.md):世界草稿里“资产已生成但结果页看不到”的根因拆解,包含角色主形象展示、分幕背景露出和 fallback 资源格式修复。 - [CUSTOM_WORLD_PHASE4_COUNT_SEMANTICS_ALIGNMENT_2026-04-20.md](./CUSTOM_WORLD_PHASE4_COUNT_SEMANTICS_ALIGNMENT_2026-04-20.md):Phase4 新增角色/地点后草稿作品卡数量统计与测试断言的语义对齐说明。 +- [AGENT_RESULT_PROFILE_SYNC_PHASE1_2026-04-20.md](./AGENT_RESULT_PROFILE_SYNC_PHASE1_2026-04-20.md):阶段一保持结果页深度编辑能力不变,同时把结果页完整世界快照同步回 Agent session 主链的方案说明。 +- [AGENT_RESULT_PROFILE_SYNC_PHASE2_2026-04-20.md](./AGENT_RESULT_PROFILE_SYNC_PHASE2_2026-04-20.md):阶段二把平台创作入口统一到聚合作品列表,并收紧 Agent 结果页的新增入口职责边界。 +- [AGENT_RESULT_PROFILE_SYNC_PHASE3_2026-04-20.md](./AGENT_RESULT_PROFILE_SYNC_PHASE3_2026-04-20.md):阶段三继续降级旧 pipeline,让创作中心只认 Agent 草稿与已发布作品,并把 Agent 结果页冻结为预览收口层。 +- [AGENT_DIALOG_AND_RESULT_REFINEMENT_BOUNDARY_2026-04-21.md](./AGENT_DIALOG_AND_RESULT_REFINEMENT_BOUNDARY_2026-04-21.md):修正 Agent 对话框与结果页职责边界,明确 Agent 只收集八锚点,已有底稿的精修进入结果页完成。 +- [CURRENT_AGENT_CREATION_FLOW_STAGE4_CLEANUP_CHECK_2026-04-21.md](./CURRENT_AGENT_CREATION_FLOW_STAGE4_CLEANUP_CHECK_2026-04-21.md):对照当前优化计划核查四阶段完成度,并明确这轮只允许物理删除旧 `custom-world/sessions` 世界生成链,不误伤 Agent 主链与已保存作品兼容编辑链。 +- [CREATION_FLOW_CHAIN_REFACTOR_EXECUTION_PLAN_2026-04-21.md](./CREATION_FLOW_CHAIN_REFACTOR_EXECUTION_PLAN_2026-04-21.md):梳理当前创作入口到结果页自动保存再到进入世界的全链前后端脚本地图,并给出文件级重构拆分方案、目标分层与阶段验收标准。 +- [RPG_ENTRY_RUNTIME_CHAIN_REFACTOR_EXECUTION_PLAN_2026-04-21.md](./RPG_ENTRY_RUNTIME_CHAIN_REFACTOR_EXECUTION_PLAN_2026-04-21.md):梳理当前 RPG 从平台入口、继续游戏、角色选择到营地开场、冒险运行态与 runtime story 后端结算的全链脚本地图,并给出 RPG 专属命名规范、目标分层和可并行执行的工作包。 +- [RPG_ENTRY_RUNTIME_CHAIN_REFACTOR_WORK_PACKAGE_A_PROGRESS_2026-04-21.md](./RPG_ENTRY_RUNTIME_CHAIN_REFACTOR_WORK_PACKAGE_A_PROGRESS_2026-04-21.md):记录 RPG 进入游戏与运行时链路工作包 A 已完成的新目录骨架、前后端 façade、按域路由 path 常量与兼容仓储入口。 +- [RPG_ENTRY_RUNTIME_CHAIN_REFACTOR_WORK_PACKAGE_B_PROGRESS_2026-04-21.md](./RPG_ENTRY_RUNTIME_CHAIN_REFACTOR_WORK_PACKAGE_B_PROGRESS_2026-04-21.md):记录工作包 B 已完成的前端 RPG 入口壳层真实迁移、`rpg-entry` 新入口 hooks 收口,以及旧 `game-shell` / `rpg-creation-flow` 路径降级为兼容层的状态。 +- [RPG_ENTRY_RUNTIME_CHAIN_REFACTOR_WORK_PACKAGE_C_PROGRESS_2026-04-21.md](./RPG_ENTRY_RUNTIME_CHAIN_REFACTOR_WORK_PACKAGE_C_PROGRESS_2026-04-21.md):记录工作包 C 已完成的 `rpg-session` 主链迁移、snapshot / save archive client 收口、旧 `useGame*` 降级为兼容 façade,以及定向回归结果。 +- [RPG_ENTRY_RUNTIME_CHAIN_REFACTOR_WORK_PACKAGE_D_PROGRESS_2026-04-21.md](./RPG_ENTRY_RUNTIME_CHAIN_REFACTOR_WORK_PACKAGE_D_PROGRESS_2026-04-21.md):记录工作包 D 已完成的前端运行态 shell / stage router / panel router 真实迁移、AdventurePanel section 拆分,以及旧 `GameShell*` 热点降级为兼容桥的现状。 +- [RPG_ENTRY_RUNTIME_CHAIN_REFACTOR_WORK_PACKAGE_F_PROGRESS_2026-04-21.md](./RPG_ENTRY_RUNTIME_CHAIN_REFACTOR_WORK_PACKAGE_F_PROGRESS_2026-04-21.md):记录 RPG 进入游戏与运行时链路工作包 F 已完成的后端 route 真正拆边界、`app.ts` 新域挂载、旧 `runtimeRoutes` / `storyActionRoutes` 兼容降级,以及定向路由回归验证结果。 +- [RPG_ENTRY_RUNTIME_CHAIN_REFACTOR_WORK_PACKAGE_E_PROGRESS_2026-04-21.md](./RPG_ENTRY_RUNTIME_CHAIN_REFACTOR_WORK_PACKAGE_E_PROGRESS_2026-04-21.md):记录工作包 E 已完成的前端 runtime story 主链真实迁移、NPC 交互与 gateway/client 收口、旧入口兼容降级,以及定向回归验证结果。 +- [RPG_ENTRY_RUNTIME_CHAIN_REFACTOR_WORK_PACKAGE_G_PROGRESS_2026-04-21.md](./RPG_ENTRY_RUNTIME_CHAIN_REFACTOR_WORK_PACKAGE_G_PROGRESS_2026-04-21.md):记录工作包 G 已完成的后端 runtime session / action service 物理迁移、新域原语导出、旧热点兼容降级,以及定向 runtime story 回归验证结果。 +- [RPG_ENTRY_RUNTIME_CHAIN_REFACTOR_WORK_PACKAGE_H_PROGRESS_2026-04-21.md](./RPG_ENTRY_RUNTIME_CHAIN_REFACTOR_WORK_PACKAGE_H_PROGRESS_2026-04-21.md):记录工作包 H 已完成的 RPG 运行时仓储拆分、shared runtime contract 分文件、旧 `story.ts` façade 兼容与定向回归结果。 +- [RPG_ENTRY_RUNTIME_CHAIN_REFACTOR_PARALLEL_BATCH_AUDIT_2026-04-21.md](./RPG_ENTRY_RUNTIME_CHAIN_REFACTOR_PARALLEL_BATCH_AUDIT_2026-04-21.md):对照执行计划逐项复核第一批与第二批并行工作的真实落地状态,记录本轮确认到的测试合流收口遗漏与文档索引补齐结果。 +- [RPG_ENTRY_RUNTIME_CHAIN_REFACTOR_PHASE3_CLOSURE_2026-04-21.md](./RPG_ENTRY_RUNTIME_CHAIN_REFACTOR_PHASE3_CLOSURE_2026-04-21.md):记录 RPG 执行计划第三批收口已完成的前端新域主链接回、后端新仓储接线、shared contract 直连收紧、旧兼容脚本物理删除,以及明确未扩到 UI 和无关历史文档的边界。 +- [RPG_ENTRY_RUNTIME_CHAIN_REFACTOR_OLD_SCRIPT_REMOVAL_2026-04-21.md](./RPG_ENTRY_RUNTIME_CHAIN_REFACTOR_OLD_SCRIPT_REMOVAL_2026-04-21.md):记录 RPG 主链旧 `GameShell`、`useGame*`、`hooks/story`、`runtimeRoutes`、`modules/story/*`、`contracts/story.ts` 脚本的物理删除范围、残留依赖扫描和定向验证结果。 +- [CREATION_FLOW_CHAIN_REFACTOR_WORK_PACKAGE_A_PROGRESS_2026-04-21.md](./CREATION_FLOW_CHAIN_REFACTOR_WORK_PACKAGE_A_PROGRESS_2026-04-21.md):记录创作链路重构工作包 A 已落地的 RPG 创作域目录骨架、兼容 façade,以及补齐后的共享契约骨架入口。 +- [CREATION_FLOW_CHAIN_REFACTOR_WORK_PACKAGE_E_PROGRESS_2026-04-21.md](./CREATION_FLOW_CHAIN_REFACTOR_WORK_PACKAGE_E_PROGRESS_2026-04-21.md):记录工作包 E 已完成的后端 Agent 编排拆分、executor 物理迁移、发布链切到 `CustomWorldAgentPublishingService`、checkpoint 真快照、场景资产 coverage 收口,以及 Phase3/Phase5 定向回归结果。 +- [CREATION_FLOW_CHAIN_REFACTOR_WORK_PACKAGE_H_PROGRESS_2026-04-21.md](./CREATION_FLOW_CHAIN_REFACTOR_WORK_PACKAGE_H_PROGRESS_2026-04-21.md):记录工作包 H 已完成的共享契约物理拆分、旧命名兼容分文件、统一 fixture,以及 shared contract test / preview compiler / works assembler / works service 回归基建。 +- [CREATION_FLOW_CHAIN_REFACTOR_WORK_PACKAGE_B_PROGRESS_2026-04-21.md](./CREATION_FLOW_CHAIN_REFACTOR_WORK_PACKAGE_B_PROGRESS_2026-04-21.md):记录工作包 B 已完成的前端平台壳层编排拆分、平台 hooks / coordinator 接入、旧入口兼容保留,以及交互回归验证结果。 +- [CREATION_FLOW_CHAIN_REFACTOR_WORK_PACKAGE_F_PROGRESS_2026-04-21.md](./CREATION_FLOW_CHAIN_REFACTOR_WORK_PACKAGE_F_PROGRESS_2026-04-21.md):记录工作包 F 已完成的后端 session/store/repository 拆分、works 读模型 service 收口、route/context 直接注入新仓储,以及定向 custom world 回归验证结果。 +- [CREATION_FLOW_CHAIN_REFACTOR_WORK_PACKAGE_D_PROGRESS_2026-04-21.md](./CREATION_FLOW_CHAIN_REFACTOR_WORK_PACKAGE_D_PROGRESS_2026-04-21.md):记录工作包 D 已完成的前端 custom world client 真正迁出、旧 service 兼容降级,以及平台壳层/结果页测试切换到 `rpgCreation` 域入口的现状。 +- [CREATION_FLOW_CHAIN_REFACTOR_WORK_PACKAGE_G_PROGRESS_2026-04-21.md](./CREATION_FLOW_CHAIN_REFACTOR_WORK_PACKAGE_G_PROGRESS_2026-04-21.md):记录工作包 G 已完成的 runtime profile 目录化、服务端 preview compiler 收口,以及 foundation draft 主生成链与 preview 编译边界的直接拆开。 +- [CREATION_FLOW_CHAIN_REFACTOR_EXECUTION_PLAN_2026-04-21.md#93-工作包-c前端结果页与编辑器拆分](./CREATION_FLOW_CHAIN_REFACTOR_EXECUTION_PLAN_2026-04-21.md#93-%E5%B7%A5%E4%BD%9C%E5%8C%85-c%E5%89%8D%E7%AB%AF%E7%BB%93%E6%9E%9C%E9%A1%B5%E4%B8%8E%E7%BC%96%E8%BE%91%E5%99%A8%E6%8B%86%E5%88%86):记录工作包 C 已完成的结果页壳层拆分、编辑器目标分发与 mapper 收口、角色资产工坊 section/workflow 拆分,以及仍保留的阶段性 shared 实现边界。 +- [CREATION_PAGE_MOBILE_UI_FIX_2026-04-21.md](./CREATION_PAGE_MOBILE_UI_FIX_2026-04-21.md):创作页移动端底部 Tab、亮色主题 token 与滚动权责修复记录。 - [TXT_MODE_VISUAL_NOVEL_MIGRATION_EXECUTION_PLAN_2026-04-20.md](./TXT_MODE_VISUAL_NOVEL_MIGRATION_EXECUTION_PLAN_2026-04-20.md):把外部仓库 TXT 模式完整迁入当前项目的冻结边界、模块映射、分阶段计划与验收清单。 - [NODE_BACKEND_MODULE_AND_API_INDEX.md](./NODE_BACKEND_MODULE_AND_API_INDEX.md):由 `server-node/src/manifest/backendCapabilityManifest.ts` 生成的 Node 后端模块职责、挂载面与接口索引,后续新增模块/接口时同步更新这一份。 - [NODE_SERVER_KNOWLEDGE_GRAPH_2026-04-08.md](./NODE_SERVER_KNOWLEDGE_GRAPH_2026-04-08.md):当前 Node 运行时后端的技术栈、入口、鉴权、存储与接口知识图谱。 diff --git a/docs/technical/RPG_ENTRY_RUNTIME_CHAIN_REFACTOR_EXECUTION_PLAN_2026-04-21.md b/docs/technical/RPG_ENTRY_RUNTIME_CHAIN_REFACTOR_EXECUTION_PLAN_2026-04-21.md new file mode 100644 index 00000000..ba2081ff --- /dev/null +++ b/docs/technical/RPG_ENTRY_RUNTIME_CHAIN_REFACTOR_EXECUTION_PLAN_2026-04-21.md @@ -0,0 +1,999 @@ +# RPG 进入游戏与运行时链路重构执行方案 + +更新时间:`2026-04-21` + +## 0. 文档目标 + +本文只处理一件事: + +**把当前 RPG 玩法从“平台入口/继续游戏/世界详情开始游戏”到“角色选择/营地开场/冒险运行态/runtime story 后端动作结算”的整条前后端脚本链路,整理成一份可以直接指导后续并行重构的执行方案。** + +本轮不直接修改业务玩法,不新增需求,只明确: + +1. 当前链路上的真实脚本地图 +2. 当前命名、目录、边界和可读性问题 +3. 面向 RPG 类型游戏的专属命名规范 +4. 目标分层与文件级拆分建议 +5. 可同时并行推进的工作包与阶段验收标准 + +同时补充一条必须冻结的执行约束: + +**本次以及后续按本文推进的 RPG 链路重构,只允许调整脚本结构、命名、职责边界、数据流和兼容 façade,不允许修改任何前端交互界面设计。** + +--- + +## 1. 范围与依据 + +### 1.1 本文覆盖的 RPG 进入游戏链路 + +```text +平台首页 / 作品详情 / 继续游戏 +-> 选择世界或恢复存档 +-> 角色选择 +-> 初始化 GameState / Snapshot / Session +-> 营地开场 +-> 冒险运行态 shell +-> 冒险面板 / 角色面板 / 背包面板 +-> runtime story 选项解析与动作结算 +-> 服务端快照持久化 / 状态回写 / 继续游戏恢复 +``` + +### 1.2 本文主要依据 + +1. `docs/prd/AI_NATIVE_SCENE_CHAPTER_GAMEPLAY_PRD_AND_EXECUTION_PLAN_2026-04-08.md` +2. `docs/design/SCENE_CHAPTER_LOOP_AND_FIRST_ENTRY_CHAPTER_QUEST_DESIGN_2026-04-08.md` +3. `docs/experience/CURRENT_GAME_FULL_FLOW_PLAYTEST_REPORT_2026-04-07.md` +4. `docs/technical/RUNTIME_STORY_BACKEND_BOUNDARY_MIGRATION_2026-04-19.md` +5. `docs/technical/FRONTEND_TO_BACKEND_MIGRATION_EXECUTION_PLAN_2026-04-21.md` +6. `docs/technical/NODE_SERVER_KNOWLEDGE_GRAPH_2026-04-08.md` + +### 1.3 本文刻意不覆盖的链路 + +本文不处理以下内容: + +1. RPG 创作流程链 +2. Agent 八锚点共创流程 +3. 自定义世界结果页编辑器内部资产工坊链 +4. 非 RPG 平台公共功能的全面改造 + +这些内容已有独立文档,本文只关注**进入 RPG 运行态之后的主玩法链**。 + +### 1.4 前端界面冻结约束 + +本次重构对前端界面的约束必须写死: + +1. 不修改任何前端交互界面设计。 +2. 不修改现有页面的视觉层级、主布局结构、按钮位置、tab 组织、弹窗/独立面板的出现方式。 +3. 不以“顺手优化体验”为理由调整入口页、选角页、冒险页、角色页、背包页的交互路径。 +4. 重构允许做的事情只包括:脚本重命名、目录迁移、hook/service 拆分、view model 收口、后端路由与服务拆分、兼容 façade 搭建。 +5. 如果个别脚本拆分必须调整 props 传递或组件装配方式,最终渲染结果与交互结果必须和当前版本保持一致。 +6. 任何会影响 UI 结构、交互节奏、面板开合形式的改动,都不属于本文工作范围,必须另开设计文档与实现任务。 + +--- + +## 2. 当前链路真实脚本地图 + +## 2.1 前端入口与进入世界链 + +| 文件 | 当前职责 | 当前问题 | +| --- | --- | --- | +| `src/App.tsx` | 应用入口,直接挂载 `useGameShellRuntime()` 与 `GameShellRuntime` | 入口极薄,但把“平台入口 + RPG 运行态”全部抽象成 `GameShell`,命名过泛 | +| `src/hooks/useGameShellRuntime.ts` | 串起 `useGameFlow`、`useGamePersistence`、`useStoryGeneration`、同伴与音乐逻辑,组装整套运行时 props | 已经是事实上的 RPG 主流程装配器,但命名仍像通用 shell | +| `src/hooks/useGameFlow.ts` | 世界选择、角色确认、`GameState` 初始化、营地遭遇创建 | 负责“进入游戏”的核心初始化,但文件名过泛,且把世界选择、选角、开局初始化混在一起 | +| `src/hooks/useGamePersistence.ts` | 远端快照加载、自动存档、保存退出、继续游戏恢复 | 快照加载、存档写入、恢复后 runtime story 刷新耦合在同一 hook 中 | +| `src/components/game-shell/PreGameSelectionFlow.tsx` | 平台首页、详情页、存档继续、创作入口、进入世界前流程壳层 | 仍承载过多平台级编排,不只负责 RPG 进入游戏链 | +| `src/components/game-shell/PlatformHomeView.tsx` | 平台首页、继续游戏、公开广场、存档/个人 tab 表现层 | 视觉层文件过大,且“平台首页”与“RPG 进入游戏入口”没有显式命名边界 | +| `src/components/game-shell/PlatformWorldDetailView.tsx` | 世界详情与“开始游戏/继续创作/发布”等操作 | `开始游戏` 是 RPG 入口动作,但当前文件名和职责仍偏平台通用详情页 | +| `src/components/game-shell/CharacterSelectionFlow.tsx` | 角色选择、角色自定义草稿、确认进入营地 | 已是纯 RPG 选角页面,但命名仍是泛化 `SelectionFlow` | + +## 2.2 前端运行态壳层与面板链 + +| 文件 | 当前职责 | 当前问题 | +| --- | --- | --- | +| `src/components/game-shell/GameShellRuntime.tsx` | 运行态最外层 shell,装配画布、主内容、各种 overlay | 同时承担平台主题外壳和 RPG 运行态外壳,命名与职责都偏泛 | +| `src/components/game-shell/GameShellMainContent.tsx` | 根据 `worldType / playerCharacter / selectionStage` 在平台、选角、冒险面板三种主阶段间切换 | 实际上是 RPG 主阶段路由器,但文件名没有表达“入口阶段切换” | +| `src/components/game-shell/GameShellStoryPanels.tsx` | 冒险/角色/背包三个主标签切换,挂载 `AdventurePanel`、`CharacterPanel`、`InventoryPanel` | 运行态主面板路由器与 tab 容器混在一起 | +| `src/components/AdventurePanel.tsx` | 冒险主面板、对话流、选项区、任务/设置/统计 overlay、NPC 聊天输入、奖励面板 | 单文件过大,是当前前端 RPG 运行时最大热点之一 | +| `src/components/game-shell/useGameShellRuntimeViewModel.ts` | 运行态视图模型、可见状态、过场、统计、对话指示器 | 负责运行态展示编排,但仍以 `GameShell` 泛名承载 | +| `src/components/game-shell/useGameShellViewModel.ts` | overlay/modal/选中实体/selectionStage 的壳层状态 | 进入游戏前与进入游戏后 UI 状态混在一起,命名不够领域化 | + +## 2.3 前端剧情运行时协调链 + +| 文件 | 当前职责 | 当前问题 | +| --- | --- | --- | +| `src/hooks/useStoryGeneration.ts` | 运行态故事主入口,拼装 runtime controller、goal session、interaction coordinator | 事实上的 RPG 叙事运行时入口,但命名过于抽象 | +| `src/hooks/story/useStoryRuntimeController.ts` | 当前故事、AI 错误、故事请求入口、fallback story 和 commit 动作 | 浏览器 AI 请求与服务端 runtime story 共同挂在同一 controller 上 | +| `src/hooks/story/useStoryFlowCoordinator.ts` | 汇总 goal option、interaction、session 行为,输出完整故事流程能力 | 多层 coordinator 套娃,可读性差 | +| `src/hooks/story/useStoryGoalSessionCoordinator.ts` | 任务领奖、重置 story、恢复 story、地图移动 | 任务会话动作与 story 生命周期控制混在一起 | +| `src/hooks/story/useStoryInteractionCoordinator.ts` | 选择分发、NPC 交互、宝藏交互、背包动作、战斗奖励、聊天输入 | 交互中心职责太重,是第二个热点文件 | +| `src/hooks/story/npcEncounterActions.ts` | NPC 聊天、切磋、委托接受/替换/放弃、服务端 runtime action、聊天 UI 细节 | 依然是巨型多职责文件,且混合 UI 组装、状态更新、服务端请求 | +| `src/hooks/story/runtimeStoryCoordinator.ts` | 继续游戏后恢复 runtime story、状态解析、server action 调用 | 是前端对接服务端 runtime story 的真正 gateway,但命名像临时协调器 | +| `src/hooks/story/storyRequestCoordinator.ts` | AI 故事请求参数编排、server option catalog 决策 | 与 runtime story 主链存在交叉语义,边界不直观 | +| `src/hooks/story/sessionActions.ts` | 地图移动、任务奖励、story reset/hydrate | “session action” 命名过泛,而且同时处理 quest/chapter/story 三类状态 | + +## 2.4 前端 service / client 链 + +| 文件 | 当前职责 | 当前问题 | +| --- | --- | --- | +| `src/services/storageService.ts` | 存档、设置、个人看板、浏览历史、作品库、继续游戏、世界详情 | RPG 快照/存档接口与平台资料/作品库接口混在一个通用 client 中 | +| `src/services/runtimeStoryService.ts` | `/api/runtime/story` 的状态读取、动作提交、story moment 转换 | 已接近 RPG 专属 client,但文件名仍偏通用 | +| `src/services/aiService.ts` | 初始剧情/续写、角色聊天、NPC 聊天、runtime item、quest、custom world 共创接口 | RPG 运行时 AI、角色聊天、创作 Agent 接口混在一起,领域过宽 | + +## 2.5 后端路由与运行时主链 + +| 文件 | 当前职责 | 当前问题 | +| --- | --- | --- | +| `server-node/src/server.ts` | 组装 `AppContext`,注入 runtimeRepository、customWorldAgentOrchestrator 等依赖 | 依赖对象过于集中,RPG 运行态缺少显式模块边界 | +| `server-node/src/app.ts` | 注册 `/api/auth`、`/api/runtime/story`、`/api` 等总路由 | RPG 进入世界链、平台路由、编辑器路由全部在总 app 中汇合,语义不够清晰 | +| `server-node/src/routes/runtimeRoutes.ts` | 资料、存档、浏览历史、作品库、runtime AI、世界生成等大杂糅路由 | 当前后端最大热点之一,平台资料与 RPG runtime 接口强耦合 | +| `server-node/src/modules/story/storyActionRoutes.ts` | runtime story 状态读取、动作结算 | 路由层本身还算薄,但命名仍然过于通用 | +| `server-node/src/modules/story/storyActionService.ts` | runtime story 状态/动作主应用服务,拼接 combat、npc、quest、treasure、LLM story | 是当前后端 RPG 运行时主热点,承担过多动作路由和 story 组装细节 | +| `server-node/src/modules/story/runtimeSession.ts` | runtime snapshot 归一化、option 构建、viewModel 编译、legacy currentStory 构建、rawGameState 同步 | 运行态编译中心过重,加载器、编译器、同步器、兼容层全部混在一起 | +| `server-node/src/modules/npc/npcInteractionService.ts` | NPC help/chat/fight/spar/recruit 等动作结算 | 与 runtime story 仍存在大量双向耦合 | +| `server-node/src/modules/quest/questStoryActionService.ts` | 委托接受、交付、待接委托读取与结算 | 已承担正式 quest 语义,但入口仍埋在 storyActionService 下游 | +| `server-node/src/repositories/runtimeRepository.ts` | 快照、存档列表、看板、浏览历史、作品库、会话等持久化读写 | 仓储过大,按技术分组而不是按 RPG 领域分组 | +| `server-node/src/modules/runtime/runtimeSnapshotHydration.ts` | 存档/gameState/currentStory 归一化、迁移补丁、默认值填充 | 既承担快照迁移,又承担业务字段补齐,是基础设施与领域逻辑混合点 | +| `packages/shared/src/contracts/story.ts` | 前后端 runtime story / npc chat / quest / runtime item 等共享契约 | RPG 运行时契约体量过大,story/action/state/chat 混放,难以独立演进 | + +--- + +## 3. 当前结构性问题 + +## 3.1 命名没有体现“这是 RPG 进入游戏主链” + +当前主链上充满以下泛化命名: + +1. `GameShell` +2. `MainContent` +3. `SelectionFlow` +4. `runtimeRoutes` +5. `storyActionService` +6. `sessionActions` + +这些命名的问题不是“不好看”,而是: + +1. 无法一眼区分平台入口、RPG 进入游戏、RPG 运行态 +2. 无法一眼看出文件属于前端壳层、状态协调器、还是后端应用服务 +3. 后续非 RPG 流程接入时,很容易继续误复用这些泛化热点文件 + +## 3.2 平台入口与 RPG 进入游戏链混在一起 + +`PreGameSelectionFlow.tsx` 当前同时承担: + +1. 平台首页 +2. 详情页 +3. 存档恢复 +4. 创作入口 +5. 进入世界 + +这会导致: + +1. 任何平台改动都可能碰到 RPG 进入游戏主链 +2. 任何 RPG 进入游戏改动都要穿过平台杂项状态 +3. 文件变成事实上的超大编排中心 + +## 3.3 `GameState` 初始化、快照恢复、运行态切入没有明确分层 + +`useGameFlow.ts`、`useGamePersistence.ts`、`useGameShellRuntime.ts` 当前共同承担: + +1. 新开局初始化 +2. 世界选择 +3. 角色选择 +4. 自动存档 +5. 继续游戏恢复 +6. runtime story 恢复刷新 + +结果是: + +1. “进入游戏前的 session bootstrap”与“进入游戏后的自动持久化”没有明确边界 +2. 继续游戏逻辑很难单独替换或扩展 +3. 任何存档策略变更都容易影响开局链 + +## 3.4 前端剧情运行时协调层过多,职责分散却仍然耦合 + +当前前端 story 主链至少经过: + +1. `useStoryGeneration` +2. `useStoryRuntimeController` +3. `useStoryFlowCoordinator` +4. `useStoryGoalSessionCoordinator` +5. `useStoryInteractionCoordinator` +6. `npcEncounterActions` +7. `runtimeStoryCoordinator` + +问题在于: + +1. 层数多,但不是稳定分层,而是热点文件之间互相穿透 +2. 有些层是 view model,有些层是 action dispatcher,有些层是 server gateway,命名看不出来 +3. 浏览器 AI 续写链与服务端 runtime story 链还没有完全收口为两个明确通道 + +## 3.5 后端路由层过于“大 runtime 大入口” + +`runtimeRoutes.ts` 当前同时覆盖: + +1. profile dashboard +2. browse history +3. save archives +4. custom world library/gallery +5. custom world profile generation +6. runtime story 外围 AI 接口 +7. runtime item / quest 生成接口 + +这会导致: + +1. RPG 进入游戏链难以抽出独立模块 +2. 平台资料接口和 RPG runtime 接口一起变更时风险高 +3. route 文件越来越像“后端总控清单” + +## 3.6 `runtimeSession.ts` 是当前后端最大可读性瓶颈之一 + +这个文件当前同时做了: + +1. snapshot 载入 +2. rawGameState 归一化 +3. option interaction 构建 +4. battle option 编译 +5. NPC option 编译 +6. viewModel 编译 +7. legacy currentStory 兼容输出 +8. rawGameState 回写同步 + +这会直接造成: + +1. 新增动作时很难判断应该改哪里 +2. 任何小调整都容易触碰多个职责 +3. 单测难以按职责拆开 + +## 3.7 持久化仓储按技术堆叠,没有按 RPG 域拆开 + +`runtimeRepository.ts` 把: + +1. snapshot +2. save archives +3. settings +4. dashboard +5. browse history +6. custom world library +7. custom world sessions + +全部堆在一起。 + +对 RPG 进入游戏链来说,至少应该显式分开: + +1. 运行时快照 +2. 存档归档 +3. 平台资料 +4. 世界库/详情 + +否则“继续游戏链”与“平台资料链”永远无法清晰拆边界。 + +--- + +## 4. 目标分层架构 + +## 4.1 目标原则 + +后续重构必须统一遵守 7 条原则: + +1. **平台入口只负责进入 RPG,会话真相不留在页面壳层。** +2. **世界选择、角色选择、新开局、继续游戏恢复属于 RPG session 入口域。** +3. **进入世界后的运行态壳层、冒险面板、story runtime 网关必须显式分层。** +4. **前端只保留展示状态、输入状态、UI 过场状态;正式快照、正式动作、正式 story option 以后端为准。** +5. **后端 route / application service / compiler / repository 必须按 RPG 域拆开,不再扩大“大 runtime 单文件”。** +6. **所有新命名都要显式表达“这是 RPG 类型游戏专属流程”,不能继续依赖 `GameShell / runtime / flow` 这类泛化词。** +7. **重构期间严格冻结前端交互界面设计,脚本重组不能改变任何页面结构与交互表现。** + +## 4.2 目标链路 + +```text +RPG 平台入口壳层 +-> RPG session bootstrap +-> RPG 角色选择 +-> RPG 运行态 shell +-> RPG 运行态面板路由 +-> RPG runtime story gateway +-> RPG runtime story routes +-> RPG runtime action/state services +-> RPG runtime session loader/compiler +-> RPG snapshot repository / save archive repository +``` + +## 4.3 推荐目录骨架 + +### 前端 + +```text +src/ +├─ components/ +│ ├─ rpg-entry/ +│ ├─ rpg-runtime-shell/ +│ ├─ rpg-runtime-panels/ +│ └─ rpg-runtime-overlays/ +├─ hooks/ +│ ├─ rpg-session/ +│ └─ rpg-runtime-story/ +└─ services/ + ├─ rpg-entry/ + └─ rpg-runtime/ +``` + +### 后端 + +```text +server-node/src/ +├─ routes/ +│ ├─ rpg-entry/ +│ ├─ rpg-profile/ +│ └─ rpg-runtime/ +├─ modules/ +│ └─ rpg-runtime-story/ +├─ services/ +│ ├─ rpg-entry/ +│ └─ rpg-runtime/ +└─ repositories/ + ├─ rpg-entry/ + └─ rpg-runtime/ +``` + +--- + +## 5. RPG 专属命名规范 + +## 5.1 命名根 + +后续进入游戏与运行态链统一使用以下命名根: + +1. `rpgEntry` + - 平台首页、详情页、世界进入、角色选择、继续游戏入口 +2. `rpgSession` + - 新开局、继续游戏恢复、快照 persistence、开局 bootstrap +3. `rpgRuntime` + - 游戏内 shell、tab、面板、overlay、view model +4. `rpgRuntimeStory` + - story state/action client、gateway、route、service、compiler +5. `rpgProfile` + - dashboard、browse history、save archive 等玩家资料域 + +## 5.2 命名规则 + +1. React 组件文件统一使用 `Rpg...` 前缀。 +2. hooks 统一使用 `useRpg...` 前缀。 +3. 前端 client/gateway/adapter 统一使用 `rpg...` 小驼峰前缀。 +4. 后端 route 使用 `rpg...Routes.ts`。 +5. 后端应用服务使用 `Rpg...Service.ts`。 +6. 后端编译器/装配器使用 `Rpg...Compiler.ts` / `Rpg...Assembler.ts`。 +7. 后端仓储使用 `Rpg...Repository.ts`。 +8. 共享契约文件优先拆成 `rpgEntry...`、`rpgRuntimeStory...`、`rpgProfile...`。 + +## 5.3 命名禁忌 + +后续重构中禁止继续新增以下主命名: + +1. `GameShell*` +2. `PreGame*` +3. `SelectionFlow*` +4. `runtimeRoutes.ts` 这种单文件总入口命名 +5. `storyActionService.ts` 这种过宽的单域名 +6. `sessionActions.ts`、`flowCoordinator.ts`、`manager.ts`、`helper.ts` 作为主业务模块名 + +## 5.4 关键文件重命名建议 + +| 当前文件 | 目标命名 | 说明 | +| --- | --- | --- | +| `src/components/game-shell/PreGameSelectionFlow.tsx` | `src/components/rpg-entry/RpgEntryFlowShell.tsx` | 平台进入世界与选角前阶段壳层 | +| `src/components/game-shell/PlatformHomeView.tsx` | `src/components/rpg-entry/RpgEntryHomeView.tsx` | RPG 平台首页 | +| `src/components/game-shell/PlatformWorldDetailView.tsx` | `src/components/rpg-entry/RpgEntryWorldDetailView.tsx` | 世界详情与开始游戏入口 | +| `src/components/game-shell/CharacterSelectionFlow.tsx` | `src/components/rpg-entry/RpgEntryCharacterSelectView.tsx` | RPG 选角页 | +| `src/hooks/useGameFlow.ts` | `src/hooks/rpg-session/useRpgSessionBootstrap.ts` | 新开局/世界选择/角色确认 | +| `src/hooks/useGamePersistence.ts` | `src/hooks/rpg-session/useRpgSessionPersistence.ts` | 自动存档/继续游戏恢复 | +| `src/hooks/useGameShellRuntime.ts` | `src/hooks/rpg-session/useRpgRuntimeSession.ts` | RPG 主运行态装配器 | +| `src/components/game-shell/GameShellRuntime.tsx` | `src/components/rpg-runtime-shell/RpgRuntimeShell.tsx` | 运行态总外壳 | +| `src/components/game-shell/GameShellMainContent.tsx` | `src/components/rpg-runtime-shell/RpgRuntimeStageRouter.tsx` | 平台/选角/冒险阶段切换 | +| `src/components/game-shell/GameShellStoryPanels.tsx` | `src/components/rpg-runtime-panels/RpgRuntimePanelRouter.tsx` | 冒险/角色/背包主标签路由 | +| `src/components/AdventurePanel.tsx` | `src/components/rpg-runtime-panels/RpgAdventurePanel.tsx` | 冒险主面板 | +| `src/hooks/useStoryGeneration.ts` | `src/hooks/rpg-runtime-story/useRpgRuntimeStory.ts` | 前端 story 运行态主入口 | +| `src/hooks/story/useStoryRuntimeController.ts` | `src/hooks/rpg-runtime-story/useRpgRuntimeStoryController.ts` | 当前 story 与请求控制 | +| `src/hooks/story/useStoryFlowCoordinator.ts` | `src/hooks/rpg-runtime-story/useRpgRuntimeStoryFlow.ts` | story 主编排 | +| `src/hooks/story/useStoryInteractionCoordinator.ts` | `src/hooks/rpg-runtime-story/useRpgRuntimeInteractionFlow.ts` | runtime 交互分发 | +| `src/hooks/story/npcEncounterActions.ts` | `src/hooks/rpg-runtime-story/useRpgRuntimeNpcInteraction.ts` | NPC 交互与聊天动作 | +| `src/hooks/story/runtimeStoryCoordinator.ts` | `src/hooks/rpg-runtime-story/rpgRuntimeStoryGateway.ts` | 前端到后端 runtime story 网关 | +| `src/services/runtimeStoryService.ts` | `src/services/rpg-runtime/rpgRuntimeStoryClient.ts` | `/api/runtime/story` client | +| `src/services/storageService.ts` | 拆成 `rpgProfileClient.ts` / `rpgEntryLibraryClient.ts` / `rpgSnapshotClient.ts` | 按领域拆 client | +| `server-node/src/routes/runtimeRoutes.ts` | 拆成 `rpgProfileRoutes.ts` / `rpgEntryRoutes.ts` / `rpgRuntimeAiRoutes.ts` / `rpgWorldLibraryRoutes.ts` | 拒绝单文件总路由 | +| `server-node/src/modules/story/storyActionRoutes.ts` | `server-node/src/routes/rpg-runtime/rpgRuntimeStoryRoutes.ts` | runtime story 专属路由 | +| `server-node/src/modules/story/storyActionService.ts` | `RpgRuntimeStoryActionService.ts` + `RpgRuntimeStoryStateService.ts` | 动作结算与状态读取拆开 | +| `server-node/src/modules/story/runtimeSession.ts` | `RpgRuntimeSessionLoader.ts` + `RpgRuntimeOptionCompiler.ts` + `RpgRuntimeSnapshotSync.ts` | 按职责拆分 | +| `server-node/src/repositories/runtimeRepository.ts` | `RpgRuntimeSnapshotRepository.ts` 等多个仓储 | 按领域拆仓储 | +| `packages/shared/src/contracts/story.ts` | 拆成 `rpgRuntimeStory.ts` / `rpgRuntimeChat.ts` / `rpgRuntimeAction.ts` | 契约拆分 | + +--- + +## 6. 前端重构拆分方案 + +## 6.1 RPG 入口壳层拆分 + +### 当前问题 + +`PreGameSelectionFlow.tsx` 当前同时承担平台首页、详情页、进入世界、存档恢复与部分创作入口逻辑。 + +### 目标拆分 + +保留一个极薄的 `RpgEntryFlowShell.tsx`,只负责: + +1. 入口阶段切换 +2. 装配子页面 +3. loading / error 壳层 + +从当前文件拆出: + +1. `useRpgEntryBootstrap.ts` +2. `useRpgEntryNavigation.ts` +3. `useRpgEntrySaveResume.ts` +4. `useRpgEntryLibraryDetail.ts` +5. `RpgEntryHomeView.tsx` +6. `RpgEntryWorldDetailView.tsx` +7. `RpgEntryCharacterSelectView.tsx` + +### 关键要求 + +1. 入口壳层不再直接操作作品库、浏览历史、看板的多路加载细节。 +2. 入口壳层不再直接处理“继续游戏后刷新 runtime story”的逻辑。 +3. 创作入口与 RPG 进入世界入口要显式分段,不再共享大文件。 +4. 页面视觉结构、按钮布局、tab 形式和独立面板交互方式保持不变。 + +## 6.2 RPG session bootstrap / persistence 拆分 + +### 当前问题 + +`useGameFlow.ts` 与 `useGamePersistence.ts` 共同承担了开局初始化、世界进入、存档自动保存和恢复。 + +### 目标拆分 + +建议拆出: + +1. `useRpgSessionBootstrap.ts` +2. `useRpgCharacterEntry.ts` +3. `useRpgSnapshotPersistence.ts` +4. `useRpgContinueGame.ts` +5. `rpgSnapshotClient.ts` + +### 关键要求 + +1. 新开局初始化与继续游戏恢复是两条显式流程。 +2. `GameState` 初始化逻辑不再和自动存档逻辑放在同一 hook 里。 +3. 存档自动保存不再直接夹带 UI 层状态决策。 +4. 脚本拆分后不改变用户看到的进入游戏流程和交互顺序。 + +## 6.3 RPG 运行态 shell 与面板拆分 + +### 当前问题 + +`GameShellRuntime.tsx`、`GameShellMainContent.tsx`、`GameShellStoryPanels.tsx`、`AdventurePanel.tsx` 共同组成了一个过于耦合的壳层群。 + +### 目标拆分 + +建议形成: + +1. `RpgRuntimeShell.tsx` +2. `RpgRuntimeStageRouter.tsx` +3. `RpgRuntimePanelRouter.tsx` +4. `RpgAdventurePanel.tsx` +5. `RpgRuntimeOverlayHost.tsx` +6. `useRpgRuntimeShellViewModel.ts` + +### 关键要求 + +1. 运行态最外层壳层只管布局、背景、过场和 overlay host。 +2. 主阶段路由器只管“平台/选角/冒险”的分流。 +3. 面板路由器只管“冒险/角色/背包”的主 tab 分流。 +4. `AdventurePanel` 内部继续按“story 区 / option 区 / overlay 区”拆 section。 +5. 不重做任何面板样式、信息层次和交互布局,拆分只发生在脚本内部。 + +## 6.4 前端 runtime story 主链拆分 + +### 当前问题 + +前端 runtime story 主链层数太多,但边界并不稳定。 + +### 目标拆分 + +建议收成四层: + +1. `useRpgRuntimeStory.ts` + - 作为唯一对上输出入口 +2. `useRpgRuntimeStoryState.ts` + - 管当前 story、loading、error、hydration +3. `useRpgRuntimeInteractionFlow.ts` + - 分发 NPC / 战斗 / 宝藏 / 任务 / 地图动作 +4. `rpgRuntimeStoryGateway.ts` + - 只负责和后端 runtime story client 交互 + +### 关键要求 + +1. 浏览器侧 AI 续写逻辑与 server runtime story 逻辑必须显式分离。 +2. `npcEncounterActions.ts` 中的 UI 细节和正式动作结算必须拆开。 +3. 任何 `resolveServerRuntimeChoice(...)` 都应只通过统一 gateway 入口调用。 +4. 交互按钮、对话区、输入区和奖励展示的前端交互形式保持现状不变。 + +## 6.5 前端 service/client 收口方案 + +### 当前问题 + +`storageService.ts` 与 `aiService.ts` 的领域过宽。 + +### 目标拆分 + +建议新增: + +1. `src/services/rpg-entry/rpgEntryLibraryClient.ts` +2. `src/services/rpg-entry/rpgProfileClient.ts` +3. `src/services/rpg-runtime/rpgSnapshotClient.ts` +4. `src/services/rpg-runtime/rpgRuntimeStoryClient.ts` +5. `src/services/rpg-runtime/rpgRuntimeChatClient.ts` + +### 关键要求 + +1. `storageService.ts` 逐步降级为兼容 façade。 +2. `aiService.ts` 逐步只保留通用 AI 与创作域 client,不继续承接 RPG runtime story 主链。 +3. 进入游戏链不能再依赖过宽的通用 client 文件。 + +--- + +## 7. 后端重构拆分方案 + +## 7.1 route 层拆分 + +### 当前问题 + +`runtimeRoutes.ts` 过重,平台资料、作品库和 RPG runtime 都在一起。 + +### 目标拆分 + +建议拆成: + +1. `server-node/src/routes/rpg-profile/rpgProfileRoutes.ts` +2. `server-node/src/routes/rpg-entry/rpgEntrySaveRoutes.ts` +3. `server-node/src/routes/rpg-entry/rpgWorldLibraryRoutes.ts` +4. `server-node/src/routes/rpg-runtime/rpgRuntimeStoryRoutes.ts` +5. `server-node/src/routes/rpg-runtime/rpgRuntimeAiAssistRoutes.ts` + +### 关键要求 + +1. RPG 进入游戏链相关接口必须从“大 runtime 总路由”中抽离。 +2. `app.ts` 中要能一眼看出平台资料、世界库、runtime story 的路由边界。 +3. route 层继续保持薄,不直接承载 story 业务决策。 + +## 7.2 runtime story service 拆分 + +### 当前问题 + +`storyActionService.ts` 当前同时承担状态读取、动作结算、LLM story 包装和 snapshot 持久化。 + +### 目标拆分 + +建议拆为: + +1. `RpgRuntimeStoryStateService.ts` +2. `RpgRuntimeStoryActionService.ts` +3. `RpgRuntimeCombatActionService.ts` +4. `RpgRuntimeNpcActionService.ts` +5. `RpgRuntimeStoryPresentationCompiler.ts` +6. `RpgRuntimeStorySnapshotCommitService.ts` + +### 关键要求 + +1. 状态读取与动作结算分开。 +2. 各子域动作要能各自单测,不再全部挂在一个 service 里。 +3. LLM 二次包装 story 文本不能继续散在动作服务主文件中。 + +## 7.3 `runtimeSession.ts` 目录化拆分 + +### 当前问题 + +`runtimeSession.ts` 已经集中了太多运行时编译职责。 + +### 目标拆分 + +建议新增目录: + +```text +server-node/src/modules/rpg-runtime-story/session/ +├─ RpgRuntimeSessionLoader.ts +├─ RpgRuntimeEncounterNormalizer.ts +├─ RpgRuntimeOptionCompiler.ts +├─ RpgRuntimeBattleOptionCompiler.ts +├─ RpgRuntimeNpcOptionCompiler.ts +├─ RpgRuntimeViewModelCompiler.ts +├─ RpgRuntimeLegacyStoryAdapter.ts +└─ RpgRuntimeSnapshotSync.ts +``` + +### 关键要求 + +1. loader、compiler、legacy adapter、snapshot sync 必须物理拆开。 +2. “是否生成 legacy currentStory”要成为单独兼容层,而不是散落在核心编排里。 +3. interaction 语义编译要可单测、可扩展。 + +## 7.4 repository 拆分 + +### 当前问题 + +`runtimeRepository.ts` 不是 RPG 进入游戏链友好的结构。 + +### 目标拆分 + +建议拆成: + +1. `RpgRuntimeSnapshotRepository.ts` +2. `RpgSaveArchiveRepository.ts` +3. `RpgProfileDashboardRepository.ts` +4. `RpgBrowseHistoryRepository.ts` +5. `RpgWorldLibraryRepository.ts` + +### 关键要求 + +1. snapshot、save archive 与资料型仓储分离。 +2. 世界库详情读取与运行时快照读写不再耦合在同一仓储。 +3. 后续“继续游戏”链可以只依赖 snapshot/save archive 仓储。 + +## 7.5 shared contract 拆分 + +### 当前问题 + +`packages/shared/src/contracts/story.ts` 已经过大。 + +### 目标拆分 + +建议拆为: + +1. `rpgRuntimeStoryAction.ts` +2. `rpgRuntimeStoryState.ts` +3. `rpgRuntimeChat.ts` +4. `rpgRuntimeQuestAssist.ts` + +### 关键要求 + +1. runtime story 主链契约要能独立演进,不被 NPC chat / quest / item 附属能力拖累。 +2. 前后端只在必要范围共享契约,减少大而全文件。 + +--- + +## 8. 可并行重构工作包 + +本次执行计划必须拆成多个可以同时推进的工作部分。 + +总原则如下: + +1. 每个工作包只负责一组明确文件。 +2. 每个工作包先建新目录和 façade,再迁真实调用,再清旧层。 +3. 同一阶段允许并行,但禁止多人同时大改同一热点主文件。 + +## 8.1 工作包 A:RPG 命名规范与目录骨架 + +### 目标 + +先建立 RPG 进入游戏链的新命名与目录落点。 + +### 负责范围 + +1. `src/components/rpg-entry/` +2. `src/components/rpg-runtime-shell/` +3. `src/components/rpg-runtime-panels/` +4. `src/hooks/rpg-session/` +5. `src/hooks/rpg-runtime-story/` +6. `src/services/rpg-entry/` +7. `src/services/rpg-runtime/` +8. `server-node/src/routes/rpg-*/` +9. `server-node/src/modules/rpg-runtime-story/` +10. `server-node/src/repositories/rpg-*/` + +### 写入边界 + +1. 只建目录、入口 façade、导出索引和基础命名规范。 +2. 不负责大规模迁移老逻辑。 + +### 前置依赖 + +无,可立即开始。 + +## 8.2 工作包 B:前端 RPG 入口壳层拆分 + +### 目标 + +把平台首页/详情页/继续游戏/进入世界从 `PreGameSelectionFlow.tsx` 拆出来。 + +### 负责范围 + +1. `PreGameSelectionFlow.tsx` +2. `PlatformHomeView.tsx` +3. `PlatformWorldDetailView.tsx` +4. `CharacterSelectionFlow.tsx` +5. 入口阶段相关新 hooks + +### 写入边界 + +1. 只改前端入口链。 +2. 不改后端接口语义。 +3. 不拆运行态冒险面板。 +4. 不修改任何前端交互界面设计。 + +### 前置依赖 + +依赖工作包 A 的目录骨架。 + +## 8.3 工作包 C:前端 session/bootstrap/persistence 拆分 + +### 目标 + +把 `useGameFlow.ts`、`useGamePersistence.ts`、`useGameShellRuntime.ts` 收成 RPG session 域。 + +### 负责范围 + +1. `useGameFlow.ts` +2. `useGamePersistence.ts` +3. `useGameShellRuntime.ts` +4. `storageService.ts` 中 snapshot/save archive 相关调用入口 + +### 写入边界 + +1. 主要改 session bootstrap、自动存档、继续游戏恢复。 +2. 不改 AdventurePanel UI。 +3. 不改后端 storyActionService 语义。 +4. 不修改任何前端交互界面设计。 + +### 前置依赖 + +依赖工作包 A,可与 B、D、F、G、H 并行。 + +## 8.4 工作包 D:前端运行态 shell 与面板拆分 + +### 目标 + +把 `GameShellRuntime.tsx`、`GameShellMainContent.tsx`、`GameShellStoryPanels.tsx`、`AdventurePanel.tsx` 拆成 RPG runtime shell 体系。 + +### 负责范围 + +1. 运行态 shell +2. 主阶段路由器 +3. 面板路由器 +4. 冒险主面板 section + +### 写入边界 + +1. 主改组件与 view model。 +2. 不改 runtime story 后端协议。 +3. 不改平台入口链。 +4. 不修改任何前端交互界面设计。 + +### 前置依赖 + +依赖工作包 A,可与 B、C、E、F、G、H 并行。 + +## 8.5 工作包 E:前端 runtime story 与 NPC 交互链拆分 + +### 目标 + +把多层 story coordinator 收成稳定的 RPG runtime story 结构。 + +### 负责范围 + +1. `useStoryGeneration.ts` +2. `useStoryRuntimeController.ts` +3. `useStoryFlowCoordinator.ts` +4. `useStoryGoalSessionCoordinator.ts` +5. `useStoryInteractionCoordinator.ts` +6. `npcEncounterActions.ts` +7. `runtimeStoryCoordinator.ts` +8. `runtimeStoryService.ts` + +### 写入边界 + +1. 只改前端 runtime story 主链。 +2. 不拆平台首页和世界详情壳层。 +3. 不直接修改后端动作语义。 +4. 不修改任何前端交互界面设计。 + +### 前置依赖 + +依赖工作包 A,可与 B、C、D、F、G、H 并行。 + +## 8.6 工作包 F:后端 route 边界拆分 + +### 目标 + +把 `runtimeRoutes.ts` 与 `storyActionRoutes.ts` 按 RPG 域拆边界。 + +### 负责范围 + +1. `app.ts` +2. `runtimeRoutes.ts` +3. `storyActionRoutes.ts` +4. 新 `rpgProfileRoutes.ts` +5. 新 `rpgEntry...Routes.ts` +6. 新 `rpgRuntimeStoryRoutes.ts` + +### 写入边界 + +1. 只调整路由组织和 façade。 +2. 不重写下游全部 service 逻辑。 + +### 前置依赖 + +依赖工作包 A,可与 B、C、D、E、G、H 并行。 + +## 8.7 工作包 G:后端 runtime session / action service 拆分 + +### 目标 + +把 `storyActionService.ts` 与 `runtimeSession.ts` 目录化拆开。 + +### 负责范围 + +1. `storyActionService.ts` +2. `runtimeSession.ts` +3. `npcInteractionService.ts` +4. `questStoryActionService.ts` +5. 新 action service / compiler / adapter 文件 + +### 写入边界 + +1. 主改后端运行时 story 主链。 +2. 不改前端入口与 UI。 +3. 不负责仓储拆分。 + +### 前置依赖 + +依赖工作包 A,可与 B、C、D、E、F、H 并行。 + +## 8.8 工作包 H:仓储、契约与测试基建 + +### 目标 + +把 snapshot/profile/library 仓储与 shared contract 收成独立层,并补齐测试。 + +### 负责范围 + +1. `runtimeRepository.ts` +2. `runtimeSnapshotHydration.ts` +3. `packages/shared/src/contracts/story.ts` +4. runtime story / snapshot / continue game 相关测试 + +### 写入边界 + +1. 只负责仓储、契约、fixture、测试。 +2. 不直接改前端 UI。 +3. 不重写 route 层。 + +### 前置依赖 + +依赖工作包 A,可与 B、C、D、E、F、G 并行。 + +## 8.9 推荐并行顺序 + +```text +第一批并行: +工作包 A + +第二批并行: +工作包 B + 工作包 C + 工作包 D + 工作包 E + 工作包 F + 工作包 G + 工作包 H + +第三批收口: +把 B~H 的 façade 接回主链 +-> 联调继续游戏 / 开始游戏 / 角色选择 / 冒险 runtime / 存档恢复 +-> 清理旧命名与兼容导出 +``` + +## 8.10 并行协作约束 + +1. 工作包 B 独占 `PreGameSelectionFlow.tsx`、`PlatformHomeView.tsx`、`PlatformWorldDetailView.tsx`、`CharacterSelectionFlow.tsx`。 +2. 工作包 C 独占 `useGameFlow.ts`、`useGamePersistence.ts`、`useGameShellRuntime.ts`。 +3. 工作包 D 独占 `GameShellRuntime.tsx`、`GameShellMainContent.tsx`、`GameShellStoryPanels.tsx`、`AdventurePanel.tsx`。 +4. 工作包 E 独占 story runtime hooks 与 `runtimeStoryService.ts`。 +5. 工作包 F 独占 `runtimeRoutes.ts`、`storyActionRoutes.ts`、`app.ts`。 +6. 工作包 G 独占 `storyActionService.ts`、`runtimeSession.ts` 与相关 runtime story modules。 +7. 工作包 H 独占 `runtimeRepository.ts`、`runtimeSnapshotHydration.ts` 与 shared runtime story contracts。 + +--- + +## 9. 分阶段落地计划 + +## Phase 0:冻结命名与边界口径 + +### 目标 + +先冻结“RPG 入口链 / RPG session / RPG runtime / RPG runtime story / RPG profile”五类命名边界。 + +### 验收标准 + +1. 后续新增文件不再继续进入 `GameShell*`、`runtimeRoutes.ts`、`storyActionService.ts` 这类旧热点。 +2. 团队对文件落点和命名根达成一致。 +3. 团队对“脚本重构不改前端交互界面设计”的冻结边界达成一致。 + +## Phase 1:目录骨架与前端入口拆分 + +### 目标 + +先建立目录骨架,并把进入游戏前的前端壳层从大文件中拆开。 + +### 验收标准 + +1. 平台首页/详情页/选角能落到 `rpgEntry` 目录。 +2. `PreGameSelectionFlow.tsx` 退化为兼容壳层或 façade。 +3. 页面视觉结构与交互方式和当前线上版本保持一致。 + +## Phase 2:session/bootstrap/persistence 收口 + +### 目标 + +把“新开局/继续游戏/自动存档/恢复 runtime story”从组件与面板层抽离。 + +### 验收标准 + +1. `useGameFlow.ts`、`useGamePersistence.ts` 不再是直接主入口命名。 +2. 继续游戏与自动存档都能走独立 session hook。 +3. 用户看到的开始游戏/继续游戏交互顺序不变。 + +## Phase 3:运行态 shell 与 runtime story 主链拆分 + +### 目标 + +把冒险运行态 UI 壳层与 runtime story 协调层分别重构。 + +### 验收标准 + +1. `AdventurePanel.tsx` 不再是大一统主热点。 +2. 前端 runtime story 主链收成 3~4 个稳定层级,而不是多层 coordinator 套娃。 +3. 冒险态界面布局、tab 交互与 overlay 呈现方式不变。 + +## Phase 4:后端 route / service / compiler / repository 目录化 + +### 目标 + +把后端“大 runtime route + 大 storyActionService + 大 runtimeSession + 大 runtimeRepository”按 RPG 域拆开。 + +### 验收标准 + +1. `runtimeRoutes.ts` 退化为 façade 或被拆空。 +2. `storyActionService.ts` 只保留兼容导出。 +3. `runtimeSession.ts` 只保留 façade 或兼容导出。 +4. `runtimeRepository.ts` 不再承载全部 RPG 资料与快照职责。 +5. 前端交互界面设计在整个后端重构阶段保持零变更。 + +## Phase 5:兼容层清理 + +### 目标 + +在主链稳定后清理旧命名与旧 façade。 + +### 验收标准 + +1. 进入游戏主链只剩 RPG 专属命名文件。 +2. `GameShell*` 与 `runtimeRoutes.ts` 不再是主链真实入口。 +3. 文档、契约、测试口径一致。 + +--- + +## 10. 验收标准 + +本次重构方案最终要达成以下结果: + +1. 从“平台首页/详情页开始游戏”到“进入 RPG 运行态”的脚本链能一眼看出前端入口、session、runtime、story 的层级。 +2. 进入游戏主链不再依赖过于泛化的 `GameShell / runtime / flow` 命名。 +3. 前端自动存档、继续游戏恢复、runtime story server gateway 具备独立文件边界。 +4. 后端 route、runtime story service、session compiler、snapshot repository 分层清晰。 +5. 所有新主文件都以 RPG 域命名,不再继续扩大旧热点文件。 +6. 整个重构过程不改变任何前端交互界面设计,用户可见交互保持一致。 + +--- + +## 11. 结论 + +当前 RPG 进入游戏后的主玩法链,真正的问题不是单点 bug,而是: + +**平台入口、会话初始化、运行态壳层、前端 runtime story 协调链、后端 route 总入口、后端 runtime session 编译器和仓储层同时过重,而且命名没有体现“这是 RPG 专属主链”。** + +后续正确的重构方向不是继续在 `GameShell`、`runtimeRoutes.ts`、`storyActionService.ts` 这些热点文件上打补丁,而是把主链收成: + +**RPG 入口域 -> RPG session 域 -> RPG runtime shell 域 -> RPG runtime story 域 -> RPG snapshot / profile 域** + +只有这样,这条“开始游戏 -> 进入运行态 -> 推进剧情 -> 自动存档 -> 继续游戏”的链路,才会真正具备后续可读、可扩、可并行维护的工程形态。 diff --git a/docs/technical/RPG_ENTRY_RUNTIME_CHAIN_REFACTOR_OLD_SCRIPT_REMOVAL_2026-04-21.md b/docs/technical/RPG_ENTRY_RUNTIME_CHAIN_REFACTOR_OLD_SCRIPT_REMOVAL_2026-04-21.md new file mode 100644 index 00000000..105f9bef --- /dev/null +++ b/docs/technical/RPG_ENTRY_RUNTIME_CHAIN_REFACTOR_OLD_SCRIPT_REMOVAL_2026-04-21.md @@ -0,0 +1,107 @@ +# RPG 进入游戏与运行时链路旧脚本删除收口记录 + +更新时间:`2026-04-21` + +## 1. 本次目标 + +本轮继续按 `RPG_ENTRY_RUNTIME_CHAIN_REFACTOR_EXECUTION_PLAN_2026-04-21.md` 收口,目标只有一个: + +**在新 `rpg-entry`、`rpg-session`、`rpg-runtime-shell`、`rpg-runtime-panels`、`rpg-runtime-story` 主链已经接回后,物理删除旧 RPG 入口与运行态脚本,并确认源码和当前后端构建产物不再依赖旧路径。** + +本轮不改 UI 布局、不改按钮位置、不改 tab 组织、不改弹层交互方式,也不把历史审计文档里的旧文件名当成运行时代码依赖处理。 + +## 2. 已删除旧脚本范围 + +前端旧入口与旧兼容层已从源码中移除: + +1. `src/components/game-shell/` +2. `src/components/game-shell/rpg-creation-flow/` +3. `src/components/AdventurePanel.tsx` +4. `src/hooks/story/` +5. `src/hooks/useGameFlow.ts` +6. `src/hooks/useGamePersistence.ts` +7. `src/hooks/useGameShellRuntime.ts` +8. `src/hooks/useStoryGeneration.ts` +9. `src/services/runtimeStoryService.ts` +10. `src/services/storageService.ts` + +后端与共享契约旧入口已从源码中移除: + +1. `server-node/src/routes/runtimeRoutes.ts` +2. `server-node/src/modules/story/runtimeSession.ts` +3. `server-node/src/modules/story/storyActionRoutes.ts` +4. `server-node/src/modules/story/storyActionService.ts` +5. `packages/shared/src/contracts/story.ts` + +## 3. 新主链落点 + +删除旧脚本后,RPG 主链只允许继续落在以下新域: + +1. 前端入口:`src/components/rpg-entry/` +2. 前端 session:`src/hooks/rpg-session/` +3. 前端运行态 shell:`src/components/rpg-runtime-shell/` +4. 前端运行态面板:`src/components/rpg-runtime-panels/` +5. 前端 runtime story:`src/hooks/rpg-runtime-story/` +6. 前端 client:`src/services/rpg-entry/`、`src/services/rpg-runtime/` +7. 后端 route:`server-node/src/routes/rpg-entry/`、`server-node/src/routes/rpg-profile/`、`server-node/src/routes/rpg-runtime/` +8. 后端 runtime story:`server-node/src/modules/rpg-runtime-story/` +9. 后端仓储:`server-node/src/repositories/rpg-entry/`、`server-node/src/repositories/rpg-profile/`、`server-node/src/repositories/rpg-runtime/` +10. 共享契约:`packages/shared/src/contracts/rpgRuntimeStoryAction.ts`、`packages/shared/src/contracts/rpgRuntimeStoryState.ts`、`packages/shared/src/contracts/rpgRuntimeChat.ts`、`packages/shared/src/contracts/rpgRuntimeQuestAssist.ts` + +## 4. 残留依赖检查 + +本轮使用旧路径和旧入口名扫描源码与当前后端构建产物,确认以下路径不存在且没有运行时代码引用: + +1. `src/components/game-shell` +2. `src/hooks/story` +3. `src/hooks/useGameFlow.ts` +4. `src/hooks/useGamePersistence.ts` +5. `src/hooks/useGameShellRuntime.ts` +6. `src/hooks/useStoryGeneration.ts` +7. `src/services/runtimeStoryService.ts` +8. `src/services/storageService.ts` +9. `server-node/src/routes/runtimeRoutes.ts` +10. `server-node/src/modules/story/runtimeSession.ts` +11. `server-node/src/modules/story/storyActionRoutes.ts` +12. `server-node/src/modules/story/storyActionService.ts` +13. `packages/shared/src/contracts/story.ts` + +补充处理: + +1. `npm --prefix server-node run build` 已重新生成当前 `server-node/dist/server.cjs`。 +2. 旧的忽略产物 `server-node/dist/server.js` 与 `server-node/dist/server.js.map` 是 `2026-04-18` 遗留 bundle,仍包含旧路径 sourcemap,本轮已定点删除,避免本地误跑旧构建。 +3. 当前 `server-node/dist/` 只保留 `server.cjs` 与 `server.cjs.map`,旧主入口路径扫描无命中。 + +## 5. 本轮补丁 + +本轮额外修正了迁移后测试 prop 类型不一致的问题: + +1. `src/components/rpg-runtime-panels/RpgAdventurePanel.npcChat.test.tsx` +2. `src/components/rpg-runtime-panels/RpgAdventurePanel.test.tsx` + +`npcChatQuestOfferUi.replacePendingOffer` 在新面板类型中是同步布尔返回,测试 mock 已从 `async () => false` 改为 `() => false`,避免旧异步 mock 继续伪装成兼容层行为。 + +## 6. 验证结果 + +已通过: + +1. `npm run check:encoding` +2. `npm --prefix server-node run build` +3. `npx vitest run src/components/rpg-runtime-panels/RpgAdventurePanel.npcChat.test.tsx src/components/rpg-runtime-panels/RpgAdventurePanel.test.tsx src/services/rpg-runtime/rpgRuntimeStoryClient.test.ts src/components/rpg-entry/RpgEntryCharacterSelectView.test.tsx src/components/rpg-entry/RpgEntryFlowShell.agent.interaction.test.tsx src/components/rpg-runtime-shell/useRpgRuntimeShellViewModel.test.ts src/hooks/rpg-runtime-story/useRpgRuntimeStoryState.test.ts src/hooks/rpg-runtime-story/useRpgRuntimeInteractionFlow.test.ts` + +定向前端回归结果:`8` 个测试文件、`37` 个测试通过。 + +未完全通过但不属于本轮 RPG 旧脚本删除阻塞: + +1. `npm run typecheck` + - 剩余错误集中在 `packages/shared/src/contracts/rpgCreationFixtures.ts`、`src/components/auth/AccountModal.test.tsx`、`src/data/customWorldLibrary.ts`、`src/services/customWorldCover.test.ts`。 + - 本轮已清掉 `RpgAdventurePanel*.test.tsx` 中与迁移相关的 `Promise` 类型错误。 +2. `npm --prefix server-node run test` + - RPG runtime story、RPG entry save、RPG world library、RPG profile route 等相关测试通过。 + - 剩余失败为 `custom world agent` HTTP 相关 `12` 个用例返回 `500 !== 200`,属于创作链/Agent 链路问题,不属于本轮 RPG 旧脚本删除范围。 + +## 7. 结论 + +当前 RPG 进入游戏与运行时主链已经不再依赖旧 `GameShell`、`useGame*`、`useStoryGeneration`、`hooks/story`、`runtimeStoryService`、`runtimeRoutes`、`modules/story/*`、`contracts/story.ts` 脚本。 + +后续如果继续开发 RPG 入口、运行态、runtime story 或存档恢复,只应扩展新 `rpg-*` 目录与分文件契约,不应重新创建旧路径 façade。 diff --git a/docs/technical/RPG_ENTRY_RUNTIME_CHAIN_REFACTOR_PARALLEL_BATCH_AUDIT_2026-04-21.md b/docs/technical/RPG_ENTRY_RUNTIME_CHAIN_REFACTOR_PARALLEL_BATCH_AUDIT_2026-04-21.md new file mode 100644 index 00000000..1642c367 --- /dev/null +++ b/docs/technical/RPG_ENTRY_RUNTIME_CHAIN_REFACTOR_PARALLEL_BATCH_AUDIT_2026-04-21.md @@ -0,0 +1,132 @@ +# RPG 进入游戏与运行时链路重构第一批第二批并行工作复核记录 + +更新时间:`2026-04-21` + +## 1. 复核目标 + +本次复核只做一件事: + +**对照 `RPG_ENTRY_RUNTIME_CHAIN_REFACTOR_EXECUTION_PLAN_2026-04-21.md`,检查第一批并行与第二批并行工作是否存在遗漏、未完成收口或仍停留在“看起来已完成但工程状态未闭合”的问题。** + +执行边界保持如下: + +1. 只检查工作包 A 到 H 是否达到各自文档声明的落地状态。 +2. 只补齐确认属于本次重构范围的遗漏项。 +3. 不顺手扩展 UI、玩法、协议或无关模块。 + +## 2. 复核范围 + +本次逐项核对了以下内容: + +1. `docs/technical/RPG_ENTRY_RUNTIME_CHAIN_REFACTOR_EXECUTION_PLAN_2026-04-21.md` +2. `docs/technical/RPG_ENTRY_RUNTIME_CHAIN_REFACTOR_WORK_PACKAGE_A_PROGRESS_2026-04-21.md` +3. `docs/technical/RPG_ENTRY_RUNTIME_CHAIN_REFACTOR_WORK_PACKAGE_B_PROGRESS_2026-04-21.md` +4. `docs/technical/RPG_ENTRY_RUNTIME_CHAIN_REFACTOR_WORK_PACKAGE_C_PROGRESS_2026-04-21.md` +5. `docs/technical/RPG_ENTRY_RUNTIME_CHAIN_REFACTOR_WORK_PACKAGE_D_PROGRESS_2026-04-21.md` +6. `docs/technical/RPG_ENTRY_RUNTIME_CHAIN_REFACTOR_WORK_PACKAGE_E_PROGRESS_2026-04-21.md` +7. `docs/technical/RPG_ENTRY_RUNTIME_CHAIN_REFACTOR_WORK_PACKAGE_F_PROGRESS_2026-04-21.md` +8. `docs/technical/RPG_ENTRY_RUNTIME_CHAIN_REFACTOR_WORK_PACKAGE_G_PROGRESS_2026-04-21.md` +9. `docs/technical/RPG_ENTRY_RUNTIME_CHAIN_REFACTOR_WORK_PACKAGE_H_PROGRESS_2026-04-21.md` +10. 前端 `rpg-entry`、`rpg-session`、`rpg-runtime-shell`、`rpg-runtime-panels`、`rpg-runtime-story` 新域真实实现 +11. 后端 `routes/rpg-*`、`modules/rpg-runtime-story`、`repositories/rpg-*` 新域真实实现 +12. shared contract 分文件与兼容 façade +13. 与工作包 E / F / G / H 直接相关的定向测试与工作树状态 + +## 3. 复核结论 + +## 3.1 第一批并行结论 + +第一批并行只包含工作包 A。 + +复核结果: + +1. 工作包 A 要求的目录骨架、façade、barrel 与按域命名落点已经建立。 +2. 工作包 A 没有发现需要继续补做的编码遗漏。 +3. 当前 A 的剩余内容本来就属于后续工作包 B 到 H 的真实迁移,不属于遗漏。 + +结论: + +**第一批并行无新增遗漏项。** + +## 3.2 第二批并行总体结论 + +对照工作包 B 到 H 的进度文档与实际主链代码,当前状态如下: + +1. 工作包 B:`rpg-entry` 已承接真实入口实现,旧 `game-shell` / `rpg-creation-flow` 路径已降级为兼容层。 +2. 工作包 C:`rpg-session` 已承接真实 session / persistence 主链,`storageService.ts` 已退化为兼容转发层。 +3. 工作包 D:`rpg-runtime-shell` 与 `rpg-runtime-panels` 已承接真实实现,旧 `GameShell*` / `AdventurePanel` 已降级为兼容入口。 +4. 工作包 E:前端 runtime story 主链已迁入 `rpg-runtime-story`,但测试工作树存在未收口的合流状态。 +5. 工作包 F:`app.ts` 已按 `rpgProfile / rpgEntry / rpgRuntimeStory / rpgRuntimeAiAssist` 挂载新域路由,旧 `runtimeRoutes.ts` 已降级。 +6. 工作包 G:后端 runtime story action / state / session 真实实现已迁入 `modules/rpg-runtime-story`,旧热点已退化为兼容导出。 +7. 工作包 H:仓储、shared contract 与定向测试基建已落到新域命名与分文件结构。 + +结论: + +**第二批并行的主链改造基本已经完成,本轮确认到的真实遗漏主要集中在“工程收口状态”而不是“功能未落地”。** + +## 4. 本轮确认的遗漏项 + +## 4.1 工作包 E 相关测试文件仍处于未解决合流状态 + +复核时发现: + +1. `src/hooks/story/npcEncounterActions.test.ts` 的文件内容已经切到 `../rpg-runtime-story/rpgRuntimeStoryGateway` 新路径。 +2. 但 Git 索引仍把该文件标记为 `UU`,说明这份并行工作没有真正完成收口。 + +这会导致: + +1. 工作包 E 虽然逻辑上已完成迁移,但工程状态仍不能算完全闭合。 +2. 后续继续判断第二批并行是否完成时,会被未解决合流状态误判为仍未完成。 + +## 4.2 后端总测试文件仍保留同一轮并行改动的未解决合流状态 + +复核时发现: + +1. `server-node/src/app.test.ts` 仍处于 `UU` 状态。 +2. 该文件中的两侧改动并不冲突: + - 一侧是把微信登录测试的 cookie 解析统一改成 `readCookieValue(...)` + - 另一侧是补充 custom world agent SSE enriched session 回归测试 +3. 当前文件内容已经同时包含两侧结果,但索引没有完成正式收口。 +4. 合并后还残留了一个未被使用的 `accessCookie` 中间变量,属于典型的合流尾巴。 + +这同样属于: + +**不是功能缺失,而是并行工作未彻底收口。** + +## 4.3 技术文档索引未完整登记本轮工作包文档 + +复核时发现: + +1. `docs/technical/README.md` 已登记 A、D、E、F、G、H 的进度文档。 +2. 但缺少工作包 B 与工作包 C 的进度文档入口。 +3. 这会让后续按文档索引回溯第二批并行工作时出现“文档已存在但目录索引缺失”的信息断层。 + +这属于文档收口遗漏,应当在本轮一并补齐。 + +## 5. 本轮补齐动作 + +本轮只补以下缺口: + +1. 清理 `server-node/src/app.test.ts` 中合流后残留的未使用变量,保留两侧都应保留的测试结果。 +2. 将 `src/hooks/story/npcEncounterActions.test.ts` 作为工作包 E 已确认的正确内容进行正式收口。 +3. 将 `server-node/src/app.test.ts` 作为同轮并行改动的正确合并结果进行正式收口。 +4. 更新 `docs/technical/README.md`,补齐工作包 B、工作包 C 与本复核文档入口。 + +## 6. 本轮明确没有做的事 + +为了避免过度开发,本轮没有继续做以下事情: + +1. 没有继续拆 UI 组件。 +2. 没有继续清理旧兼容 façade。 +3. 没有额外重构任何 route / service / repository。 +4. 没有调整任何前端交互界面设计。 +5. 没有顺手处理与本次 RPG 并行重构无直接关系的其他噪音文件。 + +## 7. 复核后状态结论 + +在本轮补齐之后,可以对第一批与第二批并行工作给出如下结论: + +1. 第一批并行没有发现新增遗漏。 +2. 第二批并行的主链功能改造已经基本齐备。 +3. 本轮确认并补齐的遗漏主要是测试合流收口与文档索引缺口。 +4. 当前不需要继续扩大改造范围,后续可以按执行计划进入第三批统一收口。 diff --git a/docs/technical/RPG_ENTRY_RUNTIME_CHAIN_REFACTOR_PHASE3_CLOSURE_2026-04-21.md b/docs/technical/RPG_ENTRY_RUNTIME_CHAIN_REFACTOR_PHASE3_CLOSURE_2026-04-21.md new file mode 100644 index 00000000..d405719f --- /dev/null +++ b/docs/technical/RPG_ENTRY_RUNTIME_CHAIN_REFACTOR_PHASE3_CLOSURE_2026-04-21.md @@ -0,0 +1,191 @@ +# RPG 进入游戏与运行时链路重构第三批收口记录 + +更新时间:`2026-04-21` + +## 1. 本次目标 + +本次只落实 `RPG_ENTRY_RUNTIME_CHAIN_REFACTOR_EXECUTION_PLAN_2026-04-21.md` 中的 **第三批收口**,严格遵守以下边界: + +1. 把第二批工作包 B 到 H 已完成的新域 façade 真正接回主链。 +2. 对照执行计划逐项检查“开始游戏 / 继续游戏 / 角色选择 / 冒险 runtime / 存档恢复”是否仍残留旧命名真实依赖。 +3. 只清理确认属于 RPG 主链的新域反向依赖与未接线仓储,不扩到创作链、UI 设计或玩法语义。 + +## 2. 本次完成的第三批收口 + +## 2.1 前端运行态主链不再反向依赖旧 `GameShell` 热点 + +本轮把以下真实实现从旧 `game-shell` 命名下摘回 `rpg-runtime-shell`: + +1. `src/components/rpg-runtime-shell/RpgRuntimeCanvasStage.tsx` +2. `src/components/rpg-runtime-shell/rpgRuntimeLoaders.tsx` +3. `src/components/rpg-runtime-shell/useRpgSceneTransitionModel.ts` + +同时完成以下主链接线: + +1. `src/components/rpg-runtime-shell/RpgRuntimeShell.tsx` 直接使用 `RpgRuntimeCanvasStage` +2. `src/components/rpg-runtime-shell/RpgRuntimeOverlayHost.tsx` 直接使用 `rpgRuntimeLoaders` +3. `src/components/rpg-runtime-shell/useRpgRuntimeShellViewModel.ts` 直接使用 `useRpgSceneTransitionModel` +4. `src/components/rpg-runtime-panels/RpgRuntimePanelRouter.tsx` 直接从 `rpg-runtime-shell` 读取 `PanelLoadingFallback` + +当前结果: + +1. 运行态主链真实实现已经不再依赖旧 `GameShellCanvasStage`、`GameShellLoaders`、`useSceneTransitionModel`。 +2. 旧 `game-shell` 文件只保留兼容 re-export,不再作为 RPG 运行态真实落点。 + +## 2.2 前端入口域主链不再反向依赖旧平台展示 helper + +本轮把以下入口域通用展示能力收回 `rpg-entry`: + +1. `src/components/rpg-entry/RpgEntryBrandLogo.tsx` +2. `src/components/rpg-entry/rpgEntryWorldPresentation.ts` +3. `src/components/rpg-entry/RpgEntryCreationTypeModal.tsx` + +并完成以下主链接线: + +1. `RpgEntryHomeView.tsx` 改为直接使用 `RpgEntryBrandLogo` 与 `rpgEntryWorldPresentation` +2. `RpgEntryWorldDetailView.tsx` 改为直接使用 `rpgEntryWorldPresentation` +3. `RpgEntryFlowShellImpl.tsx` 改为直接使用 `RpgEntryCreationTypeModal` + +当前结果: + +1. `rpg-entry` 真实实现不再依赖旧 `PlatformBrandLogo`、`platformWorldPresentation`、`PlatformCreationTypeModal`。 +2. 旧 `game-shell` 对应文件只保留兼容导出。 + +## 2.3 工作包 H 新仓储已真正接回后端 RPG 主链 + +本轮把工作包 H 中已建立但尚未接回主链的新仓储正式注入 `AppContext`: + +1. `rpgProfileDashboardRepository` +2. `rpgBrowseHistoryRepository` +3. `rpgSaveArchiveRepository` +4. `rpgRuntimeSnapshotRepository` + +并完成以下主链接线: + +1. `server-node/src/routes/rpg-profile/rpgProfileRoutes.ts` + - 资料看板、钱包流水、游玩统计走 `rpgProfileDashboardRepository` + - 浏览历史走 `rpgBrowseHistoryRepository` + - 设置读写走 `rpgProfileDashboardRepository` +2. `server-node/src/routes/rpg-entry/rpgEntrySaveRoutes.ts` + - snapshot 读写删除走 `rpgRuntimeSnapshotRepository` + - save archive 列表与恢复走 `rpgSaveArchiveRepository` +3. `server-node/src/routes/rpg-runtime/rpgRuntimeStoryRoutes.ts` + - runtime story 状态读取与动作结算走 `rpgRuntimeSnapshotRepository` + +当前结果: + +1. `rpgProfile`、`rpgEntrySave`、`rpgRuntimeStory` 主链已经不再直接把大 `runtimeRepository` 当作唯一注入边界。 +2. 工作包 H 的新仓储不再停留在“命名骨架已存在但主链未接线”的状态。 + +## 2.4 新域 shared contract 进一步脱离旧 `story.ts` façade + +本轮继续把 RPG 新主链中的 shared contract 直接切到分文件: + +1. `server-node/src/routes/rpg-runtime/rpgRuntimeStoryRoutes.ts` + - 改为直接使用 `rpgRuntimeStoryState.ts` +2. `server-node/src/routes/rpg-runtime/rpgRuntimeAiAssistRoutes.ts` + - 改为直接使用 `rpgRuntimeChat.ts`、`rpgRuntimeQuestAssist.ts` +3. `server-node/src/modules/rpg-runtime-story/RpgRuntimeSessionDomain.ts` + - 改为直接使用 `rpgRuntimeStoryAction.ts` + - snapshot 类型改为使用 `RpgRuntimeSavedSnapshot` +4. `server-node/src/modules/rpg-runtime-story/RpgRuntimeStoryActionDomain.ts` + - runtime story request/response 类型改为直接使用 `rpgRuntimeStoryState.ts` + - snapshot 仓储端口改为使用 `RpgRuntimeSnapshotRepositoryPort` + +当前结果: + +1. RPG 新域主链已经显著减少对旧 `packages/shared/src/contracts/story.ts` façade 的反向依赖。 +2. `story.ts` 继续保留兼容职责,但不再是第三批已收口主链的真实首选入口。 + +## 2.5 补齐世界库主链接线遗漏 + +代码级复核后,本轮额外补齐了第三批范围内一个真实遗漏:世界库主链虽然已经有 `rpg-entry` 路由和 `RpgWorldLibraryRepository`,但此前并未完整接回主链。 + +本次补齐包括: + +1. `server-node/src/context.ts` 与 `server-node/src/server.ts` + - 正式注入 `rpgWorldLibraryRepository` +2. `server-node/src/routes/rpg-entry/rpgWorldLibraryRoutes.ts` + - 作品库/广场/详情/发布/下架/删除等读写改为直接走 `rpgWorldLibraryRepository` + - `rpgWorldProfileRepository` 继续保留给 Agent 发布链与发布服务使用 +3. `src/services/rpg-entry/rpgEntryLibraryClient.ts` + - 改为直接承接 `/api/runtime/custom-world-library` 与 `/api/runtime/custom-world-gallery` 请求 + - 不再反向依赖旧 `storageService.ts` 兼容层 +4. `src/services/rpg-entry/rpgEntryLibraryClient.test.ts` + - 补齐入口世界库 client 的定向回归 + +当前结果: + +1. “平台首页 / 世界详情 / 开始游戏”所依赖的世界库链路,现在已经和 save/profile 一样真正回到 `rpg-entry` + `rpg-entry repository` 主链。 +2. 第三批范围内不再残留“新命名已建好,但真实主链仍穿旧兼容层/旧仓储”的世界库遗漏。 + +## 3. 老代码物理删除补充 + +根据后续收口要求,本轮在主链稳定后继续删除旧兼容层,不再让 RPG 入口与运行态链路通过旧脚本名兜底。 + +已删除的旧前端入口包括: + +1. `src/components/AdventurePanel.tsx` +2. `src/components/game-shell/*` +3. `src/components/game-shell/rpg-creation-flow/*` +4. `src/hooks/useGameFlow.ts` +5. `src/hooks/useGamePersistence.ts` +6. `src/hooks/useGameShellRuntime.ts` +7. `src/hooks/useStoryGeneration.ts` +8. `src/services/runtimeStoryService.ts` +9. `src/services/storageService.ts` + +已删除的旧后端与共享契约入口包括: + +1. `server-node/src/routes/runtimeRoutes.ts` +2. `server-node/src/modules/story/runtimeSession.ts` +3. `server-node/src/modules/story/storyActionService.ts` +4. `server-node/src/modules/story/storyActionRoutes.ts` +5. `packages/shared/src/contracts/story.ts` + +同步完成的主链迁移包括: + +1. `RpgEntryFlowShellImpl.tsx` 直接使用 `src/components/rpg-entry/useRpgCreation*` hooks,不再反向依赖 `game-shell/rpg-creation-flow`。 +2. runtime story client 测试迁到 `src/services/rpg-runtime/rpgRuntimeStoryClient.test.ts`。 +3. profile / world library 路由测试迁到 `src/services/rpg-entry/rpgEntryClients.routing.test.ts`。 +4. 冒险面板测试迁到 `src/components/rpg-runtime-panels/RpgAdventurePanel*.test.tsx`。 +5. `AdventurePanelOverlays` 子模块改名并迁到 `src/components/rpg-runtime-panels/RpgAdventurePanelOverlays.tsx`。 +6. 后端与前端 shared contract import 已切到 `rpgRuntimeChat.ts`、`rpgRuntimeQuestAssist.ts`、`rpgRuntimeStoryAction.ts`、`rpgRuntimeStoryState.ts`。 + +本轮仍然刻意没有继续扩大到以下内容: + +1. 没有重命名历史审计、旧 PRD、旧技术方案中作为历史记录出现的旧文件名。 +2. 没有继续拆分 `runtimeRepository.ts` 剩余实现。 +3. 没有改 UI 布局、按钮位置、tab 组织、弹层方式或任何用户可见交互设计。 +4. 没有清理与本执行计划无关的创作链旧文件;只删除 RPG 入口 / 运行态链路已经不再依赖的旧脚本。 + +## 4. 本轮检查后确认的未扩范围 + +为了避免过度开发,本轮明确没有继续扩到以下内容: + +1. 没有继续改 UI 布局、按钮位置、tab 组织、弹层方式或任何用户可见交互设计。 +2. 没有继续拆分 `runtimeRepository.ts` 剩余实现,也没有扩大到更多仓储接口重命名。 +3. 没有顺手清理历史文档里的旧文件名引用,只更新本次执行收口文档。 + +## 5. 第三批遗漏复核结论 + +对照执行计划中的第三批要求,本轮确认如下: + +1. **把 B 到 H 的 façade 接回主链**:已完成 + - 前端 `rpg-entry`、`rpg-runtime-shell`、`rpg-runtime-panels` + - 后端 `rpg-profile`、`rpg-entry-save`、`rpg-runtime-story` +2. **联调继续游戏 / 开始游戏 / 角色选择 / 冒险 runtime / 存档恢复**:本轮已把这些链路上的主注入边界和真实落点接回新域 +3. **清理旧命名与兼容导出**:已完成主链级清理,并按本次补充要求物理删除旧兼容脚本 + +当前仍保留但不计为遗漏的部分: + +1. 历史审计、旧 PRD、旧技术方案中仍会提到旧文件名,这是历史记录,不代表运行时代码依赖。 +2. `src/components/rpg-runtime-panels/RpgAdventurePanel.tsx` 仍保持原交互结构,只做脚本路径与命名收口。 + +## 6. 结论 + +在“不改 UI、不改玩法、不扩到创作链执行方案”的前提下,本轮已经完成 RPG 执行计划中的第三批主链收口: + +1. 新域真实实现已经接回前后端主链。 +2. RPG 主链对旧 `GameShell` / `runtimeRoutes` / `story.ts` façade 的真实依赖已经删除。 +3. 复核中发现并补齐了世界库主链接线遗漏后,当前没有再发现属于本次第三批范围、且必须继续补做的遗漏项。 diff --git a/docs/technical/RPG_ENTRY_RUNTIME_CHAIN_REFACTOR_WORK_PACKAGE_A_PROGRESS_2026-04-21.md b/docs/technical/RPG_ENTRY_RUNTIME_CHAIN_REFACTOR_WORK_PACKAGE_A_PROGRESS_2026-04-21.md new file mode 100644 index 00000000..c67cd443 --- /dev/null +++ b/docs/technical/RPG_ENTRY_RUNTIME_CHAIN_REFACTOR_WORK_PACKAGE_A_PROGRESS_2026-04-21.md @@ -0,0 +1,101 @@ +# RPG 进入游戏与运行时链路重构工作包 A 落地记录 + +更新时间:`2026-04-21` + +## 1. 本次目标 + +本次只落实 `RPG_ENTRY_RUNTIME_CHAIN_REFACTOR_EXECUTION_PLAN_2026-04-21.md` 中的 **工作包 A:RPG 命名规范与目录骨架**,严格遵守以下边界: + +1. 先建立 RPG 进入游戏链的新目录与命名落点。 +2. 先补 façade、barrel、path 常量和按域仓储入口,不提前迁移主流程逻辑。 +3. 不修改现有前端交互界面设计,不提前实现工作包 B 到 H 的真实拆分。 + +## 2. 本次已落地内容 + +## 2.1 前端目录骨架 + +已新增以下前端目录与兼容 façade: + +1. `src/components/rpg-entry/` +2. `src/components/rpg-runtime-shell/` +3. `src/components/rpg-runtime-panels/` +4. `src/hooks/rpg-session/` +5. `src/hooks/rpg-runtime-story/` +6. `src/services/rpg-entry/` +7. `src/services/rpg-runtime/` + +当前策略: + +1. `RpgEntryFlowShell` 先桥接当前真实存在的 `src/components/game-shell/rpg-creation-flow/RpgCreationShell.tsx`,不再依赖已删除的旧 `PreGameSelectionFlow.tsx`。 +2. `RpgEntryHomeView`、`RpgEntryWorldDetailView`、`RpgEntryCharacterSelectView` 继续桥接旧平台首页、世界详情和选角视图。 +3. `RpgRuntimeShell`、`RpgRuntimeStageRouter`、`RpgRuntimePanelRouter`、`RpgAdventurePanel`、`RpgRuntimeOverlayHost` 继续桥接旧 `GameShell*` 与 `AdventurePanel` 组件。 +4. `useRpgSessionBootstrap`、`useRpgSessionPersistence`、`useRpgRuntimeSession` 继续桥接 `useGameFlow`、`useGamePersistence`、`useGameShellRuntime`。 +5. `useRpgRuntimeStory`、`useRpgRuntimeStoryController`、`useRpgRuntimeStoryFlow`、`useRpgRuntimeInteractionFlow`、`useRpgRuntimeNpcInteraction`、`rpgRuntimeStoryGateway` 继续桥接旧 story runtime hooks 与 gateway。 + +## 2.2 前端 service/client 骨架 + +已新增以下 service/client 落点: + +1. `src/services/rpg-entry/rpgEntryLibraryClient.ts` +2. `src/services/rpg-entry/rpgProfileClient.ts` +3. `src/services/rpg-runtime/rpgSnapshotClient.ts` +4. `src/services/rpg-runtime/rpgRuntimeStoryClient.ts` +5. `src/services/rpg-runtime/rpgRuntimeChatClient.ts` + +当前策略: + +1. `rpgEntryLibraryClient` 继续桥接 `storageService.ts` 中的世界库、世界广场与发布相关接口。 +2. `rpgProfileClient` 继续桥接资料看板、浏览历史、设置与继续游戏归档相关接口。 +3. `rpgSnapshotClient` 继续桥接快照读写接口。 +4. `rpgRuntimeStoryClient` 继续桥接 `/api/runtime/story` 的旧 client。 +5. `rpgRuntimeChatClient` 继续桥接 `aiService.ts` 中的角色聊天、NPC 对话与招募对话接口。 + +## 2.3 后端目录骨架 + +已新增以下后端目录与 façade: + +1. `server-node/src/routes/rpg-entry/` +2. `server-node/src/routes/rpg-profile/` +3. `server-node/src/routes/rpg-runtime/` +4. `server-node/src/modules/rpg-runtime-story/` +5. `server-node/src/repositories/rpg-entry/` +6. `server-node/src/repositories/rpg-profile/` +7. `server-node/src/repositories/rpg-runtime/` + +当前策略: + +1. `createRpgProfileRoutes()`、`createRpgEntrySaveRoutes()`、`createRpgWorldLibraryRoutes()`、`createRpgRuntimeAiAssistRoutes()` 当前只提供空路由骨架与稳定 path 常量。 +2. `createRpgRuntimeStoryRoutes()` 继续桥接旧 `createStoryActionRoutes()`,确保 runtime story 路由已经有新命名落点。 +3. `RpgRuntimeStoryActionService`、`RpgRuntimeSessionLoader`、`RpgRuntimeOptionCompiler`、`RpgRuntimeSnapshotSync` 继续桥接旧 `storyActionService.ts` 与 `runtimeSession.ts`。 +4. `RpgRuntimeSnapshotRepository`、`RpgSaveArchiveRepository`、`RpgProfileDashboardRepository`、`RpgWorldLibraryRepository` 先以委托 runtimeRepository 的方式建立按域命名仓储入口。 + +## 3. 本次没有做的事 + +以下内容仍保持原状,留给后续工作包: + +1. 没有改 `GameShellMainContent.tsx`、`GameShellRuntime.tsx`、`AdventurePanel.tsx` 的内部实现。 +2. 没有拆 `useGameFlow.ts`、`useGamePersistence.ts`、`useGameShellRuntime.ts` 的真实逻辑。 +3. 没有拆 `useStoryGeneration.ts`、`npcEncounterActions.ts`、`runtimeStoryCoordinator.ts` 的内部职责。 +4. 没有改 `server-node/src/app.ts`、`server-node/src/routes/runtimeRoutes.ts`、`server-node/src/modules/story/storyActionService.ts`、`server-node/src/modules/story/runtimeSession.ts` 的真实挂载与内部逻辑。 +5. 没有补 shared contract 新文件,本轮执行方案的工作包 A 范围未把共享契约骨架列为必做项,因此保持到工作包 H 统一收口。 +6. 没有修改任何前端交互界面设计。 + +## 4. 验证与现状说明 + +本轮已执行: + +1. `npm run check:encoding` + +验证结果: + +1. 编码检查通过。 +2. 全量 `npm run typecheck` 当前未通过,但失败项主要来自工作树中已存在的并行修改与历史类型问题。 +3. 本轮已修正新 façade 中对已删除旧入口 `PreGameSelectionFlow.tsx` 的失效导入,当前 `RpgEntryFlowShell` 已改为桥接真实存在的 `rpg-creation-flow` 入口。 + +## 5. 对后续工作包的直接收益 + +1. 工作包 B 可以直接把平台入口与选角真实实现落到 `src/components/rpg-entry/`,而不必再次讨论命名与目录。 +2. 工作包 C 可以直接把 session/bootstrap/persistence 的调用方迁到 `src/hooks/rpg-session/` 与 `src/services/rpg-runtime/`。 +3. 工作包 D 可以直接让运行态壳层与面板消费 `rpg-runtime-shell`、`rpg-runtime-panels` 新入口。 +4. 工作包 E 可以直接把 story runtime 与 NPC 交互迁到 `src/hooks/rpg-runtime-story/` 和 `src/services/rpg-runtime/`。 +5. 工作包 F、G、H 可以直接基于 `server-node/src/routes/rpg-*`、`server-node/src/modules/rpg-runtime-story/`、`server-node/src/repositories/rpg-*/` 继续做真实迁移,而不用重新搭第一层命名骨架。 diff --git a/docs/technical/RPG_ENTRY_RUNTIME_CHAIN_REFACTOR_WORK_PACKAGE_B_PROGRESS_2026-04-21.md b/docs/technical/RPG_ENTRY_RUNTIME_CHAIN_REFACTOR_WORK_PACKAGE_B_PROGRESS_2026-04-21.md new file mode 100644 index 00000000..97a7b689 --- /dev/null +++ b/docs/technical/RPG_ENTRY_RUNTIME_CHAIN_REFACTOR_WORK_PACKAGE_B_PROGRESS_2026-04-21.md @@ -0,0 +1,108 @@ +# RPG 进入游戏与运行时链路重构工作包 B 落地记录 + +更新时间:`2026-04-21` + +## 1. 本次目标 + +本次只落实 `RPG_ENTRY_RUNTIME_CHAIN_REFACTOR_EXECUTION_PLAN_2026-04-21.md` 中的 **工作包 B:前端 RPG 入口壳层拆分**,严格遵守以下边界: + +1. 只改前端入口链,不改后端接口语义。 +2. 只处理平台首页、世界详情、继续游戏、进入世界、选角相关入口壳层与入口 hooks。 +3. 不拆运行态冒险面板,不修改任何前端交互界面设计。 + +## 2. 本次已完成内容 + +## 2.1 `rpg-entry` 已成为真实入口目录 + +以下文件现在承载真实实现,不再只是 façade: + +1. `src/components/rpg-entry/RpgEntryFlowShell.tsx` +2. `src/components/rpg-entry/RpgEntryFlowShellImpl.tsx` +3. `src/components/rpg-entry/RpgEntryHomeView.tsx` +4. `src/components/rpg-entry/RpgEntryWorldDetailView.tsx` +5. `src/components/rpg-entry/RpgEntryCharacterSelectView.tsx` +6. `src/components/rpg-entry/rpgEntryTypes.ts` +7. `src/components/rpg-entry/rpgEntryShared.ts` + +这意味着当前 RPG 平台入口、详情页和选角页的真实物理落点已经从旧 `game-shell` 命名转入 `rpg-entry` 域,满足执行方案里“平台首页/详情页/选角能落到 `rpgEntry` 目录”的验收要求。 + +## 2.2 入口相关 hooks 已补齐到 `rpg-entry` + +本轮新增或迁入以下入口域 hooks: + +1. `src/components/rpg-entry/useRpgEntryBootstrap.ts` +2. `src/components/rpg-entry/useRpgEntryLibraryDetail.ts` +3. `src/components/rpg-entry/useRpgEntryNavigation.ts` +4. `src/components/rpg-entry/useRpgEntrySaveResume.ts` +5. `src/components/rpg-entry/useRpgEntryCharacterSelect.ts` + +其中: + +1. `useRpgEntryBootstrap` 负责平台 works / library / gallery / history / save / dashboard 拉取与继续游戏恢复入口。 +2. `useRpgEntryLibraryDetail` 负责详情页打开、作品详情读取、继续创作入口、发布/下架/删除动作。 +3. `useRpgEntryNavigation` 收口入口阶段跳转,避免壳层里继续散落匿名 `setSelectionStage(...)`。 +4. `useRpgEntrySaveResume` 明确“继续游戏”动作入口。 +5. `useRpgEntryCharacterSelect` 补齐选角页回退与确认动作的入口域命名。 + +## 2.3 旧 `game-shell` / `rpg-creation-flow` 路径已降级为兼容层 + +以下旧文件已改为兼容 re-export,不再持有真实实现: + +1. `src/components/game-shell/PlatformHomeView.tsx` +2. `src/components/game-shell/PlatformWorldDetailView.tsx` +3. `src/components/game-shell/CharacterSelectionFlow.tsx` +4. `src/components/game-shell/rpg-creation-flow/RpgCreationShell.tsx` +5. `src/components/game-shell/rpg-creation-flow/RpgCreationShellImpl.tsx` +6. `src/components/game-shell/rpg-creation-flow/rpgCreationFlowTypes.ts` +7. `src/components/game-shell/rpg-creation-flow/rpgCreationFlowShared.ts` +8. `src/components/game-shell/rpg-creation-flow/useRpgCreationPlatformBootstrap.ts` +9. `src/components/game-shell/rpg-creation-flow/useRpgCreationDetailNavigation.ts` + +兼容策略如下: + +1. 旧命名继续导出,避免并行工作包与现有测试断开。 +2. 真实实现统一回落到 `rpg-entry`。 +3. 本轮不清理 legacy façade,只让它们退化成稳定桥接层。 + +## 2.4 主链调用方已接回 `rpg-entry` + +本轮已把主入口相关调用改为直接消费 `rpg-entry`: + +1. `src/components/game-shell/GameShellMainContent.tsx` +2. `src/components/game-shell/useGameShellViewModel.ts` +3. `src/components/rpg-entry/index.ts` + +当前结果: + +1. 主阶段路由器 lazy import 已直接走 `RpgEntryFlowShell` 与 `RpgEntryCharacterSelectView`。 +2. `SelectionStage` 类型已从 `rpg-entry` 暴露,不再依赖旧 `rpg-creation-flow` 作为主命名根。 +3. 旧路径仍可用,但已经不再是主链真实入口。 + +## 3. 对照执行方案的完成判断 + +工作包 B 本轮已完成以下计划项: + +1. 已把平台首页/详情页/继续游戏/进入世界从旧 `PreGameSelectionFlow` 所属旧命名体系中收进 `rpg-entry` 域。 +2. 已让 `rpg-entry` 成为真实实现目录,而不是只保留 façade。 +3. 已补齐入口阶段相关新 hooks。 +4. 已让旧路径退化为兼容桥接层。 +5. 没有修改任何前端交互界面设计。 + +当前仍刻意保留的边界: + +1. 没有拆运行态冒险面板,这属于工作包 D。 +2. 没有改 session/bootstrap/persistence 主逻辑,这属于工作包 C。 +3. 没有改后端接口语义与 route/service 边界,这属于工作包 F/G/H。 +4. 没有提前清理所有 legacy re-export,本轮以稳定主链为先。 + +## 4. 验证与遗漏核查 + +本轮需要重点核查的遗漏项已经逐项确认: + +1. `RpgEntryFlowShell` 不再桥接旧实现,已直接使用 `rpg-entry` 目录下的真实壳层。 +2. `PlatformHomeView`、`PlatformWorldDetailView`、`CharacterSelectionFlow` 的旧路径已不再持有真实实现。 +3. `GameShellMainContent` 已直接 lazy import `rpg-entry` 新入口。 +4. `SelectionStage` 已从 `rpg-entry` 作为主出口暴露。 +5. 复核时补齐了一个第二批收口遗漏:`RpgEntryFlowShellImpl`、`useRpgEntryBootstrap`、`useRpgEntryLibraryDetail` 这些 `rpg-entry` 真实实现已改为直接消费 `rpg-entry` 新域 client,不再反向依赖旧 `storageService.ts` 作为主链入口。 + +本轮未执行全量 `typecheck`,原因是工作树存在并行改动与未解决冲突文件;但已把本工作包范围内最容易遗漏的主链接线点和兼容层路径全部补齐。 diff --git a/docs/technical/RPG_ENTRY_RUNTIME_CHAIN_REFACTOR_WORK_PACKAGE_C_PROGRESS_2026-04-21.md b/docs/technical/RPG_ENTRY_RUNTIME_CHAIN_REFACTOR_WORK_PACKAGE_C_PROGRESS_2026-04-21.md new file mode 100644 index 00000000..f77bb823 --- /dev/null +++ b/docs/technical/RPG_ENTRY_RUNTIME_CHAIN_REFACTOR_WORK_PACKAGE_C_PROGRESS_2026-04-21.md @@ -0,0 +1,115 @@ +# RPG 进入游戏与运行时链路重构工作包 C 落地记录 + +更新时间:`2026-04-21` + +## 1. 本次目标 + +本次只落实 `RPG_ENTRY_RUNTIME_CHAIN_REFACTOR_EXECUTION_PLAN_2026-04-21.md` 中的 **工作包 C:前端 session / bootstrap / persistence 拆分**,严格遵守以下边界: + +1. 把 `useGameFlow.ts`、`useGamePersistence.ts`、`useGameShellRuntime.ts` 的真实实现收进 `rpg-session` 域。 +2. 把 snapshot / save archive 相关 client 从 `storageService.ts` 抽到 `src/services/rpg-runtime/`、`src/services/rpg-entry/`。 +3. 旧文件只保留兼容导出,不再继续承载主实现。 +4. 不修改 AdventurePanel UI,不修改后端 story 动作语义,不修改任何前端交互界面设计。 + +## 2. 本次已落地内容 + +## 2.1 `rpg-session` 主实现已承接 session 链 + +本轮已把以下真实实现迁入新域目录: + +1. `src/hooks/rpg-session/useRpgSessionBootstrap.ts` +2. `src/hooks/rpg-session/useRpgSessionPersistence.ts` +3. `src/hooks/rpg-session/useRpgRuntimeSession.ts` +4. `src/hooks/rpg-session/rpgSessionTypes.ts` + +落地结果: + +1. `useRpgSessionBootstrap` 现在直接承载世界选择、角色确认、新开局 `GameState` 初始化逻辑。 +2. `useRpgSessionPersistence` 现在直接承载自动存档、继续游戏恢复、远端快照拉取与 runtime story 恢复刷新。 +3. `useRpgRuntimeSession` 现在直接组合 bootstrap / persistence / story / combat / companion 等链路,成为主运行态装配入口。 + +## 2.2 旧 `useGame*` 文件已退化为兼容 façade + +以下旧文件已不再承载主实现,只保留兼容导出: + +1. `src/hooks/useGameFlow.ts` +2. `src/hooks/useGamePersistence.ts` +3. `src/hooks/useGameShellRuntime.ts` + +当前策略: + +1. 旧调用方仍可继续工作,避免影响并行工作包。 +2. 正式主入口已经切到 `useRpgRuntimeSession`。 +3. `BottomTab` 类型已从 `rpg-session` 域提供,避免继续绑在旧 hook 文件上。 + +## 2.3 snapshot / save archive client 已迁到新域 + +本轮已把快照与继续游戏归档的真实请求实现迁入: + +1. `src/services/rpg-runtime/rpgRuntimeRequest.ts` +2. `src/services/rpg-runtime/rpgSnapshotClient.ts` +3. `src/services/rpg-entry/rpgProfileClient.ts` + +落地结果: + +1. `rpgSnapshotClient` 现在直接承载 `/api/runtime/save/snapshot` 的读取、写入、删除。 +2. `rpgProfileClient` 现在直接承载设置、个人资料、浏览历史、继续游戏归档相关请求。 +3. `storageService.ts` 已退化为兼容转发层,不再继续作为 snapshot / save archive 主实现落点。 + +## 2.4 主入口与直接类型依赖已切换 + +本轮已更新以下直接调用点: + +1. `src/App.tsx` 改为直接使用 `useRpgRuntimeSession` +2. `src/components/game-shell/types.ts` +3. `src/components/game-shell/GameShellMainContent.tsx` +4. `src/components/game-shell/GameShellStoryPanels.tsx` + +当前状态: + +1. 运行态主入口已经不再直接依赖 `useGameShellRuntime`。 +2. `GameShell` 相关组件仍保持 UI 与结构不变,只调整了 session 域导入路径。 + +## 2.5 测试补齐 + +本轮补齐或切换了以下定向测试: + +1. `src/hooks/runtimeAuthGuards.test.tsx` +2. `src/hooks/useGameFlow.customWorld.test.tsx` +3. `src/services/rpg-entry/rpgProfileClient.test.ts` +4. `src/services/rpg-runtime/rpgSnapshotClient.test.ts` + +目的: + +1. 覆盖 `rpg-session` 新 hook 的远端鉴权守卫行为。 +2. 覆盖自定义世界进入世界后的 bootstrap 初始化结果。 +3. 覆盖迁入新域后的 browse history / save archive / snapshot 路由请求。 + +## 3. 本次没有做的事 + +以下内容仍保持原状,留给后续工作包: + +1. 没有拆 `AdventurePanel.tsx`、`GameShellRuntime.tsx`、`GameShellMainContent.tsx` 的内部 UI 组织。 +2. 没有拆 `useStoryGeneration.ts`、`npcEncounterActions.ts`、`runtimeStoryCoordinator.ts` 的 runtime story 内部职责。 +3. 没有修改后端 `storyActionService.ts`、`runtimeSession.ts` 或任何路由组织。 +4. 没有修改平台入口/世界详情/选角页面的视觉结构、按钮位置、tab 组织和独立面板交互方式。 + +## 4. 验证与检查 + +本轮应执行并记录: + +1. `npm run check:encoding` +2. `npx vitest run src/hooks/runtimeAuthGuards.test.tsx src/hooks/useGameFlow.customWorld.test.tsx src/services/rpg-entry/rpgProfileClient.test.ts src/services/rpg-runtime/rpgSnapshotClient.test.ts src/services/storageService.test.ts` + +重点核查点: + +1. 主入口是否已经切到 `useRpgRuntimeSession`。 +2. `useGameFlow.ts`、`useGamePersistence.ts`、`useGameShellRuntime.ts` 是否只剩兼容导出。 +3. `storageService.ts` 是否已不再承载 snapshot / save archive 的真实实现。 +4. 是否没有改动任何前端界面结构与交互表现。 + +## 5. 对后续工作包的直接收益 + +1. 工作包 B / D 可以继续消费 `rpg-session` 新域入口,而不必再从旧 `useGame*` 文件接主逻辑。 +2. 工作包 E 可以只关注 runtime story 链,不必再同时承担 session / persistence 主实现迁移。 +3. 后续清理旧命名时,可以直接删除 `useGame*` 兼容层,而不会再触发大规模逻辑回迁。 diff --git a/docs/technical/RPG_ENTRY_RUNTIME_CHAIN_REFACTOR_WORK_PACKAGE_D_PROGRESS_2026-04-21.md b/docs/technical/RPG_ENTRY_RUNTIME_CHAIN_REFACTOR_WORK_PACKAGE_D_PROGRESS_2026-04-21.md new file mode 100644 index 00000000..df42ce99 --- /dev/null +++ b/docs/technical/RPG_ENTRY_RUNTIME_CHAIN_REFACTOR_WORK_PACKAGE_D_PROGRESS_2026-04-21.md @@ -0,0 +1,130 @@ +# RPG 进入游戏与运行时链路重构工作包 D 落地记录 + +更新时间:`2026-04-21` + +## 1. 本次目标 + +本次只落实 `RPG_ENTRY_RUNTIME_CHAIN_REFACTOR_EXECUTION_PLAN_2026-04-21.md` 中的 **工作包 D:前端运行态 shell 与面板拆分**,严格遵守以下边界: + +1. 把 `GameShellRuntime.tsx`、`GameShellMainContent.tsx`、`GameShellStoryPanels.tsx`、`AdventurePanel.tsx` 的真实实现迁入 `rpg-runtime-shell` 与 `rpg-runtime-panels`。 +2. 旧 `GameShell*` 与 `AdventurePanel` 保留兼容桥接,不在这一轮硬删旧路径。 +3. 不改平台入口链、不改 runtime story 后端协议、不改任何前端交互界面设计。 +4. 冒险主面板只做最小必要的 section 拆分,不额外扩散成更多与工作包 D 无关的重构。 + +## 2. 本次已落地内容 + +## 2.1 RPG 运行态 shell 已承接真实实现 + +以下文件已从 façade 升级为真实实现: + +1. `src/components/rpg-runtime-shell/RpgRuntimeShell.tsx` +2. `src/components/rpg-runtime-shell/RpgRuntimeStageRouter.tsx` +3. `src/components/rpg-runtime-shell/RpgRuntimeOverlayHost.tsx` +4. `src/components/rpg-runtime-shell/useRpgRuntimeShellViewModel.ts` +5. `src/components/rpg-runtime-shell/useRpgRuntimeOverlayState.ts` +6. `src/components/rpg-runtime-shell/types.ts` + +本轮完成的真实迁移包括: + +1. `App.tsx` 主入口已经改为直接挂载 `RpgRuntimeShell`,不再把旧 `GameShellRuntime` 当作真实主入口。 +2. `RpgRuntimeShell` 已承接运行态总外壳、画布舞台、主阶段路由、overlay host 装配。 +3. `RpgRuntimeStageRouter` 已承接平台入口 / 角色选择 / 冒险运行态三阶段切换。 +4. `RpgRuntimeOverlayHost` 已承接角色面板浮层、背包浮层、冒险实体详情、营地、地图、角色聊天与 NPC 交互弹层。 +5. `useRpgRuntimeShellViewModel` 已承接运行态 overlay 状态、过场可见态、统计数据和 scene transition choice 包装。 +6. `useRpgRuntimeOverlayState` 已把旧 `useGameShellViewModel` 的壳层状态迁入 RPG 域命名。 + +## 2.2 RPG 主面板路由与冒险主面板已承接真实实现 + +以下文件已从 façade 升级为真实实现: + +1. `src/components/rpg-runtime-panels/RpgRuntimePanelRouter.tsx` +2. `src/components/rpg-runtime-panels/RpgAdventurePanel.tsx` + +本轮完成的真实迁移包括: + +1. `RpgRuntimePanelRouter` 已承接冒险 / 角色 / 背包三主标签切换。 +2. `RpgAdventurePanel` 已成为真实冒险主面板实现,不再只是桥接旧 `AdventurePanel`。 +3. 冒险主面板按执行计划要求显式拆成了三个主 section: + - story section:剧情展示区与对话流 + - choice section:按钮区、快捷入口、NPC 输入框 + - overlay section:任务/设置/统计/奖励等独立面板挂载 +4. 拆分后保持了原有 UI 结构、按钮位置、浮层方式和交互顺序不变。 +5. 同时修正了冒险面板测试里要求隐藏的说明文本,避免迁移后把 `detailText` 重新暴露到 UI 中。 + +## 2.3 旧热点文件已降级为兼容桥接层 + +以下旧热点文件已不再承载真实实现,只保留兼容入口: + +1. `src/components/game-shell/GameShellRuntime.tsx` +2. `src/components/game-shell/GameShellMainContent.tsx` +3. `src/components/game-shell/GameShellStoryPanels.tsx` +4. `src/components/game-shell/GameShellOverlays.tsx` +5. `src/components/AdventurePanel.tsx` +6. `src/components/game-shell/useGameShellRuntimeViewModel.ts` +7. `src/components/game-shell/useGameShellViewModel.ts` +8. `src/components/game-shell/types.ts` + +当前策略: + +1. 旧 `GameShell*` 路径继续可被现有调用与测试引用。 +2. 旧 hook / type 文件继续对外提供兼容别名,避免并行工作流马上失效。 +3. 真实运行态主链已经切到 `rpg-runtime-shell` 与 `rpg-runtime-panels`,旧热点不再继续扩大职责。 + +## 2.4 为兼容迁移补齐的新类型出口 + +为了避免本轮迁移造成旧桥接和 barrel 断裂,本轮额外补齐了以下类型出口: + +1. `RpgRuntimeShellProps` +2. `RpgRuntimeShellViewModelResult` +3. `RpgEntryHomeViewProps` +4. `RpgEntryWorldDetailViewProps` + +这些改动只用于保证工作包 D 新目录可以作为真实调用入口和兼容桥的稳定目标,不属于额外功能开发。 + +## 3. 本次刻意未做的事 + +以下内容明确保持到其他工作包,不在本轮越界处理: + +1. 没有改平台入口链编排;`rpg-entry` 的真实拆分仍属于工作包 B。 +2. 没有改 session/bootstrap/persistence 语义;这部分仍属于工作包 C。 +3. 没有改 runtime story hooks、NPC 交互主链与后端协议;这部分仍属于工作包 E 及后续后端工作包。 +4. 没有重做 AdventurePanel 内部更细粒度的卡片/子面板组件树,只做执行计划明确要求的三段 section 拆分。 +5. 没有删除旧 `GameShell*` 文件,只把它们降级为兼容桥,避免影响并行工作流。 +6. 没有调整任何用户可见的布局、按钮位置、tab 组织或弹窗/独立面板出现方式。 + +## 4. 验证结果 + +本轮已完成以下验证: + +1. `npm run check:encoding` +2. `npx vitest run src/components/AdventurePanel.test.tsx src/components/AdventurePanel.npcChat.test.tsx src/components/game-shell/useGameShellRuntimeViewModel.test.ts src/components/CustomWorldEntityEditorModal.test.tsx` +3. 针对本工作包改动路径执行 `tsc` 定向筛查 + +验证结果: + +1. 编码检查通过。 +2. 上述 4 组定向测试全部通过。 +3. 针对本轮改动路径的类型筛查无新增报错。 +4. 全量 `tsc` 仍然存在其他工作流文件的并行类型噪音,但未命中本轮工作包 D 改动文件。 + +## 5. 对工作包 D 完成度的复核 + +对照执行计划中的工作包 D 目标,本轮已完成: + +1. 运行态 shell 的真实迁移。 +2. 主阶段路由器的真实迁移。 +3. 主面板路由器的真实迁移。 +4. 冒险主面板的真实迁移。 +5. 冒险主面板按 section 显式分段。 +6. 旧热点文件降级为兼容桥。 + +仍刻意保留、但不属于遗漏的部分: + +1. 更细粒度的 AdventurePanel 子组件继续保留在单文件内部。 +2. 旧文件兼容导出尚未清理。 +3. 运行态 story hooks / gateway 仍在工作包 E 路径中继续演进。 +4. 本轮复核已补齐一个收口遗漏:`rpg-runtime-shell` / `rpg-runtime-panels` 真实实现所依赖的 story UI 类型,现已直接从 `hooks/rpg-runtime-story` 导入,不再反向依赖旧 `useStoryGeneration.ts` 兼容入口。 + +结论: + +**工作包 D 在“不改 UI、不改后端协议、禁止过度开发”的前提下已经完整落地,当前没有遗漏的必做项。** diff --git a/docs/technical/RPG_ENTRY_RUNTIME_CHAIN_REFACTOR_WORK_PACKAGE_E_PROGRESS_2026-04-21.md b/docs/technical/RPG_ENTRY_RUNTIME_CHAIN_REFACTOR_WORK_PACKAGE_E_PROGRESS_2026-04-21.md new file mode 100644 index 00000000..82eb4b36 --- /dev/null +++ b/docs/technical/RPG_ENTRY_RUNTIME_CHAIN_REFACTOR_WORK_PACKAGE_E_PROGRESS_2026-04-21.md @@ -0,0 +1,112 @@ +# RPG 进入游戏与运行时链路重构工作包 E 落地记录 + +更新时间:`2026-04-21` + +## 1. 本次目标 + +本次只落实 `RPG_ENTRY_RUNTIME_CHAIN_REFACTOR_EXECUTION_PLAN_2026-04-21.md` 中的 **工作包 E:前端 runtime story 与 NPC 交互链拆分**,严格遵守以下边界: + +1. 把前端 runtime story 主链真实迁到 `src/hooks/rpg-runtime-story/` 与 `src/services/rpg-runtime/`。 +2. 把 `useStoryGeneration.ts`、`useStoryRuntimeController.ts`、`useStoryFlowCoordinator.ts`、`useStoryGoalSessionCoordinator.ts`、`useStoryInteractionCoordinator.ts`、`npcEncounterActions.ts`、`runtimeStoryCoordinator.ts`、`runtimeStoryService.ts` 的正式实现从旧命名入口迁出。 +3. 保留旧 `story/*` 与 `runtimeStoryService.ts` 兼容导出,避免误伤其他并行工作包。 +4. 不改任何前端交互界面设计,不改后端动作语义,不新增玩法。 + +## 2. 本次已落地内容 + +## 2.1 RPG runtime story 主链真实迁移 + +已把以下真实实现迁入 RPG 域目录: + +1. `src/hooks/rpg-runtime-story/useRpgRuntimeStory.ts` +2. `src/hooks/rpg-runtime-story/useRpgRuntimeStoryController.ts` +3. `src/hooks/rpg-runtime-story/useRpgRuntimeStoryFlow.ts` +4. `src/hooks/rpg-runtime-story/useRpgRuntimeStoryState.ts` +5. `src/hooks/rpg-runtime-story/useRpgRuntimeInteractionFlow.ts` +6. `src/hooks/rpg-runtime-story/useRpgRuntimeNpcInteraction.ts` +7. `src/hooks/rpg-runtime-story/rpgRuntimeStoryGateway.ts` + +本轮完成后: + +1. `useRpgRuntimeStory` 已成为前端 runtime story 顶层装配入口,不再只是 façade。 +2. `useRpgRuntimeStoryController` 负责 story 状态、AI 请求与提交动作。 +3. `useRpgRuntimeStoryFlow` 负责 option 展示、交互流和 story/session 状态流收口。 +4. `useRpgRuntimeStoryState` 负责 reset、hydrate、地图跳转与 quest UI 收口。 +5. `useRpgRuntimeInteractionFlow` 负责宝箱、背包、NPC、story choice 正式分发。 +6. `useRpgRuntimeNpcInteraction` 负责 NPC 对话、待接委托、战斗后续对话重开与服务端 NPC 动作派发。 +7. `rpgRuntimeStoryGateway` 负责 option catalog 拉取、继续游戏恢复与服务端 runtime choice 结算。 + +## 2.2 runtime story client 真实迁移 + +已把 `/api/runtime/story` 的真实 client 实现迁入: + +1. `src/services/rpg-runtime/rpgRuntimeStoryClient.ts` + +本轮完成后: + +1. `rpgRuntimeStoryClient` 不再桥接旧 `runtimeStoryService.ts`,而是成为真实请求实现。 +2. `runtimeStoryService.ts` 降级为兼容导出层。 +3. `getRpgRuntimeStoryState`、`resolveRpgRuntimeStoryAction`、`resolveRpgRuntimeStoryMoment`、`isRpgRuntimeServerFunctionId`、`shouldUseRpgRuntimeServerOptions` 已成为新域主能力。 + +## 2.3 主调用链已接回 RPG 域 + +本轮已把以下主链与 helper 入口切到 RPG 域实现: + +1. `src/hooks/rpg-session/useRpgRuntimeSession.ts` 改为直接使用 `useRpgRuntimeStory` +2. `src/hooks/story/storyRequestCoordinator.ts` 改为消费 `loadRpgRuntimeOptionCatalog` 与 `shouldUseRpgRuntimeServerOptions` +3. `src/hooks/story/choiceActions.ts` 改为消费 `isRpgRuntimeServerFunctionId` +4. `src/hooks/story/storyChoiceRuntime.ts` 改为消费 `resolveRpgRuntimeChoice` +5. `src/hooks/story/inventoryActions.ts` 改为消费 `resolveRpgRuntimeChoice` +6. `src/hooks/story/npcInteraction.ts` 改为消费 `resolveRpgRuntimeChoice` +7. `src/hooks/useTreasureFlow.ts` 改为消费 `resolveRpgRuntimeChoice` + +这意味着工作包 E 范围内的前端 runtime story 正式结算主链,已经不再依赖旧 `runtimeStoryService.ts` 与旧 `runtimeStoryCoordinator.ts` 的真实实现。 + +## 2.4 兼容层处理 + +以下旧文件现已降级为兼容导出,不再承载正式实现: + +1. `src/hooks/useStoryGeneration.ts` +2. `src/hooks/story/useStoryRuntimeController.ts` +3. `src/hooks/story/useStoryFlowCoordinator.ts` +4. `src/hooks/story/useStoryGoalSessionCoordinator.ts` +5. `src/hooks/story/useStoryInteractionCoordinator.ts` +6. `src/hooks/story/npcEncounterActions.ts` +7. `src/hooks/story/runtimeStoryCoordinator.ts` +8. `src/services/runtimeStoryService.ts` + +保留这些兼容层的原因: + +1. 避免一次性改动所有 UI 与测试引用,降低并行工作冲突。 +2. 让工作包 D、C 仍能通过旧导入继续编译,再逐步切到新域命名。 +3. 明确“主实现已迁移、旧入口只兼容”的工程状态,避免后续继续扩大旧热点文件。 +4. 复核时已补齐一批主链收口点:`RpgRuntimeStageRouter`、`RpgRuntimeOverlayHost`、`RpgRuntimePanelRouter`、`RpgAdventurePanel` 及其直连组件的 story UI 类型导入,现已直接消费 `hooks/rpg-runtime-story` 新域出口,旧 `useStoryGeneration.ts` 仅保留兼容角色。 + +## 3. 本次没有做的事 + +以下内容仍保持原状,留给其他工作包: + +1. 没有修改 `AdventurePanel.tsx`、`GameShellMainContent.tsx`、`GameShellStoryPanels.tsx` 的 UI 结构。 +2. 没有重做任何对话区、奖励区、输入区、overlay 的交互形式。 +3. 没有修改后端 runtime story route / service / compiler 语义。 +4. 没有清理所有旧 UI 组件对 `useStoryGeneration.ts` 的类型导入,本轮只保证旧入口已退化为兼容层。 +5. 没有删除旧兼容文件,避免误伤其他并行中的工作包。 + +## 4. 验证结果 + +本轮已执行: + +1. `npm run check:encoding` +2. `npx vitest run src/services/runtimeStoryService.test.ts src/hooks/story/runtimeStoryCoordinator.test.ts src/hooks/story/storyRequestCoordinator.test.ts src/hooks/story/choiceActions.test.ts src/hooks/story/storyChoiceRuntime.test.ts src/hooks/story/npcEncounterActions.test.ts` + +验证结果: + +1. 编码检查通过。 +2. 工作包 E 直接相关的 6 个测试文件、44 条测试全部通过。 +3. 运行时 option catalog、runtime choice、NPC 交互、待接委托、背包动作与本地/服务端分流逻辑均完成回归。 +4. 复核补跑 `src/services/rpg-entry/rpgProfileClient.test.ts`、`src/services/rpg-runtime/rpgSnapshotClient.test.ts`、`src/services/runtimeStoryService.test.ts`、`src/components/game-shell/PreGameSelectionFlow.agent.interaction.test.tsx` 共 33 条定向测试,全部通过。 + +## 5. 对后续工作包的直接收益 + +1. 工作包 D 后续如果继续拆 runtime panel 与 adventure panel,可以直接消费 `useRpgRuntimeStory` 与 `rpg-runtime-story` 域类型,不必再穿旧 `story` 目录。 +2. 工作包 C 与 session/persistence 链路已经可以直接对接 `rpgRuntimeStoryGateway` 的继续游戏恢复能力。 +3. 后续如果要清理旧 `useStoryGeneration.ts`、`runtimeStoryService.ts` 等旧命名入口,已经具备“新主链真实可用”的前提,不再是 façade 空壳状态。 diff --git a/docs/technical/RPG_ENTRY_RUNTIME_CHAIN_REFACTOR_WORK_PACKAGE_F_PROGRESS_2026-04-21.md b/docs/technical/RPG_ENTRY_RUNTIME_CHAIN_REFACTOR_WORK_PACKAGE_F_PROGRESS_2026-04-21.md new file mode 100644 index 00000000..ffd38ed4 --- /dev/null +++ b/docs/technical/RPG_ENTRY_RUNTIME_CHAIN_REFACTOR_WORK_PACKAGE_F_PROGRESS_2026-04-21.md @@ -0,0 +1,121 @@ +# RPG 进入游戏与运行时链路重构工作包 F 落地记录 + +更新时间:`2026-04-21` + +## 1. 本次目标 + +本次落实 `RPG_ENTRY_RUNTIME_CHAIN_REFACTOR_EXECUTION_PLAN_2026-04-21.md` 中的 **工作包 F:后端 route 边界拆分**,严格遵守以下边界: + +1. 只调整后端路由组织、挂载边界与兼容 façade。 +2. 不重写下游 `service` / `repository` 业务语义。 +3. 不修改任何前端交互界面设计,不额外推进工作包 G/H。 + +## 2. 本次已落地内容 + +## 2.1 `app.ts` 显式挂载 RPG 域路由 + +已把 `server-node/src/app.ts` 中的 RPG 相关入口改成显式按域挂载: + +1. `rpgProfile`:资料看板、浏览历史、设置 +2. `rpgEntrySave`:快照读写、继续游戏归档列表与恢复 +3. `rpgWorldLibrary`:作品库、作品广场、works 列表 +4. `rpgRuntimeStory`:`/api/runtime/story` 状态读取与动作结算 +5. `rpgRuntimeAiAssist`:runtime story 之外的 AI 辅助接口 + +当前策略: + +1. 使用 `scopeToPrefixes(...)` 只匹配各自域前缀,避免新路由误拦截无关 `/api` 请求。 +2. 所有线上接口路径保持不变,仍兼容现有前端与测试调用。 +3. `routeVersion` 对新域挂载统一标记为 `2026-04-21`,与本轮路由重构窗口对齐。 + +## 2.2 新 `rpg-*` 路由从骨架升级为真实实现 + +已把以下骨架路由补成真实入口: + +1. `server-node/src/routes/rpg-profile/rpgProfileRoutes.ts` +2. `server-node/src/routes/rpg-entry/rpgEntrySaveRoutes.ts` +3. `server-node/src/routes/rpg-entry/rpgWorldLibraryRoutes.ts` +4. `server-node/src/routes/rpg-runtime/rpgRuntimeStoryRoutes.ts` +5. `server-node/src/routes/rpg-runtime/rpgRuntimeAiAssistRoutes.ts` + +本轮真实迁入的新边界包括: + +1. `rpgProfileRoutes.ts` + - `/api/runtime/profile/dashboard` + - `/api/runtime/profile/wallet-ledger` + - `/api/runtime/profile/play-stats` + - `/api/runtime/profile/browse-history` + - 兼容路径 `/api/profile/...` + - `/api/runtime/settings` +2. `rpgEntrySaveRoutes.ts` + - `/api/runtime/save/snapshot` + - `/api/runtime/profile/save-archives` + - 兼容路径 `/api/profile/save-archives` +3. `rpgWorldLibraryRoutes.ts` + - `/api/runtime/custom-world-gallery` + - `/api/runtime/custom-world/works` + - `/api/runtime/custom-world-library` + - publish / unpublish / soft delete 等库操作 +4. `rpgRuntimeStoryRoutes.ts` + - `/api/runtime/story/actions/resolve` + - `/api/runtime/story/state/:sessionId` + - `/api/runtime/story/state/resolve` +5. `rpgRuntimeAiAssistRoutes.ts` + - runtime story 之外的 LLM proxy、cover/scene 资产、custom world profile 生成、角色/NPC chat、runtime item、quest 生成、`/api/ws/health` + +## 2.3 旧大路由退化为兼容 façade + +已把旧入口降级为兼容层: + +1. `server-node/src/modules/story/storyActionRoutes.ts` + - 不再自己承载 schema 与 handler + - 直接桥接到 `createRpgRuntimeStoryRoutes(context)` +2. `server-node/src/routes/runtimeRoutes.ts` + - 不再继续承载 profile / save / world library / runtime ai assist / runtime story + - 当前只保留旧 `customWorldAgent` 挂载与兼容 `ws/health` + +这样处理后: + +1. `runtimeRoutes.ts` 不再是 RPG 主链真实入口。 +2. `storyActionRoutes.ts` 不再是 runtime story 主链真实实现。 +3. 后续工作包 G/H 可以基于新 `rpg-*` 路由继续细化,而不用再穿透旧大文件。 + +## 3. 本次没有做的事 + +以下内容仍保持原状,不属于工作包 F 本轮范围: + +1. 没有拆 `storyActionService.ts`、`runtimeSession.ts` 的内部职责。 +2. 没有继续改 `npcInteractionService.ts`、`questStoryActionService.ts` 的业务实现。 +3. 没有推进仓储、共享契约的进一步物理拆分。 +4. 没有改变任何前端页面结构、交互路径、面板形式或 UI 文案。 + +## 4. 验证结果 + +本次已执行: + +1. `npm run check:encoding` +2. `node --test --test-concurrency=1 --import tsx "src/routes/rpgRouteBoundaries.test.ts"` + +验证结果: + +1. 编码检查通过。 +2. 新增定向测试 `server-node/src/routes/rpgRouteBoundaries.test.ts` 4 项全部通过。 +3. 已覆盖以下工作包 F 验收重点: + - `rpgProfile` 新路径与 legacy 路径兼容 + - `rpgEntrySave` 快照/归档路由可用 + - `rpgWorldLibrary` / `gallery` / `works` 新边界可用 + - `rpgRuntimeStory` 新边界与旧 façade 兼容可用 + +同时确认: + +1. 全量 `npx tsc -p server-node/tsconfig.json --noEmit` 仍被仓库中既有跨模块类型问题阻塞。 +2. 这些错误覆盖 auth、inventory、custom world、scene image 等多个既有热点,与本次工作包 F 新增路由边界并非同一问题面。 + +## 5. 对执行计划的对齐结论 + +对照执行计划中的工作包 F: + +1. `app.ts` 已能一眼看出 `rpgProfile`、`rpgEntry`、`rpgRuntimeStory`、`rpgRuntimeAiAssist` 的挂载边界。 +2. `runtimeRoutes.ts` 已退化为兼容入口,不再承接 RPG 主链的大杂糅职责。 +3. `storyActionRoutes.ts` 已退化为兼容 façade,真实 runtime story 路由落点转入 `server-node/src/routes/rpg-runtime/rpgRuntimeStoryRoutes.ts`。 +4. 本轮没有越过工作包 F 去重写下游 service 语义,满足“禁止过度开发”的约束。 diff --git a/docs/technical/RPG_ENTRY_RUNTIME_CHAIN_REFACTOR_WORK_PACKAGE_G_PROGRESS_2026-04-21.md b/docs/technical/RPG_ENTRY_RUNTIME_CHAIN_REFACTOR_WORK_PACKAGE_G_PROGRESS_2026-04-21.md new file mode 100644 index 00000000..fd25b44a --- /dev/null +++ b/docs/technical/RPG_ENTRY_RUNTIME_CHAIN_REFACTOR_WORK_PACKAGE_G_PROGRESS_2026-04-21.md @@ -0,0 +1,96 @@ +# RPG 进入游戏与运行时链路重构工作包 G 落地记录 + +更新时间:`2026-04-21` + +## 1. 本次目标 + +本次只落实 `RPG_ENTRY_RUNTIME_CHAIN_REFACTOR_EXECUTION_PLAN_2026-04-21.md` 中的 **工作包 G:后端 runtime session / action service 拆分**,严格遵守以下边界: + +1. 把 `storyActionService.ts` 与 `runtimeSession.ts` 的真实实现迁入 `server-node/src/modules/rpg-runtime-story/` 新域目录。 +2. 把 runtime action 主链依赖的 session 原语、option 编译、snapshot sync、story state 读取按职责落到新文件。 +3. 旧 `server-node/src/modules/story/` 热点文件只保留兼容导出,不再承载真实实现。 +4. 不改前端入口与 UI,不改路由协议语义,不做仓储拆分。 + +## 2. 本次已落地内容 + +## 2.1 `runtimeSession.ts` 真实实现已迁入 `rpg-runtime-story` + +本轮已把旧 `server-node/src/modules/story/runtimeSession.ts` 的真实实现迁入: + +1. `server-node/src/modules/rpg-runtime-story/RpgRuntimeSessionDomain.ts` +2. `server-node/src/modules/rpg-runtime-story/RpgRuntimeSessionLoader.ts` +3. `server-node/src/modules/rpg-runtime-story/RpgRuntimeSessionPrimitives.ts` +4. `server-node/src/modules/rpg-runtime-story/RpgRuntimeOptionCompiler.ts` +5. `server-node/src/modules/rpg-runtime-story/RpgRuntimeSnapshotSync.ts` +6. `server-node/src/modules/rpg-runtime-story/RpgRuntimeStoryPresentationCompiler.ts` + +落地结果: + +1. `loadRuntimeSession(...)`、`buildAvailableOptions(...)`、`buildRuntimeViewModel(...)`、`syncRawGameState(...)`、`replaceRuntimeSessionRawGameState(...)` 已有新域真实落点。 +2. `appendStoryHistory(...)`、`getEncounterNpcState(...)`、`setEncounterNpcState(...)`、`MAX_TASK5_COMPANIONS`、`TASK6_DEFERRED_FUNCTION_IDS` 等运行时原语已通过 `RpgRuntimeSessionPrimitives.ts` 对外输出。 +3. 旧 `server-node/src/modules/story/runtimeSession.ts` 已退化为兼容层,不再承载主实现。 + +## 2.2 `storyActionService.ts` 真实实现已迁入 `rpg-runtime-story` + +本轮已把旧 `server-node/src/modules/story/storyActionService.ts` 的真实实现迁入: + +1. `server-node/src/modules/rpg-runtime-story/RpgRuntimeStoryActionDomain.ts` +2. `server-node/src/modules/rpg-runtime-story/RpgRuntimeStoryActionService.ts` +3. `server-node/src/modules/rpg-runtime-story/RpgRuntimeStoryStateService.ts` + +落地结果: + +1. `resolveRuntimeStoryAction(...)` 已从新域动作服务入口导出。 +2. `getRuntimeStoryState(...)` 已从新域状态服务入口导出。 +3. 旧 `server-node/src/modules/story/storyActionService.ts` 已退化为兼容转发层。 + +## 2.3 runtime action 主链下游依赖已切到新域 + +本轮已把以下直接依赖 `runtimeSession.ts` 的后端模块切到 `rpg-runtime-story` 新域入口: + +1. `server-node/src/modules/combat/combatResolutionService.ts` +2. `server-node/src/modules/npc/npcInteractionService.ts` +3. `server-node/src/modules/inventory/inventoryStoryActionService.ts` +4. `server-node/src/modules/inventory/npcInventoryStoryActionService.ts` +5. `server-node/src/modules/quest/questRuntimeSignalService.ts` +6. `server-node/src/modules/quest/questStoryActionService.ts` +7. `server-node/src/modules/runtime-item/treasureStoryActionService.ts` +8. `server-node/src/modules/story/storyActionRoutes.ts` +9. `server-node/src/modules/story/runtimeSession.test.ts` + +当前状态: + +1. runtime action 执行链已经直接消费 `rpg-runtime-story` 域入口,不再把旧 `modules/story/` 视为真实主实现。 +2. `storyActionRoutes.ts` 已直接从新域读取动作服务与状态服务。 +3. `runtimeSession.test.ts` 已直接验证新域编译链与 legacy currentStory 展示投影。 + +## 3. 本次没有做的事 + +以下内容仍保持原状,留给后续工作包或后续阶段: + +1. 没有修改任何前端入口、前端运行态 UI、前端交互设计。 +2. 没有拆 `server-node/src/routes/runtimeRoutes.ts`、`server-node/src/app.ts` 的路由挂载组织,这属于工作包 F。 +3. 没有拆 `runtimeRepository.ts`、`runtimeSnapshotHydration.ts` 与 shared contract,这属于工作包 H。 +4. 没有把 `RpgRuntimeStoryActionDomain.ts` 再继续切成更细颗粒度的真实实现文件;本轮只做到工作包 G 要求的“目录化拆开 + 旧热点降级”。 +5. 没有改动任何动作语义、返回协议或 LLM 编排行为。 + +## 4. 验证与检查 + +本轮已执行: + +1. `npm run check:encoding` +2. `npx tsx --test server-node/src/modules/story/runtimeSession.test.ts server-node/src/modules/story/storyActionRoutes.test.ts` +3. `npm --prefix server-node run build` + +验证结果: + +1. 编码检查通过。 +2. 与 runtime session / runtime story action 直接相关的后端定向测试 `20` 项全部通过。 +3. `server-node` 构建通过。 +4. 旧 `server-node/src/modules/story/runtimeSession.ts` 与 `server-node/src/modules/story/storyActionService.ts` 已只剩兼容导出,没有残留真实主实现。 + +## 5. 对后续工作的直接收益 + +1. 工作包 F 后续继续做 route 边界拆分时,可以直接把 runtime story 路由稳定挂到 `rpg-runtime-story` 新域入口。 +2. 工作包 H 后续拆仓储、shared contract 和测试基建时,可以围绕 `rpg-runtime-story` 新目录继续收口,而不必再穿透旧热点文件。 +3. 后续如果继续细拆 runtime story 主链,可以在新域内部继续物理拆分,而不会重新把真实实现塞回 `modules/story/`。 diff --git a/docs/technical/RPG_ENTRY_RUNTIME_CHAIN_REFACTOR_WORK_PACKAGE_H_PROGRESS_2026-04-21.md b/docs/technical/RPG_ENTRY_RUNTIME_CHAIN_REFACTOR_WORK_PACKAGE_H_PROGRESS_2026-04-21.md new file mode 100644 index 00000000..cc8f51fb --- /dev/null +++ b/docs/technical/RPG_ENTRY_RUNTIME_CHAIN_REFACTOR_WORK_PACKAGE_H_PROGRESS_2026-04-21.md @@ -0,0 +1,90 @@ +# RPG 进入游戏与运行时链路重构工作包 H 落地记录 + +更新时间:`2026-04-21` + +## 1. 本次目标 + +本次只落实 `RPG_ENTRY_RUNTIME_CHAIN_REFACTOR_EXECUTION_PLAN_2026-04-21.md` 中的 **工作包 H:仓储、契约与测试基建**,严格遵守以下边界: + +1. 只补仓储、shared contract、fixture 与测试基建。 +2. 不修改前端 UI,不改页面交互设计。 +3. 不重写 route 层与 runtime story 主流程逻辑。 + +## 2. 本次已落地内容 + +## 2.1 仓储按 RPG 域补齐独立入口 + +本次补齐并收口了工作包 H 目标中的按域仓储入口: + +1. `server-node/src/repositories/rpg-runtime/RpgRuntimeSnapshotRepository.ts` +2. `server-node/src/repositories/rpg-entry/RpgSaveArchiveRepository.ts` +3. `server-node/src/repositories/rpg-entry/RpgWorldLibraryRepository.ts` +4. `server-node/src/repositories/rpg-profile/RpgProfileDashboardRepository.ts` +5. `server-node/src/repositories/rpg-profile/RpgBrowseHistoryRepository.ts` + +本轮策略仍保持最小侵入: + +1. 新仓储继续委托 `runtimeRepository.ts` 提供真实读写。 +2. `RpgProfileDashboardRepository` 只保留资料看板、设置、钱包、游玩统计职责。 +3. 浏览历史读写从资料仓储中抽离到 `RpgBrowseHistoryRepository`,与执行方案的目标拆分保持一致。 + +## 2.2 shared runtime contract 按领域拆分并保留兼容 façade + +已把原 `packages/shared/src/contracts/story.ts` 中的 RPG runtime shared contract 拆分为: + +1. `packages/shared/src/contracts/rpgRuntimeStoryAction.ts` +2. `packages/shared/src/contracts/rpgRuntimeStoryState.ts` +3. `packages/shared/src/contracts/rpgRuntimeChat.ts` +4. `packages/shared/src/contracts/rpgRuntimeQuestAssist.ts` + +兼容策略: + +1. `story.ts` 退化为 façade,只做分文件 re-export。 +2. 现有前后端调用方仍可继续从 `contracts/story` 取用类型与常量,不要求本轮同步迁移所有 import。 +3. runtime story 主链契约与 chat / quest assist / runtime item 辅助契约已经具备独立演进落点。 + +## 2.3 测试基建补齐 + +本次补充并核对了工作包 H 范围内的测试: + +1. `server-node/src/modules/runtime/runtimeSnapshotHydration.test.ts` + 用于覆盖 snapshot 归一化、恢复继续游戏时的默认值修复与旧存档兼容。 +2. `server-node/src/repositories/rpg-profile/RpgProfileRepositories.test.ts` + 用于确认资料看板仓储与浏览历史仓储的职责边界已经分离。 +3. `server-node/src/repositories/rpg-entry/RpgEntryRepositories.test.ts` + 用于确认 continue game 归档仓储与作品库仓储已经独立命名并保持职责边界。 +4. `server-node/src/repositories/rpg-runtime/RpgRuntimeSnapshotRepository.test.ts` + 用于确认 snapshot 读写职责已经有独立仓储入口。 +5. `packages/shared/src/contracts/rpgRuntimeContracts.test.ts` + 用于确认 `story.ts` façade 在拆分后仍保持旧入口兼容。 + +## 3. 本次没有做的事 + +以下内容仍保持原状,留给其他工作包或第三批统一收口: + +1. 没有把前后端所有 `contracts/story` import 全量改写到新分文件,避免与并行工作包产生无谓冲突。 +2. 没有改 `server-node/src/routes/runtimeRoutes.ts`、`server-node/src/modules/story/storyActionService.ts`、`server-node/src/modules/story/runtimeSession.ts` 的真实逻辑。 +3. 没有改前端 continue game、角色选择、冒险运行态的界面与交互。 + +## 4. 验证结果 + +本轮执行并通过: + +1. `npm run check:encoding` +2. `npx vitest run packages/shared/src/contracts/rpgRuntimeContracts.test.ts` +3. `node --test --test-concurrency=1 --import tsx src/modules/runtime/runtimeSnapshotHydration.test.ts src/repositories/rpg-profile/RpgProfileRepositories.test.ts src/repositories/rpg-entry/RpgEntryRepositories.test.ts src/repositories/rpg-runtime/RpgRuntimeSnapshotRepository.test.ts` + +说明: + +1. 工作树当前存在其他并行修改与冲突文件,因此没有把与工作包 H 无关的全量问题一并处理。 +2. 本轮验证只覆盖工作包 H 自身改动与其直接依赖,避免过度开发。 + +## 5. 与执行方案的对照结论 + +对照 `RPG_ENTRY_RUNTIME_CHAIN_REFACTOR_EXECUTION_PLAN_2026-04-21.md` 中工作包 H 的目标,本轮已完成: + +1. snapshot / profile / library / browse history 仓储独立命名入口补齐。 +2. `story.ts` shared contract 已拆为 runtime story action、runtime story state、runtime chat、runtime quest assist 四个独立文件。 +3. runtime story / snapshot / continue game 相关测试已补齐到可直接回归的最小闭环。 + +当前未额外扩张到主流程迁移、route 改写、UI 调整,符合“禁止过度开发”的执行要求。 diff --git a/package.json b/package.json index bd07345f..fcda4d41 100644 --- a/package.json +++ b/package.json @@ -33,7 +33,6 @@ "test": "vitest run", "test:watch": "vitest", "check": "npm run lint && npm run test && npm run build && npm run check:content", - "generate:build-tags": "py -3 scripts/generate-build-tag-similarity.py", "check:data": "node scripts/run-tsx.cjs scripts/validate-content.ts", "check:overrides": "node scripts/run-tsx.cjs scripts/validate-overrides.ts", "check:smoke": "node scripts/run-tsx.cjs scripts/smoke-content.ts", diff --git a/packages/shared/src/contracts/auth.ts b/packages/shared/src/contracts/auth.ts index dfa36373..7054bba5 100644 --- a/packages/shared/src/contracts/auth.ts +++ b/packages/shared/src/contracts/auth.ts @@ -78,7 +78,8 @@ export type AuthPhoneChangeResponse = { }; export type AuthRefreshResponse = { - token: string; + ok: true; + token?: string; }; export type AuthSessionSummary = { diff --git a/packages/shared/src/contracts/customWorldAgent.ts b/packages/shared/src/contracts/customWorldAgent.ts index c40f56a9..e5d115e0 100644 --- a/packages/shared/src/contracts/customWorldAgent.ts +++ b/packages/shared/src/contracts/customWorldAgent.ts @@ -1,533 +1,12 @@ -export type CustomWorldWorkStatus = 'draft' | 'published'; -export type CustomWorldWorkSource = 'agent_session' | 'published_profile'; +/** + * 兼容出口: + * 当前仓库仍有大量旧 customWorld 命名导入,这个文件继续作为过渡层保留。 + * 工作包 H 完成后,真实类型定义已经迁移到 rpg* 契约文件中;这里仅聚合旧命名分文件。 + */ -export interface WorldPromiseValue { - hook: string; - differentiator: string; - desiredExperience: string; -} - -export interface PlayerFantasyValue { - playerRole: string; - corePursuit: string; - fearOfLoss: string; -} - -export interface ThemeBoundaryValue { - toneKeywords: string[]; - aestheticDirectives: string[]; - forbiddenDirectives: string[]; -} - -export interface PlayerEntryPointValue { - openingIdentity: string; - openingProblem: string; - entryMotivation: string; -} - -export interface CoreConflictValue { - surfaceConflicts: string[]; - hiddenCrisis: string; - firstTouchedConflict: string; -} - -export interface KeyRelationshipValue { - pairs: string; - relationshipType: string; - secretOrCost: string; -} - -export interface HiddenLineValue { - hiddenTruths: string[]; - misdirectionHints: string[]; - revealPacing: string; -} - -export interface IconicElementValue { - iconicMotifs: string[]; - institutionsOrArtifacts: string[]; - hardRules: string[]; -} - -export interface EightAnchorContent { - worldPromise: WorldPromiseValue | null; - playerFantasy: PlayerFantasyValue | null; - themeBoundary: ThemeBoundaryValue | null; - playerEntryPoint: PlayerEntryPointValue | null; - coreConflict: CoreConflictValue | null; - keyRelationships: KeyRelationshipValue[]; - hiddenLines: HiddenLineValue | null; - iconicElements: IconicElementValue | null; -} - -export interface CustomWorldWorkSummary { - workId: string; - sourceType: CustomWorldWorkSource; - status: CustomWorldWorkStatus; - title: string; - subtitle: string; - summary: string; - coverImageSrc?: string | null; - coverRenderMode?: 'image' | 'scene_with_roles'; - coverCharacterImageSrcs?: string[]; - updatedAt: string; - publishedAt?: string | null; - stage?: string | null; - stageLabel?: string | null; - playableNpcCount: number; - landmarkCount: number; - roleVisualReadyCount?: number; - roleAnimationReadyCount?: number; - roleAssetSummaryLabel?: string | null; - sessionId?: string | null; - profileId?: string | null; - canResume: boolean; - canEnterWorld: boolean; -} - -export interface CreatorIntentReadiness { - isReady: boolean; - completedKeys: string[]; - missingKeys: string[]; -} - -export interface CustomWorldPendingClarification { - id: string; - label: string; - question: string; - targetKey: - | 'world_hook' - | 'player_premise' - | 'theme_and_tone' - | 'core_conflict' - | 'relationship_seed' - | 'iconic_element'; - priority: number; - answer?: string; -} - -export type CustomWorldAgentStage = - | 'collecting_intent' - | 'clarifying' - | 'foundation_review' - | 'object_refining' - | 'visual_refining' - | 'long_tail_review' - | 'ready_to_publish' - | 'published' - | 'error'; - -export type CustomWorldAgentMessageRole = 'user' | 'assistant' | 'system'; - -export type CustomWorldAgentMessageKind = - | 'chat' - | 'clarification' - | 'summary' - | 'checkpoint' - | 'warning' - | 'action_result'; - -export interface CustomWorldAgentMessage { - id: string; - role: CustomWorldAgentMessageRole; - kind: CustomWorldAgentMessageKind; - text: string; - createdAt: string; - relatedOperationId?: string | null; -} - -export type CustomWorldDraftCardKind = - | 'world' - | 'camp' - | 'faction' - | 'character' - | 'landmark' - | 'thread' - | 'chapter' - | 'scene_chapter' - | 'carrier' - | 'sidequest_seed'; - -export type CustomWorldDraftCardStatus = - | 'suggested' - | 'confirmed' - | 'locked' - | 'warning'; - -export interface CustomWorldDraftCardSummary { - id: string; - kind: CustomWorldDraftCardKind; - title: string; - subtitle: string; - summary: string; - status: CustomWorldDraftCardStatus; - linkedIds: string[]; - warningCount: number; - assetStatus?: CustomWorldRoleAssetStatus | null; - assetStatusLabel?: string | null; -} - -export interface CustomWorldDraftCardDetailSection { - id: string; - label: string; - value: string; -} - -export interface CustomWorldFoundationDraftFaction { - id: string; - name: string; - title?: string; - subtitle?: string; - publicGoal: string; - relatedConflict: string; - tension?: string; - playerRelation: string; - summary: string; -} - -export interface CustomWorldFoundationDraftCharacter { - id: string; - name: string; - title: string; - role: string; - publicIdentity: string; - publicMask?: string; - currentPressure: string; - hiddenHook?: string; - relationToPlayer: string; - threadIds: string[]; - summary: string; - skills?: Array<{ - id: string; - name: string; - actionPreviewConfig?: Record | null; - }>; - imageSrc?: string | null; - generatedVisualAssetId?: string | null; - generatedAnimationSetId?: string | null; - animationMap?: Record | null; -} - -export interface CustomWorldFoundationDraftLandmark { - id: string; - name: string; - description?: string; - purpose: string; - mood: string; - importance: string; - secret?: string; - dangerLevel?: string; - imageSrc?: string | null; - characterIds: string[]; - threadIds: string[]; - summary: string; -} - -export interface CustomWorldFoundationDraftThread { - id: string; - title: string; - type: 'main' | 'hidden'; - conflictType?: string; - conflict: string; - stakes?: string; - characterIds: string[]; - landmarkIds: string[]; - summary: string; -} - -export interface CustomWorldFoundationDraftChapter { - id: string; - title: string; - openingEvent: string; - playerGoal: string; - characterIds: string[]; - landmarkIds: string[]; - understandingShift: string; - summary: string; -} - -export interface CustomWorldFoundationDraftCamp { - id: string; - name: string; - description: string; - mood: string; - dangerLevel?: string; - imageSrc?: string | null; - summary: string; -} - -export type CustomWorldSceneActStage = - | 'opening' - | 'expansion' - | 'turning_point' - | 'climax' - | 'aftermath'; - -export type CustomWorldSceneActAdvanceRule = - | 'after_primary_contact' - | 'after_active_step_complete' - | 'after_chapter_resolution'; - -export interface CustomWorldFoundationDraftSceneAct { - id: string; - title: string; - summary: string; - stageCoverage: CustomWorldSceneActStage[]; - backgroundImageSrc?: string | null; - backgroundAssetId?: string | null; - encounterNpcIds: string[]; - primaryNpcId: string; - linkedThreadIds: string[]; - actGoal: string; - transitionHook: string; - advanceRule: CustomWorldSceneActAdvanceRule; -} - -export interface CustomWorldFoundationDraftSceneChapter { - id: string; - sceneId: string; - sceneName: string; - title: string; - summary: string; - linkedThreadIds: string[]; - linkedLandmarkIds: string[]; - acts: CustomWorldFoundationDraftSceneAct[]; -} - -export interface CustomWorldFoundationDraftProfile { - name: string; - subtitle: string; - summary: string; - tone: string; - playerGoal: string; - majorFactions: string[]; - coreConflicts: string[]; - playableNpcs: CustomWorldFoundationDraftCharacter[]; - storyNpcs: CustomWorldFoundationDraftCharacter[]; - landmarks: CustomWorldFoundationDraftLandmark[]; - camp?: CustomWorldFoundationDraftCamp | null; - themePack?: Record | null; - storyGraph?: Record | null; - factions: CustomWorldFoundationDraftFaction[]; - threads: CustomWorldFoundationDraftThread[]; - chapters: CustomWorldFoundationDraftChapter[]; - sceneChapters: CustomWorldFoundationDraftSceneChapter[]; - worldHook: string; - playerPremise: string; - openingSituation: string; - iconicElements: string[]; - sourceAnchorSummary: string; -} - -export interface CustomWorldFoundationDraftResult { - draftProfile: CustomWorldFoundationDraftProfile; - draftCards: CustomWorldDraftCardSummary[]; -} - -export interface CustomWorldDraftCardDetail { - id: string; - kind: CustomWorldDraftCardKind; - title: string; - sections: CustomWorldDraftCardDetailSection[]; - linkedIds: string[]; - locked: false; - editable: boolean; - editableSectionIds: string[]; - warningMessages: string[]; - assetStatus?: CustomWorldRoleAssetStatus | null; - assetStatusLabel?: string | null; -} - -export interface CustomWorldSuggestedAction { - id: string; - type: - | 'request_summary' - | 'draft_foundation' - | 'refine_focus_target' - | 'lock_current_target' - | 'generate_role_assets' - | 'generate_scene_assets' - | 'expand_long_tail' - | 'publish_world'; - label: string; - targetId?: string | null; -} - -export type CustomWorldAssetPriorityTier = 'hero' | 'featured' | 'supporting'; - -export type CustomWorldRoleAssetStatus = - | 'missing' - | 'visual_ready' - | 'animations_ready' - | 'complete'; - -export interface CustomWorldRoleAssetSummary { - roleId: string; - roleName: string; - roleKind: 'playable' | 'story'; - priorityTier: CustomWorldAssetPriorityTier; - portraitPath?: string | null; - generatedVisualAssetId?: string | null; - generatedAnimationSetId?: string | null; - status: CustomWorldRoleAssetStatus; - missingAnimations: string[]; - nextPointCost: number; -} - -export interface CustomWorldSceneAssetSummary { - sceneId: string; - sceneName: string; - actId?: string | null; - actTitle?: string | null; - imageSrc?: string | null; - assetId?: string | null; - status: 'missing' | 'ready'; - nextPointCost: number; -} - -export interface CustomWorldAssetCoverageSummary { - roleAssets: CustomWorldRoleAssetSummary[]; - sceneAssets: CustomWorldSceneAssetSummary[]; - allRoleAssetsReady: boolean; - allSceneAssetsReady: boolean; -} - -export interface CustomWorldAgentSessionSnapshot { - sessionId: string; - currentTurn: number; - anchorContent: EightAnchorContent; - progressPercent: number; - lastAssistantReply: string | null; - stage: CustomWorldAgentStage; - focusCardId: string | null; - creatorIntent: Record | null; - creatorIntentReadiness: CreatorIntentReadiness; - anchorPack: Record | null; - lockState: Record | null; - draftProfile: Record | null; - messages: CustomWorldAgentMessage[]; - draftCards: CustomWorldDraftCardSummary[]; - pendingClarifications: CustomWorldPendingClarification[]; - suggestedActions: CustomWorldSuggestedAction[]; - recommendedReplies: string[]; - qualityFindings: { - id: string; - severity: 'info' | 'warning' | 'blocker'; - code: string; - targetId?: string | null; - message: string; - }[]; - assetCoverage: CustomWorldAssetCoverageSummary; - updatedAt: string; -} - -export type CustomWorldAgentOperationType = - | 'process_message' - | 'lock_cards' - | 'unlock_cards' - | 'regenerate_scope' - | 'draft_foundation' - | 'update_draft_card' - | 'generate_characters' - | 'generate_landmarks' - | 'generate_role_assets' - | 'sync_role_assets' - | 'generate_scene_assets' - | 'sync_scene_assets' - | 'expand_long_tail' - | 'publish_world' - | 'revert_checkpoint'; - -export type CustomWorldAgentOperationStatus = - | 'queued' - | 'running' - | 'completed' - | 'failed'; - -export interface CustomWorldAgentOperationRecord { - operationId: string; - type: CustomWorldAgentOperationType; - status: CustomWorldAgentOperationStatus; - phaseLabel: string; - phaseDetail: string; - progress: number; - error?: string | null; -} - -export interface CreateCustomWorldAgentSessionRequest { - seedText?: string; -} - -export interface CreateCustomWorldAgentSessionResponse { - session: CustomWorldAgentSessionSnapshot; -} - -export interface SendCustomWorldAgentMessageRequest { - clientMessageId: string; - text: string; - quickFillRequested?: boolean; - focusCardId?: string | null; - selectedCardIds?: string[]; -} - -export interface SendCustomWorldAgentMessageResponse { - operation: CustomWorldAgentOperationRecord; -} - -export type CustomWorldAgentActionRequest = - | { action: 'lock_cards'; cardIds: string[] } - | { action: 'unlock_cards'; cardIds: string[] } - | { - action: 'regenerate_scope'; - scope: - | 'focus_card' - | 'long_tail_npcs' - | 'long_tail_landmarks' - | 'sidequest_seeds' - | 'role_assets' - | 'scene_assets'; - targetCardId?: string | null; - } - | { action: 'draft_foundation' } - | { - action: 'update_draft_card'; - cardId: string; - sections: Array<{ - sectionId: string; - value: string; - }>; - } - | { - action: 'generate_characters'; - count: number; - promptText?: string | null; - anchorCardIds?: string[]; - } - | { - action: 'generate_landmarks'; - count: number; - promptText?: string | null; - anchorCardIds?: string[]; - } - | { action: 'generate_role_assets'; roleIds: string[] } - | { - action: 'sync_role_assets'; - roleId: string; - portraitPath: string; - generatedVisualAssetId: string; - generatedAnimationSetId?: string | null; - animationMap?: Record | null; - } - | { action: 'publish_world' }; - -export interface CustomWorldAgentActionResponse { - operation: CustomWorldAgentOperationRecord; -} - -export interface GetCustomWorldAgentCardDetailResponse { - card: CustomWorldDraftCardDetail; -} - -export interface ListCustomWorldWorksResponse { - items: CustomWorldWorkSummary[]; -} +export type * from './customWorldAgentAnchors'; +export type * from './customWorldAgentDraft'; +export type * from './customWorldAgentActions'; +export type * from './customWorldAgentSession'; +export type * from './customWorldResultPreview'; +export type * from './customWorldWorkSummary'; diff --git a/packages/shared/src/contracts/customWorldAgentActions.ts b/packages/shared/src/contracts/customWorldAgentActions.ts new file mode 100644 index 00000000..a959bd0a --- /dev/null +++ b/packages/shared/src/contracts/customWorldAgentActions.ts @@ -0,0 +1,14 @@ +/** + * 旧 custom world 动作契约兼容出口。 + * 后续若逐步迁移旧代码,建议直接改用 rpgAgentActions.ts。 + */ + +export type { + RpgAgentActionRequest as CustomWorldAgentActionRequest, + RpgAgentActionResponse as CustomWorldAgentActionResponse, + RpgAgentOperationRecord as CustomWorldAgentOperationRecord, + RpgAgentOperationStatus as CustomWorldAgentOperationStatus, + RpgAgentOperationType as CustomWorldAgentOperationType, + RpgAgentSupportedAction as CustomWorldSupportedAction, + RpgAgentSuggestedAction as CustomWorldSuggestedAction, +} from './rpgAgentActions'; diff --git a/packages/shared/src/contracts/customWorldAgentAnchors.ts b/packages/shared/src/contracts/customWorldAgentAnchors.ts new file mode 100644 index 00000000..67d7793a --- /dev/null +++ b/packages/shared/src/contracts/customWorldAgentAnchors.ts @@ -0,0 +1,16 @@ +/** + * 旧 custom world 八锚点兼容出口。 + * 这里只保留旧命名到 RPG 创作域新契约的映射,便于旧导入渐进迁移。 + */ + +export type { + RpgCreationAnchorContent as EightAnchorContent, + RpgCreationCoreConflictValue as CoreConflictValue, + RpgCreationHiddenLineValue as HiddenLineValue, + RpgCreationIconicElementValue as IconicElementValue, + RpgCreationKeyRelationshipValue as KeyRelationshipValue, + RpgCreationPlayerEntryPointValue as PlayerEntryPointValue, + RpgCreationPlayerFantasyValue as PlayerFantasyValue, + RpgCreationThemeBoundaryValue as ThemeBoundaryValue, + RpgCreationWorldPromiseValue as WorldPromiseValue, +} from './rpgAgentAnchors'; diff --git a/packages/shared/src/contracts/customWorldAgentDraft.ts b/packages/shared/src/contracts/customWorldAgentDraft.ts new file mode 100644 index 00000000..4717fed6 --- /dev/null +++ b/packages/shared/src/contracts/customWorldAgentDraft.ts @@ -0,0 +1,29 @@ +/** + * 旧 custom world 草稿契约兼容出口。 + * 工作包 H 完成后,真实定义已经迁到 rpgAgentDraft.ts,这里只负责旧命名映射。 + */ + +export type { + RpgAgentAssetCoverageSummary as CustomWorldAssetCoverageSummary, + RpgAgentAssetPriorityTier as CustomWorldAssetPriorityTier, + RpgAgentDraftCardDetail as CustomWorldDraftCardDetail, + RpgAgentDraftCardDetailSection as CustomWorldDraftCardDetailSection, + RpgAgentDraftCardKind as CustomWorldDraftCardKind, + RpgAgentDraftCardStatus as CustomWorldDraftCardStatus, + RpgAgentDraftCardSummary as CustomWorldDraftCardSummary, + RpgAgentFoundationDraftCamp as CustomWorldFoundationDraftCamp, + RpgAgentFoundationDraftChapter as CustomWorldFoundationDraftChapter, + RpgAgentFoundationDraftCharacter as CustomWorldFoundationDraftCharacter, + RpgAgentFoundationDraftFaction as CustomWorldFoundationDraftFaction, + RpgAgentFoundationDraftLandmark as CustomWorldFoundationDraftLandmark, + RpgAgentFoundationDraftProfile as CustomWorldFoundationDraftProfile, + RpgAgentFoundationDraftResult as CustomWorldFoundationDraftResult, + RpgAgentFoundationDraftSceneAct as CustomWorldFoundationDraftSceneAct, + RpgAgentFoundationDraftSceneChapter as CustomWorldFoundationDraftSceneChapter, + RpgAgentFoundationDraftThread as CustomWorldFoundationDraftThread, + RpgAgentRoleAssetStatus as CustomWorldRoleAssetStatus, + RpgAgentRoleAssetSummary as CustomWorldRoleAssetSummary, + RpgAgentSceneActAdvanceRule as CustomWorldSceneActAdvanceRule, + RpgAgentSceneActStage as CustomWorldSceneActStage, + RpgAgentSceneAssetSummary as CustomWorldSceneAssetSummary, +} from './rpgAgentDraft'; diff --git a/packages/shared/src/contracts/customWorldAgentSession.ts b/packages/shared/src/contracts/customWorldAgentSession.ts new file mode 100644 index 00000000..8a320a40 --- /dev/null +++ b/packages/shared/src/contracts/customWorldAgentSession.ts @@ -0,0 +1,20 @@ +/** + * 旧 custom world 会话契约兼容出口。 + * 这一层只做命名映射,不再承担 session 真相源结构定义。 + */ + +export type { + CreateRpgAgentSessionRequest as CreateCustomWorldAgentSessionRequest, + CreateRpgAgentSessionResponse as CreateCustomWorldAgentSessionResponse, + GetRpgAgentCardDetailResponse as GetCustomWorldAgentCardDetailResponse, + RpgAgentMessage as CustomWorldAgentMessage, + RpgAgentMessageKind as CustomWorldAgentMessageKind, + RpgAgentMessageRole as CustomWorldAgentMessageRole, + RpgAgentPendingClarification as CustomWorldPendingClarification, + RpgAgentQualityFinding as CustomWorldAgentQualityFinding, + RpgAgentSessionSnapshot as CustomWorldAgentSessionSnapshot, + RpgAgentStage as CustomWorldAgentStage, + RpgCreationIntentReadiness as CreatorIntentReadiness, + SendRpgAgentMessageRequest as SendCustomWorldAgentMessageRequest, + SendRpgAgentMessageResponse as SendCustomWorldAgentMessageResponse, +} from './rpgAgentSession'; diff --git a/packages/shared/src/contracts/customWorldResultPreview.ts b/packages/shared/src/contracts/customWorldResultPreview.ts new file mode 100644 index 00000000..56ea9f3c --- /dev/null +++ b/packages/shared/src/contracts/customWorldResultPreview.ts @@ -0,0 +1,12 @@ +/** + * 旧 custom world 结果页预览兼容出口。 + * 额外单独拆一个 preview 兼容文件,避免预览别名继续堆回 customWorldAgent.ts 聚合层。 + */ + +export type { + RpgCreationPreview as CustomWorldResultPreview, + RpgCreationPreviewBlocker as CustomWorldResultPreviewBlocker, + RpgCreationPreviewEnvelope as CustomWorldResultPreviewEnvelope, + RpgCreationPreviewFinding as CustomWorldResultPreviewFinding, + RpgCreationPreviewSource as CustomWorldResultPreviewSource, +} from './rpgCreationPreview'; diff --git a/packages/shared/src/contracts/customWorldWorkSummary.ts b/packages/shared/src/contracts/customWorldWorkSummary.ts new file mode 100644 index 00000000..62a952f9 --- /dev/null +++ b/packages/shared/src/contracts/customWorldWorkSummary.ts @@ -0,0 +1,11 @@ +/** + * 旧 custom world works 读模型兼容出口。 + * 用于把旧作品列表命名平滑映射到新的 RPG 创作域 works 契约。 + */ + +export type { + ListRpgCreationWorksResponse as ListCustomWorldWorksResponse, + RpgCreationWorkSource as CustomWorldWorkSource, + RpgCreationWorkStatus as CustomWorldWorkStatus, + RpgCreationWorkSummary as CustomWorldWorkSummary, +} from './rpgCreationWorkSummary'; diff --git a/packages/shared/src/contracts/rpgAgentActions.ts b/packages/shared/src/contracts/rpgAgentActions.ts new file mode 100644 index 00000000..4a47b28a --- /dev/null +++ b/packages/shared/src/contracts/rpgAgentActions.ts @@ -0,0 +1,120 @@ +/** + * RPG Agent 动作与异步操作契约。 + * 这里显式区分“建议动作”和“真实可执行动作”,为后续后端 registry 收口预留接口。 + */ + +export type RpgAgentSuggestedActionType = + | 'request_summary' + | 'draft_foundation' + | 'refine_focus_target' + | 'lock_current_target' + | 'generate_role_assets' + | 'generate_scene_assets' + | 'expand_long_tail' + | 'publish_world'; + +export interface RpgAgentSuggestedAction { + id: string; + type: RpgAgentSuggestedActionType; + label: string; + targetId?: string | null; +} + +export type RpgAgentActionType = + | 'draft_foundation' + | 'update_draft_card' + | 'sync_result_profile' + | 'generate_characters' + | 'generate_landmarks' + | 'generate_role_assets' + | 'sync_role_assets' + | 'generate_scene_assets' + | 'sync_scene_assets' + | 'expand_long_tail' + | 'publish_world' + | 'revert_checkpoint'; + +export type RpgAgentActionCapabilityKey = + | RpgAgentSuggestedActionType + | RpgAgentActionType; + +/** + * 当前先把能力矩阵定义为可选契约。 + * 等工作包 E 的 registry 落地后,后端可以把真实 supportedActions 填充到 session snapshot。 + */ +export interface RpgAgentSupportedAction { + action: RpgAgentActionCapabilityKey; + enabled: boolean; + reason?: string | null; +} + +export type RpgAgentOperationType = RpgAgentActionType | 'process_message'; + +export type RpgAgentOperationStatus = + | 'queued' + | 'running' + | 'completed' + | 'failed'; + +export interface RpgAgentOperationRecord { + operationId: string; + type: RpgAgentOperationType; + status: RpgAgentOperationStatus; + phaseLabel: string; + phaseDetail: string; + progress: number; + error?: string | null; +} + +export type RpgAgentActionRequest = + | { action: 'draft_foundation' } + | { + action: 'update_draft_card'; + cardId: string; + sections: Array<{ + sectionId: string; + value: string; + }>; + } + | { + action: 'sync_result_profile'; + profile: Record; + } + | { + action: 'generate_characters'; + count: number; + promptText?: string | null; + anchorCardIds?: string[]; + } + | { + action: 'generate_landmarks'; + count: number; + promptText?: string | null; + anchorCardIds?: string[]; + } + | { action: 'generate_role_assets'; roleIds: string[] } + | { + action: 'sync_role_assets'; + roleId: string; + portraitPath: string; + generatedVisualAssetId: string; + generatedAnimationSetId?: string | null; + animationMap?: Record | null; + } + | { action: 'generate_scene_assets'; sceneIds: string[] } + | { + action: 'sync_scene_assets'; + sceneId: string; + sceneKind: 'camp' | 'landmark'; + imageSrc: string; + generatedSceneAssetId: string; + generatedScenePrompt?: string | null; + generatedSceneModel?: string | null; + } + | { action: 'expand_long_tail' } + | { action: 'publish_world' } + | { action: 'revert_checkpoint'; checkpointId: string }; + +export interface RpgAgentActionResponse { + operation: RpgAgentOperationRecord; +} diff --git a/packages/shared/src/contracts/rpgAgentAnchors.ts b/packages/shared/src/contracts/rpgAgentAnchors.ts new file mode 100644 index 00000000..9f4f3e41 --- /dev/null +++ b/packages/shared/src/contracts/rpgAgentAnchors.ts @@ -0,0 +1,63 @@ +/** + * RPG 创作八锚点契约。 + * 这一层只描述“创作意图采集态”的结构,不混入 session 或结果页字段。 + */ + +export interface RpgCreationWorldPromiseValue { + hook: string; + differentiator: string; + desiredExperience: string; +} + +export interface RpgCreationPlayerFantasyValue { + playerRole: string; + corePursuit: string; + fearOfLoss: string; +} + +export interface RpgCreationThemeBoundaryValue { + toneKeywords: string[]; + aestheticDirectives: string[]; + forbiddenDirectives: string[]; +} + +export interface RpgCreationPlayerEntryPointValue { + openingIdentity: string; + openingProblem: string; + entryMotivation: string; +} + +export interface RpgCreationCoreConflictValue { + surfaceConflicts: string[]; + hiddenCrisis: string; + firstTouchedConflict: string; +} + +export interface RpgCreationKeyRelationshipValue { + pairs: string; + relationshipType: string; + secretOrCost: string; +} + +export interface RpgCreationHiddenLineValue { + hiddenTruths: string[]; + misdirectionHints: string[]; + revealPacing: string; +} + +export interface RpgCreationIconicElementValue { + iconicMotifs: string[]; + institutionsOrArtifacts: string[]; + hardRules: string[]; +} + +export interface RpgCreationAnchorContent { + worldPromise: RpgCreationWorldPromiseValue | null; + playerFantasy: RpgCreationPlayerFantasyValue | null; + themeBoundary: RpgCreationThemeBoundaryValue | null; + playerEntryPoint: RpgCreationPlayerEntryPointValue | null; + coreConflict: RpgCreationCoreConflictValue | null; + keyRelationships: RpgCreationKeyRelationshipValue[]; + hiddenLines: RpgCreationHiddenLineValue | null; + iconicElements: RpgCreationIconicElementValue | null; +} diff --git a/packages/shared/src/contracts/rpgAgentDraft.ts b/packages/shared/src/contracts/rpgAgentDraft.ts new file mode 100644 index 00000000..9339e234 --- /dev/null +++ b/packages/shared/src/contracts/rpgAgentDraft.ts @@ -0,0 +1,251 @@ +/** + * RPG Agent 草稿与资产覆盖率契约。 + * 这一层只描述 foundation draft、草稿卡片与资产状态,不包含会话编排语义。 + */ + +export type RpgAgentDraftCardKind = + | 'world' + | 'camp' + | 'faction' + | 'character' + | 'landmark' + | 'thread' + | 'chapter' + | 'scene_chapter' + | 'carrier' + | 'sidequest_seed'; + +export type RpgAgentDraftCardStatus = + | 'suggested' + | 'confirmed' + | 'locked' + | 'warning'; + +export interface RpgAgentDraftCardSummary { + id: string; + kind: RpgAgentDraftCardKind; + title: string; + subtitle: string; + summary: string; + status: RpgAgentDraftCardStatus; + linkedIds: string[]; + warningCount: number; + assetStatus?: RpgAgentRoleAssetStatus | null; + assetStatusLabel?: string | null; +} + +export interface RpgAgentDraftCardDetailSection { + id: string; + label: string; + value: string; +} + +export interface RpgAgentFoundationDraftFaction { + id: string; + name: string; + title?: string; + subtitle?: string; + publicGoal: string; + relatedConflict: string; + tension?: string; + playerRelation: string; + summary: string; +} + +export interface RpgAgentFoundationDraftCharacter { + id: string; + name: string; + title: string; + role: string; + publicIdentity: string; + publicMask?: string; + currentPressure: string; + hiddenHook?: string; + relationToPlayer: string; + threadIds: string[]; + summary: string; + skills?: Array<{ + id: string; + name: string; + actionPreviewConfig?: Record | null; + }>; + imageSrc?: string | null; + generatedVisualAssetId?: string | null; + generatedAnimationSetId?: string | null; + animationMap?: Record | null; +} + +export interface RpgAgentFoundationDraftLandmark { + id: string; + name: string; + description?: string; + purpose: string; + mood: string; + importance: string; + secret?: string; + dangerLevel?: string; + imageSrc?: string | null; + generatedSceneAssetId?: string | null; + generatedScenePrompt?: string | null; + generatedSceneModel?: string | null; + characterIds: string[]; + threadIds: string[]; + summary: string; +} + +export interface RpgAgentFoundationDraftThread { + id: string; + title: string; + type: 'main' | 'hidden'; + conflictType?: string; + conflict: string; + stakes?: string; + characterIds: string[]; + landmarkIds: string[]; + summary: string; +} + +export interface RpgAgentFoundationDraftChapter { + id: string; + title: string; + openingEvent: string; + playerGoal: string; + characterIds: string[]; + landmarkIds: string[]; + understandingShift: string; + summary: string; +} + +export interface RpgAgentFoundationDraftCamp { + id: string; + name: string; + description: string; + mood: string; + dangerLevel?: string; + imageSrc?: string | null; + generatedSceneAssetId?: string | null; + generatedScenePrompt?: string | null; + generatedSceneModel?: string | null; + summary: string; +} + +export type RpgAgentSceneActStage = + | 'opening' + | 'expansion' + | 'turning_point' + | 'climax' + | 'aftermath'; + +export type RpgAgentSceneActAdvanceRule = + | 'after_primary_contact' + | 'after_active_step_complete' + | 'after_chapter_resolution'; + +export interface RpgAgentFoundationDraftSceneAct { + id: string; + title: string; + summary: string; + stageCoverage: RpgAgentSceneActStage[]; + backgroundImageSrc?: string | null; + backgroundAssetId?: string | null; + encounterNpcIds: string[]; + primaryNpcId: string; + linkedThreadIds: string[]; + actGoal: string; + transitionHook: string; + advanceRule: RpgAgentSceneActAdvanceRule; +} + +export interface RpgAgentFoundationDraftSceneChapter { + id: string; + sceneId: string; + sceneName: string; + title: string; + summary: string; + linkedThreadIds: string[]; + linkedLandmarkIds: string[]; + acts: RpgAgentFoundationDraftSceneAct[]; +} + +export interface RpgAgentFoundationDraftProfile { + name: string; + subtitle: string; + summary: string; + tone: string; + playerGoal: string; + majorFactions: string[]; + coreConflicts: string[]; + playableNpcs: RpgAgentFoundationDraftCharacter[]; + storyNpcs: RpgAgentFoundationDraftCharacter[]; + landmarks: RpgAgentFoundationDraftLandmark[]; + camp?: RpgAgentFoundationDraftCamp | null; + themePack?: Record | null; + storyGraph?: Record | null; + factions: RpgAgentFoundationDraftFaction[]; + threads: RpgAgentFoundationDraftThread[]; + chapters: RpgAgentFoundationDraftChapter[]; + sceneChapters: RpgAgentFoundationDraftSceneChapter[]; + worldHook: string; + playerPremise: string; + openingSituation: string; + iconicElements: string[]; + sourceAnchorSummary: string; +} + +export interface RpgAgentFoundationDraftResult { + draftProfile: RpgAgentFoundationDraftProfile; + draftCards: RpgAgentDraftCardSummary[]; +} + +export interface RpgAgentDraftCardDetail { + id: string; + kind: RpgAgentDraftCardKind; + title: string; + sections: RpgAgentDraftCardDetailSection[]; + linkedIds: string[]; + locked: false; + editable: boolean; + editableSectionIds: string[]; + warningMessages: string[]; + assetStatus?: RpgAgentRoleAssetStatus | null; + assetStatusLabel?: string | null; +} + +export type RpgAgentAssetPriorityTier = 'hero' | 'featured' | 'supporting'; + +export type RpgAgentRoleAssetStatus = + | 'missing' + | 'visual_ready' + | 'animations_ready' + | 'complete'; + +export interface RpgAgentRoleAssetSummary { + roleId: string; + roleName: string; + roleKind: 'playable' | 'story'; + priorityTier: RpgAgentAssetPriorityTier; + portraitPath?: string | null; + generatedVisualAssetId?: string | null; + generatedAnimationSetId?: string | null; + status: RpgAgentRoleAssetStatus; + missingAnimations: string[]; + nextPointCost: number; +} + +export interface RpgAgentSceneAssetSummary { + sceneId: string; + sceneName: string; + actId?: string | null; + actTitle?: string | null; + imageSrc?: string | null; + assetId?: string | null; + status: 'missing' | 'ready'; + nextPointCost: number; +} + +export interface RpgAgentAssetCoverageSummary { + roleAssets: RpgAgentRoleAssetSummary[]; + sceneAssets: RpgAgentSceneAssetSummary[]; + allRoleAssetsReady: boolean; + allSceneAssetsReady: boolean; +} diff --git a/packages/shared/src/contracts/rpgAgentSession.ts b/packages/shared/src/contracts/rpgAgentSession.ts new file mode 100644 index 00000000..0bdb8ef1 --- /dev/null +++ b/packages/shared/src/contracts/rpgAgentSession.ts @@ -0,0 +1,134 @@ +import type { RpgAgentActionResponse, RpgAgentOperationRecord, RpgAgentSupportedAction, RpgAgentSuggestedAction } from './rpgAgentActions'; +import type { RpgCreationAnchorContent } from './rpgAgentAnchors'; +import type { RpgAgentAssetCoverageSummary, RpgAgentDraftCardDetail, RpgAgentDraftCardSummary } from './rpgAgentDraft'; +import type { RpgCreationPreviewEnvelope } from './rpgCreationPreview'; + +/** + * RPG Agent 会话层契约。 + * 这里承载 session 真相源与会话编排元数据,同时预留 resultPreview 与 supportedActions 两个后续主链字段。 + */ + +export interface RpgCreationIntentReadiness { + isReady: boolean; + completedKeys: string[]; + missingKeys: string[]; +} + +export interface RpgAgentPendingClarification { + id: string; + label: string; + question: string; + targetKey: + | 'world_hook' + | 'player_premise' + | 'theme_and_tone' + | 'core_conflict' + | 'relationship_seed' + | 'iconic_element'; + priority: number; + answer?: string; +} + +export type RpgAgentStage = + | 'collecting_intent' + | 'clarifying' + | 'foundation_review' + | 'object_refining' + | 'visual_refining' + | 'long_tail_review' + | 'ready_to_publish' + | 'published' + | 'error'; + +export type RpgAgentMessageRole = 'user' | 'assistant' | 'system'; + +export type RpgAgentMessageKind = + | 'chat' + | 'clarification' + | 'summary' + | 'checkpoint' + | 'warning' + | 'action_result'; + +export interface RpgAgentMessage { + id: string; + role: RpgAgentMessageRole; + kind: RpgAgentMessageKind; + text: string; + createdAt: string; + relatedOperationId?: string | null; +} + +export interface RpgAgentQualityFinding { + id: string; + severity: 'info' | 'warning' | 'blocker'; + code: string; + targetId?: string | null; + message: string; +} + +export interface RpgAgentSessionSnapshot { + sessionId: string; + currentTurn: number; + anchorContent: RpgCreationAnchorContent; + progressPercent: number; + lastAssistantReply: string | null; + stage: RpgAgentStage; + focusCardId: string | null; + creatorIntent: Record | null; + creatorIntentReadiness: RpgCreationIntentReadiness; + anchorPack: Record | null; + lockState: Record | null; + draftProfile: Record | null; + messages: RpgAgentMessage[]; + draftCards: RpgAgentDraftCardSummary[]; + pendingClarifications: RpgAgentPendingClarification[]; + suggestedActions: RpgAgentSuggestedAction[]; + recommendedReplies: string[]; + qualityFindings: RpgAgentQualityFinding[]; + assetCoverage: RpgAgentAssetCoverageSummary; + /** + * checkpoint 元数据需要进入 session snapshot 主链, + * 这样前端后续才能拿到真实可回滚目标,而不是只能盲发 checkpointId。 + */ + checkpoints?: Array<{ + checkpointId: string; + createdAt: string; + label: string; + }>; + /** + * 后续由工作包 E 的 action registry 真实填充。 + * 当前保持可选,确保主链迁移期间不影响旧 session snapshot。 + */ + supportedActions?: RpgAgentSupportedAction[]; + /** + * 后续由服务端 preview compiler 输出。 + * 当前保持可选,允许前端兼容层继续走 legacy profile。 + */ + resultPreview?: RpgCreationPreviewEnvelope | null; + updatedAt: string; +} + +export interface CreateRpgAgentSessionRequest { + seedText?: string; +} + +export interface CreateRpgAgentSessionResponse { + session: RpgAgentSessionSnapshot; +} + +export interface SendRpgAgentMessageRequest { + clientMessageId: string; + text: string; + quickFillRequested?: boolean; + focusCardId?: string | null; + selectedCardIds?: string[]; +} + +export interface SendRpgAgentMessageResponse extends RpgAgentActionResponse { + operation: RpgAgentOperationRecord; +} + +export interface GetRpgAgentCardDetailResponse { + card: RpgAgentDraftCardDetail; +} diff --git a/packages/shared/src/contracts/rpgContracts.test.ts b/packages/shared/src/contracts/rpgContracts.test.ts new file mode 100644 index 00000000..930d7e2a --- /dev/null +++ b/packages/shared/src/contracts/rpgContracts.test.ts @@ -0,0 +1,146 @@ +import { describe, expect, test } from 'vitest'; +import type { CustomWorldAgentSessionSnapshot } from './customWorldAgentSession'; +import type { CustomWorldResultPreviewEnvelope } from './customWorldResultPreview'; +import type { CustomWorldWorkSummary } from './customWorldWorkSummary'; + +import { + createRpgAgentFoundationDraftProfileFixture, + createRpgAgentSupportedActionsFixture, + createRpgAgentSessionFixture, + createRpgCreationAnchorContentFixture, + createRpgCreationPreviewEnvelopeFixture, + createRpgCreationPublishedProfileFixture, + createRpgCreationWorksResponseFixture, + createRpgWorldLibraryEntryFixture, +} from './rpgCreationFixtures'; + +describe('RPG 创作共享契约 fixture', () => { + test('旧命名兼容分文件可以直接承接新 fixture 的类型消费', () => { + const legacySession: CustomWorldAgentSessionSnapshot = + createRpgAgentSessionFixture(); + const legacyPreview: CustomWorldResultPreviewEnvelope = + createRpgCreationPreviewEnvelopeFixture(); + const legacyWork: CustomWorldWorkSummary = + createRpgCreationWorksResponseFixture().items[0]!; + + expect(legacySession.stage).toBe('ready_to_publish'); + expect(legacySession.resultPreview?.source).toBe(legacyPreview.source); + expect(legacyWork.status).toBe('draft'); + }); + + test('anchor fixture 与 foundation draft fixture 保持最小创作真相源对应关系', () => { + const anchors = createRpgCreationAnchorContentFixture(); + const draftProfile = createRpgAgentFoundationDraftProfileFixture(); + + expect(anchors.worldPromise?.hook).toContain('旧航路群岛'); + expect(draftProfile.worldHook).toContain('旧航路群岛'); + expect(draftProfile.playableNpcs).toHaveLength(1); + expect(draftProfile.storyNpcs).toHaveLength(1); + expect(draftProfile.sceneChapters[0]?.acts[0]?.backgroundImageSrc).toContain( + '/generated-custom-world-scenes/landmark-1/scene-act-1/scene.png', + ); + }); + + test('session fixture 同时暴露 supportedActions 与 resultPreview', () => { + const session = createRpgAgentSessionFixture(); + + expect(session.sessionId).toBe('rpg-session-fixture'); + expect(session.stage).toBe('ready_to_publish'); + expect(session.checkpoints?.[0]?.checkpointId).toBe( + 'checkpoint-foundation-v1', + ); + expect(session.supportedActions?.map((entry) => entry.action)).toEqual( + expect.arrayContaining(['draft_foundation', 'generate_role_assets', 'publish_world']), + ); + expect(session.resultPreview?.source).toBe('session_preview'); + expect(session.resultPreview?.blockers).toEqual([]); + }); + + test('preview fixture 保持预览来源、质量结论与 profile 载体三层边界', () => { + const preview = createRpgCreationPreviewEnvelopeFixture(); + + expect(preview.source).toBe('session_preview'); + expect(preview.preview.previewId).toBe('preview-fixture-1'); + expect(preview.preview.sessionId).toBe('rpg-session-fixture'); + expect(preview.qualityFindings?.[0]).toMatchObject({ + severity: 'info', + code: 'scene_asset_ready', + }); + }); + + test('supported actions fixture 明确区分可执行能力矩阵,而不是让前端自行猜测按钮状态', () => { + const supportedActions = createRpgAgentSupportedActionsFixture(); + + expect(supportedActions).toEqual([ + { action: 'draft_foundation', enabled: true }, + { action: 'generate_role_assets', enabled: true }, + { action: 'publish_world', enabled: true }, + ]); + }); + + test('published profile fixture 能稳定承载作品库与结果页所需的封面、场景幕与角色资产字段', () => { + const profile = createRpgCreationPublishedProfileFixture(); + + expect(profile.id).toBe('rpg-profile-fixture'); + expect(profile.playableNpcs).toHaveLength(1); + expect(profile.landmarks).toHaveLength(1); + expect(profile.sceneChapterBlueprints).toHaveLength(1); + expect( + (profile.sceneChapterBlueprints as Array<{ acts?: Array<{ backgroundImageSrc?: string }> }>)[0] + ?.acts?.[0]?.backgroundImageSrc, + ).toContain('/generated-custom-world-scenes/landmark-1/scene-act-1/scene.png'); + }); + + test('regression: session preview 与 published profile 需要共同保留角色动作资产和分幕背景字段', () => { + const session = createRpgAgentSessionFixture(); + const publishedProfile = createRpgCreationPublishedProfileFixture(); + const preview = createRpgCreationPreviewEnvelopeFixture(); + + expect( + ((session.draftProfile as { playableNpcs?: Array<{ animationMap?: { run?: { basePath?: string } } }> }) + .playableNpcs?.[0]?.animationMap?.run?.basePath ?? ''), + ).toContain('/generated-characters/playable-1/animations/run'); + expect( + ((preview.preview.playableNpcs as Array<{ generatedAnimationSetId?: string }>)[0] + ?.generatedAnimationSetId ?? ''), + ).toBe('animation-set-playable-1'); + expect( + ((publishedProfile.sceneChapterBlueprints as Array<{ + acts?: Array<{ backgroundAssetId?: string }>; + }>)[0]?.acts?.[0]?.backgroundAssetId ?? ''), + ).toBe('scene-asset-runtime'); + }); + + test('works fixture 与 library fixture 对齐同一 published profile', () => { + const works = createRpgCreationWorksResponseFixture(); + const libraryEntry = createRpgWorldLibraryEntryFixture(); + + const publishedWork = works.items.find((entry) => entry.status === 'published'); + + expect(publishedWork?.profileId).toBe(libraryEntry.profileId); + expect(publishedWork?.title).toBe(libraryEntry.worldName); + expect(publishedWork?.canEnterWorld).toBe(true); + expect(libraryEntry.profile.id).toBe(libraryEntry.profileId); + }); + + test('regression: works fixture 需要稳定保留草稿与发布态的作品门槛字段', () => { + const works = createRpgCreationWorksResponseFixture(); + const draftWork = works.items.find((entry) => entry.status === 'draft'); + const publishedWork = works.items.find((entry) => entry.status === 'published'); + + expect(draftWork).toMatchObject({ + stage: 'ready_to_publish', + stageLabel: '准备发布', + canResume: true, + canEnterWorld: false, + roleVisualReadyCount: 2, + roleAnimationReadyCount: 2, + }); + expect(publishedWork).toMatchObject({ + stage: 'published', + stageLabel: '已发布', + canResume: false, + canEnterWorld: true, + }); + }); +}); diff --git a/packages/shared/src/contracts/rpgCreationFixtures.ts b/packages/shared/src/contracts/rpgCreationFixtures.ts new file mode 100644 index 00000000..171ccff9 --- /dev/null +++ b/packages/shared/src/contracts/rpgCreationFixtures.ts @@ -0,0 +1,714 @@ +import type { + CustomWorldLibraryEntry, + CustomWorldProfileRecord, +} from './runtime'; +import type { RpgAgentSupportedAction } from './rpgAgentActions'; +import type { RpgCreationAnchorContent } from './rpgAgentAnchors'; +import type { + RpgAgentAssetCoverageSummary, + RpgAgentDraftCardSummary, + RpgAgentFoundationDraftProfile, +} from './rpgAgentDraft'; +import type { RpgAgentSessionSnapshot } from './rpgAgentSession'; +import type { RpgCreationPreviewEnvelope } from './rpgCreationPreview'; +import type { + ListRpgCreationWorksResponse, + RpgCreationWorkSummary, +} from './rpgCreationWorkSummary'; + +const RPG_CREATION_FIXTURE_SESSION_ID = 'rpg-session-fixture'; +const RPG_CREATION_FIXTURE_PROFILE_ID = 'rpg-profile-fixture'; +const RPG_CREATION_FIXTURE_USER_ID = 'fixture-user'; +const RPG_CREATION_FIXTURE_UPDATED_AT = '2026-04-21T09:30:00.000Z'; +const RPG_CREATION_FIXTURE_PUBLISHED_AT = '2026-04-21T10:00:00.000Z'; + +function cloneFixture(value: T): T { + return JSON.parse(JSON.stringify(value)) as T; +} + +/** + * 共享八锚点 fixture。 + * 用于 contract test、session fixture 和 works 集成测试复用同一份创作意图样本。 + */ +export function createRpgCreationAnchorContentFixture(): RpgCreationAnchorContent { + return cloneFixture({ + worldPromise: { + hook: '被海雾吞没的旧航路群岛', + differentiator: '灯塔与禁航令共同决定谁能活着穿过去。', + desiredExperience: '压抑、悬疑、潮湿', + }, + playerFantasy: { + playerRole: '玩家回到群岛调查沉船真相。', + corePursuit: '找出失控航路背后的真相。', + fearOfLoss: '失去最后一个还能对上旧案的人。', + }, + themeBoundary: { + toneKeywords: ['压抑', '潮湿', '悬疑'], + aestheticDirectives: ['旧灯塔', '潮雾', '断裂航路'], + forbiddenDirectives: ['不要出现现代枪械'], + }, + playerEntryPoint: { + openingIdentity: '被迫返乡的失职守灯人', + openingProblem: '首夜就有陌生船只闯入禁航区。', + entryMotivation: '查清沉船夜里被谁改动了灯册。', + }, + coreConflict: { + surfaceConflicts: ['守灯会与航运公会争夺旧航路控制权'], + hiddenCrisis: '沉船夜的航灯与灯册被人动过手脚。', + firstTouchedConflict: '玩家开局就会撞上新的封航命令。', + }, + keyRelationships: [ + { + pairs: '玩家 / 沈砺', + relationshipType: '旧友兼潜在背叛者', + secretOrCost: '沈砺暗地里在替沉船商盟引路。', + }, + ], + hiddenLines: { + hiddenTruths: ['沉船夜的真实失误并不是单纯天灾。'], + misdirectionHints: ['所有人都会先把问题推给潮雾本身。'], + revealPacing: '第一章露出痕迹,第二章才让玩家摸到灯册线。', + }, + iconicElements: { + iconicMotifs: ['会移动的海雾'], + institutionsOrArtifacts: ['回潮旧灯塔', '封灯令', '旧潮图'], + hardRules: ['禁航信号一旦点亮,任何船都必须退航。'], + }, + } satisfies RpgCreationAnchorContent); +} + +/** + * 共享 foundation draft fixture。 + * 这份样本同时服务 session 草稿、preview 适配回归测试和 works 聚合测试。 + */ +export function createRpgAgentFoundationDraftProfileFixture(): RpgAgentFoundationDraftProfile { + return cloneFixture({ + name: '潮雾列岛', + subtitle: '旧灯塔与失控航路', + summary: '第一版世界底稿已经整理完成,玩家将从回潮旧灯塔切入沉船旧案。', + tone: '压抑、潮湿、悬疑', + playerGoal: '查清沉船与禁航区异动的真相。', + majorFactions: ['守灯会', '航运公会'], + coreConflicts: ['守灯会与航运公会争夺旧航路控制权'], + playableNpcs: [ + { + id: 'playable-1', + name: '沈砺', + title: '旧航路引路人', + role: '关键同行者', + publicIdentity: '最熟悉旧航路的人。', + publicMask: '看上去像可靠旧友。', + currentPressure: '他必须在两股势力间站队。', + hiddenHook: '暗中替沉船商盟引路。', + relationToPlayer: '旧友兼潜在背叛者', + threadIds: ['thread-1'], + summary: '他像旧友,但也像一把始终没收回鞘的刀。', + skills: [ + { + id: 'skill-playable-1', + name: '潮行引路', + actionPreviewConfig: { + basePath: + '/generated-characters/playable-1/animations/skills/skill-playable-1', + }, + }, + ], + imageSrc: + '/generated-characters/playable-1/visual/asset-runtime/master.png', + generatedVisualAssetId: 'asset-runtime-playable', + generatedAnimationSetId: 'animation-set-playable-1', + animationMap: { + idle: { + basePath: '/generated-characters/playable-1/animations/idle', + }, + run: { + basePath: '/generated-characters/playable-1/animations/run', + }, + attack: { + basePath: '/generated-characters/playable-1/animations/attack', + }, + }, + }, + ], + storyNpcs: [ + { + id: 'story-1', + name: '顾潮音', + title: '守灯会值夜人', + role: '场景关键角色', + publicIdentity: '负责夜间巡灯与封锁。', + publicMask: '对外一直冷静克制。', + currentPressure: '她知道更多禁航区真相。', + hiddenHook: '曾亲眼见过失控海雾吞船。', + relationToPlayer: '最早愿意交换线索的人', + threadIds: ['thread-1'], + summary: '她总像比所有人更早知道海雾会往哪一侧压下来。', + skills: [ + { + id: 'skill-story-1', + name: '夜潮灯语', + actionPreviewConfig: { + basePath: + '/generated-characters/story-1/animations/skills/skill-story-1', + }, + }, + ], + imageSrc: + '/generated-characters/story-1/visual/asset-runtime/master.png', + generatedVisualAssetId: 'asset-runtime-story', + generatedAnimationSetId: 'animation-set-story-1', + animationMap: { + run: { + basePath: '/generated-characters/story-1/animations/run', + }, + attack: { + basePath: '/generated-characters/story-1/animations/attack', + }, + }, + }, + ], + landmarks: [ + { + id: 'landmark-1', + name: '回潮旧灯塔', + description: '被海雾啃旧的石塔仍在夜里维持着微弱灯火。', + purpose: '观察雾潮与往来船只', + mood: '潮湿、压抑、风声不止', + importance: '开局核心场景', + secret: '高处潮痕说明海面异常抬升过。', + dangerLevel: 'high', + imageSrc: '/generated-custom-world-scenes/landmark-1/latest-scene.png', + generatedSceneAssetId: 'scene-asset-landmark-1', + characterIds: ['story-1'], + threadIds: ['thread-1'], + summary: '旧灯塔是整片群岛最先看见异动的地方。', + }, + ], + camp: { + id: 'camp-1', + name: '回潮暂栖所', + description: '能暂时收拢队伍、整理灯册与潮路线索的落脚点。', + mood: '克制、紧绷,但还能暂时收拢局势', + dangerLevel: 'low', + imageSrc: '/custom/camp/huichao.png', + generatedSceneAssetId: 'scene-asset-camp-1', + summary: '玩家能在这里整理情报、回看旧灯册和沉船名单。', + }, + themePack: { + id: 'theme-pack:tide', + displayName: '潮雾悬疑', + }, + storyGraph: { + visibleThreads: [ + { + id: 'thread-visible-1', + title: '封航争夺', + }, + ], + }, + factions: [ + { + id: 'faction-1', + name: '守灯会', + title: '守灯会', + subtitle: '把控禁航灯令的人', + publicGoal: '维持封航秩序并压住灯册流出。', + relatedConflict: '想把旧案继续压在禁航记录之下。', + tension: '他们越强调规矩,越像在遮掩灯册。', + playerRelation: '玩家迟早要与他们正面冲突。', + summary: '掌握灯塔与封航令的势力,也是最怕旧案被翻出来的一方。', + }, + ], + threads: [ + { + id: 'thread-1', + title: '沉船旧案', + type: 'main', + conflictType: '真相遮蔽', + conflict: '沉船夜的航灯与灯册被人动过手脚。', + stakes: '真相一旦坐实,群岛秩序会先崩。', + characterIds: ['playable-1', 'story-1'], + landmarkIds: ['landmark-1'], + summary: '玩家会从灯塔高处潮痕一路追到沉船夜的真相。', + }, + ], + chapters: [ + { + id: 'chapter-1', + title: '灯塔回潮', + openingEvent: '禁航区闯入了一艘不该出现的陌生船。', + playerGoal: '先稳住局势,再拿到第一份灯册线索。', + characterIds: ['playable-1', 'story-1'], + landmarkIds: ['landmark-1'], + understandingShift: '玩家会意识到沉船旧案至今仍在操控群岛秩序。', + summary: '第一章聚焦灯塔与封航令,给玩家一条可追的旧案线索。', + }, + ], + sceneChapters: [ + { + id: 'scene-chapter-1', + sceneId: 'landmark-1', + sceneName: '回潮旧灯塔', + title: '灯塔初章', + summary: '围绕灯塔推进的首个场景章节。', + linkedThreadIds: ['thread-1'], + linkedLandmarkIds: ['landmark-1'], + acts: [ + { + id: 'scene-act-1', + title: '第一幕', + summary: '先接住回潮灯塔的入口压力。', + stageCoverage: ['opening'], + backgroundImageSrc: + '/generated-custom-world-scenes/landmark-1/scene-act-1/scene.png', + backgroundAssetId: 'scene-asset-runtime', + encounterNpcIds: ['story-1'], + primaryNpcId: 'story-1', + linkedThreadIds: ['thread-1'], + actGoal: '接住首幕入口', + transitionHook: '向第二幕推进。', + advanceRule: 'after_primary_contact', + }, + ], + }, + ], + worldHook: '被海雾吞没的旧航路群岛', + playerPremise: '玩家回到群岛调查沉船真相。', + openingSituation: '首夜就有陌生船只闯入禁航区。', + iconicElements: ['会移动的海雾', '回潮旧灯塔'], + sourceAnchorSummary: '海雾、旧灯塔、失控航路。', + } satisfies RpgAgentFoundationDraftProfile); +} + +function createRpgAgentDraftCardsFixture(): RpgAgentDraftCardSummary[] { + return cloneFixture([ + { + id: 'world-foundation', + kind: 'world', + title: '潮雾列岛', + subtitle: '旧灯塔与失控航路', + summary: '第一版世界底稿已经整理完成。', + status: 'suggested', + linkedIds: ['playable-1', 'story-1', 'landmark-1'], + warningCount: 0, + }, + { + id: 'playable-1', + kind: 'character', + title: '沈砺', + subtitle: '旧航路引路人 / 动作已齐', + summary: '最熟悉旧航路的人,也可能是最危险的旧友。', + status: 'suggested', + linkedIds: ['thread-1', 'landmark-1'], + warningCount: 0, + assetStatus: 'complete', + assetStatusLabel: '动作已齐', + }, + { + id: 'landmark-1', + kind: 'landmark', + title: '回潮旧灯塔', + subtitle: '观察雾潮与往来船只', + summary: '旧灯塔是整片群岛最先看见异动的地方。', + status: 'suggested', + linkedIds: ['story-1', 'thread-1'], + warningCount: 0, + }, + ] satisfies RpgAgentDraftCardSummary[]); +} + +function createRpgAgentAssetCoverageFixture(): RpgAgentAssetCoverageSummary { + return cloneFixture({ + roleAssets: [ + { + roleId: 'playable-1', + roleName: '沈砺', + roleKind: 'playable', + priorityTier: 'hero', + portraitPath: + '/generated-characters/playable-1/visual/asset-runtime/master.png', + generatedVisualAssetId: 'asset-runtime-playable', + generatedAnimationSetId: 'animation-set-playable-1', + status: 'complete', + missingAnimations: [], + nextPointCost: 0, + }, + { + roleId: 'story-1', + roleName: '顾潮音', + roleKind: 'story', + priorityTier: 'featured', + portraitPath: + '/generated-characters/story-1/visual/asset-runtime/master.png', + generatedVisualAssetId: 'asset-runtime-story', + generatedAnimationSetId: 'animation-set-story-1', + status: 'complete', + missingAnimations: [], + nextPointCost: 0, + }, + ], + sceneAssets: [ + { + sceneId: 'landmark-1', + sceneName: '回潮旧灯塔', + actId: 'scene-act-1', + actTitle: '第一幕', + imageSrc: + '/generated-custom-world-scenes/landmark-1/scene-act-1/scene.png', + assetId: 'scene-asset-runtime', + status: 'ready', + nextPointCost: 0, + }, + ], + allRoleAssetsReady: true, + allSceneAssetsReady: true, + } satisfies RpgAgentAssetCoverageSummary); +} + +/** + * 已发布 profile fixture。 + * 用于 preview compiler、works 聚合和 library 元数据解析测试。 + */ +export function createRpgCreationPublishedProfileFixture(): CustomWorldProfileRecord { + const draft = createRpgAgentFoundationDraftProfileFixture(); + + return cloneFixture({ + id: RPG_CREATION_FIXTURE_PROFILE_ID, + settingText: draft.worldHook, + name: draft.name, + subtitle: draft.subtitle, + summary: draft.summary, + tone: draft.tone, + playerGoal: draft.playerGoal, + templateWorldType: 'WUXIA', + compatibilityTemplateWorldType: 'WUXIA', + majorFactions: draft.majorFactions, + coreConflicts: draft.coreConflicts, + playableNpcs: draft.playableNpcs.map((role) => ({ + id: role.id, + name: role.name, + title: role.title, + role: role.role, + description: role.publicIdentity, + backstory: role.hiddenHook || role.summary, + personality: role.publicMask || role.summary, + motivation: role.currentPressure, + combatStyle: '借地形和潮路换位,先拉扯再压近。', + initialAffinity: 18, + relationshipHooks: [role.relationToPlayer], + tags: ['潮路', '旧案'], + imageSrc: role.imageSrc, + generatedVisualAssetId: role.generatedVisualAssetId, + generatedAnimationSetId: role.generatedAnimationSetId, + animationMap: role.animationMap, + skills: [ + { + id: 'skill-playable-1', + name: '潮行引路', + summary: '踩着旧潮阶切线前压,替队伍打开角度。', + style: '机动周旋', + }, + ], + templateCharacterId: 'archer-hero', + })), + storyNpcs: draft.storyNpcs.map((role) => ({ + id: role.id, + name: role.name, + title: role.title, + role: role.role, + description: role.publicIdentity, + backstory: role.hiddenHook || role.summary, + personality: role.publicMask || role.summary, + motivation: role.currentPressure, + combatStyle: '借塔顶视角和风向压制,再用灯火错位扰乱。', + initialAffinity: 8, + relationshipHooks: [role.relationToPlayer], + tags: ['守灯会', '灯塔'], + imageSrc: role.imageSrc, + generatedVisualAssetId: role.generatedVisualAssetId, + skills: [ + { + id: 'skill-story-1', + name: '夜潮灯语', + summary: '借灯语与潮声干扰对方判断。', + style: '起手压制', + }, + ], + })), + camp: { + name: draft.camp?.name, + description: draft.camp?.description, + dangerLevel: draft.camp?.dangerLevel, + imageSrc: draft.camp?.imageSrc, + }, + landmarks: draft.landmarks.map((landmark) => ({ + id: landmark.id, + name: landmark.name, + description: landmark.description, + dangerLevel: landmark.dangerLevel, + imageSrc: landmark.imageSrc, + sceneNpcIds: landmark.characterIds, + connections: [ + { + targetLandmarkId: 'landmark-1', + relativePosition: 'forward', + summary: '沿着旧潮阶继续前压到雾栈尽头。', + }, + ], + })), + cover: { + sourceType: 'default', + characterRoleIds: ['playable-1'], + }, + sceneChapterBlueprints: draft.sceneChapters.map((chapter) => ({ + id: chapter.id, + sceneId: chapter.sceneId, + sceneName: chapter.sceneName, + title: chapter.title, + summary: chapter.summary, + acts: chapter.acts.map((act) => ({ + id: act.id, + title: act.title, + summary: act.summary, + backgroundImageSrc: act.backgroundImageSrc, + backgroundAssetId: act.backgroundAssetId, + encounterNpcIds: act.encounterNpcIds, + primaryNpcId: act.primaryNpcId, + actGoal: act.actGoal, + transitionHook: act.transitionHook, + })), + })), + themePack: draft.themePack, + storyGraph: draft.storyGraph, + scenarioPackId: 'scenario-pack:tide', + campaignPackId: 'campaign-pack:tide', + generationMode: 'fast', + generationStatus: 'key_only', + updatedAt: RPG_CREATION_FIXTURE_UPDATED_AT, + publishedAt: RPG_CREATION_FIXTURE_PUBLISHED_AT, + } satisfies CustomWorldProfileRecord); +} + +export function createRpgCreationPreviewEnvelopeFixture(): RpgCreationPreviewEnvelope { + return cloneFixture({ + preview: { + ...createRpgCreationPublishedProfileFixture(), + previewId: 'preview-fixture-1', + sessionId: RPG_CREATION_FIXTURE_SESSION_ID, + profileId: RPG_CREATION_FIXTURE_PROFILE_ID, + }, + source: 'session_preview', + generatedAt: RPG_CREATION_FIXTURE_UPDATED_AT, + qualityFindings: [ + { + id: 'finding-scene-asset-ready', + severity: 'info', + code: 'scene_asset_ready', + targetId: 'scene-act-1', + message: '首幕背景图已经就绪,可直接用于结果页预览。', + }, + ], + blockers: [], + publishReady: true, + canEnterWorld: false, + } satisfies RpgCreationPreviewEnvelope); +} + +export function createRpgAgentSupportedActionsFixture(): RpgAgentSupportedAction[] { + return cloneFixture([ + { + action: 'draft_foundation', + enabled: true, + }, + { + action: 'generate_role_assets', + enabled: true, + }, + { + action: 'publish_world', + enabled: true, + }, + ] satisfies RpgAgentSupportedAction[]); +} + +/** + * 共享 session snapshot fixture。 + * 默认模拟“底稿、预览、资产都已准备好”的 ready_to_publish 状态。 + */ +export function createRpgAgentSessionFixture(): RpgAgentSessionSnapshot { + const draftProfile = createRpgAgentFoundationDraftProfileFixture(); + + return cloneFixture({ + sessionId: RPG_CREATION_FIXTURE_SESSION_ID, + currentTurn: 6, + anchorContent: createRpgCreationAnchorContentFixture(), + progressPercent: 100, + lastAssistantReply: '八锚点与底稿都已经齐备,可以进入结果页收口。', + stage: 'ready_to_publish', + focusCardId: null, + creatorIntent: { + sourceMode: 'card', + rawSettingText: draftProfile.worldHook, + worldHook: draftProfile.worldHook, + playerPremise: draftProfile.playerPremise, + themeKeywords: ['海雾', '旧航路'], + toneDirectives: ['压抑', '悬疑'], + openingSituation: draftProfile.openingSituation, + coreConflicts: draftProfile.coreConflicts, + keyFactions: ['守灯会'], + keyCharacters: ['沈砺', '顾潮音'], + keyLandmarks: ['回潮旧灯塔'], + iconicElements: draftProfile.iconicElements, + forbiddenDirectives: ['不要出现现代枪械'], + }, + creatorIntentReadiness: { + isReady: true, + completedKeys: [ + 'world_hook', + 'player_premise', + 'theme_and_tone', + 'core_conflict', + 'relationship_seed', + 'iconic_element', + ], + missingKeys: [], + }, + anchorPack: { + summary: draftProfile.sourceAnchorSummary, + }, + lockState: { + lockedCardIds: ['world-foundation'], + }, + draftProfile, + messages: [ + { + id: 'message-1', + role: 'assistant', + kind: 'summary', + text: '世界底稿已整理完成,建议进入结果页确认资产与发布门槛。', + createdAt: RPG_CREATION_FIXTURE_UPDATED_AT, + relatedOperationId: null, + }, + ], + draftCards: createRpgAgentDraftCardsFixture(), + pendingClarifications: [], + suggestedActions: [ + { + id: 'action-publish', + type: 'publish_world', + label: '发布世界', + }, + ], + recommendedReplies: ['先看结果页', '继续精修角色关系'], + qualityFindings: [ + { + id: 'finding-scene-asset-ready', + severity: 'info', + code: 'scene_asset_ready', + targetId: 'scene-act-1', + message: '首幕背景图已经就绪,可直接用于结果页预览。', + }, + ], + assetCoverage: createRpgAgentAssetCoverageFixture(), + checkpoints: [ + { + checkpointId: 'checkpoint-foundation-v1', + createdAt: RPG_CREATION_FIXTURE_UPDATED_AT, + label: '世界底稿 V1', + }, + ], + supportedActions: createRpgAgentSupportedActionsFixture(), + resultPreview: createRpgCreationPreviewEnvelopeFixture(), + updatedAt: RPG_CREATION_FIXTURE_UPDATED_AT, + } satisfies RpgAgentSessionSnapshot); +} + +export function createRpgWorldLibraryEntryFixture(): CustomWorldLibraryEntry { + const profile = createRpgCreationPublishedProfileFixture(); + + return cloneFixture({ + ownerUserId: RPG_CREATION_FIXTURE_USER_ID, + profileId: RPG_CREATION_FIXTURE_PROFILE_ID, + profile, + visibility: 'published', + publishedAt: RPG_CREATION_FIXTURE_PUBLISHED_AT, + updatedAt: RPG_CREATION_FIXTURE_UPDATED_AT, + authorDisplayName: '测试玩家', + worldName: String(profile.name ?? '潮雾列岛'), + subtitle: String(profile.subtitle ?? '旧灯塔与失控航路'), + summaryText: String(profile.summary ?? '第一版世界底稿已经整理完成。'), + coverImageSrc: + '/generated-custom-world-scenes/landmark-1/scene-act-1/scene.png', + themeMode: 'tide', + playableNpcCount: Array.isArray(profile.playableNpcs) + ? profile.playableNpcs.length + : 0, + landmarkCount: Array.isArray(profile.landmarks) + ? profile.landmarks.length + : 0, + } satisfies CustomWorldLibraryEntry); +} + +export function createRpgCreationWorksResponseFixture(): ListRpgCreationWorksResponse { + return cloneFixture({ + items: [ + { + workId: `draft:${RPG_CREATION_FIXTURE_SESSION_ID}`, + sourceType: 'agent_session', + status: 'draft', + title: '潮雾列岛', + subtitle: '旧灯塔与失控航路', + summary: '第一版世界底稿已经整理完成,玩家将从回潮旧灯塔切入沉船旧案。', + coverImageSrc: '/custom/camp/huichao.png', + coverRenderMode: 'scene_with_roles', + coverCharacterImageSrcs: [ + '/generated-characters/playable-1/visual/asset-runtime/master.png', + ], + updatedAt: RPG_CREATION_FIXTURE_UPDATED_AT, + publishedAt: null, + stage: 'ready_to_publish', + stageLabel: '准备发布', + playableNpcCount: 2, + landmarkCount: 1, + roleVisualReadyCount: 2, + roleAnimationReadyCount: 2, + roleAssetSummaryLabel: '沈砺 · 动作已就绪', + sessionId: RPG_CREATION_FIXTURE_SESSION_ID, + profileId: null, + canResume: true, + canEnterWorld: false, + blockerCount: 0, + publishReady: true, + }, + { + workId: `published:${RPG_CREATION_FIXTURE_PROFILE_ID}`, + sourceType: 'published_profile', + status: 'published', + title: '潮雾列岛', + subtitle: '旧灯塔与失控航路', + summary: '第一版世界底稿已经整理完成,玩家将从回潮旧灯塔切入沉船旧案。', + coverImageSrc: + '/generated-custom-world-scenes/landmark-1/scene-act-1/scene.png', + coverRenderMode: 'scene_with_roles', + coverCharacterImageSrcs: [ + '/generated-characters/playable-1/visual/asset-runtime/master.png', + ], + updatedAt: RPG_CREATION_FIXTURE_UPDATED_AT, + publishedAt: RPG_CREATION_FIXTURE_PUBLISHED_AT, + stage: 'published', + stageLabel: '已发布', + playableNpcCount: 1, + landmarkCount: 1, + roleVisualReadyCount: 1, + roleAnimationReadyCount: 1, + roleAssetSummaryLabel: '动作已就绪 1', + sessionId: null, + profileId: RPG_CREATION_FIXTURE_PROFILE_ID, + canResume: false, + canEnterWorld: true, + blockerCount: 0, + publishReady: true, + }, + ] satisfies RpgCreationWorkSummary[], + } satisfies ListRpgCreationWorksResponse); +} diff --git a/packages/shared/src/contracts/rpgCreationPreview.ts b/packages/shared/src/contracts/rpgCreationPreview.ts new file mode 100644 index 00000000..2c1c8e88 --- /dev/null +++ b/packages/shared/src/contracts/rpgCreationPreview.ts @@ -0,0 +1,40 @@ +import type { CustomWorldProfileRecord } from './runtime'; + +/** + * 结果页预览契约。 + * 当前 preview 仍以兼容 profile 作为承载体,但已经把来源、阻断项和质量结论从 session 草稿里显式剥离出来。 + */ + +export type RpgCreationPreviewSource = + | 'session_preview' + | 'published_profile'; + +export interface RpgCreationPreviewFinding { + id: string; + severity: 'info' | 'warning' | 'blocker'; + code: string; + targetId?: string | null; + message: string; +} + +export interface RpgCreationPreviewBlocker { + id: string; + code: string; + message: string; +} + +export type RpgCreationPreview = CustomWorldProfileRecord & { + previewId?: string; + sessionId?: string | null; + profileId?: string | null; +}; + +export interface RpgCreationPreviewEnvelope { + preview: RpgCreationPreview; + source: RpgCreationPreviewSource; + generatedAt?: string; + qualityFindings?: RpgCreationPreviewFinding[]; + blockers?: RpgCreationPreviewBlocker[]; + publishReady?: boolean; + canEnterWorld?: boolean; +} diff --git a/packages/shared/src/contracts/rpgCreationWorkSummary.ts b/packages/shared/src/contracts/rpgCreationWorkSummary.ts new file mode 100644 index 00000000..eef1b69c --- /dev/null +++ b/packages/shared/src/contracts/rpgCreationWorkSummary.ts @@ -0,0 +1,38 @@ +/** + * RPG 创作作品卡读模型契约。 + * works 列表只暴露继续创作与进入世界判断所需的稳定字段。 + */ + +export type RpgCreationWorkStatus = 'draft' | 'published'; +export type RpgCreationWorkSource = 'agent_session' | 'published_profile'; + +export interface RpgCreationWorkSummary { + workId: string; + sourceType: RpgCreationWorkSource; + status: RpgCreationWorkStatus; + title: string; + subtitle: string; + summary: string; + coverImageSrc?: string | null; + coverRenderMode?: 'image' | 'scene_with_roles'; + coverCharacterImageSrcs?: string[]; + updatedAt: string; + publishedAt?: string | null; + stage?: string | null; + stageLabel?: string | null; + playableNpcCount: number; + landmarkCount: number; + roleVisualReadyCount?: number; + roleAnimationReadyCount?: number; + roleAssetSummaryLabel?: string | null; + sessionId?: string | null; + profileId?: string | null; + canResume: boolean; + canEnterWorld: boolean; + blockerCount?: number; + publishReady?: boolean; +} + +export interface ListRpgCreationWorksResponse { + items: RpgCreationWorkSummary[]; +} diff --git a/packages/shared/src/contracts/rpgRuntimeChat.ts b/packages/shared/src/contracts/rpgRuntimeChat.ts new file mode 100644 index 00000000..a08dff5c --- /dev/null +++ b/packages/shared/src/contracts/rpgRuntimeChat.ts @@ -0,0 +1,184 @@ +/** + * RPG 运行时聊天相关共享契约。 + * 将角色聊天、NPC 对话与轻量 story 请求载荷从旧 story.ts 中独立出来。 + */ +import type { JsonObject } from './common'; + +export type NpcChatTurnLimitReason = 'negative_affinity'; + +export type NpcChatTurnClosingMode = 'free' | 'foreshadow_close'; + +export type NpcChatTurnDirective = { + sceneActId?: string | null; + turnLimit?: number | null; + remainingTurns?: number | null; + limitReason?: NpcChatTurnLimitReason | null; + closingMode?: NpcChatTurnClosingMode | null; + forceExitAfterTurn?: boolean; +}; + +export type NpcChatTurnCompletionDirective = { + turnLimit?: number | null; + remainingTurns?: number | null; + forceExit?: boolean; + closingMode?: NpcChatTurnClosingMode; +}; + +export type CharacterChatReplyRequest< + TCharacter = unknown, + TStoryMoment = unknown, + TContext = unknown, + TConversationTurn = unknown, + TTargetStatus = unknown, +> = { + worldType: string; + playerCharacter: TCharacter; + targetCharacter: TCharacter; + storyHistory: TStoryMoment[]; + context: TContext; + conversationHistory: TConversationTurn[]; + conversationSummary: string; + playerMessage: string; + targetStatus: TTargetStatus; +}; + +export type CharacterChatSuggestionsRequest< + TCharacter = unknown, + TStoryMoment = unknown, + TContext = unknown, + TConversationTurn = unknown, + TTargetStatus = unknown, +> = { + worldType: string; + playerCharacter: TCharacter; + targetCharacter: TCharacter; + storyHistory: TStoryMoment[]; + context: TContext; + conversationHistory: TConversationTurn[]; + conversationSummary: string; + targetStatus: TTargetStatus; +}; + +export type CharacterChatSummaryRequest< + TCharacter = unknown, + TStoryMoment = unknown, + TContext = unknown, + TConversationTurn = unknown, + TTargetStatus = unknown, +> = { + worldType: string; + playerCharacter: TCharacter; + targetCharacter: TCharacter; + storyHistory: TStoryMoment[]; + context: TContext; + conversationHistory: TConversationTurn[]; + previousSummary: string; + targetStatus: TTargetStatus; +}; + +export type NpcChatDialogueRequest< + TCharacter = unknown, + TEncounter = unknown, + TMonster = unknown, + TStoryMoment = unknown, + TContext = unknown, +> = { + worldType: string; + character: TCharacter; + encounter: TEncounter; + monsters: TMonster[]; + history: TStoryMoment[]; + context: TContext; + topic: string; + resultSummary: string; + npcInitiatesConversation?: boolean; +}; + +export type NpcChatTurnRequest< + TCharacter = unknown, + TEncounter = unknown, + TMonster = unknown, + TStoryMoment = unknown, + TContext = unknown, + TConversationTurn = unknown, + TCombatContext = unknown, + TNpcState = unknown, + TQuestOfferState = unknown, + TQuestOfferEncounter = unknown, + TChatDirective = NpcChatTurnDirective, +> = { + worldType: string; + character?: TCharacter; + player?: TCharacter; + encounter: TEncounter; + monsters: TMonster[]; + history: TStoryMoment[]; + context: TContext; + conversationHistory?: TConversationTurn[]; + dialogue?: TConversationTurn[]; + combatContext?: TCombatContext | null; + playerMessage: string; + npcState: TNpcState; + npcInitiatesConversation?: boolean; + questOfferContext?: { + state: TQuestOfferState; + encounter: TQuestOfferEncounter; + turnCount: number; + } | null; + chatDirective?: TChatDirective | null; +}; + +export type NpcChatPendingQuestOffer = { + quest: TQuest; + introText?: string; +}; + +export type NpcChatTurnResult = { + npcReply: string; + affinityDelta: number; + affinityText: string; + suggestions: string[]; + pendingQuestOffer?: NpcChatPendingQuestOffer | null; + chatDirective?: NpcChatTurnCompletionDirective | null; +}; + +export type NpcRecruitDialogueRequest< + TCharacter = unknown, + TEncounter = unknown, + TMonster = unknown, + TStoryMoment = unknown, + TContext = unknown, +> = { + worldType: string; + character: TCharacter; + encounter: TEncounter; + monsters: TMonster[]; + history: TStoryMoment[]; + context: TContext; + invitationText: string; + recruitSummary: string; +}; + +export type StoryRequestOptionsPayload = { + availableOptions?: JsonObject[]; + optionCatalog?: JsonObject[]; +}; + +export type StoryRequestPayload = { + worldType: TWorldType; + character: JsonObject; + monsters?: JsonObject[]; + history?: JsonObject[]; + choice?: string; + context: JsonObject; + requestOptions?: StoryRequestOptionsPayload; +}; + +export type PlainTextPromptRequest = { + systemPrompt: string; + userPrompt: string; +}; + +export type PlainTextResponse = { + text: string; +}; diff --git a/packages/shared/src/contracts/rpgRuntimeContracts.test.ts b/packages/shared/src/contracts/rpgRuntimeContracts.test.ts new file mode 100644 index 00000000..d402dcf8 --- /dev/null +++ b/packages/shared/src/contracts/rpgRuntimeContracts.test.ts @@ -0,0 +1,51 @@ +import { describe, expect, test } from 'vitest'; + +import type { CharacterChatReplyRequest } from './rpgRuntimeChat'; +import { QUEST_NARRATIVE_TYPES } from './rpgRuntimeQuestAssist'; +import { + SERVER_RUNTIME_FUNCTION_IDS, + TASK5_RUNTIME_OPTION_SCOPES, + TASK6_RUNTIME_FUNCTION_IDS, + type RuntimeStoryActionRequest, +} from './rpgRuntimeStoryAction'; +import type { RuntimeStoryStateRequest } from './rpgRuntimeStoryState'; + +describe('RPG runtime shared contracts', () => { + test('拆分后的 runtime story action 契约继续导出常量与类型', () => { + expect(SERVER_RUNTIME_FUNCTION_IDS).toContain('npc_chat'); + expect(TASK6_RUNTIME_FUNCTION_IDS).toContain('npc_trade'); + expect(TASK5_RUNTIME_OPTION_SCOPES).toEqual(['story', 'combat', 'npc']); + + const request: RuntimeStoryActionRequest = { + sessionId: 'runtime-session-1', + action: { + type: 'story_choice', + functionId: 'npc_chat', + }, + }; + + expect(request.action.functionId).toBe('npc_chat'); + }); + + test('拆分后的 chat 与 quest assist 契约继续导出运行时类型', () => { + const payload: CharacterChatReplyRequest = { + worldType: 'WUXIA', + playerCharacter: {}, + targetCharacter: {}, + storyHistory: [], + context: {}, + conversationHistory: [], + conversationSummary: '测试摘要', + playerMessage: '近况如何?', + targetStatus: {}, + }; + + const stateRequest: RuntimeStoryStateRequest = { + sessionId: 'runtime-session-2', + }; + + expect(payload.playerMessage).toBe('近况如何?'); + expect(stateRequest.sessionId).toBe('runtime-session-2'); + expect(QUEST_NARRATIVE_TYPES).toContain('relationship'); + }); +}); diff --git a/packages/shared/src/contracts/rpgRuntimeQuestAssist.ts b/packages/shared/src/contracts/rpgRuntimeQuestAssist.ts new file mode 100644 index 00000000..03d44ca1 --- /dev/null +++ b/packages/shared/src/contracts/rpgRuntimeQuestAssist.ts @@ -0,0 +1,83 @@ +/** + * RPG 运行时任务辅助与道具意图共享契约。 + * 该文件只承载 quest / runtime item 辅助类型,不混入 runtime story 主状态。 + */ +import type { JsonObject } from './common'; + +export const QUEST_NARRATIVE_TYPES = [ + 'bounty', + 'escort', + 'investigation', + 'retrieval', + 'relationship', + 'trial', +] as const; +export type SharedQuestNarrativeType = (typeof QUEST_NARRATIVE_TYPES)[number]; + +export const QUEST_OBJECTIVE_KINDS = [ + 'defeat_hostile_npc', + 'inspect_treasure', + 'spar_with_npc', + 'talk_to_npc', + 'reach_scene', + 'deliver_item', +] as const; +export type SharedQuestObjectiveKind = (typeof QUEST_OBJECTIVE_KINDS)[number]; + +export const QUEST_URGENCY_LEVELS = ['low', 'medium', 'high'] as const; +export type SharedQuestUrgency = (typeof QUEST_URGENCY_LEVELS)[number]; + +export const QUEST_INTIMACY_LEVELS = [ + 'transactional', + 'cooperative', + 'trust_based', +] as const; +export type SharedQuestIntimacy = (typeof QUEST_INTIMACY_LEVELS)[number]; + +export const QUEST_REWARD_THEMES = [ + 'currency', + 'resource', + 'relationship', + 'intel', + 'rare_item', +] as const; +export type SharedQuestRewardTheme = (typeof QUEST_REWARD_THEMES)[number]; + +export const RUNTIME_ITEM_FUNCTIONAL_BIAS_VALUES = [ + 'heal', + 'mana', + 'cooldown', + 'guard', + 'damage', +] as const; +export type SharedRuntimeItemFunctionalBias = + (typeof RUNTIME_ITEM_FUNCTIONAL_BIAS_VALUES)[number]; + +export const RUNTIME_ITEM_TONE_VALUES = [ + 'grim', + 'mysterious', + 'martial', + 'ritual', + 'survival', +] as const; +export type SharedRuntimeItemTone = (typeof RUNTIME_ITEM_TONE_VALUES)[number]; + +export type RuntimeItemIntentRequest< + TContext = JsonObject, + TPlan = JsonObject, +> = { + context: TContext; + plans: TPlan[]; +}; + +export type RuntimeItemIntentResponse = { + intents: TIntent[]; +}; + +export type QuestGenerationRequest< + TState = JsonObject, + TEncounter = JsonObject, +> = { + state: TState; + encounter: TEncounter; +}; diff --git a/packages/shared/src/contracts/rpgRuntimeStoryAction.ts b/packages/shared/src/contracts/rpgRuntimeStoryAction.ts new file mode 100644 index 00000000..12d320bd --- /dev/null +++ b/packages/shared/src/contracts/rpgRuntimeStoryAction.ts @@ -0,0 +1,136 @@ +/** + * RPG runtime story 动作层共享契约。 + * 将 function id、动作请求与交互元数据从旧 story.ts 中单独收口。 + */ +import type { JsonObject } from './common'; + +export type RuntimeAction< + TType extends string = string, + TPayload = JsonObject, +> = { + type: TType; + functionId?: string; + targetId?: string; + payload?: TPayload; +}; + +export type RuntimeActionRequest< + TAction extends RuntimeAction = RuntimeAction, +> = { + sessionId: string; + clientVersion?: number; + action: TAction; +}; + +export type RuntimeActionResponse< + TViewModel = JsonObject, + TPresentation = JsonObject, + TPatch = JsonObject, +> = { + sessionId: string; + serverVersion: number; + viewModel: TViewModel; + presentation: TPresentation; + patches: TPatch[]; +}; + +export const TASK5_RUNTIME_FUNCTION_IDS = [ + 'story_continue_adventure', + 'story_opening_camp_dialogue', + 'camp_travel_home_scene', + 'idle_call_out', + 'idle_explore_forward', + 'idle_observe_signs', + 'idle_rest_focus', + 'idle_travel_next_scene', + 'battle_attack_basic', + 'battle_use_skill', + 'battle_all_in_crush', + 'battle_escape_breakout', + 'battle_feint_step', + 'battle_finisher_window', + 'battle_guard_break', + 'battle_probe_pressure', + 'battle_recover_breath', + 'npc_chat', + 'npc_fight', + 'npc_help', + 'npc_leave', + 'npc_preview_talk', + 'npc_recruit', + 'npc_spar', +] as const; +export type Task5RuntimeFunctionId = + (typeof TASK5_RUNTIME_FUNCTION_IDS)[number]; + +export const TASK6_RUNTIME_FUNCTION_IDS = [ + 'equipment_equip', + 'equipment_unequip', + 'forge_craft', + 'forge_dismantle', + 'forge_reforge', + 'inventory_use', + 'npc_gift', + 'npc_chat_quest_offer_abandon', + 'npc_chat_quest_offer_replace', + 'npc_chat_quest_offer_view', + 'npc_quest_accept', + 'npc_quest_turn_in', + 'npc_trade', + 'treasure_inspect', + 'treasure_leave', + 'treasure_secure', +] as const; +export type Task6RuntimeFunctionId = + (typeof TASK6_RUNTIME_FUNCTION_IDS)[number]; + +export const SERVER_RUNTIME_FUNCTION_IDS = [ + ...TASK5_RUNTIME_FUNCTION_IDS, + ...TASK6_RUNTIME_FUNCTION_IDS, +] as const; +export type ServerRuntimeFunctionId = + (typeof SERVER_RUNTIME_FUNCTION_IDS)[number]; + +export const TASK5_RUNTIME_OPTION_SCOPES = ['story', 'combat', 'npc'] as const; +export type Task5RuntimeOptionScope = + (typeof TASK5_RUNTIME_OPTION_SCOPES)[number]; + +export type RuntimeStoryChoicePayload = JsonObject & { + optionText?: string; + note?: string; + releaseNpcId?: string; + preludeText?: string; +}; + +export type RuntimeStoryOptionInteraction = + | { + kind: 'npc'; + npcId: string; + action: + | 'chat' + | 'help' + | 'fight' + | 'leave' + | 'quest_offer_abandon' + | 'quest_offer_replace' + | 'quest_offer_view' + | 'recruit' + | 'spar' + | 'trade' + | 'gift' + | 'quest_accept' + | 'quest_turn_in'; + questId?: string; + } + | { + kind: 'treasure'; + action: 'inspect' | 'leave' | 'secure'; + }; + +export type RuntimeStoryChoiceAction = RuntimeAction< + 'story_choice', + RuntimeStoryChoicePayload +> & { + functionId: string; + targetId?: string; +}; diff --git a/packages/shared/src/contracts/rpgRuntimeStoryState.ts b/packages/shared/src/contracts/rpgRuntimeStoryState.ts new file mode 100644 index 00000000..ab6b4fe9 --- /dev/null +++ b/packages/shared/src/contracts/rpgRuntimeStoryState.ts @@ -0,0 +1,146 @@ +/** + * RPG runtime story 状态与响应共享契约。 + * 该文件只负责 view model、presentation、patch 与 snapshot 回包结构。 + */ +import type { JsonObject } from './common'; +import type { SavedGameSnapshot, SavedGameSnapshotInput } from './runtime'; +import type { + RuntimeActionRequest, + RuntimeActionResponse, + RuntimeStoryChoiceAction, + RuntimeStoryChoicePayload, + RuntimeStoryOptionInteraction, + Task5RuntimeOptionScope, +} from './rpgRuntimeStoryAction'; + +export type RuntimeStoryOptionView = { + functionId: string; + actionText: string; + detailText?: string; + scope: Task5RuntimeOptionScope; + interaction?: RuntimeStoryOptionInteraction; + payload?: RuntimeStoryChoicePayload; + disabled?: boolean; + reason?: string; +}; + +export type RuntimeStoryPlayerViewModel = { + hp: number; + maxHp: number; + mana: number; + maxMana: number; +}; + +export type RuntimeStoryCompanionViewModel = { + npcId: string; + characterId?: string; + joinedAtAffinity: number; +}; + +export type RuntimeStoryEncounterViewModel = { + id: string; + kind: 'npc' | 'treasure'; + npcName: string; + hostile: boolean; + affinity?: number; + recruited?: boolean; + interactionActive: boolean; + battleMode?: 'fight' | 'spar' | null; +}; + +export type RuntimeStoryStatusViewModel = { + inBattle: boolean; + npcInteractionActive: boolean; + currentNpcBattleMode: 'fight' | 'spar' | null; + currentNpcBattleOutcome: 'fight_victory' | 'spar_complete' | null; +}; + +export type RuntimeBattlePresentation = { + targetId?: string; + targetName?: string; + damageDealt?: number; + damageTaken?: number; + outcome?: 'ongoing' | 'victory' | 'spar_complete' | 'escaped'; +}; + +export type RuntimeStoryViewModel = { + player: RuntimeStoryPlayerViewModel; + encounter: RuntimeStoryEncounterViewModel | null; + companions: RuntimeStoryCompanionViewModel[]; + availableOptions: RuntimeStoryOptionView[]; + status: RuntimeStoryStatusViewModel; +}; + +export type RuntimeStoryPresentation = { + actionText: string; + resultText: string; + storyText: string; + options: RuntimeStoryOptionView[]; + toast?: string | null; + battle?: RuntimeBattlePresentation | null; +}; + +export type RuntimeStoryPatch = + | { + type: 'story_history_append'; + actionText: string; + resultText: string; + } + | { + type: 'npc_affinity_changed'; + npcId: string; + previousAffinity: number; + nextAffinity: number; + } + | { + type: 'battle_resolved'; + functionId: string; + targetId?: string; + damageDealt?: number; + damageTaken?: number; + outcome: 'ongoing' | 'victory' | 'spar_complete' | 'escaped'; + } + | { + type: 'status_changed'; + inBattle: boolean; + npcInteractionActive: boolean; + currentNpcBattleMode: 'fight' | 'spar' | null; + currentNpcBattleOutcome: 'fight_victory' | 'spar_complete' | null; + } + | { + type: 'encounter_changed'; + encounterId: string | null; + }; + +export type RuntimeStoryActionRequest = + RuntimeActionRequest & { + snapshot?: SavedGameSnapshotInput; + }; + +export type RuntimeStoryStateRequest< + TSnapshotGameState = JsonObject, + TSnapshotCurrentStory = JsonObject, +> = { + sessionId: string; + clientVersion?: number; + snapshot?: SavedGameSnapshotInput< + TSnapshotGameState, + string, + TSnapshotCurrentStory + >; +}; + +export type RuntimeStoryActionResponse< + TSnapshotGameState = JsonObject, + TSnapshotCurrentStory = JsonObject, +> = RuntimeActionResponse< + RuntimeStoryViewModel, + RuntimeStoryPresentation, + RuntimeStoryPatch +> & { + snapshot: SavedGameSnapshot< + TSnapshotGameState, + string, + TSnapshotCurrentStory + >; +}; diff --git a/packages/shared/src/contracts/story.ts b/packages/shared/src/contracts/story.ts deleted file mode 100644 index 22a54340..00000000 --- a/packages/shared/src/contracts/story.ts +++ /dev/null @@ -1,499 +0,0 @@ -import type { JsonObject } from './common'; -import type { SavedGameSnapshot } from './runtime'; - -export const QUEST_NARRATIVE_TYPES = [ - 'bounty', - 'escort', - 'investigation', - 'retrieval', - 'relationship', - 'trial', -] as const; -export type SharedQuestNarrativeType = (typeof QUEST_NARRATIVE_TYPES)[number]; - -export const QUEST_OBJECTIVE_KINDS = [ - 'defeat_hostile_npc', - 'inspect_treasure', - 'spar_with_npc', - 'talk_to_npc', - 'reach_scene', - 'deliver_item', -] as const; -export type SharedQuestObjectiveKind = (typeof QUEST_OBJECTIVE_KINDS)[number]; - -export const QUEST_URGENCY_LEVELS = ['low', 'medium', 'high'] as const; -export type SharedQuestUrgency = (typeof QUEST_URGENCY_LEVELS)[number]; - -export const QUEST_INTIMACY_LEVELS = [ - 'transactional', - 'cooperative', - 'trust_based', -] as const; -export type SharedQuestIntimacy = (typeof QUEST_INTIMACY_LEVELS)[number]; - -export const QUEST_REWARD_THEMES = [ - 'currency', - 'resource', - 'relationship', - 'intel', - 'rare_item', -] as const; -export type SharedQuestRewardTheme = (typeof QUEST_REWARD_THEMES)[number]; - -export const RUNTIME_ITEM_FUNCTIONAL_BIAS_VALUES = [ - 'heal', - 'mana', - 'cooldown', - 'guard', - 'damage', -] as const; -export type SharedRuntimeItemFunctionalBias = - (typeof RUNTIME_ITEM_FUNCTIONAL_BIAS_VALUES)[number]; - -export const RUNTIME_ITEM_TONE_VALUES = [ - 'grim', - 'mysterious', - 'martial', - 'ritual', - 'survival', -] as const; -export type SharedRuntimeItemTone = (typeof RUNTIME_ITEM_TONE_VALUES)[number]; - -export type StoryRequestOptionsPayload = { - availableOptions?: JsonObject[]; - optionCatalog?: JsonObject[]; -}; - -export type StoryRequestPayload = { - worldType: TWorldType; - character: JsonObject; - monsters?: JsonObject[]; - history?: JsonObject[]; - choice?: string; - context: JsonObject; - requestOptions?: StoryRequestOptionsPayload; -}; - -export type PlainTextPromptRequest = { - systemPrompt: string; - userPrompt: string; -}; - -export type PlainTextResponse = { - text: string; -}; - -export type NpcChatTurnLimitReason = 'negative_affinity'; - -export type NpcChatTurnClosingMode = 'free' | 'foreshadow_close'; - -export type NpcChatTurnDirective = { - sceneActId?: string | null; - turnLimit?: number | null; - remainingTurns?: number | null; - limitReason?: NpcChatTurnLimitReason | null; - closingMode?: NpcChatTurnClosingMode | null; - forceExitAfterTurn?: boolean; -}; - -export type NpcChatTurnCompletionDirective = { - turnLimit?: number | null; - remainingTurns?: number | null; - forceExit?: boolean; - closingMode?: NpcChatTurnClosingMode; -}; - -export type CharacterChatReplyRequest< - TCharacter = unknown, - TStoryMoment = unknown, - TContext = unknown, - TConversationTurn = unknown, - TTargetStatus = unknown, -> = { - worldType: string; - playerCharacter: TCharacter; - targetCharacter: TCharacter; - storyHistory: TStoryMoment[]; - context: TContext; - conversationHistory: TConversationTurn[]; - conversationSummary: string; - playerMessage: string; - targetStatus: TTargetStatus; -}; - -export type CharacterChatSuggestionsRequest< - TCharacter = unknown, - TStoryMoment = unknown, - TContext = unknown, - TConversationTurn = unknown, - TTargetStatus = unknown, -> = { - worldType: string; - playerCharacter: TCharacter; - targetCharacter: TCharacter; - storyHistory: TStoryMoment[]; - context: TContext; - conversationHistory: TConversationTurn[]; - conversationSummary: string; - targetStatus: TTargetStatus; -}; - -export type CharacterChatSummaryRequest< - TCharacter = unknown, - TStoryMoment = unknown, - TContext = unknown, - TConversationTurn = unknown, - TTargetStatus = unknown, -> = { - worldType: string; - playerCharacter: TCharacter; - targetCharacter: TCharacter; - storyHistory: TStoryMoment[]; - context: TContext; - conversationHistory: TConversationTurn[]; - previousSummary: string; - targetStatus: TTargetStatus; -}; - -export type NpcChatDialogueRequest< - TCharacter = unknown, - TEncounter = unknown, - TMonster = unknown, - TStoryMoment = unknown, - TContext = unknown, -> = { - worldType: string; - character: TCharacter; - encounter: TEncounter; - monsters: TMonster[]; - history: TStoryMoment[]; - context: TContext; - topic: string; - resultSummary: string; - npcInitiatesConversation?: boolean; -}; - -export type NpcChatTurnRequest< - TCharacter = unknown, - TEncounter = unknown, - TMonster = unknown, - TStoryMoment = unknown, - TContext = unknown, - TConversationTurn = unknown, - TCombatContext = unknown, - TNpcState = unknown, - TQuestOfferState = unknown, - TQuestOfferEncounter = unknown, - TChatDirective = NpcChatTurnDirective, -> = { - worldType: string; - character?: TCharacter; - player?: TCharacter; - encounter: TEncounter; - monsters: TMonster[]; - history: TStoryMoment[]; - context: TContext; - conversationHistory?: TConversationTurn[]; - dialogue?: TConversationTurn[]; - combatContext?: TCombatContext | null; - playerMessage: string; - npcState: TNpcState; - npcInitiatesConversation?: boolean; - questOfferContext?: { - state: TQuestOfferState; - encounter: TQuestOfferEncounter; - turnCount: number; - } | null; - chatDirective?: TChatDirective | null; -}; - -export type NpcChatPendingQuestOffer = { - quest: TQuest; - introText?: string; -}; - -export type NpcChatTurnResult = { - npcReply: string; - affinityDelta: number; - affinityText: string; - suggestions: string[]; - pendingQuestOffer?: NpcChatPendingQuestOffer | null; - chatDirective?: NpcChatTurnCompletionDirective | null; -}; - -export type NpcRecruitDialogueRequest< - TCharacter = unknown, - TEncounter = unknown, - TMonster = unknown, - TStoryMoment = unknown, - TContext = unknown, -> = { - worldType: string; - character: TCharacter; - encounter: TEncounter; - monsters: TMonster[]; - history: TStoryMoment[]; - context: TContext; - invitationText: string; - recruitSummary: string; -}; - -export type RuntimeItemIntentRequest< - TContext = JsonObject, - TPlan = JsonObject, -> = { - context: TContext; - plans: TPlan[]; -}; - -export type RuntimeItemIntentResponse = { - intents: TIntent[]; -}; - -export type QuestGenerationRequest< - TState = JsonObject, - TEncounter = JsonObject, -> = { - state: TState; - encounter: TEncounter; -}; - -export type RuntimeAction< - TType extends string = string, - TPayload = JsonObject, -> = { - type: TType; - functionId?: string; - targetId?: string; - payload?: TPayload; -}; - -export type RuntimeActionRequest< - TAction extends RuntimeAction = RuntimeAction, -> = { - sessionId: string; - clientVersion?: number; - action: TAction; -}; - -export type RuntimeActionResponse< - TViewModel = JsonObject, - TPresentation = JsonObject, - TPatch = JsonObject, -> = { - sessionId: string; - serverVersion: number; - viewModel: TViewModel; - presentation: TPresentation; - patches: TPatch[]; -}; - -export const TASK5_RUNTIME_FUNCTION_IDS = [ - 'story_continue_adventure', - 'story_opening_camp_dialogue', - 'camp_travel_home_scene', - 'idle_call_out', - 'idle_explore_forward', - 'idle_observe_signs', - 'idle_rest_focus', - 'idle_travel_next_scene', - 'battle_attack_basic', - 'battle_use_skill', - 'battle_all_in_crush', - 'battle_escape_breakout', - 'battle_feint_step', - 'battle_finisher_window', - 'battle_guard_break', - 'battle_probe_pressure', - 'battle_recover_breath', - 'npc_chat', - 'npc_fight', - 'npc_help', - 'npc_leave', - 'npc_preview_talk', - 'npc_recruit', - 'npc_spar', -] as const; -export type Task5RuntimeFunctionId = - (typeof TASK5_RUNTIME_FUNCTION_IDS)[number]; - -export const TASK6_RUNTIME_FUNCTION_IDS = [ - 'equipment_equip', - 'equipment_unequip', - 'forge_craft', - 'forge_dismantle', - 'forge_reforge', - 'inventory_use', - 'npc_gift', - 'npc_quest_accept', - 'npc_quest_turn_in', - 'npc_trade', - 'treasure_inspect', - 'treasure_leave', - 'treasure_secure', -] as const; -export type Task6RuntimeFunctionId = - (typeof TASK6_RUNTIME_FUNCTION_IDS)[number]; - -export const SERVER_RUNTIME_FUNCTION_IDS = [ - ...TASK5_RUNTIME_FUNCTION_IDS, - ...TASK6_RUNTIME_FUNCTION_IDS, -] as const; -export type ServerRuntimeFunctionId = - (typeof SERVER_RUNTIME_FUNCTION_IDS)[number]; - -export const TASK5_RUNTIME_OPTION_SCOPES = ['story', 'combat', 'npc'] as const; -export type Task5RuntimeOptionScope = - (typeof TASK5_RUNTIME_OPTION_SCOPES)[number]; - -export type RuntimeStoryChoicePayload = JsonObject & { - optionText?: string; - note?: string; -}; - -export type RuntimeStoryOptionInteraction = - | { - kind: 'npc'; - npcId: string; - action: - | 'chat' - | 'help' - | 'fight' - | 'leave' - | 'recruit' - | 'spar' - | 'trade' - | 'gift' - | 'quest_accept' - | 'quest_turn_in'; - questId?: string; - } - | { - kind: 'treasure'; - action: 'inspect' | 'leave' | 'secure'; - }; - -export type RuntimeStoryChoiceAction = RuntimeAction< - 'story_choice', - RuntimeStoryChoicePayload -> & { - functionId: string; - targetId?: string; -}; - -export type RuntimeStoryOptionView = { - functionId: string; - actionText: string; - detailText?: string; - scope: Task5RuntimeOptionScope; - interaction?: RuntimeStoryOptionInteraction; - payload?: RuntimeStoryChoicePayload; - disabled?: boolean; - reason?: string; -}; - -export type RuntimeStoryPlayerViewModel = { - hp: number; - maxHp: number; - mana: number; - maxMana: number; -}; - -export type RuntimeStoryCompanionViewModel = { - npcId: string; - characterId?: string; - joinedAtAffinity: number; -}; - -export type RuntimeStoryEncounterViewModel = { - id: string; - kind: 'npc' | 'treasure'; - npcName: string; - hostile: boolean; - affinity?: number; - recruited?: boolean; - interactionActive: boolean; - battleMode?: 'fight' | 'spar' | null; -}; - -export type RuntimeStoryStatusViewModel = { - inBattle: boolean; - npcInteractionActive: boolean; - currentNpcBattleMode: 'fight' | 'spar' | null; - currentNpcBattleOutcome: 'fight_victory' | 'spar_complete' | null; -}; - -export type RuntimeBattlePresentation = { - targetId?: string; - targetName?: string; - damageDealt?: number; - damageTaken?: number; - outcome?: 'ongoing' | 'victory' | 'spar_complete' | 'escaped'; -}; - -export type RuntimeStoryViewModel = { - player: RuntimeStoryPlayerViewModel; - encounter: RuntimeStoryEncounterViewModel | null; - companions: RuntimeStoryCompanionViewModel[]; - availableOptions: RuntimeStoryOptionView[]; - status: RuntimeStoryStatusViewModel; -}; - -export type RuntimeStoryPresentation = { - actionText: string; - resultText: string; - storyText: string; - options: RuntimeStoryOptionView[]; - toast?: string | null; - battle?: RuntimeBattlePresentation | null; -}; - -export type RuntimeStoryPatch = - | { - type: 'story_history_append'; - actionText: string; - resultText: string; - } - | { - type: 'npc_affinity_changed'; - npcId: string; - previousAffinity: number; - nextAffinity: number; - } - | { - type: 'battle_resolved'; - functionId: string; - targetId?: string; - damageDealt?: number; - damageTaken?: number; - outcome: 'ongoing' | 'victory' | 'spar_complete' | 'escaped'; - } - | { - type: 'status_changed'; - inBattle: boolean; - npcInteractionActive: boolean; - currentNpcBattleMode: 'fight' | 'spar' | null; - currentNpcBattleOutcome: 'fight_victory' | 'spar_complete' | null; - } - | { - type: 'encounter_changed'; - encounterId: string | null; - }; - -export type RuntimeStoryActionRequest = - RuntimeActionRequest; - -export type RuntimeStoryActionResponse< - TSnapshotGameState = JsonObject, - TSnapshotCurrentStory = JsonObject, -> = RuntimeActionResponse< - RuntimeStoryViewModel, - RuntimeStoryPresentation, - RuntimeStoryPatch -> & { - snapshot: SavedGameSnapshot< - TSnapshotGameState, - string, - TSnapshotCurrentStory - >; -}; diff --git a/packages/shared/src/index.ts b/packages/shared/src/index.ts index 85f44d3a..e6d6be73 100644 --- a/packages/shared/src/index.ts +++ b/packages/shared/src/index.ts @@ -1,8 +1,19 @@ export * from './assets/qwenSprite'; export * from './contracts/auth'; export * from './contracts/common'; +export type * from './contracts/customWorldAgent'; +export * from './contracts/rpgAgentActions'; +export * from './contracts/rpgAgentAnchors'; +export * from './contracts/rpgAgentDraft'; +export * from './contracts/rpgAgentSession'; +export * from './contracts/rpgCreationFixtures'; +export * from './contracts/rpgCreationPreview'; +export * from './contracts/rpgCreationWorkSummary'; +export * from './contracts/rpgRuntimeChat'; +export * from './contracts/rpgRuntimeQuestAssist'; +export * from './contracts/rpgRuntimeStoryAction'; +export * from './contracts/rpgRuntimeStoryState'; export * from './contracts/runtime'; -export * from './contracts/story'; export * from './http'; export * from './llm/narrativeLanguage'; export * from './llm/parsers'; diff --git a/scripts/generate-build-tag-similarity.py b/scripts/generate-build-tag-similarity.py deleted file mode 100644 index e9071fee..00000000 --- a/scripts/generate-build-tag-similarity.py +++ /dev/null @@ -1,357 +0,0 @@ -import json -import os -from pathlib import Path - -import numpy as np - -try: - from vikingdb import VikingDB, IAM, EmbeddingClient - from vikingdb.vector import EmbeddingData, EmbeddingModelOpt, EmbeddingRequest -except ImportError as exc: # pragma: no cover - raise SystemExit( - "Missing dependency: vikingdb-python-sdk.\n" - "Install it with: py -3 -m pip install vikingdb-python-sdk" - ) from exc - - -def zh(value: str) -> str: - return value.encode("utf-8").decode("unicode_escape") - - -BUILD_TAGS = [ - { - "label": zh(r"\u5feb\u5251"), - "aliases": ["duelist", "swift blade", "swiftblade", zh(r"\u5251\u5feb"), zh(r"\u5feb\u5203")], - "description": zh(r"\u4ee5\u9ad8\u901f\u8f7b\u5175\u5668\u3001\u8fde\u7eed\u51fa\u624b\u548c\u8d34\u8eab\u538b\u8feb\u4e3a\u6838\u5fc3\u7684\u8fd1\u6218\u6807\u7b7e\u3002"), - }, - { - "label": zh(r"\u8fde\u6bb5"), - "aliases": ["combo", "chain", zh(r"\u8fde\u51fb")], - "description": zh(r"\u4f9d\u8d56\u8fde\u7eed\u547d\u4e2d\u4e0e\u591a\u6bb5\u8282\u594f\u538b\u5236\u7684\u8f93\u51fa\u6807\u7b7e\u3002"), - }, - { - "label": zh(r"\u7a81\u8fdb"), - "aliases": ["dash", "lunge", "mobility engage"], - "description": zh(r"\u5f3a\u8c03\u5feb\u901f\u8d34\u8fd1\u76ee\u6807\u3001\u62a2\u5360\u8eab\u4f4d\u548c\u5148\u624b\u5207\u5165\u7684\u6218\u6597\u6807\u7b7e\u3002"), - }, - { - "label": zh(r"\u8ffd\u51fb"), - "aliases": ["chase", "follow-up", "finisher chase"], - "description": zh(r"\u64c5\u957f\u5728\u5bf9\u624b\u5931\u8861\u6216\u88ab\u51fb\u9000\u540e\u7ee7\u7eed\u8ffd\u6253\u7684\u6218\u6597\u6807\u7b7e\u3002"), - }, - { - "label": zh(r"\u5feb\u88ad"), - "aliases": ["assassin", "rogue", "ambush", zh(r"\u523a\u51fb")], - "description": zh(r"\u5f3a\u8c03\u77ed\u65f6\u5207\u5165\u3001\u70b9\u6740\u5f31\u70b9\u548c\u8fc5\u901f\u8131\u79bb\u7684\u523a\u51fb\u6807\u7b7e\u3002"), - }, - { - "label": zh(r"\u8fdc\u5c04"), - "aliases": ["projectile", "ranged", "arrow", zh(r"\u5c04\u51fb")], - "description": zh(r"\u4ee5\u6295\u5c04\u7269\u3001\u4e2d\u8fdc\u8ddd\u79bb\u7275\u5236\u548c\u5b89\u5168\u8f93\u51fa\u4e3a\u6838\u5fc3\u7684\u6807\u7b7e\u3002"), - }, - { - "label": zh(r"\u6e38\u51fb"), - "aliases": ["scout", "skirmish", "harass", "fieldcraft"], - "description": zh(r"\u5f3a\u8c03\u8fb9\u79fb\u52a8\u8fb9\u8f93\u51fa\u3001\u8bd5\u63a2\u62c9\u626f\u548c\u62e9\u673a\u518d\u5165\u573a\u7684\u6807\u7b7e\u3002"), - }, - { - "label": zh(r"\u673a\u52a8"), - "aliases": ["mobility", "nimble", "agile"], - "description": zh(r"\u4ee3\u8868\u9ad8\u4f4d\u79fb\u3001\u9ad8\u8eab\u6cd5\u548c\u5feb\u901f\u6362\u4f4d\u7684\u6807\u7b7e\u3002"), - }, - { - "label": zh(r"\u98ce\u884c"), - "aliases": ["wind", "gust", "speed", zh(r"\u75be\u884c")], - "description": zh(r"\u5f3a\u8c03\u8f7b\u7075\u6b65\u6cd5\u3001\u79fb\u901f\u4f18\u52bf\u548c\u8fc5\u901f\u8c03\u4f4d\u7684\u6807\u7b7e\u3002"), - }, - { - "label": zh(r"\u91cd\u51fb"), - "aliases": ["heavy", "slam", "mighty", "crush"], - "description": zh(r"\u5f3a\u8c03\u539a\u91cd\u6253\u51fb\u3001\u5355\u6b21\u9ad8\u538b\u8f93\u51fa\u548c\u6b63\u9762\u7838\u7a7f\u7684\u6807\u7b7e\u3002"), - }, - { - "label": zh(r"\u7206\u53d1"), - "aliases": ["burst", "nova", "sudden damage"], - "description": zh(r"\u4ee3\u8868\u77ed\u7a97\u53e3\u5185\u8fc5\u901f\u62ac\u9ad8\u4f24\u5bb3\u5cf0\u503c\u7684\u6807\u7b7e\u3002"), - }, - { - "label": zh(r"\u7834\u7532"), - "aliases": ["breaker", "armor break", "shatter"], - "description": zh(r"\u64c5\u957f\u6495\u5f00\u9632\u5fa1\u3001\u6253\u65ad\u5b88\u52bf\u548c\u9488\u5bf9\u786c\u76ee\u6807\u7684\u6807\u7b7e\u3002"), - }, - { - "label": zh(r"\u538b\u5236"), - "aliases": ["tempo", "pressure", "control offense"], - "description": zh(r"\u901a\u8fc7\u6301\u7eed\u4e3b\u52a8\u8fdb\u653b\u4e0e\u8282\u594f\u5360\u4f18\u8feb\u4f7f\u5bf9\u624b\u5931\u8bef\u7684\u6807\u7b7e\u3002"), - }, - { - "label": zh(r"\u538b\u8840"), - "aliases": ["low hp", "berserk", "risk damage"], - "description": zh(r"\u4ee5\u5192\u9669\u538b\u4f4e\u8840\u7ebf\u6362\u53d6\u66f4\u5f3a\u653b\u51fb\u6027\u7684\u6807\u7b7e\u3002"), - }, - { - "label": zh(r"\u5b88\u5fa1"), - "aliases": ["ward", "guard", "protector", "defense"], - "description": zh(r"\u5f3a\u8c03\u51cf\u4f24\u3001\u7a33\u5b88\u548c\u9876\u4f4f\u6b63\u9762\u4f24\u5bb3\u7684\u9632\u5fa1\u6807\u7b7e\u3002"), - }, - { - "label": zh(r"\u62a4\u4f53"), - "aliases": ["barrier", "shielding", "spirit guard", "spirit"], - "description": zh(r"\u504f\u5411\u62a4\u7f69\u3001\u62a4\u8eab\u6c14\u52b2\u548c\u72b6\u6001\u6297\u538b\u7684\u9632\u5fa1\u6807\u7b7e\u3002"), - }, - { - "label": zh(r"\u91cd\u7532"), - "aliases": ["tank", "heavy armor", "iron wall"], - "description": zh(r"\u4ee3\u8868\u9ad8\u786c\u5ea6\u62a4\u7532\u3001\u6b63\u9762\u627f\u4f24\u4e0e\u7a33\u5b9a\u7ad9\u573a\u7684\u6807\u7b7e\u3002"), - }, - { - "label": zh(r"\u53cd\u51fb"), - "aliases": ["counter", "riposte", "retaliate"], - "description": zh(r"\u901a\u8fc7\u683c\u6321\u3001\u7ad9\u6869\u4e0e\u540e\u624b\u60e9\u7f5a\u5f62\u6210\u6536\u76ca\u7684\u6807\u7b7e\u3002"), - }, - { - "label": zh(r"\u9547\u90aa"), - "aliases": ["banish", "holy ward", "warding seal"], - "description": zh(r"\u64c5\u957f\u538b\u5236\u90aa\u795f\u3001\u5492\u715e\u548c\u5f02\u7c7b\u80fd\u91cf\u7684\u6807\u7b7e\u3002"), - }, - { - "label": zh(r"\u6cd5\u4fee"), - "aliases": ["caster", "mage", "arcane", "spell"], - "description": zh(r"\u4ee5\u6cd5\u672f\u9a71\u52a8\u8f93\u51fa\u3001\u63a7\u5236\u548c\u8d44\u6e90\u8fd0\u8f6c\u7684\u6838\u5fc3\u6807\u7b7e\u3002"), - }, - { - "label": zh(r"\u6cd5\u529b"), - "aliases": ["mana", "magic", "essence", "spirit power"], - "description": zh(r"\u56f4\u7ed5\u6cd5\u529b\u4e0a\u9650\u3001\u6cd5\u672f\u6d88\u8017\u4e0e\u6cd5\u80fd\u5faa\u73af\u6784\u7b51\u7684\u6807\u7b7e\u3002"), - }, - { - "label": zh(r"\u96f7\u6cd5"), - "aliases": ["lightning", "thunder", "storm"], - "description": zh(r"\u4ee3\u8868\u9ad8\u538b\u96f7\u7cfb\u672f\u6cd5\u3001\u77ac\u65f6\u9707\u8361\u548c\u9ebb\u75f9\u538b\u5236\u7684\u6807\u7b7e\u3002"), - }, - { - "label": zh(r"\u7b26\u9635"), - "aliases": ["sigil", "formation", "seal", "rune"], - "description": zh(r"\u901a\u8fc7\u7b26\u7bb4\u3001\u6cd5\u9635\u548c\u9884\u5e03\u7f6e\u6548\u679c\u6539\u53d8\u6218\u573a\u7684\u6807\u7b7e\u3002"), - }, - { - "label": zh(r"\u63a7\u573a"), - "aliases": ["control", "crowd control", "lockdown"], - "description": zh(r"\u4ee5\u9650\u5236\u884c\u52a8\u3001\u5c01\u9501\u7a7a\u95f4\u548c\u538b\u7f29\u9009\u62e9\u4e3a\u6838\u5fc3\u7684\u6807\u7b7e\u3002"), - }, - { - "label": zh(r"\u8fc7\u8f7d"), - "aliases": ["overload", "surge", "power spike"], - "description": zh(r"\u5728\u77ed\u65f6\u95f4\u5185\u63a8\u52a8\u9ad8\u6cd5\u8017\u4e0e\u9ad8\u5f3a\u5ea6\u91ca\u653e\u7684\u6807\u7b7e\u3002"), - }, - { - "label": zh(r"\u56de\u590d"), - "aliases": ["heal", "healing", "recovery", "restore"], - "description": zh(r"\u5f3a\u8c03\u5373\u65f6\u6062\u590d\u4e0e\u6218\u540e\u7eed\u63a5\u80fd\u529b\u7684\u6807\u7b7e\u3002"), - }, - { - "label": zh(r"\u62a4\u6301"), - "aliases": ["support", "aid", "blessing"], - "description": zh(r"\u901a\u8fc7\u589e\u76ca\u3001\u62ac\u7a33\u6001\u548c\u4fdd\u62a4\u961f\u53cb\u6765\u5efa\u7acb\u4f18\u52bf\u7684\u6807\u7b7e\u3002"), - }, - { - "label": zh(r"\u7eed\u6218"), - "aliases": ["sustain", "endurance", "long fight"], - "description": zh(r"\u9762\u5411\u957f\u7ebf\u6218\u6597\u3001\u8d44\u6e90\u6301\u7eed\u4e0e\u5bb9\u9519\u63d0\u5347\u7684\u6807\u7b7e\u3002"), - }, - { - "label": zh(r"\u547d\u7eb9"), - "aliases": ["fate", "omen", "destiny"], - "description": zh(r"\u56f4\u7ed5\u547d\u8fd0\u3001\u5370\u8bb0\u4e0e\u89e6\u53d1\u5f0f\u8fde\u9501\u6536\u76ca\u6784\u7b51\u7684\u6807\u7b7e\u3002"), - }, - { - "label": zh(r"\u673a\u7f18"), - "aliases": ["fortune", "luck", "opportunity"], - "description": zh(r"\u4f9d\u8d56\u65f6\u673a\u3001\u8fd0\u52bf\u548c\u989d\u5916\u6536\u76ca\u89e6\u53d1\u7684\u6807\u7b7e\u3002"), - }, - { - "label": zh(r"\u51b7\u5374"), - "aliases": ["cooldown", "cdr", "recharge"], - "description": zh(r"\u901a\u8fc7\u66f4\u5feb\u5468\u8f6c\u6280\u80fd\u4e0e\u9053\u5177\u6765\u6eda\u52a8\u4f18\u52bf\u7684\u6807\u7b7e\u3002"), - }, - { - "label": zh(r"\u7edf\u5fa1"), - "aliases": ["commander", "command", "leader"], - "description": zh(r"\u5f3a\u8c03\u6574\u4f53\u534f\u8c03\u3001\u56e2\u961f\u6536\u76ca\u548c\u7efc\u5408\u8c03\u5ea6\u7684\u6807\u7b7e\u3002"), - }, - { - "label": zh(r"\u5747\u8861"), - "aliases": ["balanced", "adaptable", "all-round"], - "description": zh(r"\u6ca1\u6709\u660e\u663e\u77ed\u677f\uff0c\u504f\u91cd\u4e2d\u540e\u671f\u7a33\u5b9a\u6210\u578b\u7684\u6807\u7b7e\u3002"), - }, - { - "label": zh(r"\u5de5\u5de7"), - "aliases": ["craft", "artisan", "utility", "socket"], - "description": zh(r"\u504f\u5411\u5de5\u827a\u3001\u5668\u68b0\u3001\u9576\u5d4c\u548c\u8f85\u52a9\u6784\u7b51\u7684\u6807\u7b7e\u3002"), - }, - { - "label": zh(r"\u70bc\u836f"), - "aliases": ["alchemy", "potion", "tonic"], - "description": zh(r"\u56f4\u7ed5\u836f\u5242\u3001\u4e34\u65f6\u5f3a\u5316\u548c\u6218\u4e2d\u8865\u7ed9\u7684\u5de5\u827a\u6807\u7b7e\u3002"), - }, - { - "label": zh(r"\u5148\u950b"), - "aliases": ["vanguard", "frontline"], - "description": zh(r"\u4ee3\u8868\u961f\u4f0d\u4e2d\u7684\u6b63\u9762\u5f00\u8def\u3001\u5403\u7ebf\u4e0e\u538b\u524d\u6392\u804c\u8d23\u3002"), - }, - { - "label": zh(r"\u72c2\u6218"), - "aliases": ["berserker", "rage"], - "description": zh(r"\u4ee5\u8840\u91cf\u4ea4\u6362\u3001\u731b\u653b\u548c\u9ad8\u98ce\u9669\u9ad8\u56de\u62a5\u4e3a\u7279\u8272\u7684\u6807\u7b7e\u3002"), - }, - { - "label": zh(r"\u6cd5\u5251"), - "aliases": ["spellblade", "bladecaster"], - "description": zh(r"\u878d\u5408\u5175\u5203\u4e0e\u672f\u6cd5\uff0c\u64c5\u957f\u4e2d\u8ddd\u79bb\u538b\u8feb\u7684\u6df7\u5408\u6807\u7b7e\u3002"), - }, - { - "label": zh(r"\u5723\u4f51"), - "aliases": ["paladin", "holy guard"], - "description": zh(r"\u517c\u5177\u9632\u62a4\u3001\u56de\u590d\u548c\u60e9\u6212\u80fd\u529b\u7684\u795d\u798f\u6807\u7b7e\u3002"), - }, - { - "label": zh(r"\u5821\u5792"), - "aliases": ["fortress", "bulwark"], - "description": zh(r"\u4ee5\u7a33\u5b9a\u7ad9\u573a\u3001\u786c\u6297\u4e0e\u53cd\u6253\u4e3a\u6838\u5fc3\u7684\u91cd\u9632\u5fa1\u6807\u7b7e\u3002"), - }, - { - "label": zh(r"\u8d77\u624b"), - "aliases": ["starter", "legacy"], - "description": zh(r"\u504f\u8fc7\u6e21\u4e0e\u8d77\u6b65\u7528\u9014\u7684\u65e9\u671f\u6784\u7b51\u6807\u7b7e\u3002"), - }, -] - - -def build_prompt(definition: dict) -> str: - aliases = "\u3001".join(definition["aliases"]) - return f"{definition['label']}:{definition['description']} 别名:{aliases}。" - - -def load_env_file(path: Path, protected_keys: set[str]) -> None: - if not path.exists(): - return - - for raw_line in path.read_text(encoding="utf-8").splitlines(): - line = raw_line.strip() - if not line or line.startswith("#") or "=" not in line: - continue - - key, value = line.split("=", 1) - key = key.strip() - if not key or key in protected_keys: - continue - - value = value.strip() - if len(value) >= 2 and value[0] == value[-1] and value[0] in {"'", '"'}: - value = value[1:-1] - - os.environ[key] = value - - -def load_local_env() -> None: - root_dir = Path(__file__).resolve().parents[1] - protected_keys = set(os.environ) - - load_env_file(root_dir / ".env", protected_keys) - load_env_file(root_dir / ".env.local", protected_keys) - - -def create_embedding_client() -> EmbeddingClient: - access_key = os.getenv("VOLCENGINE_ACCESS_KEY_ID") or os.getenv("VIKINGDB_ACCESS_KEY_ID") - secret_key = os.getenv("VOLCENGINE_SECRET_ACCESS_KEY") or os.getenv("VIKINGDB_SECRET_ACCESS_KEY") - host = os.getenv("VIKINGDB_HOST", "api-vikingdb.vikingdb.cn-beijing.volces.com") - region = os.getenv("VIKINGDB_REGION", "cn-beijing") - - if not access_key or not secret_key: - raise SystemExit( - "Missing VikingDB credentials.\n" - "Required:\n" - " VOLCENGINE_ACCESS_KEY_ID\n" - " VOLCENGINE_SECRET_ACCESS_KEY\n" - "Optional:\n" - " VIKINGDB_HOST (default: api-vikingdb.vikingdb.cn-beijing.volces.com)\n" - " VIKINGDB_REGION (default: cn-beijing)\n" - ) - - service = VikingDB( - host=host, - region=region, - auth=IAM(ak=access_key, sk=secret_key), - ) - return EmbeddingClient(service) - - -def encode_texts(client: EmbeddingClient, texts: list[str]) -> np.ndarray: - request = EmbeddingRequest( - data=[EmbeddingData(text=text) for text in texts], - dense_model=EmbeddingModelOpt(name="bge-large-zh"), - ) - response = client.embedding(request) - result = getattr(response, "result", None) - data = getattr(result, "data", None) if result is not None else None - if data is None and isinstance(result, dict): - data = result.get("data") - if data is None: - data = getattr(response, "data", None) - - if data is None: - raise ValueError("Embedding response did not include any data entries.") - - embeddings: list[list[float]] = [] - for item in data: - dense = getattr(item, "dense", None) - if dense is None and isinstance(item, dict): - dense = item.get("dense") - if dense is None: - dense = getattr(item, "embedding", None) - if dense is None and isinstance(item, dict): - dense = item.get("embedding") - if dense is None: - raise ValueError("Embedding response item did not include a dense vector.") - embeddings.append(dense) - - matrix = np.array(embeddings, dtype=np.float32) - norms = np.linalg.norm(matrix, axis=1, keepdims=True) - norms[norms == 0] = 1.0 - return matrix / norms - - -def main(): - load_local_env() - client = create_embedding_client() - prompts = [build_prompt(definition) for definition in BUILD_TAGS] - embeddings = encode_texts(client, prompts) - - threshold = 0.35 - pairs: list[tuple[str, str, float]] = [] - for index, left in enumerate(BUILD_TAGS): - for other_index in range(index + 1, len(BUILD_TAGS)): - right = BUILD_TAGS[other_index] - similarity = float(np.dot(embeddings[index], embeddings[other_index])) - if similarity < threshold: - continue - pairs.append((left["label"], right["label"], round(similarity, 4))) - - output_path = Path(__file__).resolve().parents[1] / "src" / "data" / "buildTagSimilarity.generated.ts" - lines = [ - "export const BUILD_TAG_SIMILARITY_PAIRS: Array = [" - ] - for left, right, similarity in pairs: - lines.append(f" ['{left}', '{right}', {similarity}],") - lines.append("] as const;") - output_path.write_text("\n".join(lines) + "\n", encoding="utf-8") - - print(json.dumps({ - "output": str(output_path), - "pair_count": len(pairs), - "model": "bge-large-zh", - }, ensure_ascii=False)) - - -if __name__ == "__main__": - main() diff --git a/scripts/smoke-content.ts b/scripts/smoke-content.ts index 1eed50f0..0929a65d 100644 --- a/scripts/smoke-content.ts +++ b/scripts/smoke-content.ts @@ -25,8 +25,8 @@ import { } from '../src/data/questFlow.ts'; import { createInitialGameRuntimeStats } from '../src/data/runtimeStats.ts'; import { createSceneCallOutEncounter, createSceneEncounterPreview, ensureSceneEncounterPreview } from '../src/data/sceneEncounterPreviews.ts'; -import { buildSceneObserveSignsStoryText } from '../src/data/sceneObservation.ts'; import { getSceneHostileNpcPresetIds, getScenePresetsByWorld } from '../src/data/scenePresets.ts'; +import { resolveFunctionOption } from '../src/data/stateFunctions.ts'; import { buildTreasureEncounterStoryMoment, buildTreasureResultText, resolveTreasureReward } from '../src/data/treasureInteractions.ts'; import { AnimationState, GameState, WorldType } from '../src/types.ts'; @@ -209,8 +209,24 @@ function smokeObserveAndCallOut() { assert(callOutResult.currentEncounter?.kind !== 'treasure', `[idle] treasure call_out should be disabled for ${worldType}`); assert(callOutResult.currentEncounter || callOutResult.sceneHostileNpcs.length > 0 || getSceneHostileNpcPresetIds(scene).length === 0, `[idle] call_out failed for ${scene.id}`); - const observeText = buildSceneObserveSignsStoryText(worldType, scene.id); - assert(observeText.length > 12, `[idle] observe_signs text too short for ${scene.id}`); + const observeOption = resolveFunctionOption( + 'idle_observe_signs', + { + worldType, + playerCharacter: baseState.playerCharacter, + inBattle: false, + currentSceneId: scene.id, + currentSceneName: scene.name, + monsters: [], + playerHp: baseState.playerHp, + playerMaxHp: baseState.playerMaxHp, + playerMana: baseState.playerMana, + playerMaxMana: baseState.playerMaxMana, + }, + '观察周围动静', + ); + assert(observeOption?.functionId === 'idle_observe_signs', `[idle] observe_signs option missing for ${scene.id}`); + assert(Boolean(observeOption?.detailText?.trim()), `[idle] observe_signs detail missing for ${scene.id}`); } } diff --git a/server-node/src/app.test.ts b/server-node/src/app.test.ts index e3760fbc..de5479c0 100644 --- a/server-node/src/app.test.ts +++ b/server-node/src/app.test.ts @@ -122,6 +122,11 @@ function createTestConfig( mockAvatarUrl: '', }, authSession: { + accessCookieName: 'genarrative_access_session', + accessCookieTtlSeconds: 7200, + accessCookieSecure: false, + accessCookieSameSite: 'Lax', + accessCookiePath: '/', refreshCookieName: 'genarrative_refresh_session', refreshSessionTtlDays: 30, refreshCookieSecure: false, @@ -203,7 +208,10 @@ async function authEntry(baseUrl: string, username: string, password: string) { username: string; }; }; - const refreshCookie = response.headers.get('set-cookie'); + const refreshCookie = buildCookieHeader( + response.headers.get('set-cookie'), + 'genarrative_refresh_session', + ); assert.equal(response.status, 200); assert.ok(payload.token); @@ -258,7 +266,10 @@ async function phoneLogin(baseUrl: string, phone: string, code = '123456') { wechatBound: boolean; }; }; - const refreshCookie = response.headers.get('set-cookie'); + const refreshCookie = buildCookieHeader( + response.headers.get('set-cookie'), + 'genarrative_refresh_session', + ); assert.equal(response.status, 200); assert.ok(payload.token); @@ -444,6 +455,191 @@ async function createObjectRefiningCustomWorldAgentSession(params: { return session; } +async function markAgentSessionPublishReady(params: { + context: TestAppContext; + userId: string; + sessionId: string; +}) { + const snapshot = await params.context.customWorldAgentOrchestrator.getSessionSnapshot( + params.userId, + params.sessionId, + ); + const draftProfile = snapshot?.draftProfile as Record | null; + const playableNpcs = Array.isArray(draftProfile?.playableNpcs) + ? (draftProfile?.playableNpcs as Array>) + : []; + const storyNpcs = Array.isArray(draftProfile?.storyNpcs) + ? (draftProfile?.storyNpcs as Array>) + : []; + const landmarks = Array.isArray(draftProfile?.landmarks) + ? (draftProfile?.landmarks as Array>) + : []; + const sceneChapters = Array.isArray(draftProfile?.sceneChapters) + ? (draftProfile?.sceneChapters as Array>) + : []; + const camp = + draftProfile?.camp && typeof draftProfile.camp === 'object' + ? (draftProfile.camp as Record) + : null; + const firstPlayableRoleId = + typeof playableNpcs[0]?.id === 'string' && playableNpcs[0]?.id.trim() + ? playableNpcs[0].id.trim() + : null; + const firstStoryRoleId = + typeof storyNpcs[0]?.id === 'string' && storyNpcs[0]?.id.trim() + ? storyNpcs[0].id.trim() + : firstPlayableRoleId; + + assert.ok(snapshot); + assert.ok(draftProfile); + assert.ok(playableNpcs.length > 0); + assert.ok(storyNpcs.length > 0); + assert.ok(landmarks.length > 0); + assert.ok(sceneChapters.length > 0); + assert.ok(firstStoryRoleId); + + await params.context.customWorldAgentSessions.replaceDerivedState( + params.userId, + params.sessionId, + { + stage: 'ready_to_publish', + qualityFindings: [], + draftProfile: { + ...draftProfile, + chapters: + Array.isArray(draftProfile.chapters) && draftProfile.chapters.length > 0 + ? draftProfile.chapters + : [{ id: 'chapter-main-1', title: '主线第一章' }], + camp: { + ...(camp ?? {}), + id: + typeof camp?.id === 'string' && camp.id.trim() + ? camp.id.trim() + : 'camp-home', + name: + typeof camp?.name === 'string' && camp.name.trim() + ? camp.name.trim() + : '归潮营地', + description: + typeof camp?.description === 'string' && camp.description.trim() + ? camp.description.trim() + : '可供玩家整理线索的临时据点。', + imageSrc: + typeof camp?.imageSrc === 'string' && camp.imageSrc.trim() + ? camp.imageSrc.trim() + : '/generated/camp/publish-ready.png', + generatedSceneAssetId: + typeof camp?.generatedSceneAssetId === 'string' && + camp.generatedSceneAssetId.trim() + ? camp.generatedSceneAssetId.trim() + : 'scene-camp-publish-ready', + generatedScenePrompt: + typeof camp?.generatedScenePrompt === 'string' && + camp.generatedScenePrompt.trim() + ? camp.generatedScenePrompt.trim() + : '潮雾营地发布正式图', + generatedSceneModel: + typeof camp?.generatedSceneModel === 'string' && + camp.generatedSceneModel.trim() + ? camp.generatedSceneModel.trim() + : 'test-scene-model', + }, + playableNpcs: playableNpcs.map((entry, index) => ({ + ...entry, + imageSrc: + typeof entry.imageSrc === 'string' && entry.imageSrc.trim() + ? entry.imageSrc.trim() + : `/generated/playable/publish-ready-${index + 1}.png`, + generatedVisualAssetId: + typeof entry.generatedVisualAssetId === 'string' && + entry.generatedVisualAssetId.trim() + ? entry.generatedVisualAssetId.trim() + : `visual-playable-publish-${index + 1}`, + generatedAnimationSetId: + typeof entry.generatedAnimationSetId === 'string' && + entry.generatedAnimationSetId.trim() + ? entry.generatedAnimationSetId.trim() + : `anim-playable-publish-${index + 1}`, + })), + storyNpcs: storyNpcs.map((entry, index) => ({ + ...entry, + imageSrc: + typeof entry.imageSrc === 'string' && entry.imageSrc.trim() + ? entry.imageSrc.trim() + : `/generated/story/publish-ready-${index + 1}.png`, + generatedVisualAssetId: + typeof entry.generatedVisualAssetId === 'string' && + entry.generatedVisualAssetId.trim() + ? entry.generatedVisualAssetId.trim() + : `visual-story-publish-${index + 1}`, + generatedAnimationSetId: + typeof entry.generatedAnimationSetId === 'string' && + entry.generatedAnimationSetId.trim() + ? entry.generatedAnimationSetId.trim() + : `anim-story-publish-${index + 1}`, + })), + landmarks: landmarks.map((entry, index) => ({ + ...entry, + imageSrc: + typeof entry.imageSrc === 'string' && entry.imageSrc.trim() + ? entry.imageSrc.trim() + : `/generated/landmark/publish-ready-${index + 1}.png`, + generatedSceneAssetId: + typeof entry.generatedSceneAssetId === 'string' && + entry.generatedSceneAssetId.trim() + ? entry.generatedSceneAssetId.trim() + : `scene-landmark-publish-${index + 1}`, + generatedScenePrompt: + typeof entry.generatedScenePrompt === 'string' && + entry.generatedScenePrompt.trim() + ? entry.generatedScenePrompt.trim() + : `地点 ${typeof entry.name === 'string' ? entry.name : index + 1} 的正式场景图`, + generatedSceneModel: + typeof entry.generatedSceneModel === 'string' && + entry.generatedSceneModel.trim() + ? entry.generatedSceneModel.trim() + : 'test-scene-model', + })), + sceneChapters: sceneChapters.map((chapter, chapterIndex) => { + const acts = Array.isArray(chapter.acts) + ? (chapter.acts as Array>) + : []; + + return { + ...chapter, + linkedThreadIds: + Array.isArray(chapter.linkedThreadIds) && + chapter.linkedThreadIds.length > 0 + ? chapter.linkedThreadIds + : ['thread-publish-ready'], + acts: acts.map((act, actIndex) => ({ + ...act, + encounterNpcIds: + Array.isArray(act.encounterNpcIds) && act.encounterNpcIds.length > 0 + ? act.encounterNpcIds + : [firstStoryRoleId], + primaryNpcId: + typeof act.primaryNpcId === 'string' && act.primaryNpcId.trim() + ? act.primaryNpcId.trim() + : firstStoryRoleId, + backgroundImageSrc: + typeof act.backgroundImageSrc === 'string' && + act.backgroundImageSrc.trim() + ? act.backgroundImageSrc.trim() + : `/generated/scene/publish-ready-${chapterIndex + 1}-${actIndex + 1}.png`, + backgroundAssetId: + typeof act.backgroundAssetId === 'string' && + act.backgroundAssetId.trim() + ? act.backgroundAssetId.trim() + : `scene-act-publish-${chapterIndex + 1}-${actIndex + 1}`, + })), + }; + }), + }, + }, + ); +} + function parseRedirectHash(location: string) { const url = new URL(location, 'http://127.0.0.1'); return new URLSearchParams( @@ -451,6 +647,18 @@ function parseRedirectHash(location: string) { ); } +function readCookieValue(cookieHeader: string, cookieName: string) { + const match = cookieHeader.match( + new RegExp(`${cookieName}=([^;,\r\n]+)`, 'u'), + ); + return match?.[1] ? decodeURIComponent(match[1]) : ''; +} + +function buildCookieHeader(cookieHeader: string | null | undefined, cookieName: string) { + const value = readCookieValue(cookieHeader || '', cookieName); + return value ? `${cookieName}=${encodeURIComponent(value)}` : ''; +} + async function startWechatMockFlow(baseUrl: string, redirectPath = '/') { const startResponse = await httpRequest( `${baseUrl}/api/auth/wechat/start?redirectPath=${encodeURIComponent(redirectPath)}`, @@ -467,8 +675,7 @@ async function startWechatMockFlow(baseUrl: string, redirectPath = '/') { const location = callbackResponse.headers.get('location') || ''; assert.ok(location); const hash = parseRedirectHash(location); - const token = hash.get('auth_token') || ''; - + const token = hash.get('auth_token')?.trim() || ''; assert.ok(token); return { @@ -1536,7 +1743,10 @@ test('logout-all revokes all refresh sessions and invalidates existing access to assert.equal(refreshResponse.status, 200); const entryB = { token: refreshPayload.token, - refreshCookie: refreshResponse.headers.get('set-cookie') || '', + refreshCookie: buildCookieHeader( + refreshResponse.headers.get('set-cookie'), + 'genarrative_refresh_session', + ), }; const logoutAllResponse = await httpRequest( @@ -2503,6 +2713,34 @@ test('custom world works endpoint returns draft sessions and published worlds to assert.equal(publishResponse.status, 200); + const publishMutationResponse = await httpRequest( + `${baseUrl}/api/runtime/custom-world-library/world-published/publish`, + withBearer(entry.token, { + method: 'POST', + }), + ); + + assert.equal(publishMutationResponse.status, 200); + + const draftOnlyResponse = await httpRequest( + `${baseUrl}/api/runtime/custom-world-library/world-draft-only`, + withBearer(entry.token, { + method: 'PUT', + body: JSON.stringify({ + profile: { + id: 'world-draft-only', + name: '旧兼容草稿', + subtitle: '仍保留在作品库,但不再进入创作中心', + summary: '这个条目用来验证阶段三不会继续把 library draft 当主草稿展示。', + playableNpcs: [{ id: 'hero-draft', name: '旧草稿角色' }], + landmarks: [{ id: 'port-draft', name: '旧草稿地点' }], + }, + }), + }), + ); + + assert.equal(draftOnlyResponse.status, 200); + const worksResponse = await httpRequest( `${baseUrl}/api/runtime/custom-world/works`, { @@ -2542,6 +2780,10 @@ test('custom world works endpoint returns draft sessions and published worlds to item.canEnterWorld === true, ), ); + assert.equal( + worksPayload.items.some((item) => item.profileId === 'world-draft-only'), + false, + ); }); }); @@ -2847,6 +3089,98 @@ test('custom world agent draft_foundation action generates draft cards and card ); }); +test('custom world agent stream message returns enriched session payload over sse', async () => { + await withTestServer( + 'custom-world-agent-stream-session', + async ({ baseUrl, context }) => { + installTestCustomWorldAgentSingleTurnLlm(context); + const entry = await authEntry(baseUrl, 'cw_agent_stream', 'secret123'); + const readySession = await createReadyCustomWorldAgentSession({ + baseUrl, + token: entry.token, + }); + + const foundationResponse = await httpRequest( + `${baseUrl}/api/runtime/custom-world/agent/sessions/${encodeURIComponent(readySession.sessionId)}/actions`, + withBearer(entry.token, { + method: 'POST', + body: JSON.stringify({ + action: 'draft_foundation', + }), + }), + ); + const foundationPayload = (await foundationResponse.json()) as { + operation: { + operationId: string; + }; + }; + + assert.equal(foundationResponse.status, 200); + + await waitForCustomWorldAgentOperation({ + baseUrl, + token: entry.token, + sessionId: readySession.sessionId, + operationId: foundationPayload.operation.operationId, + expectedStatus: 'completed', + }); + + const streamResponse = await httpRequest( + `${baseUrl}/api/runtime/custom-world/agent/sessions/${encodeURIComponent(readySession.sessionId)}/messages/stream`, + withBearer(entry.token, { + method: 'POST', + headers: { + Accept: 'text/event-stream', + }, + body: JSON.stringify({ + clientMessageId: 'stream-client-1', + text: '把守灯会的压力再收紧一些,玩家与沈砺的旧案关系也再明显一点。', + focusCardId: null, + selectedCardIds: [], + }), + }), + ); + const streamText = await streamResponse.text(); + const sessionEventMatch = streamText.match(/event: session\s+data: (\{.*\})/u); + + assert.equal(streamResponse.status, 200); + assert.match( + streamResponse.headers.get('content-type') ?? '', + /text\/event-stream/u, + ); + assert.match(streamText, /event: reply_delta/u); + assert.match(streamText, /event: session/u); + assert.match(streamText, /event: done/u); + assert.ok(sessionEventMatch?.[1]); + + const sessionEvent = JSON.parse(sessionEventMatch![1]) as { + session: { + stage: string; + supportedActions?: Array<{ action: string; enabled: boolean }>; + resultPreview?: { + source: string; + preview: { name?: string }; + } | null; + }; + }; + + assert.equal(sessionEvent.session.stage, 'object_refining'); + assert.equal( + sessionEvent.session.supportedActions?.some( + (entry) => + entry.action === 'update_draft_card' && entry.enabled === true, + ), + true, + ); + assert.equal( + sessionEvent.session.resultPreview?.source, + 'session_preview', + ); + assert.ok(sessionEvent.session.resultPreview?.preview?.name); + }, + ); +}); + test('custom world agent draft_foundation action rejects not-ready sessions over http', async () => { await withTestServer( 'custom-world-agent-phase3-http-not-ready', @@ -3038,6 +3372,240 @@ test('custom world agent update_draft_card action updates draft profile and card ); }); +test('custom world agent sync_result_profile action writes result snapshot back over http', async () => { + await withTestServer( + 'custom-world-agent-sync-result-profile-http', + async ({ baseUrl, context }) => { + installTestCustomWorldAgentSingleTurnLlm(context); + const entry = await authEntry( + baseUrl, + 'cw_agent_sync_result', + 'secret123', + ); + const session = await createObjectRefiningCustomWorldAgentSession({ + baseUrl, + token: entry.token, + }); + + const actionResponse = await httpRequest( + `${baseUrl}/api/runtime/custom-world/agent/sessions/${encodeURIComponent(session.sessionId)}/actions`, + withBearer(entry.token, { + method: 'POST', + body: JSON.stringify({ + action: 'sync_result_profile', + profile: { + id: `agent-draft-${session.sessionId}`, + settingText: '被海雾吞没的旧航路群岛', + name: '潮雾列岛·结果页回写版', + subtitle: '旧灯塔与失控航路', + summary: '结果页里的最新世界概述已经回写到当前草稿。', + tone: '压抑、潮湿、悬疑', + playerGoal: '查清沉船夜与假航灯背后的操盘链。', + templateWorldType: 'WUXIA', + majorFactions: ['守灯会', '航运公会'], + coreConflicts: ['守灯会与航运公会争夺旧航路控制权'], + attributeSchema: { + id: 'schema:test', + worldId: 'CUSTOM', + schemaVersion: 1, + schemaName: '测试', + generatedFrom: { + worldType: 'CUSTOM', + worldName: '潮雾列岛·结果页回写版', + settingSummary: '测试', + tone: '测试', + conflictCore: '测试', + }, + slots: [], + }, + playableNpcs: [], + storyNpcs: [], + items: [], + landmarks: [], + generationMode: 'full', + generationStatus: 'complete', + }, + }), + }), + ); + const actionPayload = (await actionResponse.json()) as { + operation: { + operationId: string; + status: string; + }; + }; + + assert.equal(actionResponse.status, 200); + assert.equal(actionPayload.operation.status, 'queued'); + + await waitForCustomWorldAgentOperation({ + baseUrl, + token: entry.token, + sessionId: session.sessionId, + operationId: actionPayload.operation.operationId, + expectedStatus: 'completed', + }); + + const sessionResponse = await httpRequest( + `${baseUrl}/api/runtime/custom-world/agent/sessions/${encodeURIComponent(session.sessionId)}`, + { + headers: { + Authorization: `Bearer ${entry.token}`, + }, + }, + ); + const sessionPayload = (await sessionResponse.json()) as { + draftProfile: { + name?: string; + summary?: string; + legacyResultProfile?: { + name?: string; + playerGoal?: string; + }; + } | null; + }; + + assert.equal(sessionResponse.status, 200); + assert.equal(sessionPayload.draftProfile?.name, '潮雾列岛·结果页回写版'); + assert.equal( + sessionPayload.draftProfile?.summary, + '结果页里的最新世界概述已经回写到当前草稿。', + ); + assert.equal( + sessionPayload.draftProfile?.legacyResultProfile?.name, + '潮雾列岛·结果页回写版', + ); + assert.equal( + sessionPayload.draftProfile?.legacyResultProfile?.playerGoal, + '查清沉船夜与假航灯背后的操盘链。', + ); + }, + ); +}); + +test('library publish for agent-backed draft returns explicit blocker message when Phase4 gate is not ready', async () => { + await withTestServer( + 'custom-world-library-agent-publish-blocked', + async ({ baseUrl, context }) => { + installTestCustomWorldAgentSingleTurnLlm(context); + const entry = await authEntry( + baseUrl, + 'cw_library_agent_blocked', + 'secret123', + ); + const session = await createObjectRefiningCustomWorldAgentSession({ + baseUrl, + token: entry.token, + }); + const profileId = `agent-draft-${session.sessionId}`; + + const publishResponse = await httpRequest( + `${baseUrl}/api/runtime/custom-world-library/${encodeURIComponent(profileId)}/publish`, + withBearer(entry.token, { + method: 'POST', + }), + ); + const publishPayload = (await publishResponse.json()) as { + error: { + code: string; + message: string; + }; + }; + const sessionAfterPublishAttempt = + await context.customWorldAgentOrchestrator.getSessionSnapshot( + entry.user.id, + session.sessionId, + ); + + assert.equal(publishResponse.status, 409); + assert.equal(publishPayload.error.code, 'CONFLICT'); + assert.match( + publishPayload.error.message, + /当前世界仍有 \d+ 个 blocker/u, + ); + assert.match( + publishPayload.error.message, + /缺少正式主图|缺少正式场景图|主线第一幕/u, + ); + assert.notEqual(sessionAfterPublishAttempt?.stage, 'published'); + }, + ); +}); + +test('library publish for agent-backed draft reuses Phase4 gate and syncs session into published stage', async () => { + await withTestServer( + 'custom-world-library-agent-publish-success', + async ({ baseUrl, context }) => { + installTestCustomWorldAgentSingleTurnLlm(context); + const entry = await authEntry( + baseUrl, + 'cw_library_agent_success', + 'secret123', + ); + const session = await createObjectRefiningCustomWorldAgentSession({ + baseUrl, + token: entry.token, + }); + const profileId = `agent-draft-${session.sessionId}`; + + await markAgentSessionPublishReady({ + context, + userId: entry.user.id, + sessionId: session.sessionId, + }); + + const publishResponse = await httpRequest( + `${baseUrl}/api/runtime/custom-world-library/${encodeURIComponent(profileId)}/publish`, + withBearer(entry.token, { + method: 'POST', + }), + ); + const publishPayload = (await publishResponse.json()) as { + entry: { + profileId: string; + visibility: 'draft' | 'published'; + }; + }; + const libraryResponse = await httpRequest( + `${baseUrl}/api/runtime/custom-world-library`, + withBearer(entry.token), + ); + const libraryPayload = (await libraryResponse.json()) as { + entries: Array<{ + profileId: string; + visibility: 'draft' | 'published'; + }>; + }; + const sessionAfterPublish = + await context.customWorldAgentOrchestrator.getSessionSnapshot( + entry.user.id, + session.sessionId, + ); + + assert.equal(publishResponse.status, 200); + assert.equal(publishPayload.entry.profileId, profileId); + assert.equal(publishPayload.entry.visibility, 'published'); + assert.equal(libraryResponse.status, 200); + assert.equal( + libraryPayload.entries.find((item) => item.profileId === profileId) + ?.visibility, + 'published', + ); + assert.equal(sessionAfterPublish?.stage, 'published'); + assert.equal(sessionAfterPublish?.resultPreview?.publishReady, true); + assert.equal(sessionAfterPublish?.resultPreview?.canEnterWorld, true); + assert.deepEqual(sessionAfterPublish?.resultPreview?.blockers ?? [], []); + assert.ok( + sessionAfterPublish?.messages.some( + (message) => + message.kind === 'action_result' && + message.text.includes('已正式发布'), + ), + ); + }, + ); +}); + test('custom world agent generate_characters action appends character cards over http', async () => { await withTestServer( 'custom-world-agent-phase4-generate-characters-http', diff --git a/server-node/src/app.ts b/server-node/src/app.ts index e716912c..ddd24152 100644 --- a/server-node/src/app.ts +++ b/server-node/src/app.ts @@ -8,11 +8,14 @@ import { errorHandler } from './middleware/errorHandler.js'; import { requestIdMiddleware } from './middleware/requestId.js'; import { responseEnvelopeMiddleware } from './middleware/responseEnvelope.js'; import { createCharacterAssetRoutes } from './modules/assets/characterAssetRoutes.js'; -import { createQwenSpriteRoutes } from './modules/assets/qwenSpriteRoutes.js'; import { createEditorRoutes } from './modules/editor/editorRoutes.js'; -import { createStoryActionRoutes } from './modules/story/storyActionRoutes.js'; import { createAuthRoutes } from './routes/authRoutes.js'; -import { createRuntimeRoutes } from './routes/runtimeRoutes.js'; +import { createRpgEntrySaveRoutes } from './routes/rpg-entry/rpgEntrySaveRoutes.js'; +import { createRpgWorldLibraryRoutes } from './routes/rpg-entry/rpgWorldLibraryRoutes.js'; +import { createRpgProfileRoutes } from './routes/rpg-profile/rpgProfileRoutes.js'; +import { createRpgRuntimeAiAssistRoutes } from './routes/rpg-runtime/rpgRuntimeAiAssistRoutes.js'; +import { createRpgRuntimeStoryRoutes } from './routes/rpg-runtime/rpgRuntimeStoryRoutes.js'; +import { createCustomWorldAgentRoutes } from './routes/customWorldAgent.js'; function matchesRoutePrefix( request: express.Request, @@ -120,16 +123,31 @@ export function createApp(context: AppContext) { ), ); app.use( + '/api', scopeToPrefixes( - ['/api/assets/qwen-sprite'], - withRouteMeta({ routeVersion: '2026-04-08', operation: 'assets.qwen' }), + ['/runtime/profile', '/profile', '/runtime/settings'], + withRouteMeta({ routeVersion: '2026-04-21', operation: 'rpg.profile.api' }), ), + createRpgProfileRoutes(context), ); app.use( + '/api', scopeToPrefixes( - ['/api/assets/qwen-sprite'], - createQwenSpriteRoutes(context.config), + ['/runtime/save', '/runtime/profile/save-archives', '/profile/save-archives'], + withRouteMeta({ routeVersion: '2026-04-21', operation: 'rpg.entry.save.api' }), ), + createRpgEntrySaveRoutes(context), + ); + app.use( + '/api', + scopeToPrefixes( + ['/runtime/custom-world-gallery', '/runtime/custom-world/works', '/runtime/custom-world-library'], + withRouteMeta({ + routeVersion: '2026-04-21', + operation: 'rpg.entry.worldLibrary.api', + }), + ), + createRpgWorldLibraryRoutes(context), ); app.use( '/api/auth', @@ -138,13 +156,61 @@ export function createApp(context: AppContext) { ); app.use( '/api/runtime/story', - withRouteMeta({ routeVersion: '2026-04-08' }), - createStoryActionRoutes(context), + withRouteMeta({ routeVersion: '2026-04-21' }), + createRpgRuntimeStoryRoutes(context), + ); + app.use( + scopeToPrefixes( + [ + '/llm/chat/completions', + '/custom-world/cover-image', + '/custom-world/cover-upload', + '/custom-world/scene-image', + '/custom-world/entity', + '/custom-world/scene-npc', + '/runtime/custom-world/entity', + '/runtime/custom-world/scene-npc', + '/runtime/custom-world/profile', + '/runtime/story/initial', + '/runtime/story/continue', + '/runtime/chat', + '/runtime/items', + '/runtime/quests', + '/ws/health', + ], + withRouteMeta({ + routeVersion: '2026-04-21', + operation: 'rpg.runtime.aiAssist.api', + }), + ), ); app.use( '/api', - withRouteMeta({ routeVersion: '2026-04-08' }), - createRuntimeRoutes(context), + scopeToPrefixes( + [ + '/llm/chat/completions', + '/custom-world/cover-image', + '/custom-world/cover-upload', + '/custom-world/scene-image', + '/custom-world/entity', + '/custom-world/scene-npc', + '/runtime/custom-world/entity', + '/runtime/custom-world/scene-npc', + '/runtime/custom-world/profile', + '/runtime/story/initial', + '/runtime/story/continue', + '/runtime/chat', + '/runtime/items', + '/runtime/quests', + '/ws/health', + ], + createRpgRuntimeAiAssistRoutes(context), + ), + ); + app.use( + '/api/runtime/custom-world/agent', + withRouteMeta({ routeVersion: '2026-04-21', operation: 'rpg.creation.agent.api' }), + createCustomWorldAgentRoutes(context), ); app.use( express.static(context.config.publicDir, { diff --git a/server-node/src/auth/refreshSessionCookie.ts b/server-node/src/auth/refreshSessionCookie.ts index ad51d8f0..1da7a543 100644 --- a/server-node/src/auth/refreshSessionCookie.ts +++ b/server-node/src/auth/refreshSessionCookie.ts @@ -32,6 +32,21 @@ function buildCookieParts( return parts.join('; '); } +function appendSetCookieHeader(response: Response, cookieValue: string) { + const currentHeader = response.getHeader('Set-Cookie'); + if (!currentHeader) { + response.setHeader('Set-Cookie', cookieValue); + return; + } + + if (Array.isArray(currentHeader)) { + response.setHeader('Set-Cookie', [...currentHeader, cookieValue]); + return; + } + + response.setHeader('Set-Cookie', [String(currentHeader), cookieValue]); +} + export function hashRefreshSessionToken(token: string) { return crypto.createHash('sha256').update(token).digest('hex'); } @@ -46,8 +61,8 @@ export function setRefreshSessionCookie( token: string, maxAgeSeconds: number, ) { - response.setHeader( - 'Set-Cookie', + appendSetCookieHeader( + response, buildCookieParts(config, token, { maxAgeSeconds, }), @@ -55,8 +70,8 @@ export function setRefreshSessionCookie( } export function clearRefreshSessionCookie(response: Response, config: AppConfig) { - response.setHeader( - 'Set-Cookie', + appendSetCookieHeader( + response, buildCookieParts(config, '', { maxAgeSeconds: 0, }), diff --git a/server-node/src/bridges/legacyBuildRuntimeBridge.ts b/server-node/src/bridges/legacyBuildRuntimeBridge.ts deleted file mode 100644 index 63310ec3..00000000 --- a/server-node/src/bridges/legacyBuildRuntimeBridge.ts +++ /dev/null @@ -1,6 +0,0 @@ -// Temporary bridge for legacy pure build calculation logic from src/**. -export { getEquipmentBonuses } from '../modules/runtime/runtimeEquipmentModule.js'; -export { - getPlayerBuildDamageBreakdown, - resolvePlayerOutgoingDamageResult, -} from '../modules/runtime/runtimeBuildModule.js'; diff --git a/server-node/src/bridges/legacyRuntimeItemResolutionBridge.ts b/server-node/src/bridges/legacyRuntimeItemResolutionBridge.ts deleted file mode 100644 index 7e624643..00000000 --- a/server-node/src/bridges/legacyRuntimeItemResolutionBridge.ts +++ /dev/null @@ -1,8 +0,0 @@ -// Temporary bridge for legacy pure runtime item resolution logic from src/**. -export { - buildLooseRuntimeItemGenerationContext, - buildQuestRuntimeItemGenerationContext, - buildDirectedRuntimeReward, - buildRuntimeInventoryStock, - flattenDirectedRuntimeRewardItems, -} from '../modules/runtime-item/runtimeItemModule.js'; diff --git a/server-node/src/config.ts b/server-node/src/config.ts index d59dc300..62aa943e 100644 --- a/server-node/src/config.ts +++ b/server-node/src/config.ts @@ -74,6 +74,11 @@ export type AppConfig = { mockAvatarUrl: string; }; authSession: { + accessCookieName: string; + accessCookieTtlSeconds: number; + accessCookieSecure: boolean; + accessCookieSameSite: 'Lax' | 'Strict' | 'None'; + accessCookiePath: string; refreshCookieName: string; refreshSessionTtlDays: number; refreshCookieSecure: boolean; @@ -274,6 +279,11 @@ export function loadConfig(options: LoadConfigOptions = {}): AppConfig { 'AUTH_REFRESH_COOKIE_SAME_SITE', 'Lax', ); + const accessSameSite = readString( + env, + 'AUTH_ACCESS_COOKIE_SAME_SITE', + 'Lax', + ); return { nodeEnv, @@ -484,6 +494,30 @@ export function loadConfig(options: LoadConfigOptions = {}): AppConfig { mockAvatarUrl: readString(env, 'WECHAT_MOCK_AVATAR_URL', ''), }, authSession: { + accessCookieName: readString( + env, + 'AUTH_ACCESS_COOKIE_NAME', + 'genarrative_access_session', + ), + accessCookieTtlSeconds: readPositiveInt( + env, + 'AUTH_ACCESS_COOKIE_TTL_SECONDS', + 7200, + ), + accessCookieSecure: readBoolean( + env, + 'AUTH_ACCESS_COOKIE_SECURE', + readString(env, 'NODE_ENV', 'development') === 'production', + ), + accessCookieSameSite: + accessSameSite === 'None' || accessSameSite === 'Strict' + ? (accessSameSite as AppConfig['authSession']['accessCookieSameSite']) + : 'Lax', + accessCookiePath: readString( + env, + 'AUTH_ACCESS_COOKIE_PATH', + '/', + ), refreshCookieName: readString( env, 'AUTH_REFRESH_COOKIE_NAME', diff --git a/server-node/src/context.ts b/server-node/src/context.ts index 8f195f8e..01b0503d 100644 --- a/server-node/src/context.ts +++ b/server-node/src/context.ts @@ -5,6 +5,13 @@ import type { AppDatabase } from './db.js'; import { AuthAuditLogRepository } from './repositories/authAuditLogRepository.js'; import { AuthIdentityRepository } from './repositories/authIdentityRepository.js'; import { AuthRiskBlockRepository } from './repositories/authRiskBlockRepository.js'; +import type { RpgAgentSessionRepository } from './repositories/RpgAgentSessionRepository.js'; +import type { RpgSaveArchiveRepository } from './repositories/rpg-entry/RpgSaveArchiveRepository.js'; +import type { RpgWorldLibraryRepository } from './repositories/rpg-entry/RpgWorldLibraryRepository.js'; +import type { RpgBrowseHistoryRepository } from './repositories/rpg-profile/RpgBrowseHistoryRepository.js'; +import type { RpgProfileDashboardRepository } from './repositories/rpg-profile/RpgProfileDashboardRepository.js'; +import type { RpgRuntimeSnapshotRepository } from './repositories/rpg-runtime/RpgRuntimeSnapshotRepository.js'; +import type { RpgWorldProfileRepository } from './repositories/RpgWorldProfileRepository.js'; import { RuntimeRepository } from './repositories/runtimeRepository.js'; import { SmsAuthEventRepository } from './repositories/smsAuthEventRepository.js'; import { UserRepository } from './repositories/userRepository.js'; @@ -12,8 +19,8 @@ import { UserSessionRepository } from './repositories/userSessionRepository.js'; import { CaptchaChallengeStore } from './services/captchaChallengeStore.js'; import { CustomWorldAgentOrchestrator } from './services/customWorldAgentOrchestrator.js'; import { CustomWorldAgentSessionStore } from './services/customWorldAgentSessionStore.js'; -import { CustomWorldSessionStore } from './services/customWorldSessionStore.js'; import { UpstreamLlmClient } from './services/llmClient.js'; +import type { RpgWorldWorkSummaryService } from './services/RpgWorldWorkSummaryService.js'; import type { SmsVerificationService } from './services/smsVerificationService.js'; import type { WechatAuthService } from './services/wechatAuthService.js'; import { WechatAuthStateStore } from './services/wechatAuthStateStore.js'; @@ -28,11 +35,18 @@ export type AppContext = { authRiskBlockRepository: AuthRiskBlockRepository; smsAuthEventRepository: SmsAuthEventRepository; userSessionRepository: UserSessionRepository; + rpgAgentSessionRepository: RpgAgentSessionRepository; + rpgWorldProfileRepository: RpgWorldProfileRepository; + rpgProfileDashboardRepository: RpgProfileDashboardRepository; + rpgBrowseHistoryRepository: RpgBrowseHistoryRepository; + rpgSaveArchiveRepository: RpgSaveArchiveRepository; + rpgWorldLibraryRepository: RpgWorldLibraryRepository; + rpgRuntimeSnapshotRepository: RpgRuntimeSnapshotRepository; runtimeRepository: RuntimeRepository; llmClient: UpstreamLlmClient; - customWorldSessions: CustomWorldSessionStore; customWorldAgentSessions: CustomWorldAgentSessionStore; customWorldAgentOrchestrator: CustomWorldAgentOrchestrator; + rpgWorldWorkSummaryService: RpgWorldWorkSummaryService; smsVerificationService: SmsVerificationService; wechatAuthService: WechatAuthService; wechatAuthStates: WechatAuthStateStore; diff --git a/server-node/src/db.test.ts b/server-node/src/db.test.ts index b04d16be..1611dda5 100644 --- a/server-node/src/db.test.ts +++ b/server-node/src/db.test.ts @@ -81,6 +81,11 @@ function createTestConfig(databaseUrl: string): AppConfig { mockAvatarUrl: '', }, authSession: { + accessCookieName: 'genarrative_access_session', + accessCookieTtlSeconds: 7200, + accessCookieSecure: false, + accessCookieSameSite: 'Lax', + accessCookiePath: '/', refreshCookieName: 'genarrative_refresh_session', refreshSessionTtlDays: 30, refreshCookieSecure: false, diff --git a/server-node/src/modules/ai/chatOrchestrator.ts b/server-node/src/modules/ai/chatOrchestrator.ts index ab286506..124cb35d 100644 --- a/server-node/src/modules/ai/chatOrchestrator.ts +++ b/server-node/src/modules/ai/chatOrchestrator.ts @@ -9,7 +9,7 @@ import type { type NpcChatTurnCompletionDirective, NpcChatTurnRequest, NpcRecruitDialogueRequest, -} from '../../../../packages/shared/src/contracts/story.js'; +} from '../../../../packages/shared/src/contracts/rpgRuntimeChat.js'; import { parseLineListContent } from '../../../../packages/shared/src/llm/parsers.js'; import { prepareEventStreamResponse } from '../../http.js'; import type { UpstreamLlmClient } from '../../services/llmClient.js'; diff --git a/server-node/src/modules/ai/orchestrator.test.ts b/server-node/src/modules/ai/orchestrator.test.ts index e947f1b0..8bea50ce 100644 --- a/server-node/src/modules/ai/orchestrator.test.ts +++ b/server-node/src/modules/ai/orchestrator.test.ts @@ -4,7 +4,7 @@ import test from 'node:test'; import type { CharacterChatSuggestionsRequest, NpcChatTurnRequest, -} from '../../../../packages/shared/src/contracts/story.js'; +} from '../../../../packages/shared/src/contracts/rpgRuntimeChat.js'; import { createTestPlayerCharacter } from '../../testFixtures/runtimeCharacter.js'; import { generateCharacterChatSuggestionsFromOrchestrator, diff --git a/server-node/src/modules/assets/qwenSpriteRoutes.ts b/server-node/src/modules/assets/qwenSpriteRoutes.ts deleted file mode 100644 index 01c960b0..00000000 --- a/server-node/src/modules/assets/qwenSpriteRoutes.ts +++ /dev/null @@ -1,912 +0,0 @@ -import { mkdir, readFile, writeFile } from 'node:fs/promises'; -import http, { - type IncomingMessage, - type RequestOptions, - type ServerResponse, -} from 'node:http'; -import https from 'node:https'; -import path from 'node:path'; - -import { Router, type NextFunction, type Request, type Response } from 'express'; -import type { AppConfig } from '../../config.js'; -import { routeMeta } from '../../middleware/routeMeta.js'; - -const QWEN_SPRITE_MASTER_GENERATE_PATH = '/api/assets/qwen-sprite/master'; -const QWEN_SPRITE_SHEET_GENERATE_PATH = '/api/assets/qwen-sprite/sheet'; -const QWEN_SPRITE_FRAME_REPAIR_PATH = '/api/assets/qwen-sprite/frame-repair'; -const QWEN_SPRITE_SAVE_PATH = '/api/assets/qwen-sprite/save'; -const DEFAULT_DASHSCOPE_BASE_URL = 'https://dashscope.aliyuncs.com/api/v1'; -const DEFAULT_QWEN_IMAGE_MODEL = 'qwen-image-2.0'; - -function readJsonBody(req: IncomingMessage & { body?: unknown }) { - const parsedBody = req.body; - if (parsedBody && typeof parsedBody === 'object' && !Array.isArray(parsedBody)) { - return Promise.resolve(parsedBody as Record); - } - - return new Promise>((resolve, reject) => { - const chunks: Buffer[] = []; - req.on('data', (chunk) => { - chunks.push(Buffer.isBuffer(chunk) ? chunk : Buffer.from(chunk)); - }); - req.on('end', () => { - try { - const raw = - Buffer.concat(chunks) - .toString('utf8') - .replace(/^\uFEFF/u, '') || '{}'; - resolve(JSON.parse(raw)); - } catch (error) { - reject(error); - } - }); - req.on('error', reject); - }); -} - -function sendJson(res: ServerResponse, statusCode: number, payload: unknown) { - res.statusCode = statusCode; - res.setHeader('Content-Type', 'application/json; charset=utf-8'); - res.end(JSON.stringify(payload)); -} - -function isRecordValue(value: unknown): value is Record { - return Boolean(value) && typeof value === 'object' && !Array.isArray(value); -} - -function isStringArray(value: unknown): value is string[] { - return ( - Array.isArray(value) && - value.every((item) => typeof item === 'string' && item.trim().length > 0) - ); -} - -function resolveRuntimeEnv(config: AppConfig) { - return config.rawEnv; -} - -function normalizeDashScopeBaseUrl(value: string) { - return value.replace(/\/$/u, ''); -} - -function extractApiErrorMessage(responseText: string, fallbackMessage: string) { - if (!responseText.trim()) { - return fallbackMessage; - } - - try { - const parsed = JSON.parse(responseText) as { - code?: string; - message?: string; - error?: { message?: string }; - }; - if ( - typeof parsed.error?.message === 'string' && - parsed.error.message.trim() - ) { - return parsed.error.message; - } - if (typeof parsed.message === 'string' && parsed.message.trim()) { - return parsed.message; - } - if (typeof parsed.code === 'string' && parsed.code.trim()) { - return `${fallbackMessage} (${parsed.code})`; - } - } catch { - // Fall through to raw text. - } - - return responseText; -} - -function sanitizePathSegment(value: string) { - const normalized = value - .trim() - .toLowerCase() - .replace(/[^a-z0-9-_]+/gu, '-') - .replace(/-+/gu, '-') - .replace(/^-|-$/gu, ''); - - return normalized || 'asset'; -} - -function createTimestampId(prefix: string) { - return `${prefix}-${Date.now()}`; -} - -function requestTextResponse( - urlString: string, - options: { - method?: string; - headers?: Record; - bodyText?: string; - } = {}, -) { - return new Promise<{ - statusCode: number; - headers: Record; - bodyText: string; - }>((resolve, reject) => { - const url = new URL(urlString); - const transport = url.protocol === 'https:' ? https : http; - const payload = options.bodyText; - const requestOptions: RequestOptions = { - protocol: url.protocol, - hostname: url.hostname, - port: url.port ? Number(url.port) : undefined, - path: `${url.pathname}${url.search}`, - method: options.method ?? 'GET', - headers: { - ...(options.headers ?? {}), - ...(payload ? { 'Content-Length': Buffer.byteLength(payload) } : {}), - }, - }; - - const request = transport.request(requestOptions, (upstreamRes) => { - const chunks: Buffer[] = []; - upstreamRes.on('data', (chunk) => { - chunks.push(Buffer.isBuffer(chunk) ? chunk : Buffer.from(chunk)); - }); - upstreamRes.on('end', () => { - resolve({ - statusCode: upstreamRes.statusCode ?? 502, - headers: upstreamRes.headers, - bodyText: Buffer.concat(chunks).toString('utf8'), - }); - }); - upstreamRes.on('error', reject); - }); - - request.on('error', reject); - if (payload) { - request.write(payload); - } - request.end(); - }); -} - -function requestBinaryResponse( - urlString: string, - options: { - method?: string; - headers?: Record; - } = {}, -) { - return new Promise<{ - statusCode: number; - headers: Record; - body: Buffer; - }>((resolve, reject) => { - const url = new URL(urlString); - const transport = url.protocol === 'https:' ? https : http; - const requestOptions: RequestOptions = { - protocol: url.protocol, - hostname: url.hostname, - port: url.port ? Number(url.port) : undefined, - path: `${url.pathname}${url.search}`, - method: options.method ?? 'GET', - headers: options.headers ?? {}, - }; - - const request = transport.request(requestOptions, (upstreamRes) => { - const chunks: Buffer[] = []; - upstreamRes.on('data', (chunk) => { - chunks.push(Buffer.isBuffer(chunk) ? chunk : Buffer.from(chunk)); - }); - upstreamRes.on('end', () => { - resolve({ - statusCode: upstreamRes.statusCode ?? 502, - headers: upstreamRes.headers, - body: Buffer.concat(chunks), - }); - }); - upstreamRes.on('error', reject); - }); - - request.on('error', reject); - request.end(); - }); -} - -function proxyJsonRequest( - urlString: string, - apiKey: string, - body: Record, -) { - return requestTextResponse(urlString, { - method: 'POST', - headers: { - Authorization: `Bearer ${apiKey}`, - 'Content-Type': 'application/json', - }, - bodyText: JSON.stringify(body), - }); -} - -function collectStringsByKey( - value: unknown, - targetKey: string, - results: string[], -) { - if (Array.isArray(value)) { - value.forEach((item) => collectStringsByKey(item, targetKey, results)); - return; - } - - if (!isRecordValue(value)) { - return; - } - - const directValue = value[targetKey]; - if (typeof directValue === 'string' && directValue.trim()) { - results.push(directValue.trim()); - } - - Object.values(value).forEach((nestedValue) => - collectStringsByKey(nestedValue, targetKey, results), - ); -} - -function extractImageUrls(payload: Record) { - const results: string[] = []; - collectStringsByKey(payload.output, 'image', results); - collectStringsByKey(payload.output, 'url', results); - return [...new Set(results)]; -} - -function parseDataUrl(source: string) { - const matched = /^data:(image\/[^;]+);base64,(.+)$/u.exec(source); - if (!matched) { - return null; - } - - const mimeType = matched[1]; - const base64Payload = matched[2]; - const extension = (() => { - switch (mimeType) { - case 'image/jpeg': - return 'jpg'; - case 'image/webp': - return 'webp'; - default: - return 'png'; - } - })(); - - return { - buffer: Buffer.from(base64Payload, 'base64'), - extension, - }; -} - -async function resolveImageSourcePayload(rootDir: string, source: string) { - const parsedDataUrl = parseDataUrl(source); - if (parsedDataUrl) { - return parsedDataUrl; - } - - if (!source.startsWith('/')) { - throw new Error('图像来源必须是 Data URL 或 public 目录 URL。'); - } - - const normalizedSource = path.posix.normalize(source).replace(/^\/+/u, ''); - const absolutePath = path.resolve( - rootDir, - 'public', - ...normalizedSource.split('/'), - ); - const publicRoot = path.resolve(rootDir, 'public'); - - if (!absolutePath.startsWith(publicRoot)) { - throw new Error('图像来源路径越界。'); - } - - const buffer = await readFile(absolutePath); - const extension = path.extname(absolutePath).replace(/^\./u, '') || 'png'; - - return { - buffer, - extension, - }; -} - -async function resolveImageSourceAsDataUrl(rootDir: string, source: string) { - if (/^data:image\/[^;]+;base64,/u.test(source)) { - return source; - } - - const payload = await resolveImageSourcePayload(rootDir, source); - const mimeType = (() => { - switch (payload.extension) { - case 'jpg': - case 'jpeg': - return 'image/jpeg'; - case 'webp': - return 'image/webp'; - default: - return 'image/png'; - } - })(); - - return `data:${mimeType};base64,${payload.buffer.toString('base64')}`; -} - -async function writeDraftImageFile( - rootDir: string, - relativePath: string, - buffer: Buffer, -) { - const absolutePath = path.resolve(rootDir, 'public', ...relativePath.split('/')); - await mkdir(path.dirname(absolutePath), { recursive: true }); - await writeFile(absolutePath, buffer); - return `/${relativePath}`; -} - -async function generateQwenImages( - config: AppConfig, - input: { - kind: 'master' | 'sheet' | 'repair'; - promptText: string; - negativePrompt: string; - model: string; - size: string; - promptExtend: boolean; - seed?: number; - candidateCount: number; - referenceImages: string[]; - }, -) { - const rootDir = config.projectRoot; - const runtimeEnv = resolveRuntimeEnv(config); - const baseUrl = normalizeDashScopeBaseUrl( - runtimeEnv.DASHSCOPE_BASE_URL || DEFAULT_DASHSCOPE_BASE_URL, - ); - const apiKey = runtimeEnv.DASHSCOPE_API_KEY || ''; - - if (!apiKey) { - throw new Error('服务端缺少 DASHSCOPE_API_KEY,无法调用 Qwen-Image。'); - } - - const content = [ - ...(await Promise.all( - input.referenceImages - .slice(0, 3) - .map(async (image) => ({ image: await resolveImageSourceAsDataUrl(rootDir, image) })), - )), - { text: input.promptText }, - ]; - - const requestPayload: Record = { - model: input.model || DEFAULT_QWEN_IMAGE_MODEL, - input: { - messages: [ - { - role: 'user', - content, - }, - ], - }, - parameters: { - n: Math.max(1, Math.min(6, input.candidateCount)), - negative_prompt: input.negativePrompt, - prompt_extend: input.promptExtend, - watermark: false, - size: input.size, - ...(typeof input.seed === 'number' && Number.isFinite(input.seed) - ? { seed: input.seed } - : {}), - }, - }; - - const response = await proxyJsonRequest( - `${baseUrl}/services/aigc/multimodal-generation/generation`, - apiKey, - requestPayload, - ); - - if (response.statusCode < 200 || response.statusCode >= 300) { - throw new Error( - extractApiErrorMessage(response.bodyText, 'Qwen-Image 生成失败。'), - ); - } - - const parsed = JSON.parse(response.bodyText) as Record; - const imageUrls = extractImageUrls(parsed); - - if (imageUrls.length === 0) { - throw new Error('Qwen-Image 未返回可下载的图片结果。'); - } - - const draftId = createTimestampId(`qwen-${input.kind}`); - const relativeDir = path.posix.join( - 'generated-qwen-sprites', - '_drafts', - input.kind, - draftId, - ); - - const drafts = await Promise.all( - imageUrls.map(async (imageUrl, index) => { - const binaryResponse = await requestBinaryResponse(imageUrl); - if ( - binaryResponse.statusCode < 200 || - binaryResponse.statusCode >= 300 - ) { - throw new Error(`下载生成图片失败(${binaryResponse.statusCode})。`); - } - - const imageSrc = await writeDraftImageFile( - rootDir, - path.posix.join(relativeDir, `candidate-${String(index + 1).padStart(2, '0')}.png`), - binaryResponse.body, - ); - - return { - id: `${draftId}-${index + 1}`, - label: `${input.kind === 'master' ? '主图' : input.kind === 'sheet' ? '精灵表' : '修帧'} ${index + 1}`, - imageSrc, - remoteUrl: imageUrl, - }; - }), - ); - - await writeFile( - path.resolve(rootDir, 'public', ...relativeDir.split('/'), 'job.json'), - JSON.stringify( - { - draftId, - kind: input.kind, - model: input.model, - size: input.size, - promptText: input.promptText, - negativePrompt: input.negativePrompt, - promptExtend: input.promptExtend, - seed: input.seed, - candidateCount: input.candidateCount, - referenceImageCount: input.referenceImages.length, - drafts, - createdAt: new Date().toISOString(), - }, - null, - 2, - ) + '\n', - 'utf8', - ); - - return { - draftId, - drafts, - model: input.model, - size: input.size, - promptText: input.promptText, - negativePrompt: input.negativePrompt, - }; -} - -async function handleGenerateMaster( - config: AppConfig, - req: IncomingMessage & { body?: unknown }, - res: ServerResponse, -) { - if (req.method !== 'POST') { - sendJson(res, 405, { error: { message: 'Method Not Allowed' } }); - return; - } - - let body: Record; - try { - body = await readJsonBody(req); - } catch { - sendJson(res, 400, { error: { message: 'Invalid JSON body' } }); - return; - } - - const promptText = - typeof body.promptText === 'string' ? body.promptText.trim() : ''; - const negativePrompt = - typeof body.negativePrompt === 'string' ? body.negativePrompt.trim() : ''; - const model = - typeof body.model === 'string' && body.model.trim() - ? body.model.trim() - : DEFAULT_QWEN_IMAGE_MODEL; - const size = - typeof body.size === 'string' && body.size.trim() - ? body.size.trim() - : '1024*1024'; - const promptExtend = body.promptExtend !== false; - const candidateCount = - typeof body.candidateCount === 'number' && Number.isFinite(body.candidateCount) - ? body.candidateCount - : 1; - const seed = - typeof body.seed === 'number' && Number.isFinite(body.seed) - ? body.seed - : undefined; - const referenceImages = isStringArray(body.referenceImages) - ? body.referenceImages - : []; - - if (!promptText) { - sendJson(res, 400, { error: { message: 'promptText is required.' } }); - return; - } - - try { - const result = await generateQwenImages(config, { - kind: 'master', - promptText, - negativePrompt, - model, - size, - promptExtend, - seed, - candidateCount, - referenceImages, - }); - - sendJson(res, 200, { - ok: true, - ...result, - }); - } catch (error) { - sendJson(res, 500, { - error: { - message: error instanceof Error ? error.message : '生成主图失败。', - }, - }); - } -} - -async function handleGenerateSheet( - config: AppConfig, - req: IncomingMessage & { body?: unknown }, - res: ServerResponse, -) { - if (req.method !== 'POST') { - sendJson(res, 405, { error: { message: 'Method Not Allowed' } }); - return; - } - - let body: Record; - try { - body = await readJsonBody(req); - } catch { - sendJson(res, 400, { error: { message: 'Invalid JSON body' } }); - return; - } - - const promptText = - typeof body.promptText === 'string' ? body.promptText.trim() : ''; - const negativePrompt = - typeof body.negativePrompt === 'string' ? body.negativePrompt.trim() : ''; - const model = - typeof body.model === 'string' && body.model.trim() - ? body.model.trim() - : DEFAULT_QWEN_IMAGE_MODEL; - const size = - typeof body.size === 'string' && body.size.trim() - ? body.size.trim() - : '1024*1024'; - const promptExtend = body.promptExtend !== false; - const candidateCount = - typeof body.candidateCount === 'number' && Number.isFinite(body.candidateCount) - ? body.candidateCount - : 1; - const seed = - typeof body.seed === 'number' && Number.isFinite(body.seed) - ? body.seed - : undefined; - const referenceImages = isStringArray(body.referenceImages) - ? body.referenceImages - : []; - - if (!promptText) { - sendJson(res, 400, { error: { message: 'promptText is required.' } }); - return; - } - - try { - const result = await generateQwenImages(config, { - kind: 'sheet', - promptText, - negativePrompt, - model, - size, - promptExtend, - seed, - candidateCount, - referenceImages, - }); - - sendJson(res, 200, { - ok: true, - ...result, - }); - } catch (error) { - sendJson(res, 500, { - error: { - message: error instanceof Error ? error.message : '生成精灵表失败。', - }, - }); - } -} - -async function handleRepairFrame( - config: AppConfig, - req: IncomingMessage & { body?: unknown }, - res: ServerResponse, -) { - if (req.method !== 'POST') { - sendJson(res, 405, { error: { message: 'Method Not Allowed' } }); - return; - } - - let body: Record; - try { - body = await readJsonBody(req); - } catch { - sendJson(res, 400, { error: { message: 'Invalid JSON body' } }); - return; - } - - const promptText = - typeof body.promptText === 'string' ? body.promptText.trim() : ''; - const negativePrompt = - typeof body.negativePrompt === 'string' ? body.negativePrompt.trim() : ''; - const model = - typeof body.model === 'string' && body.model.trim() - ? body.model.trim() - : DEFAULT_QWEN_IMAGE_MODEL; - const size = - typeof body.size === 'string' && body.size.trim() - ? body.size.trim() - : '512*512'; - const promptExtend = body.promptExtend !== false; - const seed = - typeof body.seed === 'number' && Number.isFinite(body.seed) - ? body.seed - : undefined; - const referenceImages = isStringArray(body.referenceImages) - ? body.referenceImages - : []; - - if (!promptText) { - sendJson(res, 400, { error: { message: 'promptText is required.' } }); - return; - } - - if (referenceImages.length === 0) { - sendJson(res, 400, { - error: { message: '至少需要一张参考图来修复帧。' }, - }); - return; - } - - try { - const result = await generateQwenImages(config, { - kind: 'repair', - promptText, - negativePrompt, - model, - size, - promptExtend, - seed, - candidateCount: 1, - referenceImages, - }); - - sendJson(res, 200, { - ok: true, - ...result, - repairedFrame: result.drafts[0] ?? null, - }); - } catch (error) { - sendJson(res, 500, { - error: { - message: error instanceof Error ? error.message : '修帧失败。', - }, - }); - } -} - -async function handleSaveAsset( - rootDir: string, - req: IncomingMessage & { body?: unknown }, - res: ServerResponse, -) { - if (req.method !== 'POST') { - sendJson(res, 405, { error: { message: 'Method Not Allowed' } }); - return; - } - - let body: Record; - try { - body = await readJsonBody(req); - } catch { - sendJson(res, 400, { error: { message: 'Invalid JSON body' } }); - return; - } - - const assetKey = - typeof body.assetKey === 'string' ? sanitizePathSegment(body.assetKey) : ''; - const actionKey = - typeof body.actionKey === 'string' ? sanitizePathSegment(body.actionKey) : ''; - const masterSource = - typeof body.masterSource === 'string' ? body.masterSource.trim() : ''; - const sheetSource = - typeof body.sheetSource === 'string' ? body.sheetSource.trim() : ''; - const framesDataUrls = isStringArray(body.framesDataUrls) - ? body.framesDataUrls - : []; - const metadata = isRecordValue(body.metadata) ? body.metadata : {}; - const prompts = isRecordValue(body.prompts) ? body.prompts : {}; - - if (!assetKey) { - sendJson(res, 400, { error: { message: 'assetKey is required.' } }); - return; - } - - if (!actionKey) { - sendJson(res, 400, { error: { message: 'actionKey is required.' } }); - return; - } - - if (!sheetSource) { - sendJson(res, 400, { error: { message: 'sheetSource is required.' } }); - return; - } - - try { - const assetId = createTimestampId('qwen-sprite'); - const relativeDir = path.posix.join( - 'generated-qwen-sprites', - assetKey, - actionKey, - assetId, - ); - const absoluteDir = path.resolve(rootDir, 'public', ...relativeDir.split('/')); - await mkdir(path.join(absoluteDir, 'frames'), { recursive: true }); - - let masterImagePath: string | null = null; - if (masterSource) { - const payload = await resolveImageSourcePayload(rootDir, masterSource); - masterImagePath = await writeDraftImageFile( - rootDir, - path.posix.join(relativeDir, `master.${payload.extension}`), - payload.buffer, - ); - } - - const sheetPayload = await resolveImageSourcePayload(rootDir, sheetSource); - const sheetImagePath = await writeDraftImageFile( - rootDir, - path.posix.join(relativeDir, `sheet.${sheetPayload.extension}`), - sheetPayload.buffer, - ); - - const framePaths: string[] = []; - for (let index = 0; index < framesDataUrls.length; index += 1) { - const framePayload = await resolveImageSourcePayload( - rootDir, - framesDataUrls[index] ?? '', - ); - const framePath = await writeDraftImageFile( - rootDir, - path.posix.join( - relativeDir, - 'frames', - `frame-${String(index + 1).padStart(2, '0')}.${framePayload.extension}`, - ), - framePayload.buffer, - ); - framePaths.push(framePath); - } - - await writeFile( - path.join(absoluteDir, 'metadata.json'), - JSON.stringify( - { - assetId, - assetKey, - actionKey, - masterImagePath, - sheetImagePath, - framePaths, - metadata, - prompts, - createdAt: new Date().toISOString(), - }, - null, - 2, - ) + '\n', - 'utf8', - ); - - sendJson(res, 200, { - ok: true, - assetId, - assetDir: `/${relativeDir}`, - masterImagePath, - sheetImagePath, - framePaths, - saveMessage: '已保存到 public/generated-qwen-sprites。', - }); - } catch (error) { - sendJson(res, 500, { - error: { - message: error instanceof Error ? error.message : '保存精灵表资产失败。', - }, - }); - } -} - -function toExpressHandler( - handler: ( - request: IncomingMessage & { body?: unknown }, - response: ServerResponse, - ) => Promise | void, -) { - return (request: Request, response: Response, next: NextFunction) => { - Promise.resolve( - handler( - request as Request & IncomingMessage & { body?: unknown }, - response as Response & ServerResponse, - ), - ).catch(next); - }; -} - -export function createQwenSpriteRoutes(config: AppConfig) { - const router = Router(); - - router.use((request, response, next) => { - if ( - request.path !== '/api/assets' && - !request.path.startsWith('/api/assets/') - ) { - next(); - return; - } - - if (!config.assetsApiEnabled) { - response.status(403).json({ - error: { - message: '资产工具接口当前未启用。', - }, - }); - return; - } - next(); - }); - - router.post( - QWEN_SPRITE_MASTER_GENERATE_PATH, - routeMeta({ operation: 'assets.qwenSprite.master.generate' }), - toExpressHandler((request, response) => - handleGenerateMaster(config, request, response), - ), - ); - router.post( - QWEN_SPRITE_SHEET_GENERATE_PATH, - routeMeta({ operation: 'assets.qwenSprite.sheet.generate' }), - toExpressHandler((request, response) => - handleGenerateSheet(config, request, response), - ), - ); - router.post( - QWEN_SPRITE_FRAME_REPAIR_PATH, - routeMeta({ operation: 'assets.qwenSprite.frameRepair.generate' }), - toExpressHandler((request, response) => - handleRepairFrame(config, request, response), - ), - ); - router.post( - QWEN_SPRITE_SAVE_PATH, - routeMeta({ operation: 'assets.qwenSprite.asset.save' }), - toExpressHandler((request, response) => - handleSaveAsset(config.projectRoot, request, response), - ), - ); - - return router; -} diff --git a/server-node/src/modules/combat/combatResolutionService.ts b/server-node/src/modules/combat/combatResolutionService.ts index 42d864d2..a2117843 100644 --- a/server-node/src/modules/combat/combatResolutionService.ts +++ b/server-node/src/modules/combat/combatResolutionService.ts @@ -1,8 +1,10 @@ import type { RuntimeBattlePresentation, - RuntimeStoryChoicePayload, RuntimeStoryPatch, -} from '../../../../packages/shared/src/contracts/story.js'; +} from '../../../../packages/shared/src/contracts/rpgRuntimeStoryState.js'; +import type { + RuntimeStoryChoicePayload, +} from '../../../../packages/shared/src/contracts/rpgRuntimeStoryAction.js'; import { buildInventoryUseResultText, incrementGameRuntimeStats, @@ -26,7 +28,7 @@ import { getPlayerSkillCooldowns, setEncounterNpcState, type RuntimeSession, -} from '../story/runtimeSession.js'; +} from '../rpg-runtime-story/RpgRuntimeSessionPrimitives.js'; type CombatActionConfig = { actionText: string; diff --git a/server-node/src/modules/custom-world/runtime-profile/buildAttributeSchema.ts b/server-node/src/modules/custom-world/runtime-profile/buildAttributeSchema.ts new file mode 100644 index 00000000..0301a2fb --- /dev/null +++ b/server-node/src/modules/custom-world/runtime-profile/buildAttributeSchema.ts @@ -0,0 +1,365 @@ +import type { + AttributeVector, + CustomWorldNpc, + CustomWorldPlayableNpc, + RoleAttributeProfile, + WorldAttributeSchema, + WorldAttributeSlot, + WorldType, +} from '../runtimeTypes.js'; +import { inferWorldTypeFromSetting } from './creatorIntentBridge.js'; +import { slugify } from './normalizeShared.js'; + +/** + * 工作包 G: + * 把 attribute schema 构建和角色属性画像编译从主 runtime compiler 中抽离, + * 让结果预览编译、世界基础 profile 归一和角色属性推导有清晰边界。 + */ + +const WORLD_ATTRIBUTE_SLOT_IDS = [ + 'axis_a', + 'axis_b', + 'axis_c', + 'axis_d', + 'axis_e', + 'axis_f', +] as const; + +const AXIS_KEYWORD_RULES: Array<{ + slotId: string; + patterns: RegExp[]; + weight: number; +}> = [ + { slotId: 'axis_a', patterns: [/骨|甲|壳|岩|重|守|镇|顶|硬|锋|体/u], weight: 16 }, + { slotId: 'axis_b', patterns: [/身法|迅|影|风|闪|游|机动|追|步|轻灵/u], weight: 16 }, + { slotId: 'axis_c', patterns: [/识|眼|谋|算|阵|符|术|察|局|禁制/u], weight: 16 }, + { slotId: 'axis_d', patterns: [/心|焰|胆|威|压|怒|决|破|强推|意志/u], weight: 16 }, + { slotId: 'axis_e', patterns: [/缘|契|情|盟|商|医|助|信|交|誓/u], weight: 16 }, + { slotId: 'axis_f', patterns: [/息|稳|续|守|调|回|养|持久|回复|韧/u], weight: 16 }, +]; + +export function buildTemplateWorldAttributeSchema( + worldType: Exclude, +) { + const common = { + schemaVersion: 1, + generatedFrom: + worldType === 'XIANXIA' + ? { + worldType: 'XIANXIA' as const, + worldName: '仙侠', + settingSummary: '灵潮、宗门、禁制、秘境与道途交织。', + tone: '空灵、危险、带着灾变与大道压迫。', + conflictCore: '在裂变与因果之间稳住自我与道途。', + } + : { + worldType: 'WUXIA' as const, + worldName: '武侠', + settingSummary: '江湖、门派、旧案与人情纠葛并存。', + tone: '克制、紧张、讲究局势与心气。', + conflictCore: '在人情、威压与旧案之间立住自身。', + }, + }; + + if (worldType === 'XIANXIA') { + return { + id: 'schema:xianxia:v1', + worldId: 'XIANXIA', + schemaName: '灵界六轴', + ...common, + slots: [ + { + slotId: 'axis_a', + name: '道骨', + definition: '承载道压与高强度冲击的底子。', + positiveSignals: ['承压', '根基稳', '扛得住'], + negativeSignals: ['根基浅', '易溃', '承载不足'], + combatUseText: '扛住灵压、正面承受高强度对撞。', + socialUseText: '让人感到根基扎实,值得托付重事。', + explorationUseText: '承受秘境、禁制与裂隙带来的压迫。', + }, + { + slotId: 'axis_b', + name: '灵行', + definition: '位移、御空、转场、抢占天时地利的能力。', + positiveSignals: ['位移', '御空', '机动'], + negativeSignals: ['迟滞', '失位', '转场慢'], + combatUseText: '抢位、御空、快速重整战场位置。', + socialUseText: '反应轻快,擅长顺势接住局面的变化。', + explorationUseText: '穿越高危地形、裂隙、云海与复杂禁区。', + }, + { + slotId: 'axis_c', + name: '识海', + definition: '解析禁制、洞察因果、识破虚实的能力。', + positiveSignals: ['洞察', '解构', '看破'], + negativeSignals: ['迷失', '误判', '看不清'], + combatUseText: '识破术理、找出因果节点与破绽。', + socialUseText: '更容易辨认真话、虚言与隐藏动机。', + explorationUseText: '解读阵纹、禁制、旧史与环境异象。', + }, + { + slotId: 'axis_d', + name: '劫纹', + definition: '在高危变化中强行推进、改写局势的能力。', + positiveSignals: ['强推', '决断', '逆转'], + negativeSignals: ['畏缩', '迟疑', '不敢碰变局'], + combatUseText: '在高压窗口里压上去,逼出变化与突破。', + socialUseText: '在关键谈判中拍板,推动他人表态。', + explorationUseText: '面对异变与风险时敢于推进关键节点。', + }, + { + slotId: 'axis_e', + name: '心契', + definition: '与他者、器物、灵兽、誓约建立共鸣的能力。', + positiveSignals: ['共鸣', '结契', '安抚'], + negativeSignals: ['隔阂', '生硬', '难以共振'], + combatUseText: '与器物、灵兽、同伴形成协同与共鸣。', + socialUseText: '建立信任、誓约与更深层的关系连结。', + explorationUseText: '借由共鸣打开封印、回应遗物或安抚异兽。', + }, + { + slotId: 'axis_f', + name: '玄息', + definition: '循环灵息、稳住心神、让自身持续在线的能力。', + positiveSignals: ['稳态', '回转', '续航'], + negativeSignals: ['紊乱', '枯竭', '失衡'], + combatUseText: '维持灵息循环、拖住长线压力与消耗。', + socialUseText: '气息沉稳,不轻易乱阵脚或露出破绽。', + explorationUseText: '在漫长探索与灵潮侵蚀中维持可行动状态。', + }, + ] satisfies WorldAttributeSlot[], + } satisfies WorldAttributeSchema; + } + + return { + id: 'schema:wuxia:v1', + worldId: 'WUXIA', + schemaVersion: 1, + schemaName: '江湖六脉', + generatedFrom: common.generatedFrom, + slots: [ + { + slotId: 'axis_a', + name: '骨势', + definition: '扛压、顶冲、硬吃风险也不退的势头。', + positiveSignals: ['扛压', '硬桥硬马', '稳住正面'], + negativeSignals: ['虚浮', '怯退', '一碰就散'], + combatUseText: '顶住正面压力、换伤不退、撑住阵线。', + socialUseText: '在强压场面里不露怯,给人可靠或强硬之感。', + explorationUseText: '穿越险路、硬顶机关、承受高压环境。', + }, + { + slotId: 'axis_b', + name: '身法', + definition: '腾挪、抢位、换线、把握出手节奏的能力。', + positiveSignals: ['快', '轻灵', '抢位'], + negativeSignals: ['迟缓', '失位', '笨重'], + combatUseText: '切线换位、闪转腾挪、争夺先手。', + socialUseText: '应变快,擅长观察气口并顺势接话。', + explorationUseText: '攀越、潜入、追踪与复杂地形穿行。', + }, + { + slotId: 'axis_c', + name: '眼脉', + definition: '看破破绽、拆招、识局、看穿人心的能力。', + positiveSignals: ['识局', '洞察', '拆招'], + negativeSignals: ['迟钝', '误判', '看不透'], + combatUseText: '抓破绽、拆套路、找出最该切入的位置。', + socialUseText: '判断弦外之音、试探真假、识别来意。', + explorationUseText: '识破机关、辨认痕迹、看懂异状。', + }, + { + slotId: 'axis_d', + name: '心焰', + definition: '决断、压迫、胆气、在局面中立住自身意志的能力。', + positiveSignals: ['胆气', '决断', '压迫'], + negativeSignals: ['犹疑', '软弱', '易被动摇'], + combatUseText: '逼迫对手、强行推进、在关键时刻拍板。', + socialUseText: '立威、定调、在谈判里压住场子。', + explorationUseText: '在未知风险前保持决断,不被局势拖死。', + }, + { + slotId: 'axis_e', + name: '尘缘', + definition: '与人事、情面、承诺、牵引关系打交道的能力。', + positiveSignals: ['通人情', '会安抚', '懂交换'], + negativeSignals: ['生硬', '失礼', '不近人情'], + combatUseText: '借势协同、读懂同伴与对手的关系脉络。', + socialUseText: '安抚、求助、结盟、维系承诺与信任。', + explorationUseText: '从传闻、人脉和地方关系里打开线索。', + }, + { + slotId: 'axis_f', + name: '玄息', + definition: '调息、稳态、久战、把自身维持在可用状态的能力。', + positiveSignals: ['稳', '续战', '调息'], + negativeSignals: ['紊乱', '易崩', '续不上'], + combatUseText: '续战、回气、稳住节奏与状态。', + socialUseText: '遇事不乱,语气和姿态都更沉稳可信。', + explorationUseText: '长线跋涉、在恶劣环境下维持专注与状态。', + }, + ] satisfies WorldAttributeSlot[], + } satisfies WorldAttributeSchema; +} + +export function generateWorldAttributeSchema(input: { + worldName: string; + settingText: string; + summary: string; + tone: string; + playerGoal: string; +}) { + const inferredWorldType = inferWorldTypeFromSetting(input.settingText); + const template = buildTemplateWorldAttributeSchema( + inferredWorldType === 'XIANXIA' ? 'XIANXIA' : 'WUXIA', + ); + + return { + ...template, + id: `schema:custom:${slugify(input.worldName)}`, + worldId: `custom:${input.worldName}`, + generatedFrom: { + worldType: 'CUSTOM', + worldName: input.worldName, + settingSummary: input.summary, + tone: input.tone, + conflictCore: input.playerGoal, + }, + } satisfies WorldAttributeSchema; +} + +function normalizeAttributeValues( + values: AttributeVector, + slotIds: readonly string[], + targetTotal = 360, +) { + const positiveValues = slotIds.map((slotId) => Math.max(0, values[slotId] ?? 0)); + const rawTotal = positiveValues.reduce((sum, value) => sum + value, 0); + const normalized = + rawTotal > 0 + ? positiveValues.map((value) => (value / rawTotal) * targetTotal) + : slotIds.map(() => targetTotal / Math.max(slotIds.length, 1)); + const rounded = normalized.map((value) => Math.max(0, Math.min(100, Math.round(value)))); + return Object.fromEntries( + slotIds.map((slotId, index) => [slotId, rounded[index] ?? 0]), + ) as AttributeVector; +} + +function ensureRoleAttributeProfile( + profile: Partial | null | undefined, + schema: WorldAttributeSchema, + fallbackValues: AttributeVector, +): RoleAttributeProfile { + const slotIds = schema.slots.map((slot) => slot.slotId); + const values = normalizeAttributeValues( + { + ...fallbackValues, + ...(profile?.values ?? {}), + }, + slotIds, + ); + const sortedSlots = [...schema.slots] + .map((slot) => ({ + slot, + value: values[slot.slotId] ?? 0, + })) + .sort((left, right) => right.value - left.value); + + return { + schemaId: profile?.schemaId ?? schema.id, + values, + topTraits: sortedSlots.slice(0, 2).map((entry) => entry.slot.name), + hiddenTraits: profile?.hiddenTraits ? [...profile.hiddenTraits] : undefined, + evidence: + profile?.evidence?.length + ? [...profile.evidence] + : sortedSlots.slice(0, 3).map((entry) => ({ + slotId: entry.slot.slotId, + reason: `${entry.slot.name}在当前画像中最突出。`, + })), + }; +} + +function buildDefaultAxisVector( + overrides: Partial>, +) { + return WORLD_ATTRIBUTE_SLOT_IDS.reduce((result, slotId) => { + result[slotId] = overrides[slotId] ?? 0; + return result; + }, {}); +} + +function buildRoleAttributeProfileFromTexts(params: { + schema: WorldAttributeSchema; + textBlocks: Array; +}) { + const sourceText = params.textBlocks.filter(Boolean).join(' '); + const seed = buildDefaultAxisVector({ + axis_a: 58, + axis_b: 58, + axis_c: 58, + axis_d: 58, + axis_e: 58, + axis_f: 58, + }); + + AXIS_KEYWORD_RULES.forEach((rule) => { + const matches = rule.patterns.reduce( + (count, pattern) => count + (pattern.test(sourceText) ? 1 : 0), + 0, + ); + if (matches <= 0) { + return; + } + seed[rule.slotId] = (seed[rule.slotId] ?? 0) + rule.weight * matches; + }); + + return ensureRoleAttributeProfile( + { + schemaId: params.schema.id, + }, + params.schema, + seed, + ); +} + +export function buildCustomWorldPlayableNpcAttributeProfile( + npc: CustomWorldPlayableNpc, + schema: WorldAttributeSchema, +) { + return buildRoleAttributeProfileFromTexts({ + schema, + textBlocks: [ + npc.title, + npc.role, + npc.description, + npc.backstory, + npc.personality, + npc.motivation, + npc.combatStyle, + ...(npc.relationshipHooks ?? []), + ...(npc.tags ?? []), + ], + }); +} + +export function buildCustomWorldStoryNpcAttributeProfile( + npc: CustomWorldNpc, + schema: WorldAttributeSchema, +) { + return buildRoleAttributeProfileFromTexts({ + schema, + textBlocks: [ + npc.title, + npc.role, + npc.description, + npc.backstory, + npc.personality, + npc.motivation, + npc.combatStyle, + ...(npc.relationshipHooks ?? []), + ...(npc.tags ?? []), + ], + }); +} diff --git a/server-node/src/modules/custom-world/runtime-profile/buildCompiledProfile.ts b/server-node/src/modules/custom-world/runtime-profile/buildCompiledProfile.ts new file mode 100644 index 00000000..f9965b6f --- /dev/null +++ b/server-node/src/modules/custom-world/runtime-profile/buildCompiledProfile.ts @@ -0,0 +1,410 @@ +import type { + CustomWorldGenerationFramework, + CustomWorldProfile, +} from '../runtimeTypes.js'; +import { + buildCustomWorldPlayableNpcAttributeProfile, + buildCustomWorldStoryNpcAttributeProfile, + generateWorldAttributeSchema, +} from './buildAttributeSchema.js'; +import { + buildWorldName, + inferWorldTypeFromSetting, + normalizeWorldType, + normalizeCustomWorldCreatorIntent, + normalizeCustomWorldLockState, + resolveCustomWorldRuntimeIntentBridge, +} from './creatorIntentBridge.js'; +import { + buildFallbackCustomWorldCampScene, + normalizeCampOutline, + normalizeCampScene, +} from './normalizeCamp.js'; +import { + buildCustomWorldRawProfileLandmarksFromFramework, + normalizeLandmarkOutlineList, + normalizeLandmarks, +} from './normalizeLandmark.js'; +import { + buildCustomWorldRawProfileRolesFromFramework, + normalizeCustomWorldGenerationFrameworkRoles, + normalizePlayableNpcList, + normalizeStoryNpcList, +} from './normalizeRole.js'; +import { + buildDefaultCustomWorldCover, + MIN_CUSTOM_WORLD_LANDMARK_COUNT, + MIN_CUSTOM_WORLD_PLAYABLE_NPC_COUNT, + normalizeCustomWorldCover, + normalizeItemList, + normalizeTags, + PLAYABLE_TEMPLATE_CHARACTER_IDS, + slugify, + toRecordArray, + toText, +} from './normalizeShared.js'; +import { normalizeSceneChapterBlueprints } from './normalizeSceneChapter.js'; + +/** + * 工作包 G: + * 让 runtime profile 真正由“主编译入口 + 目录化 normalize/build 子模块”组成。 + */ + +function buildBaseCustomWorldProfile(settingText: string): CustomWorldProfile { + const templateWorldType = inferWorldTypeFromSetting(settingText); + const name = buildWorldName(settingText, templateWorldType); + const subtitle = '前路未明'; + const summary = settingText.trim() + ? `这个世界围绕“${settingText.trim().slice(0, 28)}”展开。` + : '一个仍待展开的独立世界正在成形。'; + const tone = '未知、紧绷、仍在展开'; + const playerGoal = '查清眼前局势的关键矛盾,并守住仍值得相信的人与事'; + const camp = buildFallbackCustomWorldCampScene({ + name, + summary, + tone, + playerGoal, + settingText: settingText.trim(), + }); + + return { + id: `custom-world-${Date.now().toString(36)}-${slugify(name)}`, + settingText: settingText.trim(), + name, + subtitle, + summary, + tone, + playerGoal, + cover: buildDefaultCustomWorldCover([]), + templateWorldType, + compatibilityTemplateWorldType: templateWorldType, + majorFactions: [], + coreConflicts: [summary], + attributeSchema: generateWorldAttributeSchema({ + worldName: name, + settingText: settingText.trim(), + summary, + tone, + playerGoal, + }), + playableNpcs: [], + storyNpcs: [], + items: [], + camp, + landmarks: [], + themePack: null, + storyGraph: null, + creatorIntent: null, + anchorPack: null, + lockState: normalizeCustomWorldLockState(null), + generationMode: 'full', + generationStatus: 'complete', + ownedSettingLayers: null, + scenarioPackId: null, + campaignPackId: null, + }; +} + +export function normalizeCustomWorldGenerationFramework( + raw: unknown, + settingText: string, +): CustomWorldGenerationFramework { + const fallback = buildBaseCustomWorldProfile(settingText); + if (!raw || typeof raw !== 'object') { + return { + settingText: fallback.settingText, + name: fallback.name, + subtitle: fallback.subtitle, + summary: fallback.summary, + tone: fallback.tone, + playerGoal: fallback.playerGoal, + templateWorldType: fallback.templateWorldType, + compatibilityTemplateWorldType: + fallback.compatibilityTemplateWorldType ?? fallback.templateWorldType, + majorFactions: [], + coreConflicts: [fallback.summary], + camp: { + name: fallback.camp?.name ?? '归舍', + description: fallback.camp?.description ?? '', + dangerLevel: fallback.camp?.dangerLevel ?? 'low', + }, + playableNpcs: [], + storyNpcs: [], + landmarks: [], + }; + } + + const item = raw as Record; + const roleState = normalizeCustomWorldGenerationFrameworkRoles({ + raw: item, + fallback, + settingText, + }); + + return { + settingText: settingText.trim(), + name: roleState.name, + subtitle: toText(item.subtitle) || fallback.subtitle, + summary: toText(item.summary) || fallback.summary, + tone: toText(item.tone) || fallback.tone, + playerGoal: toText(item.playerGoal) || fallback.playerGoal, + templateWorldType: roleState.templateWorldType, + compatibilityTemplateWorldType: roleState.templateWorldType, + majorFactions: normalizeTags(item.majorFactions, []), + coreConflicts: normalizeTags(item.coreConflicts, [fallback.summary]), + camp: { + name: normalizeCampOutline(item.camp, roleState.campFallbackProfile).name, + description: normalizeCampOutline(item.camp, roleState.campFallbackProfile) + .description, + dangerLevel: normalizeCampOutline(item.camp, roleState.campFallbackProfile) + .dangerLevel, + }, + playableNpcs: roleState.playableNpcs, + storyNpcs: roleState.storyNpcs, + landmarks: normalizeLandmarkOutlineList(item.landmarks), + }; +} + +export function buildCustomWorldRawProfileFromFramework( + framework: CustomWorldGenerationFramework, +) { + return { + name: framework.name, + subtitle: framework.subtitle, + summary: framework.summary, + tone: framework.tone, + playerGoal: framework.playerGoal, + templateWorldType: framework.templateWorldType, + compatibilityTemplateWorldType: framework.compatibilityTemplateWorldType, + majorFactions: framework.majorFactions, + coreConflicts: framework.coreConflicts, + camp: { + name: framework.camp.name, + description: framework.camp.description, + dangerLevel: framework.camp.dangerLevel, + }, + ...buildCustomWorldRawProfileRolesFromFramework(framework), + landmarks: buildCustomWorldRawProfileLandmarksFromFramework(framework), + }; +} + +function pickCyclic(items: readonly T[], index: number, label: string): T { + const item = items[index % items.length]; + if (item === undefined) { + throw new Error(`Missing ${label}`); + } + return item; +} + +export function normalizeCustomWorldProfile( + raw: unknown, + settingText: string, +): CustomWorldProfile { + const fallback = buildBaseCustomWorldProfile(settingText); + if (!raw || typeof raw !== 'object') { + return fallback; + } + + const item = raw as Record; + const worldSignalText = [ + settingText, + toText(item.subtitle), + toText(item.summary), + toText(item.tone), + toText(item.playerGoal), + ].join(' '); + const templateWorldType = normalizeWorldType( + item.templateWorldType, + worldSignalText, + ); + const name = + toText(item.name) || buildWorldName(settingText, templateWorldType); + const summary = toText(item.summary) || fallback.summary; + const tone = toText(item.tone) || fallback.tone; + const playerGoal = toText(item.playerGoal) || fallback.playerGoal; + const generatedAttributeSchema = generateWorldAttributeSchema({ + worldName: name, + settingText: settingText.trim(), + summary, + tone, + playerGoal, + }); + const playableNpcs = normalizePlayableNpcList(item.playableNpcs); + const storyNpcs = normalizeStoryNpcList(item.storyNpcs); + const landmarkDrafts = toRecordArray(item.landmarks); + const camp = normalizeCampScene(item.camp, { + name, + summary, + tone, + playerGoal, + settingText: settingText.trim(), + }); + const runtimeBridge = resolveCustomWorldRuntimeIntentBridge(item); + + return { + id: + toText(item.id) || `custom-world-${Date.now().toString(36)}-${slugify(name)}`, + settingText: settingText.trim(), + name, + subtitle: toText(item.subtitle) || fallback.subtitle, + summary, + tone, + playerGoal, + cover: normalizeCustomWorldCover(item.cover, playableNpcs), + templateWorldType, + compatibilityTemplateWorldType: templateWorldType, + majorFactions: normalizeTags(item.majorFactions, []), + coreConflicts: normalizeTags(item.coreConflicts, [summary]), + attributeSchema: + item.attributeSchema && typeof item.attributeSchema === 'object' + ? generatedAttributeSchema + : generatedAttributeSchema, + playableNpcs, + storyNpcs, + items: normalizeItemList(item.items), + camp, + landmarks: normalizeLandmarks({ + landmarks: landmarkDrafts, + storyNpcs, + }), + themePack: + item.themePack && typeof item.themePack === 'object' + ? (item.themePack as CustomWorldProfile['themePack']) + : null, + storyGraph: + item.storyGraph && typeof item.storyGraph === 'object' + ? (item.storyGraph as CustomWorldProfile['storyGraph']) + : null, + anchorContent: + item.anchorContent && typeof item.anchorContent === 'object' + ? (item.anchorContent as Record) + : null, + creatorIntent: runtimeBridge.creatorIntent, + anchorPack: runtimeBridge.anchorPack, + lockState: runtimeBridge.lockState, + generationMode: + item.generationMode === 'fast' || item.generationMode === 'full' + ? item.generationMode + : fallback.generationMode, + generationStatus: + item.generationStatus === 'key_only' || item.generationStatus === 'complete' + ? item.generationStatus + : fallback.generationStatus, + ownedSettingLayers: + item.ownedSettingLayers && typeof item.ownedSettingLayers === 'object' + ? (item.ownedSettingLayers as Record) + : null, + knowledgeFacts: + Array.isArray(item.knowledgeFacts) + ? (item.knowledgeFacts as Array>) + : null, + threadContracts: + Array.isArray(item.threadContracts) + ? (item.threadContracts as Array>) + : null, + sceneChapterBlueprints: normalizeSceneChapterBlueprints( + item.sceneChapterBlueprints, + ), + scenarioPackId: toText(item.scenarioPackId) || null, + campaignPackId: toText(item.campaignPackId) || null, + }; +} + +export function buildCompiledCustomWorldProfile( + raw: unknown, + settingText: string, +): CustomWorldProfile { + const profile = normalizeCustomWorldProfile(raw, settingText); + const playableNpcs = profile.playableNpcs.map((npc, index) => { + const templateCharacterId = + npc.templateCharacterId ?? + pickCyclic( + PLAYABLE_TEMPLATE_CHARACTER_IDS, + index, + 'playable template character id', + ); + + return { + ...npc, + templateCharacterId, + attributeProfile: + npc.attributeProfile ?? + buildCustomWorldPlayableNpcAttributeProfile( + { + ...npc, + templateCharacterId, + }, + profile.attributeSchema, + ), + }; + }); + + const storyNpcs = profile.storyNpcs.map((npc) => ({ + ...npc, + attributeProfile: + npc.attributeProfile ?? + buildCustomWorldStoryNpcAttributeProfile(npc, profile.attributeSchema), + })); + + return { + ...profile, + playableNpcs, + storyNpcs, + scenarioPackId: + profile.scenarioPackId ?? `scenario-pack:${slugify(profile.name)}`, + campaignPackId: + profile.campaignPackId ?? `campaign-pack:${slugify(profile.name)}`, + }; +} + +function countUniqueNames(items: Array<{ name: string }>) { + return new Set(items.map((item) => item.name.trim()).filter(Boolean)).size; +} + +export function validateGeneratedCustomWorldProfile( + profile: CustomWorldProfile, +) { + const playableCount = countUniqueNames(profile.playableNpcs); + const landmarkCount = countUniqueNames(profile.landmarks); + + if (playableCount < MIN_CUSTOM_WORLD_PLAYABLE_NPC_COUNT) { + throw new Error( + `自定义世界生成要求模型至少产出 ${MIN_CUSTOM_WORLD_PLAYABLE_NPC_COUNT} 名可扮演角色。`, + ); + } + + if (landmarkCount < MIN_CUSTOM_WORLD_LANDMARK_COUNT) { + throw new Error( + `自定义世界生成要求至少产出 ${MIN_CUSTOM_WORLD_LANDMARK_COUNT} 个场景,当前仅返回 ${landmarkCount} 个。`, + ); + } + + const validStoryNpcIds = new Set(profile.storyNpcs.map((npc) => npc.id)); + const validLandmarkIds = new Set( + profile.landmarks.map((landmark) => landmark.id), + ); + + profile.landmarks.forEach((landmark) => { + const uniqueSceneNpcIds = [...new Set(landmark.sceneNpcIds)]; + if (uniqueSceneNpcIds.length < 3) { + throw new Error( + `场景「${landmark.name}」至少需要关联 3 个场景角色,当前仅有 ${uniqueSceneNpcIds.length} 个。`, + ); + } + if (uniqueSceneNpcIds.some((npcId) => !validStoryNpcIds.has(npcId))) { + throw new Error(`场景「${landmark.name}」引用了不存在的场景角色。`); + } + if (landmark.connections.length === 0) { + throw new Error(`场景「${landmark.name}」缺少可用的场景连接关系。`); + } + if ( + landmark.connections.some( + (connection) => + connection.targetLandmarkId === landmark.id || + !validLandmarkIds.has(connection.targetLandmarkId), + ) + ) { + throw new Error(`场景「${landmark.name}」存在无效的目标场景连接。`); + } + }); +} diff --git a/server-node/src/modules/custom-world/runtime-profile/creatorIntentBridge.ts b/server-node/src/modules/custom-world/runtime-profile/creatorIntentBridge.ts new file mode 100644 index 00000000..e194eae5 --- /dev/null +++ b/server-node/src/modules/custom-world/runtime-profile/creatorIntentBridge.ts @@ -0,0 +1,82 @@ +import { + buildCustomWorldAnchorPackFromIntent, + deriveCustomWorldLockStateFromIntent, + normalizeCustomWorldCreatorIntent, + normalizeCustomWorldLockState, +} from '../creatorIntentRuntime.js'; +import type { + CustomWorldCreatorIntent, + CustomWorldProfile, + WorldType, +} from '../runtimeTypes.js'; +import { toText } from './normalizeShared.js'; + +/** + * 工作包 G: + * 统一 runtime profile 对 creator intent、anchor pack 和 lock state 的桥接入口, + * 避免主编译器继续直接拼装这些兼容字段。 + */ + +export function inferWorldTypeFromSetting(settingText: string): WorldType { + return /[仙灵修真飞升灵脉宗门法器天宫秘境道骨星舰]/u.test(settingText) + ? 'XIANXIA' + : 'WUXIA'; +} + +export function normalizeWorldType(value: unknown, sourceText: string): WorldType { + const worldType = toText(value).toUpperCase(); + if (worldType === 'WUXIA' || worldType === 'XIANXIA') { + return worldType; + } + return inferWorldTypeFromSetting(sourceText); +} + +export function buildSeedPhrase(settingText: string, fallback: string) { + const compact = settingText.replace(/\s+/g, '').trim(); + return compact ? compact.slice(0, 10) : fallback; +} + +export function buildWorldName(settingText: string, worldType: WorldType) { + const seed = buildSeedPhrase(settingText, '新旅'); + const suffix = worldType === 'XIANXIA' ? '境' : '域'; + return `${seed}${suffix}`; +} + +export { + normalizeCustomWorldCreatorIntent, + normalizeCustomWorldLockState, +}; + +export function buildEmptyCustomWorldRuntimeBridge() { + return { + creatorIntent: null, + anchorPack: null, + lockState: normalizeCustomWorldLockState(null), + } satisfies { + creatorIntent: CustomWorldCreatorIntent | null; + anchorPack: CustomWorldProfile['anchorPack']; + lockState: CustomWorldProfile['lockState']; + }; +} + +export function resolveCustomWorldRuntimeIntentBridge( + raw: Record, +) { + const creatorIntent = normalizeCustomWorldCreatorIntent(raw.creatorIntent); + + return { + creatorIntent, + anchorPack: + raw.anchorPack && typeof raw.anchorPack === 'object' + ? (raw.anchorPack as CustomWorldProfile['anchorPack']) + : buildCustomWorldAnchorPackFromIntent(creatorIntent), + lockState: + raw.lockState && typeof raw.lockState === 'object' + ? normalizeCustomWorldLockState(raw.lockState) + : deriveCustomWorldLockStateFromIntent(creatorIntent), + } satisfies { + creatorIntent: CustomWorldCreatorIntent | null; + anchorPack: CustomWorldProfile['anchorPack']; + lockState: CustomWorldProfile['lockState']; + }; +} diff --git a/server-node/src/modules/custom-world/runtime-profile/index.ts b/server-node/src/modules/custom-world/runtime-profile/index.ts new file mode 100644 index 00000000..60911cc7 --- /dev/null +++ b/server-node/src/modules/custom-world/runtime-profile/index.ts @@ -0,0 +1,13 @@ +/** + * 工作包 G: + * custom world runtime profile 的主入口统一收口到目录化模块。 + * 主编译逻辑和各类 normalize/build 子模块已经物理拆分,旧 compiler 文件只保留兼容转发。 + */ +export * from './buildAttributeSchema.js'; +export * from './buildCompiledProfile.js'; +export * from './creatorIntentBridge.js'; +export * from './normalizeCamp.js'; +export * from './normalizeLandmark.js'; +export * from './normalizeRole.js'; +export * from './normalizeSceneChapter.js'; +export * from './normalizeShared.js'; diff --git a/server-node/src/modules/custom-world/runtime-profile/normalizeCamp.ts b/server-node/src/modules/custom-world/runtime-profile/normalizeCamp.ts new file mode 100644 index 00000000..efdf5d30 --- /dev/null +++ b/server-node/src/modules/custom-world/runtime-profile/normalizeCamp.ts @@ -0,0 +1,178 @@ +import type { + CustomWorldCampScene, + CustomWorldGenerationCampOutline, +} from '../runtimeTypes.js'; +import { + clampText, + toRecordArray, + toStringArray, + toText, +} from './normalizeShared.js'; + +/** + * 工作包 G: + * 营地 fallback、outline 归一和 runtime 场景归一单独收口, + * 避免主编译器继续混合 UI 展示语义和营地领域默认值。 + */ + +export type CustomWorldCampFallbackProfile = { + name: string; + summary: string; + tone: string; + playerGoal: string; + settingText: string; +}; + +function detectCustomWorldThemeMode(profile: { + settingText: string; + summary: string; + tone: string; + playerGoal: string; +}) { + const source = [ + profile.settingText, + profile.summary, + profile.tone, + profile.playerGoal, + ].join(' '); + + if (/[机关蒸汽齿轮工坊机巧城轨炮舰]/u.test(source)) return 'machina'; + if (/[海潮港湾船舟湖泊澜雾湾]/u.test(source)) return 'tide'; + if (/[裂缝裂界边境前线断层界桥灰域]/u.test(source)) return 'rift'; + if (/[修真仙灵宗门法器道脉秘境云阙]/u.test(source)) return 'arcane'; + if (/[江湖门派镖局朝廷刀剑侠客旧案]/u.test(source)) return 'martial'; + return 'mythic'; +} + +function sanitizeCampSeed(name: string) { + const normalized = name.trim().replace(/\s+/g, ''); + if (!normalized) { + return ''; + } + + const stripped = normalized.replace( + /(世界|江湖|边城|仙洲|仙境|灵境|界|录|域|境)$/u, + '', + ); + const seed = stripped || normalized; + + return seed.slice(0, Math.min(seed.length, 4)); +} + +function buildFallbackCampName(profile: CustomWorldCampFallbackProfile) { + const seed = sanitizeCampSeed(profile.name) || '归途'; + const themeMode = detectCustomWorldThemeMode(profile); + + const suffixByMode = { + mythic: '归舍', + martial: '归舍', + arcane: '栖居', + machina: '整备居', + tide: '潮居', + rift: '界隙居所', + } as const; + + return `${seed}${suffixByMode[themeMode]}`; +} + +export function buildFallbackCustomWorldCampScene( + profile: CustomWorldCampFallbackProfile, +): CustomWorldCampScene { + const fallbackName = buildFallbackCampName(profile); + const summaryLead = clampText(profile.summary, 24) || '前路尚未明朗'; + const goalLead = clampText(profile.playerGoal, 24) || '继续整理线索'; + const themeMode = detectCustomWorldThemeMode(profile); + + const descriptionByMode = { + mythic: `${fallbackName}是你在${profile.name}暂时安顿的归处,能整备行装、交换判断,并围绕“${goalLead}”继续启程。`, + martial: `${fallbackName}是你在${profile.name}暂时落脚的归处,能整备行装、收拢同伴,并朝“${goalLead}”继续动身。`, + arcane: `${fallbackName}是你在${profile.name}暂时栖身的居所,适合调息、整理法器,并围绕“${summaryLead}”交换判断。`, + machina: `${fallbackName}是你在${profile.name}的临时居所,能检修装备、补齐物资,并为下一段行动重新校准节奏。`, + tide: `${fallbackName}是你在${profile.name}靠潮歇息的住处,能安顿队伍、整理补给,并顺着“${goalLead}”继续前探。`, + rift: `${fallbackName}是你在${profile.name}勉强守住的一处居所,既能短暂停步,也能围绕“${summaryLead}”商量下一步去向。`, + } as const; + + return { + id: 'custom-scene-camp', + name: fallbackName, + description: descriptionByMode[themeMode], + dangerLevel: 'low', + sceneNpcIds: [], + connections: [], + narrativeResidues: null, + }; +} + +export function normalizeCampOutline( + value: unknown, + fallbackProfile: CustomWorldCampFallbackProfile, +) { + const fallback = buildFallbackCustomWorldCampScene(fallbackProfile); + const item = + value && typeof value === 'object' + ? (value as Record) + : {}; + + return { + id: toText(item.id) || fallback.id, + name: toText(item.name) || fallback.name, + description: toText(item.description) || fallback.description, + visualDescription: toText(item.visualDescription) || undefined, + dangerLevel: toText(item.dangerLevel) || fallback.dangerLevel, + imageSrc: toText(item.imageSrc) || undefined, + sceneNpcIds: toStringArray(item.sceneNpcIds), + connections: toRecordArray(item.connections) + .map((connection) => ({ + targetLandmarkName: + toText(connection.targetLandmarkName) || + toText(connection.target) || + toText(connection.sceneName), + relativePosition: + toText(connection.relativePosition) || + toText(connection.position) || + 'forward', + summary: toText(connection.summary) || toText(connection.description), + })) + .filter((connection) => connection.targetLandmarkName), + } satisfies CustomWorldGenerationCampOutline & { + id: string; + visualDescription?: string; + imageSrc?: string; + sceneNpcIds: string[]; + connections: Array<{ + targetLandmarkName: string; + relativePosition: string; + summary: string; + }>; + }; +} + +export function normalizeCampScene( + value: unknown, + fallbackProfile: CustomWorldCampFallbackProfile, +): CustomWorldCampScene { + const fallback = buildFallbackCustomWorldCampScene(fallbackProfile); + const item = + value && typeof value === 'object' + ? (value as Record) + : {}; + + return { + id: toText(item.id) || fallback.id, + name: toText(item.name) || fallback.name, + description: toText(item.description) || fallback.description, + visualDescription: toText(item.visualDescription) || undefined, + dangerLevel: toText(item.dangerLevel) || fallback.dangerLevel, + imageSrc: toText(item.imageSrc) || undefined, + sceneNpcIds: toStringArray(item.sceneNpcIds), + connections: toRecordArray(item.connections) + .map((connection) => ({ + targetLandmarkId: toText(connection.targetLandmarkId), + relativePosition: + toText(connection.relativePosition) || toText(connection.position) || 'forward', + summary: toText(connection.summary) || toText(connection.description), + })) + .filter((connection) => connection.targetLandmarkId), + narrativeResidues: null, + }; +} diff --git a/server-node/src/modules/custom-world/runtime-profile/normalizeLandmark.ts b/server-node/src/modules/custom-world/runtime-profile/normalizeLandmark.ts new file mode 100644 index 00000000..ee7f5041 --- /dev/null +++ b/server-node/src/modules/custom-world/runtime-profile/normalizeLandmark.ts @@ -0,0 +1,151 @@ +import type { + CustomWorldGenerationFramework, + CustomWorldGenerationLandmarkOutline, + CustomWorldNpc, +} from '../runtimeTypes.js'; +import { + clampText, + createEntryId, + MIN_CUSTOM_WORLD_LANDMARK_COUNT, + toRecordArray, + toStringArray, + toText, +} from './normalizeShared.js'; + +/** + * 工作包 G: + * 世界地点 outline/runtime 归一独立收口,避免地点网络解析继续和角色、场景章节逻辑混在一个文件里。 + */ + +export function normalizeLandmarkOutlineList(value: unknown) { + return toRecordArray(value) + .map((item) => { + const name = toText(item.name); + return { + name, + description: + toText(item.description) || + clampText(`${name}暗藏新的局势变化。`, 40), + visualDescription: toText(item.visualDescription) || undefined, + dangerLevel: toText(item.dangerLevel) || 'medium', + sceneNpcNames: [ + ...toStringArray(item.sceneNpcNames), + ...toStringArray(item.npcs, 'name'), + ...toStringArray(item.sceneNpcs, 'name'), + ...toStringArray(item.npcNames), + ], + connections: toRecordArray(item.connections) + .map((connection) => ({ + targetLandmarkName: + toText(connection.targetLandmarkName) || + toText(connection.target) || + toText(connection.sceneName), + relativePosition: + toText(connection.relativePosition) || + toText(connection.position) || + 'forward', + summary: + toText(connection.summary) || toText(connection.description), + })) + .filter((connection) => connection.targetLandmarkName), + } satisfies CustomWorldGenerationLandmarkOutline; + }) + .filter((entry) => entry.name) + .slice(0, MIN_CUSTOM_WORLD_LANDMARK_COUNT); +} + +export function normalizeCustomWorldGenerationLandmarkOutlineBatch(raw: unknown) { + const item = + raw && typeof raw === 'object' ? (raw as Record) : {}; + return normalizeLandmarkOutlineList(item.landmarks); +} + +export function buildCustomWorldRawProfileLandmarksFromFramework( + framework: CustomWorldGenerationFramework, +) { + return framework.landmarks.map((landmark) => ({ + name: landmark.name, + description: landmark.description, + visualDescription: landmark.visualDescription, + dangerLevel: landmark.dangerLevel, + sceneNpcNames: [...landmark.sceneNpcNames], + connections: landmark.connections.map((connection) => ({ + targetLandmarkName: connection.targetLandmarkName, + relativePosition: connection.relativePosition, + summary: connection.summary, + })), + })); +} + +export function normalizeLandmarks(params: { + landmarks: Array>; + storyNpcs: CustomWorldNpc[]; +}) { + const storyNpcIdByName = new Map( + params.storyNpcs.map((npc) => [npc.name.trim(), npc.id] as const), + ); + const landmarkEntries = params.landmarks + .map((item, index) => ({ + id: toText(item.id) || createEntryId('landmark', toText(item.name), index), + name: toText(item.name), + description: toText(item.description), + visualDescription: toText(item.visualDescription) || undefined, + dangerLevel: toText(item.dangerLevel) || 'medium', + imageSrc: toText(item.imageSrc) || undefined, + sceneNpcIds: toStringArray(item.sceneNpcIds), + sceneNpcNames: [ + ...toStringArray(item.sceneNpcNames), + ...toStringArray(item.npcs, 'name'), + ...toStringArray(item.sceneNpcs, 'name'), + ...toStringArray(item.npcNames), + ], + connections: toRecordArray(item.connections).map((connection) => ({ + targetLandmarkId: toText(connection.targetLandmarkId), + targetLandmarkName: + toText(connection.targetLandmarkName) || + toText(connection.target) || + toText(connection.sceneName), + relativePosition: + toText(connection.relativePosition) || toText(connection.position), + summary: toText(connection.summary) || toText(connection.description), + })), + })) + .filter((entry) => entry.name); + + const landmarkIdByName = new Map( + landmarkEntries.map((landmark) => [landmark.name.trim(), landmark.id] as const), + ); + + return landmarkEntries.map((landmark) => { + const resolvedSceneNpcIds = [ + ...new Set( + [ + ...landmark.sceneNpcIds, + ...landmark.sceneNpcNames + .map((name) => storyNpcIdByName.get(name.trim()) ?? '') + .filter(Boolean), + ].filter(Boolean), + ), + ]; + + return { + id: landmark.id, + name: landmark.name, + description: landmark.description, + visualDescription: landmark.visualDescription, + dangerLevel: landmark.dangerLevel, + imageSrc: landmark.imageSrc, + sceneNpcIds: resolvedSceneNpcIds, + connections: landmark.connections + .map((connection) => ({ + targetLandmarkId: + connection.targetLandmarkId || + landmarkIdByName.get(connection.targetLandmarkName.trim()) || + '', + relativePosition: connection.relativePosition || 'forward', + summary: connection.summary, + })) + .filter((connection) => connection.targetLandmarkId), + }; + }); +} diff --git a/server-node/src/modules/custom-world/runtime-profile/normalizeRole.ts b/server-node/src/modules/custom-world/runtime-profile/normalizeRole.ts new file mode 100644 index 00000000..ff8e5e92 --- /dev/null +++ b/server-node/src/modules/custom-world/runtime-profile/normalizeRole.ts @@ -0,0 +1,541 @@ +import type { + CharacterBackstoryChapter, + CharacterBackstoryRevealConfig, + CustomWorldGenerationFramework, + CustomWorldGenerationRoleBatchType, + CustomWorldGenerationRoleOutline, + CustomWorldNpc, + CustomWorldPlayableNpc, + CustomWorldProfile, + CustomWorldRoleInitialItem, + CustomWorldRoleProfile, + CustomWorldRoleSkill, +} from '../runtimeTypes.js'; +import { + buildWorldName, + normalizeWorldType, +} from './creatorIntentBridge.js'; +import { + clampCustomWorldAffinity, + clampText, + createEntryId, + MIN_CUSTOM_WORLD_PLAYABLE_NPC_COUNT, + MIN_CUSTOM_WORLD_STORY_NPC_COUNT, + normalizeInitialAffinity, + normalizeRarity, + normalizeRoleItemCategory, + normalizeTags, + toRecordArray, + toText, +} from './normalizeShared.js'; + +/** + * 工作包 G: + * 把角色相关的 outline/runtime 归一、背景揭示、初始技能与物品 fallback 统一收口, + * 让主编译器只负责装配,不继续内嵌角色画像细节。 + */ + +const AFFINITY_BACKSTORY_CHAPTER_THRESHOLDS = [15, 30, 60, 90] as const; +const DEFAULT_PRIVATE_CHAT_UNLOCK_AFFINITY = 60; +const DEFAULT_PLAYABLE_INITIAL_AFFINITY = 18; +const DEFAULT_STORY_NPC_INITIAL_AFFINITY = 6; +const DEFAULT_CUSTOM_WORLD_ROLE_SKILL_COUNT = 3; +const DEFAULT_CUSTOM_WORLD_ROLE_INITIAL_ITEM_COUNT = 3; +const CUSTOM_WORLD_BACKSTORY_CHAPTER_TITLES = [ + '表层来意', + '旧事裂痕', + '隐藏执念', + '最终底牌', +] as const; + +type CustomWorldRoleFallbackSource = Pick< + CustomWorldRoleProfile, + | 'name' + | 'title' + | 'role' + | 'description' + | 'backstory' + | 'personality' + | 'motivation' + | 'combatStyle' + | 'relationshipHooks' + | 'tags' +>; + +function splitNarrativeSentences(text: string) { + const normalized = text.replace(/\s+/g, ' ').trim(); + if (!normalized) { + return []; + } + const matches = normalized.match(/[^。!?!?]+[。!?!?]?/gu); + return (matches ?? [normalized]).map((item) => item.trim()).filter(Boolean); +} + +function buildFallbackBackstoryReveal( + source: CustomWorldRoleFallbackSource, +): CharacterBackstoryRevealConfig { + const normalizedBackstory = + source.backstory.trim() || `${source.name}对自己的过去始终有所保留。`; + const backstorySentences = splitNarrativeSentences(normalizedBackstory); + const backstoryLead = backstorySentences[0] ?? normalizedBackstory; + const backstoryDetail = + backstorySentences.slice(0, 2).join('') || normalizedBackstory; + const publicSummary = + source.description.trim() || clampText(normalizedBackstory, 42); + const fallbackContents = [ + source.description.trim() || backstoryLead, + backstoryDetail, + source.motivation.trim() + ? `${source.name}真正挂念的,是:${source.motivation.trim()}` + : `${source.name}的选择与“${clampText(backstoryLead, 24)}”直接相关。`, + source.personality.trim() + ? `${source.name}不会轻易说出的底色,是:${source.personality.trim()}` + : `${source.name}仍把最深的筹码藏在过去之中。`, + ]; + + return { + publicSummary, + privateChatUnlockAffinity: DEFAULT_PRIVATE_CHAT_UNLOCK_AFFINITY, + chapters: AFFINITY_BACKSTORY_CHAPTER_THRESHOLDS.map( + (affinityRequired, index) => + ({ + id: createEntryId( + 'backstory-chapter', + `${source.name}-${CUSTOM_WORLD_BACKSTORY_CHAPTER_TITLES[index] ?? index + 1}`, + index, + ), + title: + CUSTOM_WORLD_BACKSTORY_CHAPTER_TITLES[index] ?? + `背景片段${index + 1}`, + affinityRequired, + teaser: clampText( + fallbackContents[index] ?? normalizedBackstory, + 22, + ), + content: clampText( + fallbackContents[index] ?? normalizedBackstory, + 72, + ), + contextSnippet: clampText( + `${source.name}的背景正在向“${fallbackContents[index] ?? normalizedBackstory}”这条线索展开。`, + 48, + ), + }) satisfies CharacterBackstoryChapter, + ), + }; +} + +function normalizeBackstoryReveal( + value: unknown, + fallbackSource: CustomWorldRoleFallbackSource, +) { + const fallback = buildFallbackBackstoryReveal(fallbackSource); + if (!value || typeof value !== 'object') { + return fallback; + } + + const item = value as Record; + const rawChapters = toRecordArray(item.chapters); + + return { + publicSummary: toText(item.publicSummary) || fallback.publicSummary, + privateChatUnlockAffinity: + typeof item.privateChatUnlockAffinity === 'number' && + Number.isFinite(item.privateChatUnlockAffinity) + ? clampCustomWorldAffinity(item.privateChatUnlockAffinity) + : fallback.privateChatUnlockAffinity, + chapters: AFFINITY_BACKSTORY_CHAPTER_THRESHOLDS.map( + (defaultAffinity, index) => { + const fallbackChapter = fallback.chapters[index]; + const rawChapter = rawChapters[index]; + return { + id: + (rawChapter && toText(rawChapter.id)) || + fallbackChapter?.id || + `backstory-chapter-${index + 1}`, + title: + (rawChapter && toText(rawChapter.title)) || + fallbackChapter?.title || + `背景片段${index + 1}`, + affinityRequired: + fallbackChapter?.affinityRequired ?? defaultAffinity, + teaser: + (rawChapter && toText(rawChapter.teaser)) || + fallbackChapter?.teaser || + '', + content: + (rawChapter && toText(rawChapter.content)) || + fallbackChapter?.content || + '', + contextSnippet: + (rawChapter && toText(rawChapter.contextSnippet)) || + fallbackChapter?.contextSnippet || + '', + } satisfies CharacterBackstoryChapter; + }, + ), + } satisfies CharacterBackstoryRevealConfig; +} + +function buildFallbackRoleSkills(source: CustomWorldRoleFallbackSource) { + const skillNameSeed = source.title || source.role || source.name || '角色'; + const skillSummarySeed = + source.combatStyle || source.description || `${source.name}善于把握局势。`; + const motivationSeed = + source.motivation || source.personality || source.backstory; + + return [ + { + id: createEntryId('role-skill', `${skillNameSeed}-起手`, 0), + name: `${skillNameSeed}起手`, + summary: clampText(skillSummarySeed, 36), + style: '起手压制', + }, + { + id: createEntryId('role-skill', `${skillNameSeed}-机动`, 1), + name: `${skillNameSeed}变招`, + summary: clampText( + source.personality || `${source.name}习惯在试探中寻找破绽。`, + 36, + ), + style: '机动周旋', + }, + { + id: createEntryId('role-skill', `${skillNameSeed}-底牌`, 2), + name: `${skillNameSeed}底牌`, + summary: clampText( + motivationSeed || `${source.name}会在关键时刻亮出压箱手段。`, + 36, + ), + style: '爆发终结', + }, + ] satisfies CustomWorldRoleSkill[]; +} + +function normalizeRoleSkillList( + value: unknown, + fallbackSource: CustomWorldRoleFallbackSource, +) { + const normalized = toRecordArray(value) + .map((item, index) => { + const name = toText(item.name); + const summary = toText(item.summary) || toText(item.description); + const style = toText(item.style) || toText(item.category) || '常用'; + + return { + id: createEntryId('role-skill', name || style, index), + name, + summary, + style, + } satisfies CustomWorldRoleSkill; + }) + .filter((entry) => entry.name) + .slice(0, DEFAULT_CUSTOM_WORLD_ROLE_SKILL_COUNT); + + return normalized.length > 0 + ? normalized + : buildFallbackRoleSkills(fallbackSource); +} + +function buildFallbackRoleInitialItems(source: CustomWorldRoleFallbackSource) { + const itemNameSeed = source.title || source.role || source.name || '角色'; + return [ + { + id: createEntryId('role-item', `${itemNameSeed}-1`, 0), + name: `${itemNameSeed}常备武具`, + category: '武器', + quantity: 1, + rarity: 'rare', + description: clampText( + source.combatStyle || `${source.name}随身携带的主要作战物件。`, + 36, + ), + tags: normalizeTags(source.tags, ['战斗', '随身']), + }, + { + id: createEntryId('role-item', `${itemNameSeed}-2`, 1), + name: `${itemNameSeed}补给包`, + category: '消耗品', + quantity: 2, + rarity: 'uncommon', + description: clampText( + source.personality || `${source.name}为了长期行动准备的基础补给。`, + 36, + ), + tags: normalizeTags(source.relationshipHooks, ['补给', '行动']), + }, + { + id: createEntryId('role-item', `${itemNameSeed}-3`, 2), + name: `${itemNameSeed}私人物件`, + category: '专属物品', + quantity: 1, + rarity: 'rare', + description: clampText( + source.backstory || + source.motivation || + `${source.name}不愿随意交出的信物。`, + 36, + ), + tags: normalizeTags( + [...source.tags, ...source.relationshipHooks], + ['信物', '线索'], + ), + }, + ] satisfies CustomWorldRoleInitialItem[]; +} + +function normalizeRoleInitialItemList( + value: unknown, + fallbackSource: CustomWorldRoleFallbackSource, +) { + const normalized = toRecordArray(value) + .map((item, index) => { + const name = toText(item.name); + return { + id: createEntryId('role-item', name, index), + name, + category: normalizeRoleItemCategory(item.category), + quantity: + typeof item.quantity === 'number' && Number.isFinite(item.quantity) + ? Math.max(1, Math.min(99, Math.round(item.quantity))) + : 1, + rarity: normalizeRarity(item.rarity, 'rare'), + description: toText(item.description), + tags: normalizeTags(item.tags), + } satisfies CustomWorldRoleInitialItem; + }) + .filter((entry) => entry.name) + .slice(0, DEFAULT_CUSTOM_WORLD_ROLE_INITIAL_ITEM_COUNT); + + return normalized.length > 0 + ? normalized + : buildFallbackRoleInitialItems(fallbackSource); +} + +function normalizeRoleOutlineList( + value: unknown, + options: { + titleFallback: string; + defaultAffinity: number; + maxCount?: number; + }, +) { + const normalized = toRecordArray(value) + .map((item) => { + const name = toText(item.name); + const title = + toText(item.title) || toText(item.role) || options.titleFallback; + const role = toText(item.role) || title; + const relationshipHooks = normalizeTags( + item.relationshipHooks, + normalizeTags(item.tags), + ); + + return { + name, + title, + role, + description: + toText(item.description) || + clampText(`${name || title}在世界中以${role}身份活动。`, 36), + visualDescription: toText(item.visualDescription) || undefined, + actionDescription: toText(item.actionDescription) || undefined, + sceneVisualDescription: toText(item.sceneVisualDescription) || undefined, + initialAffinity: normalizeInitialAffinity( + item.initialAffinity, + options.defaultAffinity, + ), + relationshipHooks, + tags: normalizeTags(item.tags, relationshipHooks), + } satisfies CustomWorldGenerationRoleOutline; + }) + .filter((entry) => entry.name); + + return typeof options.maxCount === 'number' + ? normalized.slice(0, options.maxCount) + : normalized; +} + +export function normalizeCustomWorldGenerationRoleOutlineBatch( + raw: unknown, + roleType: CustomWorldGenerationRoleBatchType, +) { + const item = + raw && typeof raw === 'object' ? (raw as Record) : {}; + const key = roleType === 'playable' ? 'playableNpcs' : 'storyNpcs'; + + return normalizeRoleOutlineList(item[key], { + titleFallback: '未定称号', + defaultAffinity: + roleType === 'playable' + ? DEFAULT_PLAYABLE_INITIAL_AFFINITY + : DEFAULT_STORY_NPC_INITIAL_AFFINITY, + }); +} + +export function normalizeCustomWorldGenerationFrameworkRoles(params: { + raw: Record; + fallback: CustomWorldProfile; + settingText: string; +}) { + const worldSignalText = [ + params.settingText, + toText(params.raw.subtitle), + toText(params.raw.summary), + toText(params.raw.tone), + toText(params.raw.playerGoal), + ].join(' '); + const templateWorldType = normalizeWorldType( + params.raw.templateWorldType, + worldSignalText, + ); + const name = + toText(params.raw.name) || buildWorldName(params.settingText, templateWorldType); + + return { + name, + templateWorldType, + playableNpcs: normalizeRoleOutlineList(params.raw.playableNpcs, { + titleFallback: '未定称号', + defaultAffinity: DEFAULT_PLAYABLE_INITIAL_AFFINITY, + maxCount: MIN_CUSTOM_WORLD_PLAYABLE_NPC_COUNT, + }), + storyNpcs: normalizeRoleOutlineList(params.raw.storyNpcs, { + titleFallback: '未定称号', + defaultAffinity: DEFAULT_STORY_NPC_INITIAL_AFFINITY, + maxCount: MIN_CUSTOM_WORLD_STORY_NPC_COUNT, + }), + campFallbackProfile: { + name, + summary: toText(params.raw.summary) || params.fallback.summary, + tone: toText(params.raw.tone) || params.fallback.tone, + playerGoal: toText(params.raw.playerGoal) || params.fallback.playerGoal, + settingText: params.settingText.trim(), + }, + }; +} + +export function buildCustomWorldRawProfileRolesFromFramework( + framework: CustomWorldGenerationFramework, +) { + return { + playableNpcs: framework.playableNpcs.map((npc) => ({ + name: npc.name, + title: npc.title, + role: npc.role, + description: npc.description, + visualDescription: npc.visualDescription, + actionDescription: npc.actionDescription, + sceneVisualDescription: npc.sceneVisualDescription, + initialAffinity: npc.initialAffinity, + relationshipHooks: [...npc.relationshipHooks], + tags: [...npc.tags], + })), + storyNpcs: framework.storyNpcs.map((npc) => ({ + name: npc.name, + title: npc.title, + role: npc.role, + description: npc.description, + visualDescription: npc.visualDescription, + actionDescription: npc.actionDescription, + sceneVisualDescription: npc.sceneVisualDescription, + initialAffinity: npc.initialAffinity, + relationshipHooks: [...npc.relationshipHooks], + tags: [...npc.tags], + })), + }; +} + +function normalizeRoleProfile( + item: Record, + index: number, + options: { + idPrefix: 'playable-npc' | 'story-npc'; + titleFallback: string; + defaultAffinity: number; + }, +) { + const name = toText(item.name); + const title = + toText(item.title) || toText(item.role) || options.titleFallback; + const role = toText(item.role) || title; + const relationshipHooks = normalizeTags( + item.relationshipHooks, + normalizeTags(item.tags), + ); + const normalizedRole = { + id: toText(item.id) || createEntryId(options.idPrefix, name, index), + name, + title, + role, + description: toText(item.description), + visualDescription: toText(item.visualDescription) || undefined, + actionDescription: toText(item.actionDescription) || undefined, + sceneVisualDescription: toText(item.sceneVisualDescription) || undefined, + backstory: toText(item.backstory), + personality: toText(item.personality), + motivation: toText(item.motivation) || toText(item.description), + combatStyle: toText(item.combatStyle), + initialAffinity: normalizeInitialAffinity( + item.initialAffinity, + options.defaultAffinity, + ), + relationshipHooks, + tags: normalizeTags(item.tags, relationshipHooks), + }; + + return { + ...normalizedRole, + backstoryReveal: normalizeBackstoryReveal( + item.backstoryReveal, + normalizedRole, + ), + skills: normalizeRoleSkillList(item.skills, normalizedRole), + initialItems: normalizeRoleInitialItemList(item.initialItems, normalizedRole), + imageSrc: toText(item.imageSrc) || undefined, + generatedVisualAssetId: toText(item.generatedVisualAssetId) || undefined, + generatedAnimationSetId: + toText(item.generatedAnimationSetId) || undefined, + animationMap: + item.animationMap && typeof item.animationMap === 'object' + ? (item.animationMap as Record) + : undefined, + narrativeProfile: + item.narrativeProfile && typeof item.narrativeProfile === 'object' + ? (item.narrativeProfile as CustomWorldRoleProfile['narrativeProfile']) + : null, + }; +} + +export function normalizePlayableNpcList(value: unknown) { + return toRecordArray(value) + .map((item, index) => ({ + ...normalizeRoleProfile(item, index, { + idPrefix: 'playable-npc', + titleFallback: '未定称号', + defaultAffinity: DEFAULT_PLAYABLE_INITIAL_AFFINITY, + }), + templateCharacterId: toText(item.templateCharacterId) || undefined, + })) + .filter((entry) => entry.name) + .slice(0, MIN_CUSTOM_WORLD_PLAYABLE_NPC_COUNT); +} + +export function normalizeStoryNpcList(value: unknown) { + return toRecordArray(value) + .map( + (item, index) => + ({ + ...normalizeRoleProfile(item, index, { + idPrefix: 'story-npc', + titleFallback: '未定称号', + defaultAffinity: DEFAULT_STORY_NPC_INITIAL_AFFINITY, + }), + visual: + item.visual && typeof item.visual === 'object' + ? (item.visual as Record) + : undefined, + }) satisfies CustomWorldNpc, + ) + .filter((entry) => entry.name); +} diff --git a/server-node/src/modules/custom-world/runtime-profile/normalizeSceneChapter.ts b/server-node/src/modules/custom-world/runtime-profile/normalizeSceneChapter.ts new file mode 100644 index 00000000..08d1bad3 --- /dev/null +++ b/server-node/src/modules/custom-world/runtime-profile/normalizeSceneChapter.ts @@ -0,0 +1,123 @@ +import type { SceneActBlueprint, SceneChapterBlueprint } from '../runtimeTypes.js'; +import { createEntryId, toRecordArray, toStringArray, toText } from './normalizeShared.js'; + +/** + * 工作包 G: + * 分幕与场景章节 blueprint 归一独立收口,让结果预览编译层只消费稳定的章节结构。 + */ + +const SCENE_ACT_STAGES = new Set([ + 'opening', + 'expansion', + 'turning_point', + 'climax', + 'aftermath', +]); +const SCENE_ACT_ADVANCE_RULES = new Set([ + 'after_primary_contact', + 'after_active_step_complete', + 'after_chapter_resolution', +]); + +function normalizeSceneActStageCoverage(value: unknown) { + const stageCoverage = Array.isArray(value) + ? value + .filter((entry): entry is string => typeof entry === 'string') + .map((entry) => entry.trim()) + .filter((entry): entry is SceneActBlueprint['stageCoverage'][number] => + SCENE_ACT_STAGES.has(entry as never), + ) + : []; + + return [...new Set(stageCoverage)]; +} + +function normalizeSceneActBlueprint( + value: unknown, + index: number, + sceneId: string, +): SceneActBlueprint | null { + const item = + value && typeof value === 'object' + ? (value as Record) + : null; + if (!item) { + return null; + } + + const encounterNpcIds = toStringArray(item.encounterNpcIds); + const stageCoverage = normalizeSceneActStageCoverage(item.stageCoverage); + const advanceRule = toText(item.advanceRule); + const title = toText(item.title); + const summary = toText(item.summary); + + if (!title && !summary && encounterNpcIds.length === 0) { + return null; + } + + return { + id: + toText(item.id) || + createEntryId(`saved-scene-act-${sceneId}`, title || sceneId, index), + sceneId, + title: title || `第 ${index + 1} 幕`, + summary: summary || title || `围绕${sceneId}继续推进`, + stageCoverage: + stageCoverage.length > 0 + ? stageCoverage + : index === 0 + ? ['opening'] + : ['climax', 'aftermath'], + backgroundImageSrc: toText(item.backgroundImageSrc) || undefined, + backgroundAssetId: toText(item.backgroundAssetId) || undefined, + encounterNpcIds, + primaryNpcId: toText(item.primaryNpcId) || encounterNpcIds[0] || '', + linkedThreadIds: toStringArray(item.linkedThreadIds), + advanceRule: SCENE_ACT_ADVANCE_RULES.has(advanceRule as never) + ? (advanceRule as SceneActBlueprint['advanceRule']) + : 'after_active_step_complete', + actGoal: toText(item.actGoal), + transitionHook: toText(item.transitionHook), + }; +} + +export function normalizeSceneChapterBlueprints(value: unknown) { + if (!Array.isArray(value)) { + return null; + } + + const normalized = value + .filter( + (entry): entry is Record => + Boolean(entry) && typeof entry === 'object' && !Array.isArray(entry), + ) + .map((entry, index) => { + const sceneId = toText(entry.sceneId); + if (!sceneId) { + return null; + } + + const acts = Array.isArray(entry.acts) + ? entry.acts + .map((act, actIndex) => + normalizeSceneActBlueprint(act, actIndex, sceneId), + ) + .filter((act): act is SceneActBlueprint => Boolean(act)) + : []; + + return { + id: + toText(entry.id) || + createEntryId('saved-scene-chapter', sceneId, index), + sceneId, + title: toText(entry.title) || toText(entry.sceneName) || sceneId, + summary: toText(entry.summary), + linkedThreadIds: toStringArray(entry.linkedThreadIds), + linkedLandmarkIds: toStringArray(entry.linkedLandmarkIds), + acts, + } satisfies SceneChapterBlueprint; + }) + .filter((entry): entry is SceneChapterBlueprint => Boolean(entry)); + + return normalized.length > 0 ? normalized : null; +} diff --git a/server-node/src/modules/custom-world/runtime-profile/normalizeShared.ts b/server-node/src/modules/custom-world/runtime-profile/normalizeShared.ts new file mode 100644 index 00000000..9a18ef63 --- /dev/null +++ b/server-node/src/modules/custom-world/runtime-profile/normalizeShared.ts @@ -0,0 +1,248 @@ +import type { + CustomWorldCoverProfile, + CustomWorldCoverSourceType, + CustomWorldItem, + CustomWorldPlayableNpc, +} from '../runtimeTypes.js'; + +/** + * 工作包 G: + * 把 runtime profile 编译流程里的通用常量、基础文本归一和通用小型归一逻辑收口到共享模块, + * 让角色、地点、场景章节和主编译入口都不再重复维护这些基础能力。 + */ + +const MIN_CUSTOM_WORLD_AFFINITY = -40; +const MAX_CUSTOM_WORLD_AFFINITY = 90; +const CUSTOM_WORLD_RARITIES = [ + 'common', + 'uncommon', + 'rare', + 'epic', + 'legendary', +] as const; +const CUSTOM_WORLD_ROLE_ITEM_CATEGORIES = [ + '武器', + '护甲', + '饰品', + '消耗品', + '材料', + '稀有品', + '专属物品', + '专属物', +] as const; + +export const MIN_CUSTOM_WORLD_PLAYABLE_NPC_COUNT = 5; +export const MIN_CUSTOM_WORLD_TOTAL_NPC_COUNT = 30; +export const MIN_CUSTOM_WORLD_LANDMARK_COUNT = 10; +export const MIN_CUSTOM_WORLD_STORY_NPC_COUNT = Math.max( + 0, + MIN_CUSTOM_WORLD_TOTAL_NPC_COUNT - MIN_CUSTOM_WORLD_PLAYABLE_NPC_COUNT, +); + +export const PLAYABLE_TEMPLATE_CHARACTER_IDS = [ + 'sword-princess', + 'archer-hero', + 'girl-hero', + 'punch-hero', + 'fighter-4', +] as const; + +export function toText(value: unknown) { + return typeof value === 'string' ? value.trim() : ''; +} + +export function toFiniteInteger(value: unknown) { + return typeof value === 'number' && Number.isFinite(value) + ? Math.round(value) + : undefined; +} + +export function toRecord(value: unknown) { + return value && typeof value === 'object' && !Array.isArray(value) + ? (value as Record) + : null; +} + +export function toRecordArray(value: unknown) { + return Array.isArray(value) + ? (value.filter((item) => item && typeof item === 'object') as Array< + Record + >) + : []; +} + +export function toStringArray(value: unknown, nestedKey?: string) { + if (!Array.isArray(value)) { + return []; + } + + return value + .map((item) => { + if (typeof item === 'string') { + return item.trim(); + } + if (nestedKey && item && typeof item === 'object') { + return toText((item as Record)[nestedKey]); + } + return ''; + }) + .filter(Boolean); +} + +export function normalizeTags(value: unknown, fallbackTags: string[] = []) { + const tags = Array.isArray(value) + ? value.map((item) => toText(item)).filter(Boolean) + : []; + return [ + ...new Set((tags.length > 0 ? tags : fallbackTags).filter(Boolean)), + ].slice(0, 5); +} + +export function clampText(value: string, maxLength: number) { + const normalized = value.trim().replace(/\s+/g, ' '); + if (!normalized) { + return ''; + } + if (normalized.length <= maxLength) { + return normalized; + } + return `${normalized.slice(0, Math.max(0, maxLength - 1)).trim()}…`; +} + +export function slugify(value: string) { + const ascii = value + .toLowerCase() + .replace(/[^a-z0-9\u4e00-\u9fa5]+/g, '-') + .replace(/^-+|-+$/g, ''); + + return ascii ? ascii.slice(0, 24) : 'entry'; +} + +export function createEntryId(prefix: string, label: string, index: number) { + return `${prefix}-${slugify(label || `${prefix}-${index + 1}`)}-${index + 1}`; +} + +export function clampCustomWorldAffinity(value: number) { + return Math.max( + MIN_CUSTOM_WORLD_AFFINITY, + Math.min(MAX_CUSTOM_WORLD_AFFINITY, Math.round(value)), + ); +} + +export function normalizeInitialAffinity(value: unknown, fallback: number) { + return typeof value === 'number' && Number.isFinite(value) + ? clampCustomWorldAffinity(value) + : fallback; +} + +export function normalizeRarity( + value: unknown, + fallback: (typeof CUSTOM_WORLD_RARITIES)[number] = 'rare', +) { + const rarity = toText(value).toLowerCase(); + return CUSTOM_WORLD_RARITIES.includes( + rarity as (typeof CUSTOM_WORLD_RARITIES)[number], + ) + ? (rarity as (typeof CUSTOM_WORLD_RARITIES)[number]) + : fallback; +} + +export function normalizeRoleItemCategory(value: unknown, fallback = '材料') { + const category = toText(value); + if ( + (CUSTOM_WORLD_ROLE_ITEM_CATEGORIES as readonly string[]).includes(category) + ) { + return category === '专属物' ? '专属物品' : category; + } + if (/武器|刀|剑|弓|枪|炮|锤/u.test(category)) return '武器'; + if (/甲|护|盾|衣|袍/u.test(category)) return '护甲'; + if (/饰|符|佩|坠|珠|印/u.test(category)) return '饰品'; + if (/药|食|补给|瓶|卷轴/u.test(category)) return '消耗品'; + if (/材|矿|木|石|鳞|骨/u.test(category)) return '材料'; + if (/秘|钥|图|册|录|卷/u.test(category)) return '稀有品'; + if (/信物|遗物|核心|母印|真符/u.test(category)) return '专属物品'; + return fallback; +} + +export function normalizeCustomWorldCoverCharacterRoleIds( + value: unknown, + playableNpcs: Array>, +) { + const availableIds = new Set( + playableNpcs.map((entry) => entry.id.trim()).filter(Boolean), + ); + const selectedIds = Array.isArray(value) + ? [ + ...new Set( + value + .map((entry) => toText(entry)) + .filter((entry) => entry && availableIds.has(entry)), + ), + ].slice(0, 3) + : []; + + if (selectedIds.length > 0) { + return selectedIds; + } + + return playableNpcs + .map((entry) => entry.id.trim()) + .filter(Boolean) + .slice(0, 3); +} + +export function buildDefaultCustomWorldCover( + playableNpcs: Array>, +): CustomWorldCoverProfile { + return { + sourceType: 'default' as const, + imageSrc: null, + characterRoleIds: normalizeCustomWorldCoverCharacterRoleIds( + undefined, + playableNpcs, + ), + }; +} + +export function normalizeCustomWorldCover( + value: unknown, + playableNpcs: Array>, +): CustomWorldCoverProfile { + if (!value || typeof value !== 'object' || Array.isArray(value)) { + return buildDefaultCustomWorldCover(playableNpcs); + } + + const item = value as Record; + const sourceType: CustomWorldCoverSourceType = + item.sourceType === 'uploaded' || item.sourceType === 'generated' + ? item.sourceType + : 'default'; + const imageSrc = toText(item.imageSrc) || null; + + if (sourceType !== 'default' && imageSrc) { + return { + sourceType, + imageSrc, + characterRoleIds: [], + }; + } + + return buildDefaultCustomWorldCover(playableNpcs); +} + +export function normalizeItemList(value: unknown) { + return toRecordArray(value) + .map((item, index) => { + const name = toText(item.name); + const category = toText(item.category); + return { + id: toText(item.id) || createEntryId('item', name, index), + name, + category, + rarity: normalizeRarity(item.rarity, 'rare'), + description: toText(item.description), + tags: normalizeTags(item.tags), + } satisfies CustomWorldItem; + }) + .filter((entry) => entry.name && entry.category); +} diff --git a/server-node/src/modules/custom-world/runtimeProfile.ts b/server-node/src/modules/custom-world/runtimeProfile.ts index b4a706ff..f5debeb6 100644 --- a/server-node/src/modules/custom-world/runtimeProfile.ts +++ b/server-node/src/modules/custom-world/runtimeProfile.ts @@ -1,1768 +1,6 @@ -import { - buildCustomWorldAnchorPackFromIntent, - deriveCustomWorldLockStateFromIntent, - normalizeCustomWorldCreatorIntent, - normalizeCustomWorldLockState, -} from './creatorIntentRuntime.js'; -import type { - AttributeVector, - CharacterBackstoryChapter, - CharacterBackstoryRevealConfig, - CustomWorldCampScene, - CustomWorldCoverProfile, - CustomWorldCoverSourceType, - CustomWorldGenerationFramework, - CustomWorldGenerationLandmarkOutline, - CustomWorldGenerationRoleBatchType, - CustomWorldGenerationRoleOutline, - CustomWorldItem, - CustomWorldNpc, - CustomWorldPlayableNpc, - CustomWorldProfile, - CustomWorldRoleInitialItem, - CustomWorldRoleProfile, - CustomWorldRoleSkill, - RoleAttributeProfile, - WorldAttributeSchema, - WorldAttributeSlot, - WorldType, -} from './runtimeTypes.js'; - -export type { - CustomWorldGenerationFramework, - CustomWorldGenerationLandmarkOutline, - CustomWorldGenerationRoleBatchStage, - CustomWorldGenerationRoleBatchType, - CustomWorldGenerationRoleOutline, -} from './runtimeTypes.js'; - -const AFFINITY_BACKSTORY_CHAPTER_THRESHOLDS = [15, 30, 60, 90] as const; -const DEFAULT_PRIVATE_CHAT_UNLOCK_AFFINITY = 60; - -const MIN_CUSTOM_WORLD_AFFINITY = -40; -const MAX_CUSTOM_WORLD_AFFINITY = 90; -const DEFAULT_PLAYABLE_INITIAL_AFFINITY = 18; -const DEFAULT_STORY_NPC_INITIAL_AFFINITY = 6; -const DEFAULT_CUSTOM_WORLD_ROLE_SKILL_COUNT = 3; -const DEFAULT_CUSTOM_WORLD_ROLE_INITIAL_ITEM_COUNT = 3; -const CUSTOM_WORLD_BACKSTORY_CHAPTER_TITLES = [ - '表层来意', - '旧事裂痕', - '隐藏执念', - '最终底牌', -] as const; -const CUSTOM_WORLD_ROLE_ITEM_CATEGORIES = [ - '武器', - '护甲', - '饰品', - '消耗品', - '材料', - '稀有品', - '专属物品', - '专属物', -] as const; -const CUSTOM_WORLD_RARITIES = [ - 'common', - 'uncommon', - 'rare', - 'epic', - 'legendary', -] as const; -const PLAYABLE_TEMPLATE_CHARACTER_IDS = [ - 'sword-princess', - 'archer-hero', - 'girl-hero', - 'punch-hero', - 'fighter-4', -] as const; -const WORLD_ATTRIBUTE_SLOT_IDS = [ - 'axis_a', - 'axis_b', - 'axis_c', - 'axis_d', - 'axis_e', - 'axis_f', -] as const; - -export const MIN_CUSTOM_WORLD_PLAYABLE_NPC_COUNT = 5; -export const MIN_CUSTOM_WORLD_TOTAL_NPC_COUNT = 30; -export const MIN_CUSTOM_WORLD_LANDMARK_COUNT = 10; -export const MIN_CUSTOM_WORLD_STORY_NPC_COUNT = Math.max( - 0, - MIN_CUSTOM_WORLD_TOTAL_NPC_COUNT - MIN_CUSTOM_WORLD_PLAYABLE_NPC_COUNT, -); - -function toText(value: unknown) { - return typeof value === 'string' ? value.trim() : ''; -} - -function toFiniteInteger(value: unknown) { - return typeof value === 'number' && Number.isFinite(value) - ? Math.round(value) - : undefined; -} - -function toRecordArray(value: unknown) { - return Array.isArray(value) - ? (value.filter((item) => item && typeof item === 'object') as Array< - Record - >) - : []; -} - -function toStringArray(value: unknown, nestedKey?: string) { - if (!Array.isArray(value)) { - return []; - } - - return value - .map((item) => { - if (typeof item === 'string') { - return item.trim(); - } - if (nestedKey && item && typeof item === 'object') { - return toText((item as Record)[nestedKey]); - } - return ''; - }) - .filter(Boolean); -} - -function normalizeTags(value: unknown, fallbackTags: string[] = []) { - const tags = Array.isArray(value) - ? value.map((item) => toText(item)).filter(Boolean) - : []; - return [ - ...new Set((tags.length > 0 ? tags : fallbackTags).filter(Boolean)), - ].slice(0, 5); -} - -function clampText(value: string, maxLength: number) { - const normalized = value.trim().replace(/\s+/g, ' '); - if (!normalized) { - return ''; - } - if (normalized.length <= maxLength) { - return normalized; - } - return `${normalized.slice(0, Math.max(0, maxLength - 1)).trim()}…`; -} - -function slugify(value: string) { - const ascii = value - .toLowerCase() - .replace(/[^a-z0-9\u4e00-\u9fa5]+/g, '-') - .replace(/^-+|-+$/g, ''); - - return ascii ? ascii.slice(0, 24) : 'entry'; -} - -function createEntryId(prefix: string, label: string, index: number) { - return `${prefix}-${slugify(label || `${prefix}-${index + 1}`)}-${index + 1}`; -} - -function clampCustomWorldAffinity(value: number) { - return Math.max( - MIN_CUSTOM_WORLD_AFFINITY, - Math.min(MAX_CUSTOM_WORLD_AFFINITY, Math.round(value)), - ); -} - -function normalizeInitialAffinity(value: unknown, fallback: number) { - return typeof value === 'number' && Number.isFinite(value) - ? clampCustomWorldAffinity(value) - : fallback; -} - -function normalizeRarity( - value: unknown, - fallback: (typeof CUSTOM_WORLD_RARITIES)[number] = 'rare', -) { - const rarity = toText(value).toLowerCase(); - return CUSTOM_WORLD_RARITIES.includes( - rarity as (typeof CUSTOM_WORLD_RARITIES)[number], - ) - ? (rarity as (typeof CUSTOM_WORLD_RARITIES)[number]) - : fallback; -} - -function normalizeRoleItemCategory(value: unknown, fallback = '材料') { - const category = toText(value); - if ( - (CUSTOM_WORLD_ROLE_ITEM_CATEGORIES as readonly string[]).includes(category) - ) { - return category === '专属物' ? '专属物品' : category; - } - if (/武器|刀|剑|弓|枪|炮|锤/u.test(category)) return '武器'; - if (/甲|护|盾|衣|袍/u.test(category)) return '护甲'; - if (/饰|符|佩|坠|珠|印/u.test(category)) return '饰品'; - if (/药|食|补给|瓶|卷轴/u.test(category)) return '消耗品'; - if (/材|矿|木|石|鳞|骨/u.test(category)) return '材料'; - if (/秘|钥|图|册|录|卷/u.test(category)) return '稀有品'; - if (/信物|遗物|核心|母印|真符/u.test(category)) return '专属物品'; - return fallback; -} - -function splitNarrativeSentences(text: string) { - const normalized = text.replace(/\s+/g, ' ').trim(); - if (!normalized) { - return []; - } - const matches = normalized.match(/[^。!?!?]+[。!?!?]?/gu); - return (matches ?? [normalized]).map((item) => item.trim()).filter(Boolean); -} - -type CustomWorldRoleFallbackSource = Pick< - CustomWorldRoleProfile, - | 'name' - | 'title' - | 'role' - | 'description' - | 'backstory' - | 'personality' - | 'motivation' - | 'combatStyle' - | 'relationshipHooks' - | 'tags' ->; - -function buildFallbackBackstoryReveal( - source: CustomWorldRoleFallbackSource, -): CharacterBackstoryRevealConfig { - const normalizedBackstory = - source.backstory.trim() || `${source.name}对自己的过去始终有所保留。`; - const backstorySentences = splitNarrativeSentences(normalizedBackstory); - const backstoryLead = backstorySentences[0] ?? normalizedBackstory; - const backstoryDetail = - backstorySentences.slice(0, 2).join('') || normalizedBackstory; - const publicSummary = - source.description.trim() || clampText(normalizedBackstory, 42); - const fallbackContents = [ - source.description.trim() || backstoryLead, - backstoryDetail, - source.motivation.trim() - ? `${source.name}真正挂念的,是:${source.motivation.trim()}` - : `${source.name}的选择与“${clampText(backstoryLead, 24)}”直接相关。`, - source.personality.trim() - ? `${source.name}不会轻易说出的底色,是:${source.personality.trim()}` - : `${source.name}仍把最深的筹码藏在过去之中。`, - ]; - - return { - publicSummary, - privateChatUnlockAffinity: DEFAULT_PRIVATE_CHAT_UNLOCK_AFFINITY, - chapters: AFFINITY_BACKSTORY_CHAPTER_THRESHOLDS.map( - (affinityRequired, index) => - ({ - id: createEntryId( - 'backstory-chapter', - `${source.name}-${CUSTOM_WORLD_BACKSTORY_CHAPTER_TITLES[index] ?? index + 1}`, - index, - ), - title: - CUSTOM_WORLD_BACKSTORY_CHAPTER_TITLES[index] ?? - `背景片段${index + 1}`, - affinityRequired, - teaser: clampText( - fallbackContents[index] ?? normalizedBackstory, - 22, - ), - content: clampText( - fallbackContents[index] ?? normalizedBackstory, - 72, - ), - contextSnippet: clampText( - `${source.name}的背景正在向“${fallbackContents[index] ?? normalizedBackstory}”这条线索展开。`, - 48, - ), - }) satisfies CharacterBackstoryChapter, - ), - }; -} - -function normalizeBackstoryReveal( - value: unknown, - fallbackSource: CustomWorldRoleFallbackSource, -) { - const fallback = buildFallbackBackstoryReveal(fallbackSource); - if (!value || typeof value !== 'object') { - return fallback; - } - - const item = value as Record; - const rawChapters = toRecordArray(item.chapters); - - return { - publicSummary: toText(item.publicSummary) || fallback.publicSummary, - privateChatUnlockAffinity: - typeof item.privateChatUnlockAffinity === 'number' && - Number.isFinite(item.privateChatUnlockAffinity) - ? clampCustomWorldAffinity(item.privateChatUnlockAffinity) - : fallback.privateChatUnlockAffinity, - chapters: AFFINITY_BACKSTORY_CHAPTER_THRESHOLDS.map( - (defaultAffinity, index) => { - const fallbackChapter = fallback.chapters[index]; - const rawChapter = rawChapters[index]; - return { - id: - (rawChapter && toText(rawChapter.id)) || - fallbackChapter?.id || - `backstory-chapter-${index + 1}`, - title: - (rawChapter && toText(rawChapter.title)) || - fallbackChapter?.title || - `背景片段${index + 1}`, - affinityRequired: - fallbackChapter?.affinityRequired ?? defaultAffinity, - teaser: - (rawChapter && toText(rawChapter.teaser)) || - fallbackChapter?.teaser || - '', - content: - (rawChapter && toText(rawChapter.content)) || - fallbackChapter?.content || - '', - contextSnippet: - (rawChapter && toText(rawChapter.contextSnippet)) || - fallbackChapter?.contextSnippet || - '', - } satisfies CharacterBackstoryChapter; - }, - ), - } satisfies CharacterBackstoryRevealConfig; -} - -function buildFallbackRoleSkills(source: CustomWorldRoleFallbackSource) { - const skillNameSeed = source.title || source.role || source.name || '角色'; - const skillSummarySeed = - source.combatStyle || source.description || `${source.name}善于把握局势。`; - const motivationSeed = - source.motivation || source.personality || source.backstory; - - return [ - { - id: createEntryId('role-skill', `${skillNameSeed}-起手`, 0), - name: `${skillNameSeed}起手`, - summary: clampText(skillSummarySeed, 36), - style: '起手压制', - }, - { - id: createEntryId('role-skill', `${skillNameSeed}-机动`, 1), - name: `${skillNameSeed}变招`, - summary: clampText( - source.personality || `${source.name}习惯在试探中寻找破绽。`, - 36, - ), - style: '机动周旋', - }, - { - id: createEntryId('role-skill', `${skillNameSeed}-底牌`, 2), - name: `${skillNameSeed}底牌`, - summary: clampText( - motivationSeed || `${source.name}会在关键时刻亮出压箱手段。`, - 36, - ), - style: '爆发终结', - }, - ] satisfies CustomWorldRoleSkill[]; -} - -function normalizeRoleSkillList( - value: unknown, - fallbackSource: CustomWorldRoleFallbackSource, -) { - const normalized = toRecordArray(value) - .map((item, index) => { - const name = toText(item.name); - const summary = toText(item.summary) || toText(item.description); - const style = toText(item.style) || toText(item.category) || '常用'; - - return { - id: createEntryId('role-skill', name || style, index), - name, - summary, - style, - } satisfies CustomWorldRoleSkill; - }) - .filter((entry) => entry.name) - .slice(0, DEFAULT_CUSTOM_WORLD_ROLE_SKILL_COUNT); - - return normalized.length > 0 - ? normalized - : buildFallbackRoleSkills(fallbackSource); -} - -function buildFallbackRoleInitialItems(source: CustomWorldRoleFallbackSource) { - const itemNameSeed = source.title || source.role || source.name || '角色'; - return [ - { - id: createEntryId('role-item', `${itemNameSeed}-1`, 0), - name: `${itemNameSeed}常备武具`, - category: '武器', - quantity: 1, - rarity: 'rare', - description: clampText( - source.combatStyle || `${source.name}随身携带的主要作战物件。`, - 36, - ), - tags: normalizeTags(source.tags, ['战斗', '随身']), - }, - { - id: createEntryId('role-item', `${itemNameSeed}-2`, 1), - name: `${itemNameSeed}补给包`, - category: '消耗品', - quantity: 2, - rarity: 'uncommon', - description: clampText( - source.personality || `${source.name}为了长期行动准备的基础补给。`, - 36, - ), - tags: normalizeTags(source.relationshipHooks, ['补给', '行动']), - }, - { - id: createEntryId('role-item', `${itemNameSeed}-3`, 2), - name: `${itemNameSeed}私人物件`, - category: '专属物品', - quantity: 1, - rarity: 'rare', - description: clampText( - source.backstory || - source.motivation || - `${source.name}不愿随意交出的信物。`, - 36, - ), - tags: normalizeTags( - [...source.tags, ...source.relationshipHooks], - ['信物', '线索'], - ), - }, - ] satisfies CustomWorldRoleInitialItem[]; -} - -function normalizeRoleInitialItemList( - value: unknown, - fallbackSource: CustomWorldRoleFallbackSource, -) { - const normalized = toRecordArray(value) - .map((item, index) => { - const name = toText(item.name); - return { - id: createEntryId('role-item', name, index), - name, - category: normalizeRoleItemCategory(item.category), - quantity: - typeof item.quantity === 'number' && Number.isFinite(item.quantity) - ? Math.max(1, Math.min(99, Math.round(item.quantity))) - : 1, - rarity: normalizeRarity(item.rarity, 'rare'), - description: toText(item.description), - tags: normalizeTags(item.tags), - } satisfies CustomWorldRoleInitialItem; - }) - .filter((entry) => entry.name) - .slice(0, DEFAULT_CUSTOM_WORLD_ROLE_INITIAL_ITEM_COUNT); - - return normalized.length > 0 - ? normalized - : buildFallbackRoleInitialItems(fallbackSource); -} - -function inferWorldTypeFromSetting(settingText: string): WorldType { - return /[仙灵修真飞升灵脉宗门法器天宫秘境道骨星舰]/u.test(settingText) - ? 'XIANXIA' - : 'WUXIA'; -} - -function normalizeWorldType(value: unknown, sourceText: string): WorldType { - const worldType = toText(value).toUpperCase(); - if (worldType === 'WUXIA' || worldType === 'XIANXIA') { - return worldType; - } - return inferWorldTypeFromSetting(sourceText); -} - -function buildSeedPhrase(settingText: string, fallback: string) { - const compact = settingText.replace(/\s+/g, '').trim(); - return compact ? compact.slice(0, 10) : fallback; -} - -function buildWorldName(settingText: string, worldType: WorldType) { - const seed = buildSeedPhrase(settingText, '新旅'); - const suffix = worldType === 'XIANXIA' ? '境' : '域'; - return `${seed}${suffix}`; -} - -function detectCustomWorldThemeMode(profile: { - settingText: string; - summary: string; - tone: string; - playerGoal: string; -}) { - const source = [ - profile.settingText, - profile.summary, - profile.tone, - profile.playerGoal, - ].join(' '); - - if (/[机关蒸汽齿轮工坊机巧城轨炮舰]/u.test(source)) return 'machina'; - if (/[海潮港湾船舟湖泊澜雾湾]/u.test(source)) return 'tide'; - if (/[裂缝裂界边境前线断层界桥灰域]/u.test(source)) return 'rift'; - if (/[修真仙灵宗门法器道脉秘境云阙]/u.test(source)) return 'arcane'; - if (/[江湖门派镖局朝廷刀剑侠客旧案]/u.test(source)) return 'martial'; - return 'mythic'; -} - -function sanitizeCampSeed(name: string) { - const normalized = name.trim().replace(/\s+/g, ''); - if (!normalized) { - return ''; - } - - const stripped = normalized.replace( - /(世界|江湖|边城|仙洲|仙境|灵境|界|录|域|境)$/u, - '', - ); - const seed = stripped || normalized; - - return seed.slice(0, Math.min(seed.length, 4)); -} - -function buildFallbackCampName(profile: { - name: string; - summary: string; - tone: string; - playerGoal: string; - settingText: string; -}) { - const seed = sanitizeCampSeed(profile.name) || '归途'; - const themeMode = detectCustomWorldThemeMode(profile); - - const suffixByMode = { - mythic: '归舍', - martial: '归舍', - arcane: '栖居', - machina: '整备居', - tide: '潮居', - rift: '界隙居所', - } as const; - - return `${seed}${suffixByMode[themeMode]}`; -} - -function buildFallbackCustomWorldCampScene(profile: { - name: string; - summary: string; - tone: string; - playerGoal: string; - settingText: string; -}): CustomWorldCampScene { - const fallbackName = buildFallbackCampName(profile); - const summaryLead = clampText(profile.summary, 24) || '前路尚未明朗'; - const goalLead = clampText(profile.playerGoal, 24) || '继续整理线索'; - const themeMode = detectCustomWorldThemeMode(profile); - - const descriptionByMode = { - mythic: `${fallbackName}是你在${profile.name}暂时安顿的归处,能整备行装、交换判断,并围绕“${goalLead}”继续启程。`, - martial: `${fallbackName}是你在${profile.name}暂时落脚的归处,能整备行装、收拢同伴,并朝“${goalLead}”继续动身。`, - arcane: `${fallbackName}是你在${profile.name}暂时栖身的居所,适合调息、整理法器,并围绕“${summaryLead}”交换判断。`, - machina: `${fallbackName}是你在${profile.name}的临时居所,能检修装备、补齐物资,并为下一段行动重新校准节奏。`, - tide: `${fallbackName}是你在${profile.name}靠潮歇息的住处,能安顿队伍、整理补给,并顺着“${goalLead}”继续前探。`, - rift: `${fallbackName}是你在${profile.name}勉强守住的一处居所,既能短暂停步,也能围绕“${summaryLead}”商量下一步去向。`, - } as const; - - return { - id: 'custom-scene-camp', - name: fallbackName, - description: descriptionByMode[themeMode], - dangerLevel: 'low', - sceneNpcIds: [], - connections: [], - narrativeResidues: null, - }; -} - -function buildTemplateWorldAttributeSchema(worldType: Exclude) { - const common = { - schemaVersion: 1, - generatedFrom: - worldType === 'XIANXIA' - ? { - worldType: 'XIANXIA' as const, - worldName: '仙侠', - settingSummary: '灵潮、宗门、禁制、秘境与道途交织。', - tone: '空灵、危险、带着灾变与大道压迫。', - conflictCore: '在裂变与因果之间稳住自我与道途。', - } - : { - worldType: 'WUXIA' as const, - worldName: '武侠', - settingSummary: '江湖、门派、旧案与人情纠葛并存。', - tone: '克制、紧张、讲究局势与心气。', - conflictCore: '在人情、威压与旧案之间立住自身。', - }, - }; - - if (worldType === 'XIANXIA') { - return { - id: 'schema:xianxia:v1', - worldId: 'XIANXIA', - schemaName: '灵界六轴', - ...common, - slots: [ - { - slotId: 'axis_a', - name: '道骨', - definition: '承载道压与高强度冲击的底子。', - positiveSignals: ['承压', '根基稳', '扛得住'], - negativeSignals: ['根基浅', '易溃', '承载不足'], - combatUseText: '扛住灵压、正面承受高强度对撞。', - socialUseText: '让人感到根基扎实,值得托付重事。', - explorationUseText: '承受秘境、禁制与裂隙带来的压迫。', - }, - { - slotId: 'axis_b', - name: '灵行', - definition: '位移、御空、转场、抢占天时地利的能力。', - positiveSignals: ['位移', '御空', '机动'], - negativeSignals: ['迟滞', '失位', '转场慢'], - combatUseText: '抢位、御空、快速重整战场位置。', - socialUseText: '反应轻快,擅长顺势接住局面的变化。', - explorationUseText: '穿越高危地形、裂隙、云海与复杂禁区。', - }, - { - slotId: 'axis_c', - name: '识海', - definition: '解析禁制、洞察因果、识破虚实的能力。', - positiveSignals: ['洞察', '解构', '看破'], - negativeSignals: ['迷失', '误判', '看不清'], - combatUseText: '识破术理、找出因果节点与破绽。', - socialUseText: '更容易辨认真话、虚言与隐藏动机。', - explorationUseText: '解读阵纹、禁制、旧史与环境异象。', - }, - { - slotId: 'axis_d', - name: '劫纹', - definition: '在高危变化中强行推进、改写局势的能力。', - positiveSignals: ['强推', '决断', '逆转'], - negativeSignals: ['畏缩', '迟疑', '不敢碰变局'], - combatUseText: '在高压窗口里压上去,逼出变化与突破。', - socialUseText: '在关键谈判中拍板,推动他人表态。', - explorationUseText: '面对异变与风险时敢于推进关键节点。', - }, - { - slotId: 'axis_e', - name: '心契', - definition: '与他者、器物、灵兽、誓约建立共鸣的能力。', - positiveSignals: ['共鸣', '结契', '安抚'], - negativeSignals: ['隔阂', '生硬', '难以共振'], - combatUseText: '与器物、灵兽、同伴形成协同与共鸣。', - socialUseText: '建立信任、誓约与更深层的关系连结。', - explorationUseText: '借由共鸣打开封印、回应遗物或安抚异兽。', - }, - { - slotId: 'axis_f', - name: '玄息', - definition: '循环灵息、稳住心神、让自身持续在线的能力。', - positiveSignals: ['稳态', '回转', '续航'], - negativeSignals: ['紊乱', '枯竭', '失衡'], - combatUseText: '维持灵息循环、拖住长线压力与消耗。', - socialUseText: '气息沉稳,不轻易乱阵脚或露出破绽。', - explorationUseText: '在漫长探索与灵潮侵蚀中维持可行动状态。', - }, - ] satisfies WorldAttributeSlot[], - } satisfies WorldAttributeSchema; - } - - return { - id: 'schema:wuxia:v1', - worldId: 'WUXIA', - schemaVersion: 1, - schemaName: '江湖六脉', - generatedFrom: common.generatedFrom, - slots: [ - { - slotId: 'axis_a', - name: '骨势', - definition: '扛压、顶冲、硬吃风险也不退的势头。', - positiveSignals: ['扛压', '硬桥硬马', '稳住正面'], - negativeSignals: ['虚浮', '怯退', '一碰就散'], - combatUseText: '顶住正面压力、换伤不退、撑住阵线。', - socialUseText: '在强压场面里不露怯,给人可靠或强硬之感。', - explorationUseText: '穿越险路、硬顶机关、承受高压环境。', - }, - { - slotId: 'axis_b', - name: '身法', - definition: '腾挪、抢位、换线、把握出手节奏的能力。', - positiveSignals: ['快', '轻灵', '抢位'], - negativeSignals: ['迟缓', '失位', '笨重'], - combatUseText: '切线换位、闪转腾挪、争夺先手。', - socialUseText: '应变快,擅长观察气口并顺势接话。', - explorationUseText: '攀越、潜入、追踪与复杂地形穿行。', - }, - { - slotId: 'axis_c', - name: '眼脉', - definition: '看破破绽、拆招、识局、看穿人心的能力。', - positiveSignals: ['识局', '洞察', '拆招'], - negativeSignals: ['迟钝', '误判', '看不透'], - combatUseText: '抓破绽、拆套路、找出最该切入的位置。', - socialUseText: '判断弦外之音、试探真假、识别来意。', - explorationUseText: '识破机关、辨认痕迹、看懂异状。', - }, - { - slotId: 'axis_d', - name: '心焰', - definition: '决断、压迫、胆气、在局面中立住自身意志的能力。', - positiveSignals: ['胆气', '决断', '压迫'], - negativeSignals: ['犹疑', '软弱', '易被动摇'], - combatUseText: '逼迫对手、强行推进、在关键时刻拍板。', - socialUseText: '立威、定调、在谈判里压住场子。', - explorationUseText: '在未知风险前保持决断,不被局势拖死。', - }, - { - slotId: 'axis_e', - name: '尘缘', - definition: '与人事、情面、承诺、牵引关系打交道的能力。', - positiveSignals: ['通人情', '会安抚', '懂交换'], - negativeSignals: ['生硬', '失礼', '不近人情'], - combatUseText: '借势协同、读懂同伴与对手的关系脉络。', - socialUseText: '安抚、求助、结盟、维系承诺与信任。', - explorationUseText: '从传闻、人脉和地方关系里打开线索。', - }, - { - slotId: 'axis_f', - name: '玄息', - definition: '调息、稳态、久战、把自身维持在可用状态的能力。', - positiveSignals: ['稳', '续战', '调息'], - negativeSignals: ['紊乱', '易崩', '续不上'], - combatUseText: '续战、回气、稳住节奏与状态。', - socialUseText: '遇事不乱,语气和姿态都更沉稳可信。', - explorationUseText: '长线跋涉、在恶劣环境下维持专注与状态。', - }, - ] satisfies WorldAttributeSlot[], - } satisfies WorldAttributeSchema; -} - -function generateWorldAttributeSchema(input: { - worldName: string; - settingText: string; - summary: string; - tone: string; - playerGoal: string; -}) { - const inferredWorldType = inferWorldTypeFromSetting(input.settingText); - const template = buildTemplateWorldAttributeSchema( - inferredWorldType === 'XIANXIA' ? 'XIANXIA' : 'WUXIA', - ); - - return { - ...template, - id: `schema:custom:${slugify(input.worldName)}`, - worldId: `custom:${input.worldName}`, - generatedFrom: { - worldType: 'CUSTOM', - worldName: input.worldName, - settingSummary: input.summary, - tone: input.tone, - conflictCore: input.playerGoal, - }, - } satisfies WorldAttributeSchema; -} - -function normalizeAttributeValues( - values: AttributeVector, - slotIds: readonly string[], - targetTotal = 360, -) { - const positiveValues = slotIds.map((slotId) => Math.max(0, values[slotId] ?? 0)); - const rawTotal = positiveValues.reduce((sum, value) => sum + value, 0); - const normalized = - rawTotal > 0 - ? positiveValues.map((value) => (value / rawTotal) * targetTotal) - : slotIds.map(() => targetTotal / Math.max(slotIds.length, 1)); - const rounded = normalized.map((value) => Math.max(0, Math.min(100, Math.round(value)))); - return Object.fromEntries( - slotIds.map((slotId, index) => [slotId, rounded[index] ?? 0]), - ) as AttributeVector; -} - -function ensureRoleAttributeProfile( - profile: Partial | null | undefined, - schema: WorldAttributeSchema, - fallbackValues: AttributeVector, -): RoleAttributeProfile { - const slotIds = schema.slots.map((slot) => slot.slotId); - const values = normalizeAttributeValues( - { - ...fallbackValues, - ...(profile?.values ?? {}), - }, - slotIds, - ); - const sortedSlots = [...schema.slots] - .map((slot) => ({ - slot, - value: values[slot.slotId] ?? 0, - })) - .sort((left, right) => right.value - left.value); - - return { - schemaId: profile?.schemaId ?? schema.id, - values, - topTraits: sortedSlots.slice(0, 2).map((entry) => entry.slot.name), - hiddenTraits: profile?.hiddenTraits ? [...profile.hiddenTraits] : undefined, - evidence: - profile?.evidence?.length - ? [...profile.evidence] - : sortedSlots.slice(0, 3).map((entry) => ({ - slotId: entry.slot.slotId, - reason: `${entry.slot.name}在当前画像中最突出。`, - })), - }; -} - -const AXIS_KEYWORD_RULES: Array<{ - slotId: string; - patterns: RegExp[]; - weight: number; -}> = [ - { slotId: 'axis_a', patterns: [/骨|甲|壳|岩|重|守|镇|顶|硬|锋|体/u], weight: 16 }, - { slotId: 'axis_b', patterns: [/身法|迅|影|风|闪|游|机动|追|步|轻灵/u], weight: 16 }, - { slotId: 'axis_c', patterns: [/识|眼|谋|算|阵|符|术|察|局|禁制/u], weight: 16 }, - { slotId: 'axis_d', patterns: [/心|焰|胆|威|压|怒|决|破|强推|意志/u], weight: 16 }, - { slotId: 'axis_e', patterns: [/缘|契|情|盟|商|医|助|信|交|誓/u], weight: 16 }, - { slotId: 'axis_f', patterns: [/息|稳|续|守|调|回|养|持久|回复|韧/u], weight: 16 }, -]; - -function buildDefaultAxisVector( - overrides: Partial>, -) { - return WORLD_ATTRIBUTE_SLOT_IDS.reduce((result, slotId) => { - result[slotId] = overrides[slotId] ?? 0; - return result; - }, {}); -} - -function buildRoleAttributeProfileFromTexts(params: { - entityId: string; - schema: WorldAttributeSchema; - textBlocks: Array; -}) { - const sourceText = params.textBlocks.filter(Boolean).join(' '); - const seed = buildDefaultAxisVector({ - axis_a: 58, - axis_b: 58, - axis_c: 58, - axis_d: 58, - axis_e: 58, - axis_f: 58, - }); - - AXIS_KEYWORD_RULES.forEach((rule) => { - const matches = rule.patterns.reduce( - (count, pattern) => count + (pattern.test(sourceText) ? 1 : 0), - 0, - ); - if (matches <= 0) { - return; - } - seed[rule.slotId] = (seed[rule.slotId] ?? 0) + rule.weight * matches; - }); - - return ensureRoleAttributeProfile( - { - schemaId: params.schema.id, - }, - params.schema, - seed, - ); -} - -function buildCustomWorldPlayableNpcAttributeProfile( - npc: CustomWorldPlayableNpc, - schema: WorldAttributeSchema, -) { - return buildRoleAttributeProfileFromTexts({ - entityId: npc.id, - schema, - textBlocks: [ - npc.title, - npc.role, - npc.description, - npc.backstory, - npc.personality, - npc.motivation, - npc.combatStyle, - ...(npc.relationshipHooks ?? []), - ...(npc.tags ?? []), - ], - }); -} - -function buildCustomWorldStoryNpcAttributeProfile( - npc: CustomWorldNpc, - schema: WorldAttributeSchema, -) { - return buildRoleAttributeProfileFromTexts({ - entityId: npc.id, - schema, - textBlocks: [ - npc.title, - npc.role, - npc.description, - npc.backstory, - npc.personality, - npc.motivation, - npc.combatStyle, - ...(npc.relationshipHooks ?? []), - ...(npc.tags ?? []), - ], - }); -} - -function buildBaseCustomWorldProfile(settingText: string): CustomWorldProfile { - const templateWorldType = inferWorldTypeFromSetting(settingText); - const name = buildWorldName(settingText, templateWorldType); - const subtitle = '前路未明'; - const summary = settingText.trim() - ? `这个世界围绕“${settingText.trim().slice(0, 28)}”展开。` - : '一个仍待展开的独立世界正在成形。'; - const tone = '未知、紧绷、仍在展开'; - const playerGoal = '查清眼前局势的关键矛盾,并守住仍值得相信的人与事'; - const camp = buildFallbackCustomWorldCampScene({ - name, - summary, - tone, - playerGoal, - settingText: settingText.trim(), - }); - - return { - id: `custom-world-${Date.now().toString(36)}-${slugify(name)}`, - settingText: settingText.trim(), - name, - subtitle, - summary, - tone, - playerGoal, - cover: buildDefaultCustomWorldCover([]), - templateWorldType, - compatibilityTemplateWorldType: templateWorldType, - majorFactions: [], - coreConflicts: [summary], - attributeSchema: generateWorldAttributeSchema({ - worldName: name, - settingText: settingText.trim(), - summary, - tone, - playerGoal, - }), - playableNpcs: [], - storyNpcs: [], - items: [], - camp, - landmarks: [], - themePack: null, - storyGraph: null, - creatorIntent: null, - anchorPack: null, - lockState: normalizeCustomWorldLockState(null), - generationMode: 'full', - generationStatus: 'complete', - ownedSettingLayers: null, - scenarioPackId: null, - campaignPackId: null, - }; -} - -function normalizeRoleOutlineList( - value: unknown, - options: { - titleFallback: string; - defaultAffinity: number; - maxCount?: number; - }, -) { - const normalized = toRecordArray(value) - .map((item) => { - const name = toText(item.name); - const title = - toText(item.title) || toText(item.role) || options.titleFallback; - const role = toText(item.role) || title; - const relationshipHooks = normalizeTags( - item.relationshipHooks, - normalizeTags(item.tags), - ); - - return { - name, - title, - role, - description: - toText(item.description) || - clampText(`${name || title}在世界中以${role}身份活动。`, 36), - visualDescription: toText(item.visualDescription) || undefined, - actionDescription: toText(item.actionDescription) || undefined, - sceneVisualDescription: toText(item.sceneVisualDescription) || undefined, - initialAffinity: normalizeInitialAffinity( - item.initialAffinity, - options.defaultAffinity, - ), - relationshipHooks, - tags: normalizeTags(item.tags, relationshipHooks), - } satisfies CustomWorldGenerationRoleOutline; - }) - .filter((entry) => entry.name); - - return typeof options.maxCount === 'number' - ? normalized.slice(0, options.maxCount) - : normalized; -} - -function normalizeCampOutline( - value: unknown, - fallbackProfile: { - name: string; - summary: string; - tone: string; - playerGoal: string; - settingText: string; - }, -) { - const fallback = buildFallbackCustomWorldCampScene(fallbackProfile); - const item = - value && typeof value === 'object' - ? (value as Record) - : {}; - - return { - id: toText(item.id) || fallback.id, - name: toText(item.name) || fallback.name, - description: toText(item.description) || fallback.description, - visualDescription: toText(item.visualDescription) || undefined, - dangerLevel: toText(item.dangerLevel) || fallback.dangerLevel, - imageSrc: toText(item.imageSrc) || undefined, - sceneNpcIds: toStringArray(item.sceneNpcIds), - connections: toRecordArray(item.connections) - .map((connection) => ({ - targetLandmarkName: - toText(connection.targetLandmarkName) || - toText(connection.target) || - toText(connection.sceneName), - relativePosition: - toText(connection.relativePosition) || - toText(connection.position) || - 'forward', - summary: - toText(connection.summary) || toText(connection.description), - })) - .filter((connection) => connection.targetLandmarkName), - }; -} - -function normalizeLandmarkOutlineList(value: unknown) { - return toRecordArray(value) - .map((item) => { - const name = toText(item.name); - return { - name, - description: - toText(item.description) || - clampText(`${name}暗藏新的局势变化。`, 40), - visualDescription: toText(item.visualDescription) || undefined, - dangerLevel: toText(item.dangerLevel) || 'medium', - sceneNpcNames: [ - ...toStringArray(item.sceneNpcNames), - ...toStringArray(item.npcs, 'name'), - ...toStringArray(item.sceneNpcs, 'name'), - ...toStringArray(item.npcNames), - ], - connections: toRecordArray(item.connections) - .map((connection) => ({ - targetLandmarkName: - toText(connection.targetLandmarkName) || - toText(connection.target) || - toText(connection.sceneName), - relativePosition: - toText(connection.relativePosition) || - toText(connection.position) || - 'forward', - summary: - toText(connection.summary) || toText(connection.description), - })) - .filter((connection) => connection.targetLandmarkName), - } satisfies CustomWorldGenerationLandmarkOutline; - }) - .filter((entry) => entry.name) - .slice(0, MIN_CUSTOM_WORLD_LANDMARK_COUNT); -} - -export function normalizeCustomWorldGenerationRoleOutlineBatch( - raw: unknown, - roleType: CustomWorldGenerationRoleBatchType, -) { - const item = - raw && typeof raw === 'object' ? (raw as Record) : {}; - const key = roleType === 'playable' ? 'playableNpcs' : 'storyNpcs'; - - return normalizeRoleOutlineList(item[key], { - titleFallback: '未定称号', - defaultAffinity: - roleType === 'playable' - ? DEFAULT_PLAYABLE_INITIAL_AFFINITY - : DEFAULT_STORY_NPC_INITIAL_AFFINITY, - }); -} - -export function normalizeCustomWorldGenerationLandmarkOutlineBatch( - raw: unknown, -) { - const item = - raw && typeof raw === 'object' ? (raw as Record) : {}; - return normalizeLandmarkOutlineList(item.landmarks); -} - -export function normalizeCustomWorldGenerationFramework( - raw: unknown, - settingText: string, -): CustomWorldGenerationFramework { - const fallback = buildBaseCustomWorldProfile(settingText); - if (!raw || typeof raw !== 'object') { - return { - settingText: fallback.settingText, - name: fallback.name, - subtitle: fallback.subtitle, - summary: fallback.summary, - tone: fallback.tone, - playerGoal: fallback.playerGoal, - templateWorldType: fallback.templateWorldType, - compatibilityTemplateWorldType: - fallback.compatibilityTemplateWorldType ?? fallback.templateWorldType, - majorFactions: [], - coreConflicts: [fallback.summary], - camp: { - name: fallback.camp?.name ?? '归舍', - description: fallback.camp?.description ?? '', - dangerLevel: fallback.camp?.dangerLevel ?? 'low', - }, - playableNpcs: [], - storyNpcs: [], - landmarks: [], - }; - } - - const item = raw as Record; - const worldSignalText = [ - settingText, - toText(item.subtitle), - toText(item.summary), - toText(item.tone), - toText(item.playerGoal), - ].join(' '); - const templateWorldType = normalizeWorldType( - item.templateWorldType, - worldSignalText, - ); - const name = - toText(item.name) || buildWorldName(settingText, templateWorldType); - - return { - settingText: settingText.trim(), - name, - subtitle: toText(item.subtitle) || fallback.subtitle, - summary: toText(item.summary) || fallback.summary, - tone: toText(item.tone) || fallback.tone, - playerGoal: toText(item.playerGoal) || fallback.playerGoal, - templateWorldType, - compatibilityTemplateWorldType: templateWorldType, - majorFactions: normalizeTags(item.majorFactions, []), - coreConflicts: normalizeTags(item.coreConflicts, [fallback.summary]), - camp: normalizeCampOutline(item.camp, { - name, - summary: toText(item.summary) || fallback.summary, - tone: toText(item.tone) || fallback.tone, - playerGoal: toText(item.playerGoal) || fallback.playerGoal, - settingText: settingText.trim(), - }), - playableNpcs: normalizeRoleOutlineList(item.playableNpcs, { - titleFallback: '未定称号', - defaultAffinity: DEFAULT_PLAYABLE_INITIAL_AFFINITY, - maxCount: MIN_CUSTOM_WORLD_PLAYABLE_NPC_COUNT, - }), - storyNpcs: normalizeRoleOutlineList(item.storyNpcs, { - titleFallback: '未定称号', - defaultAffinity: DEFAULT_STORY_NPC_INITIAL_AFFINITY, - maxCount: MIN_CUSTOM_WORLD_STORY_NPC_COUNT, - }), - landmarks: normalizeLandmarkOutlineList(item.landmarks), - }; -} - -export function buildCustomWorldRawProfileFromFramework( - framework: CustomWorldGenerationFramework, -) { - return { - name: framework.name, - subtitle: framework.subtitle, - summary: framework.summary, - tone: framework.tone, - playerGoal: framework.playerGoal, - templateWorldType: framework.templateWorldType, - compatibilityTemplateWorldType: framework.compatibilityTemplateWorldType, - majorFactions: framework.majorFactions, - coreConflicts: framework.coreConflicts, - camp: { - name: framework.camp.name, - description: framework.camp.description, - dangerLevel: framework.camp.dangerLevel, - }, - playableNpcs: framework.playableNpcs.map((npc) => ({ - name: npc.name, - title: npc.title, - role: npc.role, - description: npc.description, - visualDescription: npc.visualDescription, - actionDescription: npc.actionDescription, - sceneVisualDescription: npc.sceneVisualDescription, - initialAffinity: npc.initialAffinity, - relationshipHooks: [...npc.relationshipHooks], - tags: [...npc.tags], - })), - storyNpcs: framework.storyNpcs.map((npc) => ({ - name: npc.name, - title: npc.title, - role: npc.role, - description: npc.description, - visualDescription: npc.visualDescription, - actionDescription: npc.actionDescription, - sceneVisualDescription: npc.sceneVisualDescription, - initialAffinity: npc.initialAffinity, - relationshipHooks: [...npc.relationshipHooks], - tags: [...npc.tags], - })), - landmarks: framework.landmarks.map((landmark) => ({ - name: landmark.name, - description: landmark.description, - visualDescription: landmark.visualDescription, - dangerLevel: landmark.dangerLevel, - sceneNpcNames: [...landmark.sceneNpcNames], - connections: landmark.connections.map((connection) => ({ - targetLandmarkName: connection.targetLandmarkName, - relativePosition: connection.relativePosition, - summary: connection.summary, - })), - })), - }; -} - -function normalizeRoleProfile( - item: Record, - index: number, - options: { - idPrefix: 'playable-npc' | 'story-npc'; - titleFallback: string; - defaultAffinity: number; - }, -) { - const name = toText(item.name); - const title = - toText(item.title) || toText(item.role) || options.titleFallback; - const role = toText(item.role) || title; - const relationshipHooks = normalizeTags( - item.relationshipHooks, - normalizeTags(item.tags), - ); - const normalizedRole = { - id: toText(item.id) || createEntryId(options.idPrefix, name, index), - name, - title, - role, - description: toText(item.description), - visualDescription: toText(item.visualDescription) || undefined, - actionDescription: toText(item.actionDescription) || undefined, - sceneVisualDescription: toText(item.sceneVisualDescription) || undefined, - backstory: toText(item.backstory), - personality: toText(item.personality), - motivation: toText(item.motivation) || toText(item.description), - combatStyle: toText(item.combatStyle), - initialAffinity: normalizeInitialAffinity( - item.initialAffinity, - options.defaultAffinity, - ), - relationshipHooks, - tags: normalizeTags(item.tags, relationshipHooks), - }; - - return { - ...normalizedRole, - backstoryReveal: normalizeBackstoryReveal( - item.backstoryReveal, - normalizedRole, - ), - skills: normalizeRoleSkillList(item.skills, normalizedRole), - initialItems: normalizeRoleInitialItemList(item.initialItems, normalizedRole), - imageSrc: toText(item.imageSrc) || undefined, - generatedVisualAssetId: toText(item.generatedVisualAssetId) || undefined, - generatedAnimationSetId: - toText(item.generatedAnimationSetId) || undefined, - animationMap: - item.animationMap && typeof item.animationMap === 'object' - ? (item.animationMap as Record) - : undefined, - narrativeProfile: - item.narrativeProfile && typeof item.narrativeProfile === 'object' - ? (item.narrativeProfile as CustomWorldRoleProfile['narrativeProfile']) - : null, - }; -} - -function normalizePlayableNpcList(value: unknown) { - return toRecordArray(value) - .map((item, index) => ({ - ...normalizeRoleProfile(item, index, { - idPrefix: 'playable-npc', - titleFallback: '未定称号', - defaultAffinity: DEFAULT_PLAYABLE_INITIAL_AFFINITY, - }), - templateCharacterId: toText(item.templateCharacterId) || undefined, - })) - .filter((entry) => entry.name) - .slice(0, MIN_CUSTOM_WORLD_PLAYABLE_NPC_COUNT); -} - -function normalizeStoryNpcList(value: unknown) { - return toRecordArray(value) - .map( - (item, index) => - ({ - ...normalizeRoleProfile(item, index, { - idPrefix: 'story-npc', - titleFallback: '未定称号', - defaultAffinity: DEFAULT_STORY_NPC_INITIAL_AFFINITY, - }), - visual: - item.visual && typeof item.visual === 'object' - ? (item.visual as Record) - : undefined, - }) satisfies CustomWorldNpc, - ) - .filter((entry) => entry.name); -} - -function normalizeCustomWorldCoverCharacterRoleIds( - value: unknown, - playableNpcs: Array>, -) { - const availableIds = new Set( - playableNpcs.map((entry) => entry.id.trim()).filter(Boolean), - ); - const selectedIds = Array.isArray(value) - ? [ - ...new Set( - value - .map((entry) => toText(entry)) - .filter((entry) => entry && availableIds.has(entry)), - ), - ].slice(0, 3) - : []; - - if (selectedIds.length > 0) { - return selectedIds; - } - - return playableNpcs - .map((entry) => entry.id.trim()) - .filter(Boolean) - .slice(0, 3); -} - -function buildDefaultCustomWorldCover( - playableNpcs: Array>, -): CustomWorldCoverProfile { - return { - sourceType: 'default' as const, - imageSrc: null, - characterRoleIds: normalizeCustomWorldCoverCharacterRoleIds( - undefined, - playableNpcs, - ), - }; -} - -function normalizeCustomWorldCover( - value: unknown, - playableNpcs: Array>, -): CustomWorldCoverProfile { - if (!value || typeof value !== 'object' || Array.isArray(value)) { - return buildDefaultCustomWorldCover(playableNpcs); - } - - const item = value as Record; - const sourceType: CustomWorldCoverSourceType = - item.sourceType === 'uploaded' || item.sourceType === 'generated' - ? item.sourceType - : 'default'; - const imageSrc = toText(item.imageSrc) || null; - - if (sourceType !== 'default' && imageSrc) { - return { - sourceType, - imageSrc, - characterRoleIds: [], - }; - } - - return buildDefaultCustomWorldCover(playableNpcs); -} - -function normalizeItemList(value: unknown) { - return toRecordArray(value) - .map((item, index) => { - const name = toText(item.name); - const category = toText(item.category); - return { - id: toText(item.id) || createEntryId('item', name, index), - name, - category, - rarity: normalizeRarity(item.rarity, 'rare'), - description: toText(item.description), - tags: normalizeTags(item.tags), - } satisfies CustomWorldItem; - }) - .filter((entry) => entry.name && entry.category); -} - -function normalizeLandmarks(params: { - landmarks: Array>; - storyNpcs: CustomWorldNpc[]; -}) { - const storyNpcIdByName = new Map( - params.storyNpcs.map((npc) => [npc.name.trim(), npc.id] as const), - ); - const landmarkEntries = params.landmarks - .map((item, index) => ({ - id: toText(item.id) || createEntryId('landmark', toText(item.name), index), - name: toText(item.name), - description: toText(item.description), - visualDescription: toText(item.visualDescription) || undefined, - dangerLevel: toText(item.dangerLevel) || 'medium', - imageSrc: toText(item.imageSrc) || undefined, - sceneNpcIds: toStringArray(item.sceneNpcIds), - sceneNpcNames: [ - ...toStringArray(item.sceneNpcNames), - ...toStringArray(item.npcs, 'name'), - ...toStringArray(item.sceneNpcs, 'name'), - ...toStringArray(item.npcNames), - ], - connections: toRecordArray(item.connections).map((connection) => ({ - targetLandmarkId: toText(connection.targetLandmarkId), - targetLandmarkName: - toText(connection.targetLandmarkName) || - toText(connection.target) || - toText(connection.sceneName), - relativePosition: - toText(connection.relativePosition) || toText(connection.position), - summary: toText(connection.summary) || toText(connection.description), - })), - })) - .filter((entry) => entry.name); - - const landmarkIdByName = new Map( - landmarkEntries.map((landmark) => [landmark.name.trim(), landmark.id] as const), - ); - - return landmarkEntries.map((landmark) => { - const resolvedSceneNpcIds = [ - ...new Set( - [ - ...landmark.sceneNpcIds, - ...landmark.sceneNpcNames - .map((name) => storyNpcIdByName.get(name.trim()) ?? '') - .filter(Boolean), - ].filter(Boolean), - ), - ]; - - return { - id: landmark.id, - name: landmark.name, - description: landmark.description, - visualDescription: landmark.visualDescription, - dangerLevel: landmark.dangerLevel, - imageSrc: landmark.imageSrc, - sceneNpcIds: resolvedSceneNpcIds, - connections: landmark.connections - .map((connection) => ({ - targetLandmarkId: - connection.targetLandmarkId || - landmarkIdByName.get(connection.targetLandmarkName.trim()) || - '', - relativePosition: connection.relativePosition || 'forward', - summary: connection.summary, - })) - .filter((connection) => connection.targetLandmarkId), - }; - }); -} - -function normalizeCampScene( - value: unknown, - fallbackProfile: { - name: string; - summary: string; - tone: string; - playerGoal: string; - settingText: string; - }, -): CustomWorldCampScene { - const fallback = buildFallbackCustomWorldCampScene(fallbackProfile); - const item = - value && typeof value === 'object' - ? (value as Record) - : {}; - - return { - id: toText(item.id) || fallback.id, - name: toText(item.name) || fallback.name, - description: toText(item.description) || fallback.description, - visualDescription: toText(item.visualDescription) || undefined, - dangerLevel: toText(item.dangerLevel) || fallback.dangerLevel, - imageSrc: toText(item.imageSrc) || undefined, - sceneNpcIds: toStringArray(item.sceneNpcIds), - connections: toRecordArray(item.connections) - .map((connection) => ({ - targetLandmarkId: toText(connection.targetLandmarkId), - relativePosition: - toText(connection.relativePosition) || toText(connection.position) || 'forward', - summary: toText(connection.summary) || toText(connection.description), - })) - .filter((connection) => connection.targetLandmarkId), - narrativeResidues: null, - }; -} - -export function normalizeCustomWorldProfile( - raw: unknown, - settingText: string, -): CustomWorldProfile { - const fallback = buildBaseCustomWorldProfile(settingText); - if (!raw || typeof raw !== 'object') { - return fallback; - } - - const item = raw as Record; - const worldSignalText = [ - settingText, - toText(item.subtitle), - toText(item.summary), - toText(item.tone), - toText(item.playerGoal), - ].join(' '); - const templateWorldType = normalizeWorldType( - item.templateWorldType, - worldSignalText, - ); - const name = - toText(item.name) || buildWorldName(settingText, templateWorldType); - const summary = toText(item.summary) || fallback.summary; - const tone = toText(item.tone) || fallback.tone; - const playerGoal = toText(item.playerGoal) || fallback.playerGoal; - const generatedAttributeSchema = generateWorldAttributeSchema({ - worldName: name, - settingText: settingText.trim(), - summary, - tone, - playerGoal, - }); - const playableNpcs = normalizePlayableNpcList(item.playableNpcs); - const storyNpcs = normalizeStoryNpcList(item.storyNpcs); - const landmarkDrafts = toRecordArray(item.landmarks); - const camp = normalizeCampScene(item.camp, { - name, - summary, - tone, - playerGoal, - settingText: settingText.trim(), - }); - const creatorIntent = normalizeCustomWorldCreatorIntent(item.creatorIntent); - - return { - id: - toText(item.id) || `custom-world-${Date.now().toString(36)}-${slugify(name)}`, - settingText: settingText.trim(), - name, - subtitle: toText(item.subtitle) || fallback.subtitle, - summary, - tone, - playerGoal, - cover: normalizeCustomWorldCover(item.cover, playableNpcs), - templateWorldType, - compatibilityTemplateWorldType: templateWorldType, - majorFactions: normalizeTags(item.majorFactions, []), - coreConflicts: normalizeTags(item.coreConflicts, [summary]), - attributeSchema: - item.attributeSchema && typeof item.attributeSchema === 'object' - ? generatedAttributeSchema - : generatedAttributeSchema, - playableNpcs, - storyNpcs, - items: normalizeItemList(item.items), - camp, - landmarks: normalizeLandmarks({ - landmarks: landmarkDrafts, - storyNpcs, - }), - themePack: - item.themePack && typeof item.themePack === 'object' - ? (item.themePack as CustomWorldProfile['themePack']) - : null, - storyGraph: - item.storyGraph && typeof item.storyGraph === 'object' - ? (item.storyGraph as CustomWorldProfile['storyGraph']) - : null, - anchorContent: - item.anchorContent && typeof item.anchorContent === 'object' - ? (item.anchorContent as Record) - : null, - creatorIntent, - anchorPack: - item.anchorPack && typeof item.anchorPack === 'object' - ? (item.anchorPack as CustomWorldProfile['anchorPack']) - : buildCustomWorldAnchorPackFromIntent(creatorIntent), - lockState: - item.lockState && typeof item.lockState === 'object' - ? normalizeCustomWorldLockState(item.lockState) - : deriveCustomWorldLockStateFromIntent(creatorIntent), - generationMode: - item.generationMode === 'fast' || item.generationMode === 'full' - ? item.generationMode - : fallback.generationMode, - generationStatus: - item.generationStatus === 'key_only' || item.generationStatus === 'complete' - ? item.generationStatus - : fallback.generationStatus, - ownedSettingLayers: - item.ownedSettingLayers && typeof item.ownedSettingLayers === 'object' - ? (item.ownedSettingLayers as Record) - : null, - knowledgeFacts: - Array.isArray(item.knowledgeFacts) - ? (item.knowledgeFacts as Array>) - : null, - threadContracts: - Array.isArray(item.threadContracts) - ? (item.threadContracts as Array>) - : null, - scenarioPackId: toText(item.scenarioPackId) || null, - campaignPackId: toText(item.campaignPackId) || null, - }; -} - -function pickCyclic(items: readonly T[], index: number, label: string): T { - const item = items[index % items.length]; - if (item === undefined) { - throw new Error(`Missing ${label}`); - } - return item; -} - -export function buildCompiledCustomWorldProfile( - raw: unknown, - settingText: string, -): CustomWorldProfile { - const profile = normalizeCustomWorldProfile(raw, settingText); - const playableNpcs = profile.playableNpcs.map((npc, index) => { - const templateCharacterId = - npc.templateCharacterId ?? - pickCyclic( - PLAYABLE_TEMPLATE_CHARACTER_IDS, - index, - 'playable template character id', - ); - - return { - ...npc, - templateCharacterId, - attributeProfile: - npc.attributeProfile ?? - buildCustomWorldPlayableNpcAttributeProfile( - { - ...npc, - templateCharacterId, - }, - profile.attributeSchema, - ), - }; - }); - - const storyNpcs = profile.storyNpcs.map((npc) => ({ - ...npc, - attributeProfile: - npc.attributeProfile ?? - buildCustomWorldStoryNpcAttributeProfile(npc, profile.attributeSchema), - })); - - return { - ...profile, - playableNpcs, - storyNpcs, - scenarioPackId: - profile.scenarioPackId ?? `scenario-pack:${slugify(profile.name)}`, - campaignPackId: - profile.campaignPackId ?? `campaign-pack:${slugify(profile.name)}`, - }; -} - -function countUniqueNames(items: Array<{ name: string }>) { - return new Set(items.map((item) => item.name.trim()).filter(Boolean)).size; -} - -export function validateGeneratedCustomWorldProfile( - profile: CustomWorldProfile, -) { - const playableCount = countUniqueNames(profile.playableNpcs); - const landmarkCount = countUniqueNames(profile.landmarks); - - if (playableCount < MIN_CUSTOM_WORLD_PLAYABLE_NPC_COUNT) { - throw new Error( - `自定义世界生成要求模型至少产出 ${MIN_CUSTOM_WORLD_PLAYABLE_NPC_COUNT} 名可扮演角色。`, - ); - } - - if (landmarkCount < MIN_CUSTOM_WORLD_LANDMARK_COUNT) { - throw new Error( - `自定义世界生成要求至少产出 ${MIN_CUSTOM_WORLD_LANDMARK_COUNT} 个场景,当前仅返回 ${landmarkCount} 个。`, - ); - } - - const validStoryNpcIds = new Set(profile.storyNpcs.map((npc) => npc.id)); - const validLandmarkIds = new Set( - profile.landmarks.map((landmark) => landmark.id), - ); - - profile.landmarks.forEach((landmark) => { - const uniqueSceneNpcIds = [...new Set(landmark.sceneNpcIds)]; - if (uniqueSceneNpcIds.length < 3) { - throw new Error( - `场景「${landmark.name}」至少需要关联 3 个场景角色,当前仅有 ${uniqueSceneNpcIds.length} 个。`, - ); - } - if (uniqueSceneNpcIds.some((npcId) => !validStoryNpcIds.has(npcId))) { - throw new Error(`场景「${landmark.name}」引用了不存在的场景角色。`); - } - if (landmark.connections.length === 0) { - throw new Error(`场景「${landmark.name}」缺少可用的场景连接关系。`); - } - if ( - landmark.connections.some( - (connection) => - connection.targetLandmarkId === landmark.id || - !validLandmarkIds.has(connection.targetLandmarkId), - ) - ) { - throw new Error(`场景「${landmark.name}」存在无效的目标场景连接。`); - } - }); -} +/** + * 兼容期 façade: + * 旧调用暂时继续从 runtimeProfile.ts 导入,避免在工作包 G 首轮落地时放大迁移范围。 + * 新代码应逐步改走 runtime-profile/ 目录入口。 + */ +export * from './runtime-profile/index.js'; diff --git a/server-node/src/modules/inventory/index.ts b/server-node/src/modules/inventory/index.ts deleted file mode 100644 index 4db0479b..00000000 --- a/server-node/src/modules/inventory/index.ts +++ /dev/null @@ -1 +0,0 @@ -export * from './inventoryMutationService.js'; diff --git a/server-node/src/modules/inventory/inventoryStoryActionService.ts b/server-node/src/modules/inventory/inventoryStoryActionService.ts index 39670c2f..9e86d417 100644 --- a/server-node/src/modules/inventory/inventoryStoryActionService.ts +++ b/server-node/src/modules/inventory/inventoryStoryActionService.ts @@ -1,7 +1,7 @@ import type { RuntimeStoryActionRequest, RuntimeStoryPatch, -} from '../../../../packages/shared/src/contracts/story.js'; +} from '../../../../packages/shared/src/contracts/rpgRuntimeStoryState.js'; import { conflict, invalidRequest } from '../../errors.js'; import { getPlayerBuildDamageBreakdown, @@ -19,8 +19,8 @@ import { } from './inventoryMutationService.js'; import { replaceRuntimeSessionRawGameState, - type RuntimeSession, -} from '../story/runtimeSession.js'; +} from '../rpg-runtime-story/RpgRuntimeSnapshotSync.js'; +import type { RuntimeSession } from '../rpg-runtime-story/RpgRuntimeSessionLoader.js'; const SUPPORTED_INVENTORY_STORY_FUNCTION_IDS = new Set([ 'equipment_equip', diff --git a/server-node/src/modules/inventory/npcInventoryStoryActionService.ts b/server-node/src/modules/inventory/npcInventoryStoryActionService.ts index 86f87d18..0b093731 100644 --- a/server-node/src/modules/inventory/npcInventoryStoryActionService.ts +++ b/server-node/src/modules/inventory/npcInventoryStoryActionService.ts @@ -1,7 +1,7 @@ import type { RuntimeStoryActionRequest, RuntimeStoryPatch, -} from '../../../../packages/shared/src/contracts/story.js'; +} from '../../../../packages/shared/src/contracts/rpgRuntimeStoryState.js'; import { conflict, invalidRequest } from '../../errors.js'; import { addInventoryItems, @@ -23,8 +23,8 @@ import { } from '../../bridges/legacyNpcTask6Bridge.js'; import { replaceRuntimeSessionRawGameState, - type RuntimeSession, -} from '../story/runtimeSession.js'; +} from '../rpg-runtime-story/RpgRuntimeSnapshotSync.js'; +import type { RuntimeSession } from '../rpg-runtime-story/RpgRuntimeSessionLoader.js'; const SUPPORTED_NPC_INVENTORY_STORY_FUNCTION_IDS = new Set([ 'npc_gift', diff --git a/server-node/src/modules/npc/npcInteractionService.ts b/server-node/src/modules/npc/npcInteractionService.ts index 71ae0cf7..3e1acbca 100644 --- a/server-node/src/modules/npc/npcInteractionService.ts +++ b/server-node/src/modules/npc/npcInteractionService.ts @@ -1,6 +1,10 @@ -import type { RuntimeStoryPatch } from '../../../../packages/shared/src/contracts/story.js'; +import type { RuntimeStoryPatch } from '../../../../packages/shared/src/contracts/rpgRuntimeStoryState.js'; import { conflict } from '../../errors.js'; import { resolveHostileBattleProfile } from '../progression/hostileProgressionService.js'; +import { + applyStoryChoiceToStanceProfile, +} from './npcTask6Primitives.js'; +import { markNpcFirstMeaningfulContactResolved } from '../runtime/runtimeNpcStatePrimitives.js'; import { MAX_TASK5_COMPANIONS, getEncounterNpcState, @@ -8,7 +12,7 @@ import { type RuntimeEncounter, type RuntimeNpcState, type RuntimeSession, -} from '../story/runtimeSession.js'; +} from '../rpg-runtime-story/RpgRuntimeSessionPrimitives.js'; type JsonRecord = Record; @@ -57,6 +61,158 @@ function isRecord(value: unknown): value is JsonRecord { return typeof value === 'object' && value !== null && !Array.isArray(value); } +function readString(value: unknown) { + return typeof value === 'string' && value.trim() ? value.trim() : ''; +} + +function buildRecruitedCompanion( + session: RuntimeSession, + encounter: RuntimeEncounter, + npcState: RuntimeNpcState, +) { + const rawCompanionSource = isRecord(session.rawGameState.currentEncounter) + ? session.rawGameState.currentEncounter + : {}; + const maxHp = Math.max( + 1, + Math.round( + typeof rawCompanionSource.maxHp === 'number' && + Number.isFinite(rawCompanionSource.maxHp) + ? rawCompanionSource.maxHp + : 180, + ), + ); + const maxMana = Math.max( + 1, + Math.round( + typeof rawCompanionSource.maxMana === 'number' && + Number.isFinite(rawCompanionSource.maxMana) + ? rawCompanionSource.maxMana + : 999, + ), + ); + const skillCooldowns = Object.fromEntries( + Object.entries( + isRecord(rawCompanionSource.skillCooldowns) + ? rawCompanionSource.skillCooldowns + : {}, + ).map(([skillId, turns]) => [ + skillId, + typeof turns === 'number' && Number.isFinite(turns) + ? Math.max(0, Math.round(turns)) + : 0, + ]), + ); + + return { + npcId: encounter.id, + characterId: encounter.characterId ?? '', + joinedAtAffinity: npcState.affinity, + hp: maxHp, + maxHp, + mana: maxMana, + maxMana, + skillCooldowns, + animationState: readString(rawCompanionSource.animationState) || 'idle', + actionMode: readString(rawCompanionSource.actionMode) || 'idle', + offsetX: + typeof rawCompanionSource.offsetX === 'number' && + Number.isFinite(rawCompanionSource.offsetX) + ? rawCompanionSource.offsetX + : 0, + offsetY: + typeof rawCompanionSource.offsetY === 'number' && + Number.isFinite(rawCompanionSource.offsetY) + ? rawCompanionSource.offsetY + : 0, + transitionMs: + typeof rawCompanionSource.transitionMs === 'number' && + Number.isFinite(rawCompanionSource.transitionMs) + ? Math.max(0, Math.round(rawCompanionSource.transitionMs)) + : 0, + }; +} + +function upsertCompanion( + list: RuntimeSession['companions'], + companion: RuntimeSession['companions'][number], +) { + const next = [...list]; + const existingIndex = next.findIndex((item) => item.npcId === companion.npcId); + if (existingIndex >= 0) { + next[existingIndex] = companion; + return next; + } + + next.push(companion); + return next; +} + +function removeCompanion( + list: RuntimeSession['companions'], + npcId: string, +) { + return list.filter((item) => item.npcId !== npcId); +} + +function normalizeRoster( + roster: RuntimeSession['roster'], + activeCompanions: RuntimeSession['companions'], +) { + const activeIds = new Set(activeCompanions.map((companion) => companion.npcId)); + return roster.filter((companion) => !activeIds.has(companion.npcId)); +} + +function recruitCompanionToParty(params: { + session: RuntimeSession; + companion: RuntimeSession['companions'][number]; + releaseNpcId?: string | null; +}) { + const nextRosterWithoutRecruit = removeCompanion( + params.session.roster, + params.companion.npcId, + ); + + if ( + !params.releaseNpcId && + params.session.companions.length < MAX_TASK5_COMPANIONS + ) { + return { + companions: [...params.session.companions, params.companion], + roster: nextRosterWithoutRecruit, + releasedCompanion: null, + }; + } + + if (!params.releaseNpcId) { + throw conflict('队伍已满时必须明确指定一名离队同伴'); + } + + const replaceIndex = params.session.companions.findIndex( + (item) => item.npcId === params.releaseNpcId, + ); + if (replaceIndex < 0) { + throw conflict('指定的离队同伴不存在,无法完成换队招募'); + } + + const releasedCompanion = params.session.companions[replaceIndex]; + if (!releasedCompanion) { + throw conflict('指定的离队同伴不存在,无法完成换队招募'); + } + + const nextCompanions = [...params.session.companions]; + nextCompanions[replaceIndex] = params.companion; + + return { + companions: nextCompanions, + roster: normalizeRoster( + upsertCompanion(nextRosterWithoutRecruit, releasedCompanion), + nextCompanions, + ), + releasedCompanion, + }; +} + function buildBattleTarget( encounter: RuntimeEncounter, rawGameState: JsonRecord, @@ -92,6 +248,7 @@ function buildBattleTarget( export function resolveNpcInteraction( session: RuntimeSession, functionId: string, + payload?: JsonRecord, ): NpcInteractionResolution { const encounter = requireNpcEncounter(session); const npcState = requireNpcState(session, encounter); @@ -179,20 +336,29 @@ export function resolveNpcInteraction( if (npcState.affinity < 60) { throw conflict('当前关系还没达到招募阈值,暂时不能邀请入队'); } - if (session.companions.length >= MAX_TASK5_COMPANIONS) { - throw conflict('队伍已满,任务5首轮后端接口暂不处理换队逻辑'); - } - - setEncounterNpcState(session, { - ...npcState, + const releaseNpcId = readString(payload?.releaseNpcId) || null; + const recruitedCompanion = buildRecruitedCompanion( + session, + encounter, + npcState, + ); + const recruitmentResult = recruitCompanionToParty({ + session, + companion: recruitedCompanion, + releaseNpcId, + }); + const nextNpcState = { + ...markNpcFirstMeaningfulContactResolved(npcState), recruited: true, - firstMeaningfulContactResolved: true, - }); - session.companions.push({ - npcId: encounter.id, - characterId: encounter.characterId ?? '', - joinedAtAffinity: npcState.affinity, - }); + stanceProfile: applyStoryChoiceToStanceProfile( + npcState.stanceProfile, + 'npc_recruit', + { recruited: true }, + ), + }; + setEncounterNpcState(session, nextNpcState); + session.companions = recruitmentResult.companions; + session.roster = recruitmentResult.roster; session.currentEncounter = null; session.npcInteractionActive = false; session.currentNpcBattleMode = null; @@ -202,7 +368,9 @@ export function resolveNpcInteraction( return { actionText: `邀请${encounter.npcName}加入队伍`, - resultText: `${encounter.npcName}接受了你的邀请,正式进入了同行队伍。`, + resultText: recruitmentResult.releasedCompanion + ? `${encounter.npcName}接受了你的邀请,你先让一名当前同行暂时离队,把位置腾给了新的同行者。` + : `${encounter.npcName}接受了你的邀请,正式进入了同行队伍。`, patches: [ { type: 'status_changed', diff --git a/server-node/src/modules/quest/index.ts b/server-node/src/modules/quest/index.ts deleted file mode 100644 index b06b5a3f..00000000 --- a/server-node/src/modules/quest/index.ts +++ /dev/null @@ -1,2 +0,0 @@ -export * from './questProgressionService.js'; -export { generateQuestForNpcEncounter } from '../../services/questService.js'; diff --git a/server-node/src/modules/quest/questRuntimeSignalService.ts b/server-node/src/modules/quest/questRuntimeSignalService.ts index 7fb86601..2ebec2c9 100644 --- a/server-node/src/modules/quest/questRuntimeSignalService.ts +++ b/server-node/src/modules/quest/questRuntimeSignalService.ts @@ -1,12 +1,12 @@ -import type { RuntimeBattlePresentation } from '../../../../packages/shared/src/contracts/story.js'; +import type { RuntimeBattlePresentation } from '../../../../packages/shared/src/contracts/rpgRuntimeStoryState.js'; import { applyQuestSignal, normalizeQuestEntries, } from './questProgressionService.js'; import { replaceRuntimeSessionRawGameState, - type RuntimeSession, -} from '../story/runtimeSession.js'; +} from '../rpg-runtime-story/RpgRuntimeSnapshotSync.js'; +import type { RuntimeSession } from '../rpg-runtime-story/RpgRuntimeSessionLoader.js'; type JsonRecord = Record; type RuntimeGameState = { diff --git a/server-node/src/modules/quest/questStoryActionService.ts b/server-node/src/modules/quest/questStoryActionService.ts index 42f35395..10098622 100644 --- a/server-node/src/modules/quest/questStoryActionService.ts +++ b/server-node/src/modules/quest/questStoryActionService.ts @@ -1,7 +1,8 @@ import type { + RuntimeStoryOptionView, RuntimeStoryActionRequest, RuntimeStoryPatch, -} from '../../../../packages/shared/src/contracts/story.js'; +} from '../../../../packages/shared/src/contracts/rpgRuntimeStoryState.js'; import { buildExperienceGrantResultText, grantPlayerExperience, @@ -25,10 +26,13 @@ import { } from './questTask6Bridge.js'; import { replaceRuntimeSessionRawGameState, - type RuntimeSession, -} from '../story/runtimeSession.js'; +} from '../rpg-runtime-story/RpgRuntimeSnapshotSync.js'; +import type { RuntimeSession } from '../rpg-runtime-story/RpgRuntimeSessionLoader.js'; const SUPPORTED_QUEST_STORY_FUNCTION_IDS = new Set([ + 'npc_chat_quest_offer_abandon', + 'npc_chat_quest_offer_replace', + 'npc_chat_quest_offer_view', 'npc_quest_accept', 'npc_quest_turn_in', ]); @@ -37,6 +41,9 @@ type QuestStoryResolution = { actionText: string; resultText: string; patches: RuntimeStoryPatch[]; + storyText?: string; + presentationOptions?: RuntimeStoryOptionView[]; + savedCurrentStory?: JsonRecord; }; type JsonRecord = Record; @@ -140,6 +147,144 @@ function readPendingQuestOffer( return quest as RuntimeQuestLogEntry; } +function readPendingQuestOfferContext( + currentStory: unknown, + npcKey: string, +) { + if (!isObject(currentStory)) { + return null; + } + + const npcChatState = isObject(currentStory.npcChatState) + ? currentStory.npcChatState + : null; + const pendingQuestOffer = isObject(npcChatState?.pendingQuestOffer) + ? npcChatState.pendingQuestOffer + : null; + const quest = readPendingQuestOffer(currentStory, npcKey); + + if (!quest) { + return null; + } + + const dialogue = Array.isArray(currentStory.dialogue) + ? currentStory.dialogue + .filter((entry) => isObject(entry)) + .map((entry) => ({ ...entry })) + : []; + const turnCount = + typeof npcChatState?.turnCount === 'number' && + Number.isFinite(npcChatState.turnCount) + ? Math.max(0, Math.round(npcChatState.turnCount)) + : 0; + const customInputPlaceholder = + readString(npcChatState?.customInputPlaceholder) || '输入你想对 TA 说的话'; + + return { + dialogue, + turnCount, + customInputPlaceholder, + quest, + introText: readString(pendingQuestOffer?.introText), + }; +} + +function buildNpcChatOption( + encounter: RuntimeEncounter, + actionText: string, +) { + return { + functionId: 'npc_chat', + actionText, + text: actionText, + detailText: '', + interaction: { + kind: 'npc', + npcId: encounter.id ?? encounter.npcName, + action: 'chat', + }, + visuals: { + playerAnimation: 'idle', + playerMoveMeters: 0, + playerOffsetY: 0, + playerFacing: 'right', + scrollWorld: false, + monsterChanges: [], + }, + } satisfies JsonRecord; +} + +function buildPendingQuestOfferOptions(encounter: RuntimeEncounter) { + const npcId = encounter.id ?? encounter.npcName; + const buildOption = ( + functionId: + | 'npc_chat_quest_offer_view' + | 'npc_chat_quest_offer_replace' + | 'npc_chat_quest_offer_abandon', + actionText: string, + action: 'quest_offer_view' | 'quest_offer_replace' | 'quest_offer_abandon', + ) => + ({ + functionId, + actionText, + text: actionText, + detailText: '', + interaction: { + kind: 'npc', + npcId, + action, + }, + visuals: { + playerAnimation: 'idle', + playerMoveMeters: 0, + playerOffsetY: 0, + playerFacing: 'right', + scrollWorld: false, + monsterChanges: [], + }, + runtimePayload: + functionId === 'npc_chat_quest_offer_view' + ? { npcChatQuestOfferAction: 'view' } + : functionId === 'npc_chat_quest_offer_replace' + ? { npcChatQuestOfferAction: 'replace' } + : { npcChatQuestOfferAction: 'abandon' }, + }) satisfies JsonRecord; + + return [ + buildOption('npc_chat_quest_offer_view', '查看任务', 'quest_offer_view'), + buildOption( + 'npc_chat_quest_offer_replace', + '更换任务', + 'quest_offer_replace', + ), + buildOption( + 'npc_chat_quest_offer_abandon', + '放弃任务', + 'quest_offer_abandon', + ), + ]; +} + +function buildPostQuestOfferChatOptions(encounter: RuntimeEncounter) { + return [ + '那先继续聊聊你刚才没说完的部分', + '除了委托,你对眼前局势还有什么判断', + '先把这附近真正危险的地方说清楚', + ].map((actionText) => buildNpcChatOption(encounter, actionText)); +} + +function buildQuestOfferDialogueText( + encounter: RuntimeEncounter, + quest: RuntimeQuestLogEntry, +) { + const summaryText = readString(quest.summary) || readString(quest.description); + return `${encounter.npcName}沉吟了片刻,像是终于把真正想托付的事说了出来。${ + summaryText + ? `如果你愿意,我想把这件事正式交给你:${summaryText}` + : '如果你愿意,我想把眼前这件事正式交给你。' + }`; +} + function ensureEncounterQuestContext(session: RuntimeSession) { const state = session.rawGameState as unknown as RuntimeGameState; const encounter = getNpcEncounter(session, state); @@ -225,6 +370,171 @@ function resolveQuestAcceptAction( }; } +function resolveQuestOfferViewAction( + session: RuntimeSession, + currentStory?: unknown, +): QuestStoryResolution { + const { encounter, npcKey } = ensureEncounterQuestContext(session); + const pendingOffer = readPendingQuestOfferContext(currentStory, npcKey); + if (!pendingOffer) { + throw conflict('当前没有待处理的委托可查看。'); + } + + return { + actionText: `查看${encounter.npcName}提出的委托`, + resultText: readString(pendingOffer.introText) || buildQuestOfferDialogueText(encounter, pendingOffer.quest), + patches: [], + }; +} + +function resolveQuestOfferReplaceAction( + session: RuntimeSession, + currentStory?: unknown, +): QuestStoryResolution { + const { state, encounter, npcKey } = ensureEncounterQuestContext(session); + const pendingOffer = readPendingQuestOfferContext(currentStory, npcKey); + if (!pendingOffer) { + throw conflict('当前没有待处理的委托可更换。'); + } + + const nextQuest = buildQuestForEncounter({ + issuerNpcId: npcKey, + issuerNpcName: encounter.npcName, + roleText: encounter.context, + scene: state.currentScenePreset, + worldType: state.worldType, + context: { + worldType: state.worldType, + recentStoryMoments: Array.isArray(state.storyHistory) + ? state.storyHistory.slice(-6) + : [], + playerCharacter: state.playerCharacter ?? null, + playerProgression: state.playerProgression ?? null, + }, + currentQuests: (Array.isArray(state.quests) ? state.quests : []).map((item) => ({ + id: item.id, + issuerNpcId: item.issuerNpcId, + status: item.status, + })), + }); + + if (!nextQuest) { + throw conflict('当前没有更合适的委托可供更换。'); + } + + const dialogue = [ + ...pendingOffer.dialogue, + { + speaker: 'player', + text: '能不能换一份更适合眼下局势的委托?', + }, + { + speaker: 'npc', + speakerName: encounter.npcName, + text: buildQuestOfferDialogueText(encounter, nextQuest), + }, + ]; + + return { + actionText: `请${encounter.npcName}更换委托`, + resultText: buildQuestOfferDialogueText(encounter, nextQuest), + storyText: buildQuestOfferDialogueText(encounter, nextQuest), + savedCurrentStory: { + text: dialogue + .map((entry) => readString(entry.text)) + .filter(Boolean) + .join('\n'), + options: buildPendingQuestOfferOptions(encounter), + displayMode: 'dialogue', + dialogue, + streaming: false, + npcChatState: { + npcId: npcKey, + npcName: encounter.npcName, + turnCount: pendingOffer.turnCount, + customInputPlaceholder: pendingOffer.customInputPlaceholder, + pendingQuestOffer: { + quest: nextQuest, + }, + }, + }, + presentationOptions: buildPendingQuestOfferOptions(encounter).map((option) => ({ + functionId: readString(option.functionId), + actionText: readString(option.actionText), + detailText: '', + scope: 'npc', + interaction: isObject(option.interaction) + ? (option.interaction as RuntimeStoryOptionView['interaction']) + : undefined, + payload: isObject(option.runtimePayload) + ? (option.runtimePayload as Record) + : undefined, + })), + patches: [], + }; +} + +function resolveQuestOfferAbandonAction( + session: RuntimeSession, + currentStory?: unknown, +): QuestStoryResolution { + const { encounter, npcKey } = ensureEncounterQuestContext(session); + const pendingOffer = readPendingQuestOfferContext(currentStory, npcKey); + if (!pendingOffer) { + throw conflict('当前没有待处理的委托可放弃。'); + } + + const npcReply = `${encounter.npcName}点了点头,没有继续强求,只把这份委托暂时收了回去。`; + const dialogue = [ + ...pendingOffer.dialogue, + { + speaker: 'player', + text: '这件事我先不接,咱们还是先聊别的。', + }, + { + speaker: 'npc', + speakerName: encounter.npcName, + text: npcReply, + }, + ]; + + return { + actionText: `暂不接受${encounter.npcName}的委托`, + resultText: npcReply, + storyText: npcReply, + savedCurrentStory: { + text: dialogue + .map((entry) => readString(entry.text)) + .filter(Boolean) + .join('\n'), + options: buildPostQuestOfferChatOptions(encounter), + displayMode: 'dialogue', + dialogue, + streaming: false, + npcChatState: { + npcId: npcKey, + npcName: encounter.npcName, + turnCount: pendingOffer.turnCount, + customInputPlaceholder: pendingOffer.customInputPlaceholder, + pendingQuestOffer: null, + }, + }, + presentationOptions: buildPostQuestOfferChatOptions(encounter).map((option) => ({ + functionId: readString(option.functionId), + actionText: readString(option.actionText), + detailText: '', + scope: 'npc', + interaction: isObject(option.interaction) + ? (option.interaction as RuntimeStoryOptionView['interaction']) + : undefined, + payload: isObject(option.runtimePayload) + ? (option.runtimePayload as Record) + : undefined, + })), + patches: [], + }; +} + function resolveQuestTurnInAction( session: RuntimeSession, request: RuntimeStoryActionRequest, @@ -311,6 +621,12 @@ export function resolveQuestStoryAction( } = {}, ): QuestStoryResolution { switch (request.action.functionId) { + case 'npc_chat_quest_offer_view': + return resolveQuestOfferViewAction(session, options.currentStory); + case 'npc_chat_quest_offer_replace': + return resolveQuestOfferReplaceAction(session, options.currentStory); + case 'npc_chat_quest_offer_abandon': + return resolveQuestOfferAbandonAction(session, options.currentStory); case 'npc_quest_accept': return resolveQuestAcceptAction(session, options.currentStory); case 'npc_quest_turn_in': diff --git a/server-node/src/modules/quest/runtimeQuestModule.ts b/server-node/src/modules/quest/runtimeQuestModule.ts index 66a97e2f..2d38377b 100644 --- a/server-node/src/modules/quest/runtimeQuestModule.ts +++ b/server-node/src/modules/quest/runtimeQuestModule.ts @@ -4,7 +4,7 @@ import { QUEST_OBJECTIVE_KINDS, QUEST_REWARD_THEMES, QUEST_URGENCY_LEVELS, -} from '../../../../packages/shared/src/contracts/story.js'; +} from '../../../../packages/shared/src/contracts/rpgRuntimeQuestAssist.js'; import { buildQuestIntentPrompt, QUEST_INTENT_SYSTEM_PROMPT, diff --git a/server-node/src/modules/rpg-runtime-story/RpgRuntimeOptionCompiler.ts b/server-node/src/modules/rpg-runtime-story/RpgRuntimeOptionCompiler.ts new file mode 100644 index 00000000..a327b94c --- /dev/null +++ b/server-node/src/modules/rpg-runtime-story/RpgRuntimeOptionCompiler.ts @@ -0,0 +1,14 @@ +import { + buildAvailableOptions, + buildLegacyCurrentStory, + buildRuntimeViewModel, +} from './RpgRuntimeSessionDomain.js'; + +/** + * RPG runtime option / view model 编译入口。 + * 工作包 G 后所有可见 option 与 view model 都从新域目录输出。 + */ +export { buildAvailableOptions, buildRuntimeViewModel }; +export const buildRpgRuntimeAvailableOptions = buildAvailableOptions; +export const buildRpgRuntimeViewModel = buildRuntimeViewModel; +export const buildRpgRuntimeLegacyCurrentStory = buildLegacyCurrentStory; diff --git a/server-node/src/modules/story/runtimeSession.test.ts b/server-node/src/modules/rpg-runtime-story/RpgRuntimeSessionDomain.test.ts similarity index 94% rename from server-node/src/modules/story/runtimeSession.test.ts rename to server-node/src/modules/rpg-runtime-story/RpgRuntimeSessionDomain.test.ts index 8ca7b522..87cba5c2 100644 --- a/server-node/src/modules/story/runtimeSession.test.ts +++ b/server-node/src/modules/rpg-runtime-story/RpgRuntimeSessionDomain.test.ts @@ -3,9 +3,13 @@ import test from 'node:test'; import { buildAvailableOptions, +} from './RpgRuntimeOptionCompiler.js'; +import { buildLegacyCurrentStory, +} from './RpgRuntimeStoryPresentationCompiler.js'; +import { loadRuntimeSession, -} from './runtimeSession.ts'; +} from './RpgRuntimeSessionLoader.js'; function createNpcSnapshot() { return { diff --git a/server-node/src/modules/story/runtimeSession.ts b/server-node/src/modules/rpg-runtime-story/RpgRuntimeSessionDomain.ts similarity index 92% rename from server-node/src/modules/story/runtimeSession.ts rename to server-node/src/modules/rpg-runtime-story/RpgRuntimeSessionDomain.ts index 7a87dfff..2e20ea51 100644 --- a/server-node/src/modules/story/runtimeSession.ts +++ b/server-node/src/modules/rpg-runtime-story/RpgRuntimeSessionDomain.ts @@ -1,3 +1,7 @@ +/** + * RPG runtime session 编译主实现。 + * 工作包 G 把旧 `runtimeSession.ts` 的真实逻辑迁到这里,旧文件后续只保留兼容职责。 + */ import type { RuntimeStoryChoicePayload, RuntimeStoryEncounterViewModel, @@ -5,9 +9,9 @@ import type { RuntimeStoryOptionView, RuntimeStoryViewModel, Task5RuntimeOptionScope, -} from '../../../../packages/shared/src/contracts/story.js'; -import { TASK6_RUNTIME_FUNCTION_IDS } from '../../../../packages/shared/src/contracts/story.js'; -import type { SavedSnapshot } from '../../repositories/runtimeRepository.js'; +} from '../../../../packages/shared/src/contracts/rpgRuntimeStoryState.js'; +import { TASK6_RUNTIME_FUNCTION_IDS } from '../../../../packages/shared/src/contracts/rpgRuntimeStoryAction.js'; +import type { RpgRuntimeSavedSnapshot } from '../../repositories/rpg-runtime/RpgRuntimeSnapshotRepository.js'; import { normalizeRuntimeEntityLevelProfile, type RuntimeEntityLevelProfile, @@ -75,6 +79,16 @@ export type RuntimeCompanion = { npcId: string; characterId: string; joinedAtAffinity: number; + hp: number; + maxHp: number; + mana: number; + maxMana: number; + skillCooldowns: Record; + animationState?: string; + actionMode?: string; + offsetX?: number; + offsetY?: number; + transitionMs?: number; }; type RuntimePlayerAttributes = { @@ -146,6 +160,7 @@ export type RuntimeSession = { playerMaxMana: number; npcStates: Record; companions: RuntimeCompanion[]; + roster: RuntimeCompanion[]; currentNpcBattleMode: 'fight' | 'spar' | null; currentNpcBattleOutcome: 'fight_victory' | 'spar_complete' | null; }; @@ -511,6 +526,53 @@ function normalizeCompanion(value: unknown): RuntimeCompanion | null { npcId, characterId: readString(rawCompanion.characterId), joinedAtAffinity: Math.round(readNumber(rawCompanion.joinedAtAffinity, 0)), + hp: Math.max( + 0, + Math.round( + readNumber( + rawCompanion.hp, + readNumber(rawCompanion.maxHp, 1), + ), + ), + ), + maxHp: Math.max(1, Math.round(readNumber(rawCompanion.maxHp, 1))), + mana: Math.max( + 0, + Math.round( + readNumber( + rawCompanion.mana, + readNumber(rawCompanion.maxMana, 1), + ), + ), + ), + maxMana: Math.max(1, Math.round(readNumber(rawCompanion.maxMana, 1))), + skillCooldowns: Object.fromEntries( + Object.entries( + isObject(rawCompanion.skillCooldowns) + ? rawCompanion.skillCooldowns + : {}, + ).map(([skillId, turns]) => [ + skillId, + Math.max(0, Math.round(readNumber(turns, 0))), + ]), + ), + animationState: readString(rawCompanion.animationState) || undefined, + actionMode: readString(rawCompanion.actionMode) || undefined, + offsetX: + typeof rawCompanion.offsetX === 'number' && + Number.isFinite(rawCompanion.offsetX) + ? rawCompanion.offsetX + : undefined, + offsetY: + typeof rawCompanion.offsetY === 'number' && + Number.isFinite(rawCompanion.offsetY) + ? rawCompanion.offsetY + : undefined, + transitionMs: + typeof rawCompanion.transitionMs === 'number' && + Number.isFinite(rawCompanion.transitionMs) + ? Math.max(0, Math.round(rawCompanion.transitionMs)) + : undefined, }; } @@ -531,6 +593,15 @@ function normalizeCompanions(value: unknown) { .filter((entry): entry is RuntimeCompanion => Boolean(entry)); } +function normalizeRoster( + roster: RuntimeCompanion[], + companions: RuntimeCompanion[], +) { + const activeNpcIds = new Set(companions.map((companion) => companion.npcId)); + + return roster.filter((companion) => !activeNpcIds.has(companion.npcId)); +} + function normalizeHostileNpcs(value: unknown) { return readArray(value) .map((entry) => normalizeHostileNpc(entry)) @@ -738,6 +809,21 @@ function buildOptionInteraction( npc_spar: { kind: 'npc', npcId, action: 'spar' }, npc_trade: { kind: 'npc', npcId, action: 'trade' }, npc_gift: { kind: 'npc', npcId, action: 'gift' }, + npc_chat_quest_offer_view: { + kind: 'npc', + npcId, + action: 'quest_offer_view', + }, + npc_chat_quest_offer_replace: { + kind: 'npc', + npcId, + action: 'quest_offer_replace', + }, + npc_chat_quest_offer_abandon: { + kind: 'npc', + npcId, + action: 'quest_offer_abandon', + }, npc_quest_accept: { kind: 'npc', npcId, action: 'quest_accept' }, npc_quest_turn_in: { kind: 'npc', npcId, action: 'quest_turn_in' }, }; @@ -929,7 +1015,7 @@ export function getEncounterKey(encounter: RuntimeEncounter) { } export function loadRuntimeSession( - snapshot: SavedSnapshot, + snapshot: RpgRuntimeSavedSnapshot, requestedSessionId: string, ): RuntimeSession { const rawGameState = isObject(snapshot.gameState) @@ -967,6 +1053,10 @@ export function loadRuntimeSession( ), npcStates: normalizeNpcStates(rawGameState.npcStates), companions: normalizeCompanions(rawGameState.companions), + roster: normalizeRoster( + normalizeCompanions(rawGameState.roster), + normalizeCompanions(rawGameState.companions), + ), currentNpcBattleMode: rawGameState.currentNpcBattleMode === 'fight' || rawGameState.currentNpcBattleMode === 'spar' @@ -1170,16 +1260,7 @@ export function buildAvailableOptions(session: RuntimeSession) { if (npcState && !npcState.recruited && npcState.affinity >= 60) { options.push( - buildOptionView( - session, - 'npc_recruit', - session.companions.length >= MAX_TASK5_COMPANIONS - ? { - disabled: true, - reason: '队伍已满,任务5首轮后端接口暂不处理换队逻辑。', - } - : {}, - ), + buildOptionView(session, 'npc_recruit'), ); } @@ -1313,6 +1394,7 @@ export function syncRawGameState(session: RuntimeSession) { session.rawGameState.playerMaxMana = session.playerMaxMana; session.rawGameState.npcStates = cloneJson(session.npcStates); session.rawGameState.companions = cloneJson(session.companions); + session.rawGameState.roster = cloneJson(session.roster); session.rawGameState.currentNpcBattleMode = session.currentNpcBattleMode; session.rawGameState.currentNpcBattleOutcome = session.currentNpcBattleOutcome; @@ -1352,6 +1434,7 @@ export function replaceRuntimeSessionRawGameState( session.playerMaxMana = refreshed.playerMaxMana; session.npcStates = refreshed.npcStates; session.companions = refreshed.companions; + session.roster = refreshed.roster; session.currentNpcBattleMode = refreshed.currentNpcBattleMode; session.currentNpcBattleOutcome = refreshed.currentNpcBattleOutcome; } diff --git a/server-node/src/modules/rpg-runtime-story/RpgRuntimeSessionLoader.ts b/server-node/src/modules/rpg-runtime-story/RpgRuntimeSessionLoader.ts new file mode 100644 index 00000000..581774b6 --- /dev/null +++ b/server-node/src/modules/rpg-runtime-story/RpgRuntimeSessionLoader.ts @@ -0,0 +1,13 @@ +import { + loadRuntimeSession, + type RuntimeSession, +} from './RpgRuntimeSessionDomain.js'; + +export type { RuntimeSession }; + +/** + * RPG runtime session loader 的主入口。 + * 工作包 G 把旧 `runtimeSession.ts` 的真实实现迁入新域后,这里负责承接稳定命名。 + */ +export { loadRuntimeSession }; +export const loadRpgRuntimeSession = loadRuntimeSession; diff --git a/server-node/src/modules/rpg-runtime-story/RpgRuntimeSessionPrimitives.ts b/server-node/src/modules/rpg-runtime-story/RpgRuntimeSessionPrimitives.ts new file mode 100644 index 00000000..2fccbcc9 --- /dev/null +++ b/server-node/src/modules/rpg-runtime-story/RpgRuntimeSessionPrimitives.ts @@ -0,0 +1,29 @@ +/** + * RPG runtime session 原子能力导出。 + * 这里集中输出运行时动作链直接依赖的 session 原语,避免再次回到旧热点文件取用。 + */ +export { + appendStoryHistory, + getEncounterKey, + getEncounterNpcState, + getPlayerCharacter, + getPlayerSkillCooldowns, + isCombatFunctionId, + isNpcFunctionId, + isStoryFunctionId, + isTask5FunctionId, + isTask6RuntimeFunctionId, + MAX_TASK5_COMPANIONS, + setEncounterNpcState, + syncRawGameState, + TASK6_DEFERRED_FUNCTION_IDS, +} from './RpgRuntimeSessionDomain.js'; + +export type { + RuntimeCompanion, + RuntimeEncounter, + RuntimeHostileNpc, + RuntimeNpcState, + RuntimeSession, + RuntimeStoryHistoryEntry, +} from './RpgRuntimeSessionDomain.js'; diff --git a/server-node/src/modules/rpg-runtime-story/RpgRuntimeSnapshotSync.ts b/server-node/src/modules/rpg-runtime-story/RpgRuntimeSnapshotSync.ts new file mode 100644 index 00000000..282dae39 --- /dev/null +++ b/server-node/src/modules/rpg-runtime-story/RpgRuntimeSnapshotSync.ts @@ -0,0 +1,13 @@ +import { + replaceRuntimeSessionRawGameState, + syncRawGameState, +} from './RpgRuntimeSessionDomain.js'; + +/** + * RPG runtime snapshot 同步入口。 + * 工作包 G 后 rawGameState 的回写与替换都统一从新域目录输出。 + */ +export { replaceRuntimeSessionRawGameState, syncRawGameState }; +export const syncRpgRuntimeSnapshot = syncRawGameState; +export const replaceRpgRuntimeSessionRawGameState = + replaceRuntimeSessionRawGameState; diff --git a/server-node/src/modules/story/storyActionService.ts b/server-node/src/modules/rpg-runtime-story/RpgRuntimeStoryActionDomain.ts similarity index 90% rename from server-node/src/modules/story/storyActionService.ts rename to server-node/src/modules/rpg-runtime-story/RpgRuntimeStoryActionDomain.ts index 15c84ef6..b83dbb89 100644 --- a/server-node/src/modules/story/storyActionService.ts +++ b/server-node/src/modules/rpg-runtime-story/RpgRuntimeStoryActionDomain.ts @@ -1,12 +1,17 @@ +/** + * RPG runtime story 主链迁移后的真实动作/状态实现。 + * 工作包 G 完成后,运行时动作解析直接落在 RPG runtime story 新域。 + */ import type { RuntimeBattlePresentation, RuntimeStoryActionRequest, RuntimeStoryActionResponse, RuntimeStoryOptionView, RuntimeStoryPatch, -} from '../../../../packages/shared/src/contracts/story.js'; + RuntimeStoryStateRequest, +} from '../../../../packages/shared/src/contracts/rpgRuntimeStoryState.js'; import { conflict, invalidRequest } from '../../errors.js'; -import type { RuntimeRepositoryPort } from '../../repositories/runtimeRepository.js'; +import type { RpgRuntimeSnapshotRepositoryPort } from '../../repositories/rpg-runtime/RpgRuntimeSnapshotRepository.js'; import type { UpstreamLlmClient } from '../../services/llmClient.js'; import { buildStrictNpcChatDialoguePrompt, @@ -38,27 +43,35 @@ import { resolveTreasureStoryAction, } from '../runtime-item/treasureStoryActionService.js'; import { - appendStoryHistory, buildAvailableOptions, - buildLegacyCurrentStory, buildRuntimeViewModel, +} from './RpgRuntimeOptionCompiler.js'; +import { + appendStoryHistory, getEncounterNpcState, isCombatFunctionId, isNpcFunctionId, isStoryFunctionId, isTask5FunctionId, - loadRuntimeSession, - type RuntimeSession, setEncounterNpcState, syncRawGameState, TASK6_DEFERRED_FUNCTION_IDS, -} from './runtimeSession.js'; +} from './RpgRuntimeSessionPrimitives.js'; +import { + buildLegacyCurrentStory, +} from './RpgRuntimeStoryPresentationCompiler.js'; +import { + loadRuntimeSession, + type RuntimeSession, +} from './RpgRuntimeSessionLoader.js'; type StoryResolution = { actionText: string; resultText: string; patches: RuntimeStoryPatch[]; storyText?: string; + presentationOptions?: RuntimeStoryOptionView[]; + savedCurrentStory?: JsonRecord; battle?: RuntimeBattlePresentation | null; toast?: string | null; }; @@ -604,6 +617,53 @@ function readSavedStoryText(currentStory: unknown) { return ''; } +function normalizeIncomingSnapshot(snapshot: unknown) { + if (!isObject(snapshot)) { + return null; + } + + const gameState = 'gameState' in snapshot ? snapshot.gameState : null; + const bottomTab = readString(snapshot.bottomTab) || 'adventure'; + const currentStory = 'currentStory' in snapshot ? snapshot.currentStory : null; + const savedAt = readString(snapshot.savedAt) || new Date().toISOString(); + + if (!gameState || !isObject(gameState)) { + return null; + } + + return normalizeSavedSnapshotPayload({ + savedAt, + bottomTab, + gameState, + currentStory: currentStory ?? null, + }); +} + +async function resolveSnapshotForRequest(params: { + snapshotRepository: RpgRuntimeSnapshotRepositoryPort; + userId: string; + snapshot?: unknown; +}) { + const incomingSnapshot = normalizeIncomingSnapshot(params.snapshot); + if (incomingSnapshot) { + return hydrateSavedSnapshot( + await params.snapshotRepository.putSnapshot( + params.userId, + incomingSnapshot, + ), + )!; + } + + const persistedSnapshot = await params.snapshotRepository.getSnapshot( + params.userId, + ); + if (!persistedSnapshot) { + throw conflict('运行时快照不存在,请先初始化并保存一次游戏'); + } + + return hydrateSavedSnapshot(persistedSnapshot)!; +} + function buildFallbackStoryText(session: RuntimeSession) { if (session.inBattle && session.sceneHostileNpcs.length > 0) { return `眼前的冲突还没结束,${session.sceneHostileNpcs[0]!.name}仍在逼你立刻做出下一步判断。`; @@ -855,16 +915,16 @@ function resolveStoryFlowAction( } export async function resolveRuntimeStoryAction(params: { - runtimeRepository: RuntimeRepositoryPort; + snapshotRepository: RpgRuntimeSnapshotRepositoryPort; llmClient?: UpstreamLlmClient; userId: string; request: RuntimeStoryActionRequest; }) { - const snapshot = await params.runtimeRepository.getSnapshot(params.userId); - if (!snapshot) { - throw conflict('运行时快照不存在,请先初始化并保存一次游戏'); - } - const hydratedSnapshot = hydrateSavedSnapshot(snapshot)!; + const hydratedSnapshot = await resolveSnapshotForRequest({ + snapshotRepository: params.snapshotRepository, + userId: params.userId, + snapshot: params.request.snapshot, + }); const functionId = typeof params.request.action.functionId === 'string' @@ -924,7 +984,13 @@ export async function resolveRuntimeStoryAction(params: { : undefined, }); } else if (isNpcFunctionId(functionId)) { - resolution = resolveNpcInteraction(session, functionId); + resolution = resolveNpcInteraction( + session, + functionId, + isObject(params.request.action.payload) + ? params.request.action.payload + : undefined, + ); } else if (isSupportedInventoryStoryFunctionId(functionId)) { resolution = resolveInventoryStoryAction(session, params.request); } else if (isSupportedNpcInventoryStoryFunctionId(functionId)) { @@ -968,6 +1034,12 @@ export async function resolveRuntimeStoryAction(params: { storyText, options, ); + if (resolution.presentationOptions?.length) { + options = resolution.presentationOptions; + } + if (resolution.savedCurrentStory) { + savedCurrentStory = resolution.savedCurrentStory; + } const pendingQuestAcceptedCurrentStory = functionId === 'npc_quest_accept' ? buildPendingQuestAcceptedCurrentStory({ @@ -1023,7 +1095,7 @@ export async function resolveRuntimeStoryAction(params: { appendStoryHistory(session, actionText, historyResultText); syncRawGameState(session); - const persistedSnapshot = await params.runtimeRepository.putSnapshot( + const persistedSnapshot = await params.snapshotRepository.putSnapshot( params.userId, normalizeSavedSnapshotPayload({ savedAt: new Date().toISOString(), @@ -1058,17 +1130,28 @@ export async function resolveRuntimeStoryAction(params: { } export async function getRuntimeStoryState(params: { - runtimeRepository: RuntimeRepositoryPort; + snapshotRepository: RpgRuntimeSnapshotRepositoryPort; userId: string; sessionId: string; + clientVersion?: number; + snapshot?: RuntimeStoryStateRequest['snapshot']; }) { - const snapshot = await params.runtimeRepository.getSnapshot(params.userId); - if (!snapshot) { - throw conflict('运行时快照不存在,请先初始化并保存一次游戏'); - } - const hydratedSnapshot = hydrateSavedSnapshot(snapshot)!; + const hydratedSnapshot = await resolveSnapshotForRequest({ + snapshotRepository: params.snapshotRepository, + userId: params.userId, + snapshot: params.snapshot, + }); const session = loadRuntimeSession(hydratedSnapshot, params.sessionId); + if ( + typeof params.clientVersion === 'number' && + params.clientVersion !== session.runtimeVersion + ) { + throw conflict('运行时版本已变化,请先同步最新快照后再读取状态', { + clientVersion: params.clientVersion, + serverVersion: session.runtimeVersion, + }); + } ensureNpcInventorySessionState(session); const options = buildAvailableOptions(session); const storyText = diff --git a/server-node/src/modules/rpg-runtime-story/RpgRuntimeStoryActionService.ts b/server-node/src/modules/rpg-runtime-story/RpgRuntimeStoryActionService.ts new file mode 100644 index 00000000..475dee89 --- /dev/null +++ b/server-node/src/modules/rpg-runtime-story/RpgRuntimeStoryActionService.ts @@ -0,0 +1,10 @@ +import { + resolveRuntimeStoryAction, +} from './RpgRuntimeStoryActionDomain.js'; + +/** + * RPG runtime story 动作服务入口。 + * 工作包 G 后 runtime action 主链直接落到新域实现,不再继续桥接旧热点文件。 + */ +export { resolveRuntimeStoryAction }; +export const resolveRpgRuntimeStoryAction = resolveRuntimeStoryAction; diff --git a/server-node/src/modules/rpg-runtime-story/RpgRuntimeStoryPresentationCompiler.ts b/server-node/src/modules/rpg-runtime-story/RpgRuntimeStoryPresentationCompiler.ts new file mode 100644 index 00000000..fd661f97 --- /dev/null +++ b/server-node/src/modules/rpg-runtime-story/RpgRuntimeStoryPresentationCompiler.ts @@ -0,0 +1,8 @@ +import { buildLegacyCurrentStory } from './RpgRuntimeSessionDomain.js'; + +/** + * RPG runtime story 展示兼容编译器。 + * 当前仍复用 legacy currentStory 投影规则,但真实落点已经切到新域目录。 + */ +export { buildLegacyCurrentStory }; +export const buildRpgRuntimeLegacyCurrentStory = buildLegacyCurrentStory; diff --git a/server-node/src/modules/rpg-runtime-story/RpgRuntimeStoryStateService.ts b/server-node/src/modules/rpg-runtime-story/RpgRuntimeStoryStateService.ts new file mode 100644 index 00000000..71a1f8c0 --- /dev/null +++ b/server-node/src/modules/rpg-runtime-story/RpgRuntimeStoryStateService.ts @@ -0,0 +1,8 @@ +import { getRuntimeStoryState } from './RpgRuntimeStoryActionDomain.js'; + +/** + * RPG runtime story 状态读取入口。 + * 工作包 G 先把状态读取从旧热点文件迁到新域命名,再保持动作与状态的物理落点分离。 + */ +export { getRuntimeStoryState }; +export const getRpgRuntimeStoryState = getRuntimeStoryState; diff --git a/server-node/src/modules/runtime-item/index.ts b/server-node/src/modules/runtime-item/index.ts deleted file mode 100644 index 5d4b46ca..00000000 --- a/server-node/src/modules/runtime-item/index.ts +++ /dev/null @@ -1,2 +0,0 @@ -export * from './runtimeItemResolutionService.js'; -export { generateRuntimeItemIntents } from '../../services/runtimeItemService.js'; diff --git a/server-node/src/modules/runtime-item/runtimeItemModule.ts b/server-node/src/modules/runtime-item/runtimeItemModule.ts index b03938c5..f3d223a3 100644 --- a/server-node/src/modules/runtime-item/runtimeItemModule.ts +++ b/server-node/src/modules/runtime-item/runtimeItemModule.ts @@ -1,7 +1,7 @@ import { RUNTIME_ITEM_FUNCTIONAL_BIAS_VALUES, RUNTIME_ITEM_TONE_VALUES, -} from '../../../../packages/shared/src/contracts/story.js'; +} from '../../../../packages/shared/src/contracts/rpgRuntimeQuestAssist.js'; import { buildRuntimeItemIntentPromptText, RUNTIME_ITEM_INTENT_SYSTEM_PROMPT, diff --git a/server-node/src/modules/runtime-item/runtimeItemResolutionService.test.ts b/server-node/src/modules/runtime-item/runtimeItemResolutionService.test.ts deleted file mode 100644 index 43dc64c1..00000000 --- a/server-node/src/modules/runtime-item/runtimeItemResolutionService.test.ts +++ /dev/null @@ -1,96 +0,0 @@ -import assert from 'node:assert/strict'; -import test from 'node:test'; - -import { - buildLooseRuntimeItemGenerationContext, - buildQuestRuntimeItemGenerationContext, -} from '../../bridges/legacyRuntimeItemResolutionBridge.js'; -import { - resolveDirectedReward, - resolveRuntimeInventoryStock, -} from './runtimeItemResolutionService.js'; - -const TEST_WUXIA_WORLD = 'WUXIA' as Parameters< - typeof buildLooseRuntimeItemGenerationContext ->[0]['worldType']; -const TEST_XIANXIA_WORLD = 'XIANXIA' as NonNullable< - Parameters[0]['context']['worldType'] ->; - -test('resolveDirectedReward returns flattened runtime reward items on the server side', () => { - const context = buildLooseRuntimeItemGenerationContext({ - worldType: TEST_WUXIA_WORLD, - scene: { - id: 'scene-ruins', - name: '断碑古道', - description: '碎碑与旧誓散落在路旁。', - treasureHints: ['残匣', '旧祭火'], - }, - encounter: { - id: 'treasure-altar', - kind: 'treasure', - npcName: '断誓秘匣', - npcDescription: '匣盖上留着未熄的旧印。', - npcAvatar: '', - context: '古道祭坛', - }, - playerCharacterId: 'hero', - playerBuildTags: ['快剑', '追击'], - generationChannel: 'treasure', - }); - - const result = resolveDirectedReward(context, { - seedKey: 'task6:treasure', - fixedKinds: ['relic', 'consumable'], - fixedPermanence: ['permanent', 'timed'], - itemCount: 2, - }); - - assert.equal(result.items.length, 2); - assert.equal( - result.reward.primaryItem?.runtimeMetadata?.generationChannel, - 'treasure', - ); - assert.equal(result.items[0]?.id, result.reward.primaryItem?.id); - assert.ok(result.reward.primaryItem?.description?.includes('构筑')); -}); - -test('resolveRuntimeInventoryStock builds quest-flavored stock without browser fallback', () => { - const context = buildQuestRuntimeItemGenerationContext({ - context: { - worldType: TEST_XIANXIA_WORLD, - currentSceneId: 'scene-cloud', - currentSceneName: '云阙旧渡', - currentSceneDescription: '旧渡口残留着灵潮和巡守痕迹。', - issuerNpcId: 'npc-issuer', - issuerNpcName: '巡守使', - issuerNpcContext: '巡守', - issuerAffinity: 24, - recentStoryMoments: [], - playerCharacter: null, - }, - issuerNpcId: 'npc-issuer', - issuerNpcName: '巡守使', - roleText: '巡守', - scene: { - id: 'scene-cloud', - name: '云阙旧渡', - description: '旧渡口残留着灵潮和巡守痕迹。', - treasureHints: ['旧印'], - }, - }); - - const items = resolveRuntimeInventoryStock(context, { - seedKey: 'task6:quest', - fixedKinds: ['equipment', 'consumable'], - fixedPermanence: ['permanent', 'timed'], - itemCount: 2, - }); - - assert.equal(items.length, 2); - assert.equal( - items.every((item) => item.runtimeMetadata?.generationChannel === 'quest_reward'), - true, - ); - assert.equal(items.some((item) => Boolean(item.buildProfile || item.useProfile)), true); -}); diff --git a/server-node/src/modules/runtime-item/runtimeItemResolutionService.ts b/server-node/src/modules/runtime-item/runtimeItemResolutionService.ts deleted file mode 100644 index 0f43c088..00000000 --- a/server-node/src/modules/runtime-item/runtimeItemResolutionService.ts +++ /dev/null @@ -1,39 +0,0 @@ -import { - buildDirectedRuntimeReward, - buildRuntimeInventoryStock, - flattenDirectedRuntimeRewardItems, -} from '../../bridges/legacyRuntimeItemResolutionBridge.js'; - -export type RuntimeItemGenerationContext = Parameters< - typeof buildDirectedRuntimeReward ->[0]; -export type RuntimeRewardOptions = Parameters< - typeof buildDirectedRuntimeReward ->[1]; -export type DirectedRuntimeReward = ReturnType; -export type ResolvedRuntimeRewardItem = ReturnType< - typeof buildRuntimeInventoryStock ->[number]; - -export type RuntimeRewardResolution = { - reward: DirectedRuntimeReward; - items: ResolvedRuntimeRewardItem[]; -}; - -export function resolveDirectedReward( - context: RuntimeItemGenerationContext, - options: RuntimeRewardOptions, -): RuntimeRewardResolution { - const reward = buildDirectedRuntimeReward(context, options); - return { - reward, - items: flattenDirectedRuntimeRewardItems(reward), - }; -} - -export function resolveRuntimeInventoryStock( - context: RuntimeItemGenerationContext, - options: RuntimeRewardOptions, -): ResolvedRuntimeRewardItem[] { - return buildRuntimeInventoryStock(context, options); -} diff --git a/server-node/src/modules/runtime-item/treasureStoryActionService.ts b/server-node/src/modules/runtime-item/treasureStoryActionService.ts index 82b797ca..4160fcef 100644 --- a/server-node/src/modules/runtime-item/treasureStoryActionService.ts +++ b/server-node/src/modules/runtime-item/treasureStoryActionService.ts @@ -1,7 +1,7 @@ import type { RuntimeStoryActionRequest, RuntimeStoryPatch, -} from '../../../../packages/shared/src/contracts/story.js'; +} from '../../../../packages/shared/src/contracts/rpgRuntimeStoryState.js'; import { conflict, invalidRequest } from '../../errors.js'; import { addInventoryItems, @@ -14,8 +14,8 @@ import { import { buildBuildToast } from '../inventory/inventoryStoryActionService.js'; import { replaceRuntimeSessionRawGameState, - type RuntimeSession, -} from '../story/runtimeSession.js'; +} from '../rpg-runtime-story/RpgRuntimeSnapshotSync.js'; +import type { RuntimeSession } from '../rpg-runtime-story/RpgRuntimeSessionLoader.js'; const SUPPORTED_TREASURE_STORY_FUNCTION_IDS = new Set([ 'treasure_inspect', diff --git a/server-node/src/observability.test.ts b/server-node/src/observability.test.ts index e571bda7..3741ecff 100644 --- a/server-node/src/observability.test.ts +++ b/server-node/src/observability.test.ts @@ -93,6 +93,11 @@ function createTestConfig(testName: string): AppConfig { mockAvatarUrl: '', }, authSession: { + accessCookieName: 'genarrative_access_session', + accessCookieTtlSeconds: 7200, + accessCookieSecure: false, + accessCookieSameSite: 'Lax', + accessCookiePath: '/', refreshCookieName: 'genarrative_refresh_session', refreshSessionTtlDays: 30, refreshCookieSecure: false, diff --git a/server-node/src/prompts/chatPromptBuilders.ts b/server-node/src/prompts/chatPromptBuilders.ts index 6d80b488..3dab3f01 100644 --- a/server-node/src/prompts/chatPromptBuilders.ts +++ b/server-node/src/prompts/chatPromptBuilders.ts @@ -5,7 +5,7 @@ import type { NpcChatDialogueRequest, NpcChatTurnRequest, NpcRecruitDialogueRequest, -} from '../../../packages/shared/src/contracts/story.js'; +} from '../../../packages/shared/src/contracts/rpgRuntimeChat.js'; type JsonRecord = Record; diff --git a/server-node/src/repositories/RpgAgentSessionRepository.ts b/server-node/src/repositories/RpgAgentSessionRepository.ts new file mode 100644 index 00000000..b75f66b6 --- /dev/null +++ b/server-node/src/repositories/RpgAgentSessionRepository.ts @@ -0,0 +1,100 @@ +import type { CustomWorldSessionRecord } from '../../../packages/shared/src/contracts/runtime.js'; +import type { AppDatabase } from '../db.js'; +import { + type RpgAgentSessionRow, +} from './rpgWorldRepositoryShared.js'; + +/** + * RPG Agent session 仓储最小读写接口。 + * 工作包 F 后续所有 session store / test stub 都应优先依赖这个领域端口。 + */ +export type RpgAgentSessionRepositoryPort = { + listSessions(userId: string): Promise; + getSession( + userId: string, + sessionId: string, + ): Promise; + upsertSession( + userId: string, + sessionId: string, + session: CustomWorldSessionRecord, + ): Promise; +}; + +/** + * RPG Agent session 仓储只负责 session 表读写,不再承担兼容补齐与快照派生。 + */ +export class RpgAgentSessionRepository implements RpgAgentSessionRepositoryPort { + constructor(private readonly db: AppDatabase) {} + + async listSessions(userId: string) { + const result = await this.db.query( + `SELECT payload_json AS payload, + created_at AS "createdAt", + updated_at AS "updatedAt" + FROM custom_world_sessions + WHERE user_id = $1 + ORDER BY updated_at DESC`, + [userId], + ); + + return result.rows.map((row) => ({ + ...row.payload, + createdAt: row.createdAt, + updatedAt: row.updatedAt, + })); + } + + async getSession(userId: string, sessionId: string) { + const result = await this.db.query( + `SELECT payload_json AS payload, + created_at AS "createdAt", + updated_at AS "updatedAt" + FROM custom_world_sessions + WHERE user_id = $1 AND session_id = $2`, + [userId, sessionId], + ); + const row = result.rows[0]; + + if (!row) { + return null; + } + + return { + ...row.payload, + createdAt: row.createdAt, + updatedAt: row.updatedAt, + }; + } + + async upsertSession( + userId: string, + sessionId: string, + session: CustomWorldSessionRecord, + ) { + const payload = { + ...session, + sessionId, + } satisfies CustomWorldSessionRecord; + + await this.db.query( + `INSERT INTO custom_world_sessions ( + user_id, + session_id, + payload_json, + created_at, + updated_at + ) VALUES ($1, $2, $3, $4, $5) + ON CONFLICT (user_id, session_id) DO UPDATE SET + payload_json = EXCLUDED.payload_json, + updated_at = EXCLUDED.updated_at`, + [userId, sessionId, payload, session.createdAt, session.updatedAt], + ); + + return { + ...payload, + createdAt: session.createdAt, + updatedAt: session.updatedAt, + }; + } +} diff --git a/server-node/src/repositories/RpgWorldProfileRepository.ts b/server-node/src/repositories/RpgWorldProfileRepository.ts new file mode 100644 index 00000000..5fa2588d --- /dev/null +++ b/server-node/src/repositories/RpgWorldProfileRepository.ts @@ -0,0 +1,433 @@ +import type { + CustomWorldGalleryCard, + CustomWorldLibraryEntry, + CustomWorldProfileRecord, +} from '../../../packages/shared/src/contracts/runtime.js'; +import { extractCustomWorldLibraryMetadata } from './customWorldLibraryMetadata.js'; +import type { AppDatabase } from '../db.js'; +import { + MAX_RPG_WORLD_GALLERY_ENTRIES, + MAX_RPG_WORLD_PROFILE_ENTRIES, + normalizeStoredRpgWorldProfile, + toRpgWorldGalleryCard, + toRpgWorldLibraryEntry, + type RpgWorldGalleryRow, + type RpgWorldProfileRow, +} from './rpgWorldRepositoryShared.js'; + +/** + * RPG 世界 profile 领域端口。 + * works、library、gallery、脚本同步等链路后续统一依赖这个接口,而不是 RuntimeRepositoryPort。 + */ +export type RpgWorldProfileRepositoryPort = { + listOwnProfiles( + userId: string, + ): Promise[]>; + upsertOwnProfile( + userId: string, + profileId: string, + profile: Record, + authorDisplayName: string, + ): Promise<{ + entry: CustomWorldLibraryEntry; + entries: CustomWorldLibraryEntry[]; + }>; + syncProfileFromSnapshot( + userId: string, + profileId: string, + profile: Record, + syncedAt: string, + ): Promise; + softDeleteOwnProfile( + userId: string, + profileId: string, + ): Promise[]>; + publishOwnProfile( + userId: string, + profileId: string, + authorDisplayName: string, + ): Promise<{ + entry: CustomWorldLibraryEntry; + entries: CustomWorldLibraryEntry[]; + } | null>; + unpublishOwnProfile( + userId: string, + profileId: string, + authorDisplayName: string, + ): Promise<{ + entry: CustomWorldLibraryEntry; + entries: CustomWorldLibraryEntry[]; + } | null>; + listPublishedGallery(): Promise; + getPublishedGalleryDetail( + ownerUserId: string, + profileId: string, + ): Promise | null>; +}; + +/** + * RPG 世界 profile 仓储统一负责作品库、发布态与画廊读写。 + */ +export class RpgWorldProfileRepository implements RpgWorldProfileRepositoryPort { + constructor(private readonly db: AppDatabase) {} + + private async findOwnProfileEntry(userId: string, profileId: string) { + const result = await this.db.query( + `SELECT user_id AS "ownerUserId", + profile_id AS "profileId", + payload_json AS payload, + visibility, + published_at AS "publishedAt", + updated_at AS "updatedAt", + author_display_name AS "authorDisplayName", + world_name AS "worldName", + subtitle, + summary_text AS "summaryText", + cover_image_src AS "coverImageSrc", + theme_mode AS "themeMode", + playable_npc_count AS "playableNpcCount", + landmark_count AS "landmarkCount" + FROM custom_world_profiles + WHERE user_id = $1 + AND profile_id = $2 + AND deleted_at IS NULL`, + [userId, profileId], + ); + + const row = result.rows[0]; + return row ? toRpgWorldLibraryEntry(row) : null; + } + + async listOwnProfiles(userId: string) { + const result = await this.db.query( + `SELECT user_id AS "ownerUserId", + profile_id AS "profileId", + payload_json AS payload, + visibility, + published_at AS "publishedAt", + updated_at AS "updatedAt", + author_display_name AS "authorDisplayName", + world_name AS "worldName", + subtitle, + summary_text AS "summaryText", + cover_image_src AS "coverImageSrc", + theme_mode AS "themeMode", + playable_npc_count AS "playableNpcCount", + landmark_count AS "landmarkCount" + FROM custom_world_profiles + WHERE user_id = $1 + AND deleted_at IS NULL + ORDER BY updated_at DESC + LIMIT $2`, + [userId, MAX_RPG_WORLD_PROFILE_ENTRIES], + ); + + return result.rows.map((row) => toRpgWorldLibraryEntry(row)); + } + + async upsertOwnProfile( + userId: string, + profileId: string, + profile: Record, + authorDisplayName: string, + ) { + const payload = normalizeStoredRpgWorldProfile(profileId, profile); + const metadata = extractCustomWorldLibraryMetadata(payload); + const now = new Date().toISOString(); + + await this.db.query( + `INSERT INTO custom_world_profiles ( + user_id, + profile_id, + payload_json, + updated_at, + author_display_name, + world_name, + subtitle, + summary_text, + cover_image_src, + theme_mode, + playable_npc_count, + landmark_count + ) + VALUES ($1, $2, $3, $4, $5, $6, $7, $8, $9, $10, $11, $12) + ON CONFLICT (user_id, profile_id) DO UPDATE SET + payload_json = EXCLUDED.payload_json, + updated_at = EXCLUDED.updated_at, + deleted_at = NULL, + author_display_name = EXCLUDED.author_display_name, + world_name = EXCLUDED.world_name, + subtitle = EXCLUDED.subtitle, + summary_text = EXCLUDED.summary_text, + cover_image_src = EXCLUDED.cover_image_src, + theme_mode = EXCLUDED.theme_mode, + playable_npc_count = EXCLUDED.playable_npc_count, + landmark_count = EXCLUDED.landmark_count`, + [ + userId, + profileId, + payload, + now, + authorDisplayName || '玩家', + metadata.worldName, + metadata.subtitle, + metadata.summaryText, + metadata.coverImageSrc, + metadata.themeMode, + metadata.playableNpcCount, + metadata.landmarkCount, + ], + ); + + const entry = await this.findOwnProfileEntry(userId, profileId); + if (!entry) { + throw new Error('failed to resolve custom world after upsert'); + } + + return { + entry, + entries: await this.listOwnProfiles(userId), + }; + } + + async syncProfileFromSnapshot( + userId: string, + profileId: string, + profile: Record, + syncedAt: string, + ) { + const payload = normalizeStoredRpgWorldProfile(profileId, profile); + const metadata = extractCustomWorldLibraryMetadata(payload); + + await this.db.query( + `INSERT INTO custom_world_profiles ( + user_id, + profile_id, + payload_json, + updated_at, + author_display_name, + world_name, + subtitle, + summary_text, + cover_image_src, + theme_mode, + playable_npc_count, + landmark_count, + deleted_at + ) + VALUES ($1, $2, $3, $4, $5, $6, $7, $8, $9, $10, $11, $12, NULL) + ON CONFLICT (user_id, profile_id) DO UPDATE SET + payload_json = EXCLUDED.payload_json, + updated_at = EXCLUDED.updated_at, + deleted_at = NULL, + world_name = EXCLUDED.world_name, + subtitle = EXCLUDED.subtitle, + summary_text = EXCLUDED.summary_text, + cover_image_src = EXCLUDED.cover_image_src, + theme_mode = EXCLUDED.theme_mode, + playable_npc_count = EXCLUDED.playable_npc_count, + landmark_count = EXCLUDED.landmark_count`, + [ + userId, + profileId, + payload, + syncedAt, + '玩家', + metadata.worldName, + metadata.subtitle, + metadata.summaryText, + metadata.coverImageSrc, + metadata.themeMode, + metadata.playableNpcCount, + metadata.landmarkCount, + ], + ); + } + + async softDeleteOwnProfile(userId: string, profileId: string) { + const deletedAt = new Date().toISOString(); + await this.db.query( + `UPDATE custom_world_profiles + SET deleted_at = $1, + updated_at = $1, + visibility = 'draft', + published_at = NULL + WHERE user_id = $2 + AND profile_id = $3 + AND deleted_at IS NULL`, + [deletedAt, userId, profileId], + ); + + return this.listOwnProfiles(userId); + } + + async publishOwnProfile( + userId: string, + profileId: string, + authorDisplayName: string, + ) { + const existingEntry = await this.findOwnProfileEntry(userId, profileId); + if (!existingEntry) { + return null; + } + + const payload = normalizeStoredRpgWorldProfile( + profileId, + existingEntry.profile, + ); + const metadata = extractCustomWorldLibraryMetadata(payload); + const now = new Date().toISOString(); + + await this.db.query( + `UPDATE custom_world_profiles + SET visibility = 'published', + published_at = $1, + updated_at = $1, + author_display_name = $2, + world_name = $3, + subtitle = $4, + summary_text = $5, + cover_image_src = $6, + theme_mode = $7, + playable_npc_count = $8, + landmark_count = $9 + WHERE user_id = $10 + AND profile_id = $11`, + [ + now, + authorDisplayName || '玩家', + metadata.worldName, + metadata.subtitle, + metadata.summaryText, + metadata.coverImageSrc, + metadata.themeMode, + metadata.playableNpcCount, + metadata.landmarkCount, + userId, + profileId, + ], + ); + + const entry = await this.findOwnProfileEntry(userId, profileId); + if (!entry) { + throw new Error('failed to resolve custom world after publish'); + } + + return { + entry, + entries: await this.listOwnProfiles(userId), + }; + } + + async unpublishOwnProfile( + userId: string, + profileId: string, + authorDisplayName: string, + ) { + const existingEntry = await this.findOwnProfileEntry(userId, profileId); + if (!existingEntry) { + return null; + } + + const payload = normalizeStoredRpgWorldProfile( + profileId, + existingEntry.profile, + ); + const metadata = extractCustomWorldLibraryMetadata(payload); + const now = new Date().toISOString(); + + await this.db.query( + `UPDATE custom_world_profiles + SET visibility = 'draft', + published_at = NULL, + updated_at = $1, + author_display_name = $2, + world_name = $3, + subtitle = $4, + summary_text = $5, + cover_image_src = $6, + theme_mode = $7, + playable_npc_count = $8, + landmark_count = $9 + WHERE user_id = $10 + AND profile_id = $11`, + [ + now, + authorDisplayName || '玩家', + metadata.worldName, + metadata.subtitle, + metadata.summaryText, + metadata.coverImageSrc, + metadata.themeMode, + metadata.playableNpcCount, + metadata.landmarkCount, + userId, + profileId, + ], + ); + + const entry = await this.findOwnProfileEntry(userId, profileId); + if (!entry) { + throw new Error('failed to resolve custom world after unpublish'); + } + + return { + entry, + entries: await this.listOwnProfiles(userId), + }; + } + + async listPublishedGallery() { + const result = await this.db.query( + `SELECT user_id AS "ownerUserId", + profile_id AS "profileId", + visibility, + published_at AS "publishedAt", + updated_at AS "updatedAt", + author_display_name AS "authorDisplayName", + world_name AS "worldName", + subtitle, + summary_text AS "summaryText", + cover_image_src AS "coverImageSrc", + theme_mode AS "themeMode", + playable_npc_count AS "playableNpcCount", + landmark_count AS "landmarkCount" + FROM custom_world_profiles + WHERE visibility = 'published' + AND deleted_at IS NULL + ORDER BY published_at DESC, updated_at DESC + LIMIT $1`, + [MAX_RPG_WORLD_GALLERY_ENTRIES], + ); + + return result.rows.map((row) => toRpgWorldGalleryCard(row)); + } + + async getPublishedGalleryDetail(ownerUserId: string, profileId: string) { + const result = await this.db.query( + `SELECT user_id AS "ownerUserId", + profile_id AS "profileId", + payload_json AS payload, + visibility, + published_at AS "publishedAt", + updated_at AS "updatedAt", + author_display_name AS "authorDisplayName", + world_name AS "worldName", + subtitle, + summary_text AS "summaryText", + cover_image_src AS "coverImageSrc", + theme_mode AS "themeMode", + playable_npc_count AS "playableNpcCount", + landmark_count AS "landmarkCount" + FROM custom_world_profiles + WHERE user_id = $1 + AND profile_id = $2 + AND visibility = 'published' + AND deleted_at IS NULL`, + [ownerUserId, profileId], + ); + + const row = result.rows[0]; + return row ? toRpgWorldLibraryEntry(row) : null; + } +} diff --git a/server-node/src/repositories/rpg-entry/RpgEntryRepositories.test.ts b/server-node/src/repositories/rpg-entry/RpgEntryRepositories.test.ts new file mode 100644 index 00000000..e2237114 --- /dev/null +++ b/server-node/src/repositories/rpg-entry/RpgEntryRepositories.test.ts @@ -0,0 +1,241 @@ +import assert from 'node:assert/strict'; +import test from 'node:test'; + +import type { RuntimeRepositoryPort } from '../runtimeRepository.js'; +import { RpgSaveArchiveRepository } from './RpgSaveArchiveRepository.js'; +import { RpgWorldLibraryRepository } from './RpgWorldLibraryRepository.js'; + +function createRuntimeRepositoryStub(): RuntimeRepositoryPort { + return { + async getSnapshot() { + return null; + }, + async putSnapshot(_userId, payload) { + return { + version: 1, + ...payload, + }; + }, + async getProfileDashboard() { + return { + walletBalance: 0, + totalPlayTimeMs: 0, + playedWorldCount: 0, + updatedAt: '2026-04-21T00:00:00.000Z', + }; + }, + async listProfileWalletLedger() { + return []; + }, + async getProfilePlayStats() { + return { + totalPlayTimeMs: 0, + playedWorks: [], + updatedAt: '2026-04-21T00:00:00.000Z', + }; + }, + async listProfileSaveArchives() { + return [ + { + worldKey: 'world-1', + ownerUserId: 'owner-1', + profileId: 'profile-1', + worldType: 'custom', + worldName: '潮影群岛', + subtitle: '港雾与旧航道', + summaryText: '最近一次继续游戏入口', + coverImageSrc: null, + lastPlayedAt: '2026-04-20T23:59:59.000Z', + }, + ]; + }, + async resumeProfileSaveArchive() { + return { + entry: { + worldKey: 'world-1', + ownerUserId: 'owner-1', + profileId: 'profile-1', + worldType: 'custom', + worldName: '潮影群岛', + subtitle: '港雾与旧航道', + summaryText: '最近一次继续游戏入口', + coverImageSrc: null, + lastPlayedAt: '2026-04-20T23:59:59.000Z', + }, + snapshot: { + version: 1, + savedAt: '2026-04-20T23:59:59.000Z', + bottomTab: 'adventure', + gameState: { currentScene: '潮影港' }, + currentStory: null, + }, + }; + }, + async deleteSnapshot() {}, + async getSettings() { + return { + musicVolume: 0.42, + platformTheme: 'light', + }; + }, + async putSettings(_userId, settings) { + return settings; + }, + async listCustomWorldProfiles() { + return [ + { + ownerUserId: 'owner-1', + profileId: 'profile-1', + profile: { + id: 'profile-1', + }, + visibility: 'published', + publishedAt: '2026-04-20T08:00:00.000Z', + updatedAt: '2026-04-20T08:00:00.000Z', + authorDisplayName: '造物者', + worldName: '潮影群岛', + subtitle: '港雾与旧航道', + summaryText: '一座在潮汐中漂移的群岛。', + coverImageSrc: '/covers/tide.png', + themeMode: 'tide', + playableNpcCount: 2, + landmarkCount: 3, + }, + ]; + }, + async listPlatformBrowseHistory() { + return []; + }, + async upsertPlatformBrowseHistoryEntries() { + return []; + }, + async clearPlatformBrowseHistory() {}, + async upsertCustomWorldProfile() { + return { + entry: { + ownerUserId: 'owner-1', + profileId: 'profile-1', + profile: { + id: 'profile-1', + }, + visibility: 'draft', + publishedAt: null, + updatedAt: '2026-04-20T08:00:00.000Z', + authorDisplayName: '造物者', + worldName: '潮影群岛', + subtitle: '港雾与旧航道', + summaryText: '一座在潮汐中漂移的群岛。', + coverImageSrc: '/covers/tide.png', + themeMode: 'tide', + playableNpcCount: 2, + landmarkCount: 3, + }, + entries: [], + }; + }, + async deleteCustomWorldProfile() { + return []; + }, + async listCustomWorldSessions() { + return []; + }, + async getCustomWorldSession() { + return null; + }, + async upsertCustomWorldSession(_userId, _sessionId, session) { + return session; + }, + async publishCustomWorldProfile() { + return { + entry: { + ownerUserId: 'owner-1', + profileId: 'profile-1', + profile: { + id: 'profile-1', + }, + visibility: 'published', + publishedAt: '2026-04-20T08:00:00.000Z', + updatedAt: '2026-04-20T08:00:00.000Z', + authorDisplayName: '造物者', + worldName: '潮影群岛', + subtitle: '港雾与旧航道', + summaryText: '一座在潮汐中漂移的群岛。', + coverImageSrc: '/covers/tide.png', + themeMode: 'tide', + playableNpcCount: 2, + landmarkCount: 3, + }, + entries: [], + }; + }, + async unpublishCustomWorldProfile() { + return null; + }, + async listPublishedCustomWorldGallery() { + return [ + { + ownerUserId: 'owner-1', + profileId: 'profile-1', + visibility: 'published', + publishedAt: '2026-04-20T08:00:00.000Z', + updatedAt: '2026-04-20T08:00:00.000Z', + authorDisplayName: '造物者', + worldName: '潮影群岛', + subtitle: '港雾与旧航道', + summaryText: '一座在潮汐中漂移的群岛。', + coverImageSrc: '/covers/tide.png', + themeMode: 'tide', + playableNpcCount: 2, + landmarkCount: 3, + }, + ]; + }, + async getPublishedCustomWorldGalleryDetail() { + return { + ownerUserId: 'owner-1', + profileId: 'profile-1', + profile: { + id: 'profile-1', + }, + visibility: 'published', + publishedAt: '2026-04-20T08:00:00.000Z', + updatedAt: '2026-04-20T08:00:00.000Z', + authorDisplayName: '造物者', + worldName: '潮影群岛', + subtitle: '港雾与旧航道', + summaryText: '一座在潮汐中漂移的群岛。', + coverImageSrc: '/covers/tide.png', + themeMode: 'tide', + playableNpcCount: 2, + landmarkCount: 3, + }; + }, + }; +} + +test('RpgSaveArchiveRepository 只承接继续游戏归档读取职责', async () => { + const repository = new RpgSaveArchiveRepository(createRuntimeRepositoryStub()); + + const archives = await repository.listProfileSaveArchives('user-1'); + const resumed = await repository.resumeProfileSaveArchive('user-1', 'world-1'); + + assert.equal(archives[0]?.worldName, '潮影群岛'); + assert.equal(resumed?.snapshot.bottomTab, 'adventure'); + assert.equal('getSnapshot' in repository, false); +}); + +test('RpgWorldLibraryRepository 独立承接作品库与广场读取职责', async () => { + const repository = new RpgWorldLibraryRepository(createRuntimeRepositoryStub()); + + const profiles = await repository.listCustomWorldProfiles('user-1'); + const gallery = await repository.listPublishedCustomWorldGallery(); + const detail = await repository.getPublishedCustomWorldGalleryDetail( + 'owner-1', + 'profile-1', + ); + + assert.equal(profiles[0]?.worldName, '潮影群岛'); + assert.equal(gallery[0]?.themeMode, 'tide'); + assert.equal(detail?.profileId, 'profile-1'); + assert.equal('listProfileSaveArchives' in repository, false); +}); diff --git a/server-node/src/repositories/rpg-entry/RpgSaveArchiveRepository.ts b/server-node/src/repositories/rpg-entry/RpgSaveArchiveRepository.ts new file mode 100644 index 00000000..4b79c559 --- /dev/null +++ b/server-node/src/repositories/rpg-entry/RpgSaveArchiveRepository.ts @@ -0,0 +1,36 @@ +import type { + RuntimeRepositoryPort, + SavedSnapshot, +} from '../runtimeRepository.js'; +import type { ProfileSaveArchiveSummary } from '../../../../packages/shared/src/contracts/runtime.js'; + +/** + * RPG 继续游戏归档仓储端口。 + * 当前仍由 runtimeRepository 提供真实实现,本文件只建立按领域命名的兼容入口。 + */ +export type RpgSaveArchiveRepositoryPort = Pick< + RuntimeRepositoryPort, + 'listProfileSaveArchives' | 'resumeProfileSaveArchive' +>; +export type RpgSaveArchiveSnapshot = SavedSnapshot; + +export class RpgSaveArchiveRepository implements RpgSaveArchiveRepositoryPort { + constructor(private readonly runtimeRepository: RuntimeRepositoryPort) {} + + listProfileSaveArchives(userId: string): Promise { + return this.runtimeRepository.listProfileSaveArchives(userId); + } + + resumeProfileSaveArchive( + userId: string, + worldKey: string, + ): Promise< + | { + entry: ProfileSaveArchiveSummary; + snapshot: RpgSaveArchiveSnapshot; + } + | null + > { + return this.runtimeRepository.resumeProfileSaveArchive(userId, worldKey); + } +} diff --git a/server-node/src/repositories/rpg-entry/RpgWorldLibraryRepository.ts b/server-node/src/repositories/rpg-entry/RpgWorldLibraryRepository.ts new file mode 100644 index 00000000..e22ef70e --- /dev/null +++ b/server-node/src/repositories/rpg-entry/RpgWorldLibraryRepository.ts @@ -0,0 +1,92 @@ +import type { + CustomWorldGalleryCard, + CustomWorldLibraryEntry, + CustomWorldProfileRecord, +} from '../../../../packages/shared/src/contracts/runtime.js'; +import type { RuntimeRepositoryPort } from '../runtimeRepository.js'; + +/** + * RPG 世界库仓储端口。 + * 当前先桥接旧 runtimeRepository 中的世界库与广场读写,为工作包 H 的按域拆仓储提供命名骨架。 + */ +export type RpgWorldLibraryRepositoryPort = Pick< + RuntimeRepositoryPort, + | 'deleteCustomWorldProfile' + | 'getPublishedCustomWorldGalleryDetail' + | 'listCustomWorldProfiles' + | 'listPublishedCustomWorldGallery' + | 'publishCustomWorldProfile' + | 'unpublishCustomWorldProfile' + | 'upsertCustomWorldProfile' +>; + +export class RpgWorldLibraryRepository + implements RpgWorldLibraryRepositoryPort +{ + constructor(private readonly runtimeRepository: RuntimeRepositoryPort) {} + + listCustomWorldProfiles( + userId: string, + ): Promise[]> { + return this.runtimeRepository.listCustomWorldProfiles(userId); + } + + upsertCustomWorldProfile( + userId: string, + profileId: string, + profile: Record, + authorDisplayName: string, + ) { + return this.runtimeRepository.upsertCustomWorldProfile( + userId, + profileId, + profile, + authorDisplayName, + ); + } + + deleteCustomWorldProfile( + userId: string, + profileId: string, + ): Promise[]> { + return this.runtimeRepository.deleteCustomWorldProfile(userId, profileId); + } + + publishCustomWorldProfile( + userId: string, + profileId: string, + authorDisplayName: string, + ) { + return this.runtimeRepository.publishCustomWorldProfile( + userId, + profileId, + authorDisplayName, + ); + } + + unpublishCustomWorldProfile( + userId: string, + profileId: string, + authorDisplayName: string, + ) { + return this.runtimeRepository.unpublishCustomWorldProfile( + userId, + profileId, + authorDisplayName, + ); + } + + listPublishedCustomWorldGallery(): Promise { + return this.runtimeRepository.listPublishedCustomWorldGallery(); + } + + getPublishedCustomWorldGalleryDetail( + ownerUserId: string, + profileId: string, + ): Promise | null> { + return this.runtimeRepository.getPublishedCustomWorldGalleryDetail( + ownerUserId, + profileId, + ); + } +} diff --git a/server-node/src/repositories/rpg-profile/RpgBrowseHistoryRepository.ts b/server-node/src/repositories/rpg-profile/RpgBrowseHistoryRepository.ts new file mode 100644 index 00000000..44a5e773 --- /dev/null +++ b/server-node/src/repositories/rpg-profile/RpgBrowseHistoryRepository.ts @@ -0,0 +1,42 @@ +import type { + PlatformBrowseHistoryEntry, + PlatformBrowseHistoryWriteEntry, +} from '../../../../packages/shared/src/contracts/runtime.js'; +import type { RuntimeRepositoryPort } from '../runtimeRepository.js'; + +/** + * RPG 浏览历史仓储端口。 + * 将继续游戏与平台浏览足迹独立命名,避免继续堆叠在资料看板仓储内。 + */ +export type RpgBrowseHistoryRepositoryPort = Pick< + RuntimeRepositoryPort, + | 'clearPlatformBrowseHistory' + | 'listPlatformBrowseHistory' + | 'upsertPlatformBrowseHistoryEntries' +>; + +export class RpgBrowseHistoryRepository + implements RpgBrowseHistoryRepositoryPort +{ + constructor(private readonly runtimeRepository: RuntimeRepositoryPort) {} + + listPlatformBrowseHistory( + userId: string, + ): Promise { + return this.runtimeRepository.listPlatformBrowseHistory(userId); + } + + upsertPlatformBrowseHistoryEntries( + userId: string, + entries: PlatformBrowseHistoryWriteEntry[], + ): Promise { + return this.runtimeRepository.upsertPlatformBrowseHistoryEntries( + userId, + entries, + ); + } + + clearPlatformBrowseHistory(userId: string): Promise { + return this.runtimeRepository.clearPlatformBrowseHistory(userId); + } +} diff --git a/server-node/src/repositories/rpg-profile/RpgProfileDashboardRepository.ts b/server-node/src/repositories/rpg-profile/RpgProfileDashboardRepository.ts new file mode 100644 index 00000000..cc5ff5be --- /dev/null +++ b/server-node/src/repositories/rpg-profile/RpgProfileDashboardRepository.ts @@ -0,0 +1,49 @@ +import type { + ProfileDashboardSummary, + ProfilePlayStatsResponse, + ProfileWalletLedgerEntry, + RuntimeSettings, +} from '../../../../packages/shared/src/contracts/runtime.js'; +import type { RuntimeRepositoryPort } from '../runtimeRepository.js'; + +/** + * RPG profile 域仓储端口。 + * 当前以委托方式桥接旧 runtimeRepository,给后续按域仓储拆分保留稳定依赖面。 + */ +export type RpgProfileDashboardRepositoryPort = Pick< + RuntimeRepositoryPort, + | 'getProfileDashboard' + | 'getProfilePlayStats' + | 'getSettings' + | 'listProfileWalletLedger' + | 'putSettings' +>; + +export class RpgProfileDashboardRepository + implements RpgProfileDashboardRepositoryPort +{ + constructor(private readonly runtimeRepository: RuntimeRepositoryPort) {} + + getProfileDashboard(userId: string): Promise { + return this.runtimeRepository.getProfileDashboard(userId); + } + + listProfileWalletLedger(userId: string): Promise { + return this.runtimeRepository.listProfileWalletLedger(userId); + } + + getProfilePlayStats(userId: string): Promise { + return this.runtimeRepository.getProfilePlayStats(userId); + } + + getSettings(userId: string): Promise { + return this.runtimeRepository.getSettings(userId); + } + + putSettings( + userId: string, + settings: RuntimeSettings, + ): Promise { + return this.runtimeRepository.putSettings(userId, settings); + } +} diff --git a/server-node/src/repositories/rpg-profile/RpgProfileRepositories.test.ts b/server-node/src/repositories/rpg-profile/RpgProfileRepositories.test.ts new file mode 100644 index 00000000..bbb4cf3f --- /dev/null +++ b/server-node/src/repositories/rpg-profile/RpgProfileRepositories.test.ts @@ -0,0 +1,154 @@ +import assert from 'node:assert/strict'; +import test from 'node:test'; + +import type { RuntimeRepositoryPort } from '../runtimeRepository.js'; +import { RpgBrowseHistoryRepository } from './RpgBrowseHistoryRepository.js'; +import { RpgProfileDashboardRepository } from './RpgProfileDashboardRepository.js'; + +function createRuntimeRepositoryStub(): RuntimeRepositoryPort { + return { + async getSnapshot() { + return null; + }, + async putSnapshot(_userId, payload) { + return { + version: 1, + ...payload, + }; + }, + async getProfileDashboard() { + return { + walletBalance: 0, + totalPlayTimeMs: 0, + playedWorldCount: 0, + updatedAt: '2026-04-21T00:00:00.000Z', + }; + }, + async listProfileWalletLedger() { + return []; + }, + async getProfilePlayStats() { + return { + totalPlayTimeMs: 0, + playedWorks: [], + updatedAt: '2026-04-21T00:00:00.000Z', + }; + }, + async listProfileSaveArchives() { + return []; + }, + async resumeProfileSaveArchive() { + return null; + }, + async deleteSnapshot() {}, + async getSettings() { + return { + musicVolume: 0.5, + platformTheme: 'light', + }; + }, + async putSettings(_userId, settings) { + return settings; + }, + async listCustomWorldProfiles() { + return []; + }, + async listPlatformBrowseHistory() { + return [ + { + ownerUserId: 'owner-1', + profileId: 'profile-1', + worldName: '雾港', + subtitle: '沿海试炼', + summaryText: '最近访问', + coverImageSrc: null, + themeMode: 'mythic', + authorDisplayName: '测试者', + visitedAt: '2026-04-21T00:00:00.000Z', + }, + ]; + }, + async upsertPlatformBrowseHistoryEntries(_userId, entries) { + return entries.map((entry) => ({ + ownerUserId: entry.ownerUserId, + profileId: entry.profileId, + worldName: entry.worldName, + subtitle: entry.subtitle ?? '', + summaryText: entry.summaryText ?? '', + coverImageSrc: entry.coverImageSrc ?? null, + themeMode: entry.themeMode ?? 'mythic', + authorDisplayName: entry.authorDisplayName ?? '玩家', + visitedAt: entry.visitedAt ?? '2026-04-21T00:00:00.000Z', + })); + }, + async clearPlatformBrowseHistory() {}, + async upsertCustomWorldProfile() { + return { + entry: {} as never, + entries: [], + }; + }, + async deleteCustomWorldProfile() { + return []; + }, + async listCustomWorldSessions() { + return []; + }, + async getCustomWorldSession() { + return null; + }, + async upsertCustomWorldSession(_userId, _sessionId, session) { + return session; + }, + async publishCustomWorldProfile() { + return null; + }, + async unpublishCustomWorldProfile() { + return null; + }, + async listPublishedCustomWorldGallery() { + return []; + }, + async getPublishedCustomWorldGalleryDetail() { + return null; + }, + }; +} + +test('RpgProfileDashboardRepository 只暴露资料看板域方法', async () => { + const repository = new RpgProfileDashboardRepository( + createRuntimeRepositoryStub(), + ); + + const dashboard = await repository.getProfileDashboard('user-1'); + const playStats = await repository.getProfilePlayStats('user-1'); + const settings = await repository.getSettings('user-1'); + + assert.equal(dashboard.playedWorldCount, 0); + assert.equal(playStats.playedWorks.length, 0); + assert.equal(settings.platformTheme, 'light'); + assert.equal('listPlatformBrowseHistory' in repository, false); +}); + +test('RpgBrowseHistoryRepository 独立承接浏览历史读写,不再混入资料看板仓储', async () => { + const repository = new RpgBrowseHistoryRepository(createRuntimeRepositoryStub()); + + const history = await repository.listPlatformBrowseHistory('user-1'); + const updated = await repository.upsertPlatformBrowseHistoryEntries('user-1', [ + { + ownerUserId: 'owner-2', + profileId: 'profile-2', + worldName: '盐雾镇', + subtitle: '盐路补给点', + summaryText: '测试写入浏览历史', + coverImageSrc: null, + themeMode: 'mythic', + authorDisplayName: '测试者二号', + visitedAt: '2026-04-21T01:00:00.000Z', + }, + ]); + + assert.equal(history[0]?.worldName, '雾港'); + assert.equal(updated[0]?.profileId, 'profile-2'); + assert.equal('getProfileDashboard' in repository, false); +}); diff --git a/server-node/src/repositories/rpg-runtime/RpgRuntimeSnapshotRepository.test.ts b/server-node/src/repositories/rpg-runtime/RpgRuntimeSnapshotRepository.test.ts new file mode 100644 index 00000000..f1d01b08 --- /dev/null +++ b/server-node/src/repositories/rpg-runtime/RpgRuntimeSnapshotRepository.test.ts @@ -0,0 +1,126 @@ +import assert from 'node:assert/strict'; +import test from 'node:test'; + +import type { RuntimeRepositoryPort } from '../runtimeRepository.js'; +import { RpgRuntimeSnapshotRepository } from './RpgRuntimeSnapshotRepository.js'; + +function createRuntimeRepositoryStub(): RuntimeRepositoryPort { + const deletedUserIds: string[] = []; + + return { + async getSnapshot(userId) { + return { + version: 2, + savedAt: '2026-04-21T00:00:00.000Z', + bottomTab: 'adventure', + gameState: { + owner: userId, + }, + currentStory: null, + }; + }, + async putSnapshot(_userId, payload) { + return { + version: 2, + ...payload, + }; + }, + async getProfileDashboard() { + return { + walletBalance: 0, + totalPlayTimeMs: 0, + playedWorldCount: 0, + updatedAt: '2026-04-21T00:00:00.000Z', + }; + }, + async listProfileWalletLedger() { + return []; + }, + async getProfilePlayStats() { + return { + totalPlayTimeMs: 0, + playedWorks: [], + updatedAt: '2026-04-21T00:00:00.000Z', + }; + }, + async listProfileSaveArchives() { + return []; + }, + async resumeProfileSaveArchive() { + return null; + }, + async deleteSnapshot(userId) { + deletedUserIds.push(userId); + }, + async getSettings() { + return { + musicVolume: 0.42, + platformTheme: 'light', + }; + }, + async putSettings(_userId, settings) { + return settings; + }, + async listCustomWorldProfiles() { + return []; + }, + async listPlatformBrowseHistory() { + return []; + }, + async upsertPlatformBrowseHistoryEntries() { + return []; + }, + async clearPlatformBrowseHistory() {}, + async upsertCustomWorldProfile() { + return { + entry: {} as never, + entries: [], + }; + }, + async deleteCustomWorldProfile() { + return []; + }, + async listCustomWorldSessions() { + return []; + }, + async getCustomWorldSession() { + return null; + }, + async upsertCustomWorldSession(_userId, _sessionId, session) { + return session; + }, + async publishCustomWorldProfile() { + return null; + }, + async unpublishCustomWorldProfile() { + return null; + }, + async listPublishedCustomWorldGallery() { + return []; + }, + async getPublishedCustomWorldGalleryDetail() { + return null; + }, + }; +} + +test('RpgRuntimeSnapshotRepository 独立承接 snapshot 读写与删除职责', async () => { + const runtimeRepository = createRuntimeRepositoryStub(); + const repository = new RpgRuntimeSnapshotRepository(runtimeRepository); + + const snapshot = await repository.getSnapshot('user-7'); + const saved = await repository.putSnapshot('user-7', { + savedAt: '2026-04-21T01:00:00.000Z', + bottomTab: 'inventory', + gameState: { + owner: 'user-7', + currentScene: '雾港', + }, + currentStory: null, + }); + await repository.deleteSnapshot('user-7'); + + assert.equal(snapshot?.gameState.owner, 'user-7'); + assert.equal(saved.bottomTab, 'inventory'); + assert.equal('listProfileSaveArchives' in repository, false); +}); diff --git a/server-node/src/repositories/rpg-runtime/RpgRuntimeSnapshotRepository.ts b/server-node/src/repositories/rpg-runtime/RpgRuntimeSnapshotRepository.ts new file mode 100644 index 00000000..73c6fffb --- /dev/null +++ b/server-node/src/repositories/rpg-runtime/RpgRuntimeSnapshotRepository.ts @@ -0,0 +1,35 @@ +import type { + RuntimeRepositoryPort, + SavedSnapshot, +} from '../runtimeRepository.js'; + +/** + * RPG runtime 快照仓储端口。 + * 工作包 A 先把 snapshot 领域从大仓储中抽出独立命名入口,真实读写仍委托现有 runtimeRepository。 + */ +export type RpgRuntimeSnapshotRepositoryPort = Pick< + RuntimeRepositoryPort, + 'deleteSnapshot' | 'getSnapshot' | 'putSnapshot' +>; +export type RpgRuntimeSavedSnapshot = SavedSnapshot; + +export class RpgRuntimeSnapshotRepository + implements RpgRuntimeSnapshotRepositoryPort +{ + constructor(private readonly runtimeRepository: RuntimeRepositoryPort) {} + + getSnapshot(userId: string): Promise { + return this.runtimeRepository.getSnapshot(userId); + } + + putSnapshot( + userId: string, + payload: Omit, + ): Promise { + return this.runtimeRepository.putSnapshot(userId, payload); + } + + deleteSnapshot(userId: string): Promise { + return this.runtimeRepository.deleteSnapshot(userId); + } +} diff --git a/server-node/src/repositories/rpgWorldRepositoryShared.ts b/server-node/src/repositories/rpgWorldRepositoryShared.ts new file mode 100644 index 00000000..639255f1 --- /dev/null +++ b/server-node/src/repositories/rpgWorldRepositoryShared.ts @@ -0,0 +1,116 @@ +import type { QueryResultRow } from 'pg'; + +import type { + CustomWorldProfileRecord, +} from '../../../packages/shared/src/contracts/runtime.js'; +import { + type CustomWorldGalleryCard, + type CustomWorldLibraryEntry, + type CustomWorldPublicationStatus, + type CustomWorldSessionRecord, +} from '../../../packages/shared/src/contracts/runtime.js'; +import { extractCustomWorldLibraryMetadata } from './customWorldLibraryMetadata.js'; + +export const MAX_RPG_WORLD_PROFILE_ENTRIES = 12; +export const MAX_RPG_WORLD_GALLERY_ENTRIES = 36; + +export type RpgWorldProfileRow = QueryResultRow & { + ownerUserId: string; + profileId: string; + payload: CustomWorldProfileRecord; + visibility: CustomWorldPublicationStatus; + publishedAt: string | null; + updatedAt: string; + authorDisplayName: string; + worldName: string; + subtitle: string; + summaryText: string; + coverImageSrc: string | null; + themeMode: CustomWorldLibraryEntry['themeMode']; + playableNpcCount: number; + landmarkCount: number; +}; + +export type RpgAgentSessionRow = QueryResultRow & { + payload: CustomWorldSessionRecord; + createdAt: string; + updatedAt: string; +}; + +export type RpgWorldGalleryRow = QueryResultRow & { + ownerUserId: string; + profileId: string; + visibility: CustomWorldPublicationStatus; + publishedAt: string | null; + updatedAt: string; + authorDisplayName: string; + worldName: string; + subtitle: string; + summaryText: string; + coverImageSrc: string | null; + themeMode: CustomWorldGalleryCard['themeMode']; + playableNpcCount: number; + landmarkCount: number; +}; + +/** + * 落库前统一补齐 profileId,避免不同入口写入时出现同一世界两个 id 口径。 + */ +export function normalizeStoredRpgWorldProfile( + profileId: string, + profile: Record, +): CustomWorldProfileRecord { + return { + ...profile, + id: profileId, + }; +} + +export function toRpgWorldLibraryEntry( + row: RpgWorldProfileRow, +): CustomWorldLibraryEntry { + const fallbackMetadata = extractCustomWorldLibraryMetadata(row.payload); + + return { + ownerUserId: row.ownerUserId, + profileId: row.profileId, + profile: row.payload, + visibility: row.visibility, + publishedAt: row.publishedAt, + updatedAt: row.updatedAt, + authorDisplayName: row.authorDisplayName || '玩家', + worldName: row.worldName || fallbackMetadata.worldName, + subtitle: row.subtitle || fallbackMetadata.subtitle, + summaryText: row.summaryText || fallbackMetadata.summaryText, + coverImageSrc: row.coverImageSrc || fallbackMetadata.coverImageSrc, + themeMode: row.themeMode || fallbackMetadata.themeMode, + playableNpcCount: + row.playableNpcCount > 0 + ? row.playableNpcCount + : fallbackMetadata.playableNpcCount, + landmarkCount: + row.landmarkCount > 0 + ? row.landmarkCount + : fallbackMetadata.landmarkCount, + }; +} + +export function toRpgWorldGalleryCard( + row: RpgWorldGalleryRow, +): CustomWorldGalleryCard { + return { + ownerUserId: row.ownerUserId, + profileId: row.profileId, + visibility: row.visibility, + publishedAt: row.publishedAt, + updatedAt: row.updatedAt, + authorDisplayName: row.authorDisplayName || '玩家', + worldName: row.worldName || '未命名世界', + subtitle: row.subtitle || '', + summaryText: row.summaryText || '', + coverImageSrc: row.coverImageSrc || null, + themeMode: row.themeMode || 'mythic', + playableNpcCount: row.playableNpcCount, + landmarkCount: row.landmarkCount, + }; +} diff --git a/server-node/src/repositories/runtimeRepository.ts b/server-node/src/repositories/runtimeRepository.ts index ade6f6c8..aed72c0e 100644 --- a/server-node/src/repositories/runtimeRepository.ts +++ b/server-node/src/repositories/runtimeRepository.ts @@ -25,9 +25,9 @@ import { } from '../../../packages/shared/src/contracts/runtime.js'; import type { AppDatabase } from '../db.js'; import { extractCustomWorldLibraryMetadata } from './customWorldLibraryMetadata.js'; - -const MAX_CUSTOM_WORLD_PROFILES = 12; -const MAX_PUBLIC_CUSTOM_WORLD_PROFILES = 36; +import { RpgAgentSessionRepository } from './RpgAgentSessionRepository.js'; +import { RpgWorldProfileRepository } from './RpgWorldProfileRepository.js'; +import { normalizeStoredRpgWorldProfile } from './rpgWorldRepositoryShared.js'; export type SavedSnapshot = SavedGameSnapshot; @@ -44,45 +44,6 @@ type SettingsRow = QueryResultRow & { platformTheme: RuntimeSettings['platformTheme']; }; -type CustomWorldEntryRow = QueryResultRow & { - ownerUserId: string; - profileId: string; - payload: CustomWorldProfileRecord; - visibility: CustomWorldPublicationStatus; - publishedAt: string | null; - updatedAt: string; - authorDisplayName: string; - worldName: string; - subtitle: string; - summaryText: string; - coverImageSrc: string | null; - themeMode: CustomWorldLibraryEntry['themeMode']; - playableNpcCount: number; - landmarkCount: number; -}; - -type SessionRow = QueryResultRow & { - payload: CustomWorldSessionRecord; - createdAt: string; - updatedAt: string; -}; - -type CustomWorldCardRow = QueryResultRow & { - ownerUserId: string; - profileId: string; - visibility: CustomWorldPublicationStatus; - publishedAt: string | null; - updatedAt: string; - authorDisplayName: string; - worldName: string; - subtitle: string; - summaryText: string; - coverImageSrc: string | null; - themeMode: CustomWorldGalleryCard['themeMode']; - playableNpcCount: number; - landmarkCount: number; -}; - type PlatformBrowseHistoryRow = QueryResultRow & { ownerUserId: string; profileId: string; @@ -227,65 +188,6 @@ export type RuntimeRepositoryPort = { ): Promise | null>; }; -function normalizeStoredProfile( - profileId: string, - profile: Record, -): CustomWorldProfileRecord { - return { - ...profile, - id: profileId, - }; -} - -function toCustomWorldLibraryEntry( - row: CustomWorldEntryRow, -): CustomWorldLibraryEntry { - const fallbackMetadata = extractCustomWorldLibraryMetadata(row.payload); - - return { - ownerUserId: row.ownerUserId, - profileId: row.profileId, - profile: row.payload, - visibility: row.visibility, - publishedAt: row.publishedAt, - updatedAt: row.updatedAt, - authorDisplayName: row.authorDisplayName || '玩家', - worldName: row.worldName || fallbackMetadata.worldName, - subtitle: row.subtitle || fallbackMetadata.subtitle, - summaryText: row.summaryText || fallbackMetadata.summaryText, - coverImageSrc: row.coverImageSrc || fallbackMetadata.coverImageSrc, - themeMode: row.themeMode || fallbackMetadata.themeMode, - playableNpcCount: - row.playableNpcCount > 0 - ? row.playableNpcCount - : fallbackMetadata.playableNpcCount, - landmarkCount: - row.landmarkCount > 0 - ? row.landmarkCount - : fallbackMetadata.landmarkCount, - }; -} - -function toCustomWorldGalleryCard( - row: CustomWorldCardRow, -): CustomWorldGalleryCard { - return { - ownerUserId: row.ownerUserId, - profileId: row.profileId, - visibility: row.visibility, - publishedAt: row.publishedAt, - updatedAt: row.updatedAt, - authorDisplayName: row.authorDisplayName || '玩家', - worldName: row.worldName || '未命名世界', - subtitle: row.subtitle || '', - summaryText: row.summaryText || '', - coverImageSrc: row.coverImageSrc || null, - themeMode: row.themeMode || 'mythic', - playableNpcCount: row.playableNpcCount, - landmarkCount: row.landmarkCount, - }; -} - function toPlatformBrowseHistoryEntry( row: PlatformBrowseHistoryRow, ): PlatformBrowseHistoryEntry { @@ -678,7 +580,7 @@ function resolveProfileSaveArchiveMeta( if (customWorldProfile) { const profileId = readString(customWorldProfile.id) || 'custom-world'; const metadata = extractCustomWorldLibraryMetadata( - normalizeStoredProfile(profileId, customWorldProfile), + normalizeStoredRpgWorldProfile(profileId, customWorldProfile), ); return { @@ -717,33 +619,12 @@ function resolveProfileSaveArchiveMeta( } export class RuntimeRepository implements RuntimeRepositoryPort { - constructor(private readonly db: AppDatabase) {} + private readonly rpgAgentSessionRepository: RpgAgentSessionRepository; + private readonly rpgWorldProfileRepository: RpgWorldProfileRepository; - private async findCustomWorldProfileEntry(userId: string, profileId: string) { - const result = await this.db.query( - `SELECT user_id AS "ownerUserId", - profile_id AS "profileId", - payload_json AS payload, - visibility, - published_at AS "publishedAt", - updated_at AS "updatedAt", - author_display_name AS "authorDisplayName", - world_name AS "worldName", - subtitle, - summary_text AS "summaryText", - cover_image_src AS "coverImageSrc", - theme_mode AS "themeMode", - playable_npc_count AS "playableNpcCount", - landmark_count AS "landmarkCount" - FROM custom_world_profiles - WHERE user_id = $1 - AND profile_id = $2 - AND deleted_at IS NULL`, - [userId, profileId], - ); - - const row = result.rows[0]; - return row ? toCustomWorldLibraryEntry(row) : null; + constructor(private readonly db: AppDatabase) { + this.rpgAgentSessionRepository = new RpgAgentSessionRepository(db); + this.rpgWorldProfileRepository = new RpgWorldProfileRepository(db); } private async getProfileDashboardState(userId: string) { @@ -1043,52 +924,13 @@ export class RuntimeRepository implements RuntimeRepositoryPort { return; } - const payload = normalizeStoredProfile(profileId, customWorldProfile); - const metadata = extractCustomWorldLibraryMetadata(payload); const syncedAt = snapshot.savedAt || new Date().toISOString(); - await this.db.query( - `INSERT INTO custom_world_profiles ( - user_id, - profile_id, - payload_json, - updated_at, - author_display_name, - world_name, - subtitle, - summary_text, - cover_image_src, - theme_mode, - playable_npc_count, - landmark_count, - deleted_at - ) - VALUES ($1, $2, $3, $4, $5, $6, $7, $8, $9, $10, $11, $12, NULL) - ON CONFLICT (user_id, profile_id) DO UPDATE SET - payload_json = EXCLUDED.payload_json, - updated_at = EXCLUDED.updated_at, - deleted_at = NULL, - world_name = EXCLUDED.world_name, - subtitle = EXCLUDED.subtitle, - summary_text = EXCLUDED.summary_text, - cover_image_src = EXCLUDED.cover_image_src, - theme_mode = EXCLUDED.theme_mode, - playable_npc_count = EXCLUDED.playable_npc_count, - landmark_count = EXCLUDED.landmark_count`, - [ - userId, - profileId, - payload, - syncedAt, - '玩家', - metadata.worldName, - metadata.subtitle, - metadata.summaryText, - metadata.coverImageSrc, - metadata.themeMode, - metadata.playableNpcCount, - metadata.landmarkCount, - ], + await this.rpgWorldProfileRepository.syncProfileFromSnapshot( + userId, + profileId, + customWorldProfile, + syncedAt, ); } @@ -1394,29 +1236,7 @@ export class RuntimeRepository implements RuntimeRepositoryPort { } async listCustomWorldProfiles(userId: string) { - const result = await this.db.query( - `SELECT user_id AS "ownerUserId", - profile_id AS "profileId", - payload_json AS payload, - visibility, - published_at AS "publishedAt", - updated_at AS "updatedAt", - author_display_name AS "authorDisplayName", - world_name AS "worldName", - subtitle, - summary_text AS "summaryText", - cover_image_src AS "coverImageSrc", - theme_mode AS "themeMode", - playable_npc_count AS "playableNpcCount", - landmark_count AS "landmarkCount" - FROM custom_world_profiles - WHERE user_id = $1 - AND deleted_at IS NULL - ORDER BY updated_at DESC - LIMIT $2`, - [userId, MAX_CUSTOM_WORLD_PROFILES], - ); - return result.rows.map((row) => toCustomWorldLibraryEntry(row)); + return this.rpgWorldProfileRepository.listOwnProfiles(userId); } async upsertCustomWorldProfile( @@ -1425,120 +1245,27 @@ export class RuntimeRepository implements RuntimeRepositoryPort { profile: Record, authorDisplayName: string, ) { - const payload = normalizeStoredProfile(profileId, profile); - const metadata = extractCustomWorldLibraryMetadata(payload); - const now = new Date().toISOString(); - - await this.db.query( - `INSERT INTO custom_world_profiles ( - user_id, - profile_id, - payload_json, - updated_at, - author_display_name, - world_name, - subtitle, - summary_text, - cover_image_src, - theme_mode, - playable_npc_count, - landmark_count - ) - VALUES ($1, $2, $3, $4, $5, $6, $7, $8, $9, $10, $11, $12) - ON CONFLICT (user_id, profile_id) DO UPDATE SET - payload_json = EXCLUDED.payload_json, - updated_at = EXCLUDED.updated_at, - deleted_at = NULL, - author_display_name = EXCLUDED.author_display_name, - world_name = EXCLUDED.world_name, - subtitle = EXCLUDED.subtitle, - summary_text = EXCLUDED.summary_text, - cover_image_src = EXCLUDED.cover_image_src, - theme_mode = EXCLUDED.theme_mode, - playable_npc_count = EXCLUDED.playable_npc_count, - landmark_count = EXCLUDED.landmark_count`, - [ - userId, - profileId, - payload, - now, - authorDisplayName || '玩家', - metadata.worldName, - metadata.subtitle, - metadata.summaryText, - metadata.coverImageSrc, - metadata.themeMode, - metadata.playableNpcCount, - metadata.landmarkCount, - ], + return this.rpgWorldProfileRepository.upsertOwnProfile( + userId, + profileId, + profile, + authorDisplayName, ); - - const entry = await this.findCustomWorldProfileEntry(userId, profileId); - if (!entry) { - throw new Error('failed to resolve custom world after upsert'); - } - - return { - entry, - entries: await this.listCustomWorldProfiles(userId), - }; } async deleteCustomWorldProfile(userId: string, profileId: string) { - const deletedAt = new Date().toISOString(); - await this.db.query( - `UPDATE custom_world_profiles - SET deleted_at = $1, - updated_at = $1, - visibility = 'draft', - published_at = NULL - WHERE user_id = $2 - AND profile_id = $3 - AND deleted_at IS NULL`, - [deletedAt, userId, profileId], + return this.rpgWorldProfileRepository.softDeleteOwnProfile( + userId, + profileId, ); - - return this.listCustomWorldProfiles(userId); } async listCustomWorldSessions(userId: string) { - const result = await this.db.query( - `SELECT payload_json AS payload, - created_at AS "createdAt", - updated_at AS "updatedAt" - FROM custom_world_sessions - WHERE user_id = $1 - ORDER BY updated_at DESC`, - [userId], - ); - - return result.rows.map((row) => ({ - ...row.payload, - createdAt: row.createdAt, - updatedAt: row.updatedAt, - })); + return this.rpgAgentSessionRepository.listSessions(userId); } async getCustomWorldSession(userId: string, sessionId: string) { - const result = await this.db.query( - `SELECT payload_json AS payload, - created_at AS "createdAt", - updated_at AS "updatedAt" - FROM custom_world_sessions - WHERE user_id = $1 AND session_id = $2`, - [userId, sessionId], - ); - const row = result.rows[0]; - - if (!row) { - return null; - } - - return { - ...row.payload, - createdAt: row.createdAt, - updatedAt: row.updatedAt, - }; + return this.rpgAgentSessionRepository.getSession(userId, sessionId); } async upsertCustomWorldSession( @@ -1546,30 +1273,11 @@ export class RuntimeRepository implements RuntimeRepositoryPort { sessionId: string, session: CustomWorldSessionRecord, ) { - const payload = { - ...session, + return this.rpgAgentSessionRepository.upsertSession( + userId, sessionId, - } satisfies CustomWorldSessionRecord; - - await this.db.query( - `INSERT INTO custom_world_sessions ( - user_id, - session_id, - payload_json, - created_at, - updated_at - ) VALUES ($1, $2, $3, $4, $5) - ON CONFLICT (user_id, session_id) DO UPDATE SET - payload_json = EXCLUDED.payload_json, - updated_at = EXCLUDED.updated_at`, - [userId, sessionId, payload, session.createdAt, session.updatedAt], + session, ); - - return { - ...payload, - createdAt: session.createdAt, - updatedAt: session.updatedAt, - }; } async publishCustomWorldProfile( @@ -1577,57 +1285,11 @@ export class RuntimeRepository implements RuntimeRepositoryPort { profileId: string, authorDisplayName: string, ) { - const existingEntry = await this.findCustomWorldProfileEntry( + return this.rpgWorldProfileRepository.publishOwnProfile( userId, profileId, + authorDisplayName, ); - if (!existingEntry) { - return null; - } - - const payload = normalizeStoredProfile(profileId, existingEntry.profile); - const metadata = extractCustomWorldLibraryMetadata(payload); - const now = new Date().toISOString(); - - await this.db.query( - `UPDATE custom_world_profiles - SET visibility = 'published', - published_at = $1, - updated_at = $1, - author_display_name = $2, - world_name = $3, - subtitle = $4, - summary_text = $5, - cover_image_src = $6, - theme_mode = $7, - playable_npc_count = $8, - landmark_count = $9 - WHERE user_id = $10 - AND profile_id = $11`, - [ - now, - authorDisplayName || '玩家', - metadata.worldName, - metadata.subtitle, - metadata.summaryText, - metadata.coverImageSrc, - metadata.themeMode, - metadata.playableNpcCount, - metadata.landmarkCount, - userId, - profileId, - ], - ); - - const entry = await this.findCustomWorldProfileEntry(userId, profileId); - if (!entry) { - throw new Error('failed to resolve custom world after publish'); - } - - return { - entry, - entries: await this.listCustomWorldProfiles(userId), - }; } async unpublishCustomWorldProfile( @@ -1635,113 +1297,24 @@ export class RuntimeRepository implements RuntimeRepositoryPort { profileId: string, authorDisplayName: string, ) { - const existingEntry = await this.findCustomWorldProfileEntry( + return this.rpgWorldProfileRepository.unpublishOwnProfile( userId, profileId, + authorDisplayName, ); - if (!existingEntry) { - return null; - } - - const payload = normalizeStoredProfile(profileId, existingEntry.profile); - const metadata = extractCustomWorldLibraryMetadata(payload); - const now = new Date().toISOString(); - - await this.db.query( - `UPDATE custom_world_profiles - SET visibility = 'draft', - published_at = NULL, - updated_at = $1, - author_display_name = $2, - world_name = $3, - subtitle = $4, - summary_text = $5, - cover_image_src = $6, - theme_mode = $7, - playable_npc_count = $8, - landmark_count = $9 - WHERE user_id = $10 - AND profile_id = $11`, - [ - now, - authorDisplayName || '玩家', - metadata.worldName, - metadata.subtitle, - metadata.summaryText, - metadata.coverImageSrc, - metadata.themeMode, - metadata.playableNpcCount, - metadata.landmarkCount, - userId, - profileId, - ], - ); - - const entry = await this.findCustomWorldProfileEntry(userId, profileId); - if (!entry) { - throw new Error('failed to resolve custom world after unpublish'); - } - - return { - entry, - entries: await this.listCustomWorldProfiles(userId), - }; } async listPublishedCustomWorldGallery() { - const result = await this.db.query( - `SELECT user_id AS "ownerUserId", - profile_id AS "profileId", - visibility, - published_at AS "publishedAt", - updated_at AS "updatedAt", - author_display_name AS "authorDisplayName", - world_name AS "worldName", - subtitle, - summary_text AS "summaryText", - cover_image_src AS "coverImageSrc", - theme_mode AS "themeMode", - playable_npc_count AS "playableNpcCount", - landmark_count AS "landmarkCount" - FROM custom_world_profiles - WHERE visibility = 'published' - AND deleted_at IS NULL - ORDER BY published_at DESC, updated_at DESC - LIMIT $1`, - [MAX_PUBLIC_CUSTOM_WORLD_PROFILES], - ); - - return result.rows.map((row) => toCustomWorldGalleryCard(row)); + return this.rpgWorldProfileRepository.listPublishedGallery(); } async getPublishedCustomWorldGalleryDetail( ownerUserId: string, profileId: string, ) { - const result = await this.db.query( - `SELECT user_id AS "ownerUserId", - profile_id AS "profileId", - payload_json AS payload, - visibility, - published_at AS "publishedAt", - updated_at AS "updatedAt", - author_display_name AS "authorDisplayName", - world_name AS "worldName", - subtitle, - summary_text AS "summaryText", - cover_image_src AS "coverImageSrc", - theme_mode AS "themeMode", - playable_npc_count AS "playableNpcCount", - landmark_count AS "landmarkCount" - FROM custom_world_profiles - WHERE user_id = $1 - AND profile_id = $2 - AND visibility = 'published' - AND deleted_at IS NULL`, - [ownerUserId, profileId], + return this.rpgWorldProfileRepository.getPublishedGalleryDetail( + ownerUserId, + profileId, ); - - const row = result.rows[0]; - return row ? toCustomWorldLibraryEntry(row) : null; } } diff --git a/server-node/src/routes/authRoutes.ts b/server-node/src/routes/authRoutes.ts index 4afe0e86..025d6ca8 100644 --- a/server-node/src/routes/authRoutes.ts +++ b/server-node/src/routes/authRoutes.ts @@ -1,4 +1,4 @@ -import { type Request, Router } from 'express'; +import { type Request, type Response, Router } from 'express'; import { z } from 'zod'; import type { @@ -376,6 +376,7 @@ export function createAuthRoutes(context: AppContext) { buildRefreshCookieLifetimeSeconds(context, result.refreshExpiresAt), ); sendApiResponse(response, { + ok: true, token: result.token, }); } catch (error) { diff --git a/server-node/src/routes/customWorldAgent.ts b/server-node/src/routes/customWorldAgent.ts index 33e8762f..b862452c 100644 --- a/server-node/src/routes/customWorldAgent.ts +++ b/server-node/src/routes/customWorldAgent.ts @@ -9,6 +9,7 @@ import type { import type { AppContext } from '../context.js'; import { badRequest, notFound } from '../errors.js'; import { asyncHandler, prepareApiResponse, sendApiResponse } from '../http.js'; +import { requireJwtAuth } from '../middleware/auth.js'; import { routeMeta } from '../middleware/routeMeta.js'; const createSessionSchema = z.object({ @@ -39,6 +40,10 @@ const actionSchema = z.discriminatedUnion('action', [ ) .min(1), }), + z.object({ + action: z.literal('sync_result_profile'), + profile: z.record(z.string(), z.unknown()), + }), z.object({ action: z.literal('generate_characters'), count: z.number().int().min(1).max(3), @@ -63,9 +68,29 @@ const actionSchema = z.discriminatedUnion('action', [ generatedAnimationSetId: z.string().trim().nullable().optional(), animationMap: z.record(z.string(), z.unknown()).nullable().optional(), }), + z.object({ + action: z.literal('generate_scene_assets'), + sceneIds: z.array(z.string().trim().min(1)).min(1), + }), + z.object({ + action: z.literal('sync_scene_assets'), + sceneId: z.string().trim().min(1), + sceneKind: z.enum(['camp', 'landmark']), + imageSrc: z.string().trim().min(1), + generatedSceneAssetId: z.string().trim().min(1), + generatedScenePrompt: z.string().trim().nullable().optional(), + generatedSceneModel: z.string().trim().nullable().optional(), + }), + z.object({ + action: z.literal('expand_long_tail'), + }), z.object({ action: z.literal('publish_world'), }), + z.object({ + action: z.literal('revert_checkpoint'), + checkpointId: z.string().trim().min(1), + }), ]); function readParam(param: string | string[] | undefined) { @@ -74,6 +99,9 @@ function readParam(param: string | string[] | undefined) { export function createCustomWorldAgentRoutes(context: AppContext) { const router = Router(); + const requireAuth = requireJwtAuth(context.config, context.userRepository); + + router.use(requireAuth); router.post( '/sessions', diff --git a/server-node/src/routes/rpg-entry/rpgEntrySaveRoutes.ts b/server-node/src/routes/rpg-entry/rpgEntrySaveRoutes.ts new file mode 100644 index 00000000..52e88c76 --- /dev/null +++ b/server-node/src/routes/rpg-entry/rpgEntrySaveRoutes.ts @@ -0,0 +1,151 @@ +import { Router } from 'express'; +import { z } from 'zod'; + +import type { + ProfileSaveArchiveResumeResponse, + SavedGameSnapshotInput, +} from '../../../../packages/shared/src/contracts/runtime.js'; +import type { AppContext } from '../../context.js'; +import { badRequest, notFound } from '../../errors.js'; +import { asyncHandler, sendApiResponse } from '../../http.js'; +import { requireJwtAuth } from '../../middleware/auth.js'; +import { routeMeta } from '../../middleware/routeMeta.js'; +import { + hydrateSavedSnapshot, + normalizeSavedSnapshotPayload, +} from '../../modules/runtime/runtimeSnapshotHydration.js'; + +const saveSnapshotSchema = z.object({ + gameState: z.unknown(), + bottomTab: z.string().trim().min(1), + currentStory: z.unknown().nullable().optional().default(null), + savedAt: z.string().trim().optional().default(''), +}); + +export const RPG_ENTRY_SAVE_ROUTE_BASE_PATH = '/api/runtime/save'; +export const RPG_ENTRY_SAVE_ARCHIVE_ROUTE_BASE_PATH = + '/api/runtime/profile/save-archives'; +export const RPG_ENTRY_SAVE_ARCHIVE_LEGACY_ROUTE_BASE_PATH = + '/api/profile/save-archives'; + +function readParam(param: string | string[] | undefined) { + return Array.isArray(param) ? param[0]?.trim() || '' : param?.trim() || ''; +} + +function routeCompatPaths(path: string) { + return [path, path.replace('runtime/', '')] as const; +} + +export function createRpgEntrySaveRoutes(context: AppContext) { + const router = Router(); + const requireAuth = requireJwtAuth(context.config, context.userRepository); + + router.get( + '/runtime/save/snapshot', + requireAuth, + routeMeta({ operation: 'runtime.snapshot.get' }), + asyncHandler(async (request, response) => { + sendApiResponse( + response, + hydrateSavedSnapshot( + await context.rpgRuntimeSnapshotRepository.getSnapshot(request.userId!), + ), + ); + }), + ); + + router.put( + '/runtime/save/snapshot', + requireAuth, + routeMeta({ operation: 'runtime.snapshot.put' }), + asyncHandler(async (request, response) => { + const payload = saveSnapshotSchema.parse( + request.body, + ) as SavedGameSnapshotInput; + const normalizedSnapshot = normalizeSavedSnapshotPayload({ + savedAt: payload.savedAt || new Date().toISOString(), + gameState: payload.gameState, + bottomTab: payload.bottomTab, + currentStory: payload.currentStory ?? null, + }); + sendApiResponse( + response, + hydrateSavedSnapshot( + await context.rpgRuntimeSnapshotRepository.putSnapshot( + request.userId!, + normalizedSnapshot, + ), + ), + ); + }), + ); + + router.delete( + '/runtime/save/snapshot', + requireAuth, + routeMeta({ operation: 'runtime.snapshot.delete' }), + asyncHandler(async (request, response) => { + await context.rpgRuntimeSnapshotRepository.deleteSnapshot(request.userId!); + sendApiResponse(response, { ok: true }); + }), + ); + + [ + '/runtime/profile/save-archives/:worldKey', + '/profile/save-archives/:worldKey', + ].forEach((path, index) => { + router.post( + path, + requireAuth, + routeMeta({ + operation: + index === 0 + ? 'profile.saveArchives.resume' + : 'profile.saveArchives.resume.compat', + }), + asyncHandler(async (request, response) => { + const worldKey = readParam(request.params.worldKey); + if (!worldKey) { + throw badRequest('worldKey 不能为空'); + } + + const resumedArchive = + await context.rpgSaveArchiveRepository.resumeProfileSaveArchive( + request.userId!, + worldKey, + ); + + if (!resumedArchive) { + throw notFound('指定存档不存在'); + } + + sendApiResponse(response, { + entry: resumedArchive.entry, + snapshot: hydrateSavedSnapshot(resumedArchive.snapshot)!, + }); + }), + ); + }); + + routeCompatPaths('/api/runtime/profile/save-archives').forEach((path, index) => { + router.get( + path.replace('/api/', '/'), + requireAuth, + routeMeta({ + operation: + index === 0 + ? 'profile.saveArchives.list' + : 'profile.saveArchives.list.compat', + }), + asyncHandler(async (request, response) => { + sendApiResponse(response, { + entries: await context.rpgSaveArchiveRepository.listProfileSaveArchives( + request.userId!, + ), + }); + }), + ); + }); + + return router; +} diff --git a/server-node/src/routes/rpg-entry/rpgWorldLibraryRoutes.ts b/server-node/src/routes/rpg-entry/rpgWorldLibraryRoutes.ts new file mode 100644 index 00000000..5be8d8f0 --- /dev/null +++ b/server-node/src/routes/rpg-entry/rpgWorldLibraryRoutes.ts @@ -0,0 +1,338 @@ +import { Router } from 'express'; +import { z } from 'zod'; + +import type { ListCustomWorldWorksResponse } from '../../../../packages/shared/src/contracts/customWorldAgent.js'; +import type { + CustomWorldGalleryDetailResponse, + CustomWorldGalleryResponse, + CustomWorldLibraryMutationResponse, + CustomWorldLibraryResponse, +} from '../../../../packages/shared/src/contracts/runtime.js'; +import type { AppContext } from '../../context.js'; +import { badRequest, conflict, notFound } from '../../errors.js'; +import { asyncHandler, jsonClone, sendApiResponse } from '../../http.js'; +import { requireJwtAuth } from '../../middleware/auth.js'; +import { routeMeta } from '../../middleware/routeMeta.js'; +import { CustomWorldAgentPublishingService } from '../../services/customWorldAgentPublishingService.js'; + +const jsonObjectSchema = z.record(z.string(), z.unknown()); + +const customWorldProfileSchema = z.object({ + profile: jsonObjectSchema, +}); + +export const RPG_WORLD_LIBRARY_ROUTE_BASE_PATH = + '/api/runtime/custom-world-library'; +export const RPG_WORLD_GALLERY_ROUTE_BASE_PATH = + '/api/runtime/custom-world-gallery'; +export const RPG_WORLD_WORKS_ROUTE_BASE_PATH = + '/api/runtime/custom-world/works'; +const AGENT_DRAFT_PROFILE_ID_PREFIX = 'agent-draft-'; + +function readParam(param: string | string[] | undefined) { + return Array.isArray(param) ? param[0]?.trim() || '' : param?.trim() || ''; +} + +function toText(value: unknown) { + return typeof value === 'string' ? value.trim() : ''; +} + +function resolveAgentSessionIdFromProfileId(profileId: string) { + if (!profileId.startsWith(AGENT_DRAFT_PROFILE_ID_PREFIX)) { + return null; + } + + const sessionId = profileId.slice(AGENT_DRAFT_PROFILE_ID_PREFIX.length).trim(); + return sessionId || null; +} + +function resolvePublishedWorldName(profile: unknown) { + const profileRecord = + profile && typeof profile === 'object' && !Array.isArray(profile) + ? (profile as Record) + : null; + + return toText(profileRecord?.name) || '当前世界'; +} + +async function syncAgentSessionPublishedState(params: { + context: AppContext; + userId: string; + sessionId: string; + worldName: string; + qualityFindings: Array<{ + id: string; + severity: 'info' | 'warning' | 'blocker'; + code: string; + targetId?: string | null; + message: string; + }>; +}) { + const publishedQualityFindings = params.qualityFindings.filter( + (entry) => entry.severity !== 'blocker', + ); + const publishedState = { + stage: 'published' as const, + qualityFindings: publishedQualityFindings, + }; + + await params.context.customWorldAgentSessions.replaceDerivedState( + params.userId, + params.sessionId, + publishedState, + ); + await params.context.customWorldAgentSessions.appendCheckpoint( + params.userId, + params.sessionId, + { + label: `发布世界 ${params.worldName}`, + snapshot: publishedState, + }, + ); + await params.context.customWorldAgentSessions.appendMessage( + params.userId, + params.sessionId, + { + id: `message-${Date.now().toString(36)}-library-publish`, + role: 'assistant', + kind: 'action_result', + text: + publishedQualityFindings.length > 0 + ? `世界「${params.worldName}」已发布,并保留 ${publishedQualityFindings.length} 条 warning 供后续继续优化。` + : `世界「${params.worldName}」已正式发布,可以进入作品库与世界入口。`, + createdAt: new Date().toISOString(), + relatedOperationId: null, + }, + ); +} + +async function resolveAuthDisplayName(context: AppContext, userId: string) { + const user = await context.userRepository.findById(userId); + if (!user) { + throw notFound('user not found'); + } + + return user.displayName?.trim() || '玩家'; +} + +export function createRpgWorldLibraryRoutes(context: AppContext) { + const router = Router(); + const requireAuth = requireJwtAuth(context.config, context.userRepository); + const publishingService = new CustomWorldAgentPublishingService( + context.rpgWorldProfileRepository, + ); + + router.get( + '/runtime/custom-world-gallery', + routeMeta({ operation: 'runtime.customWorldGallery.list' }), + asyncHandler(async (_request, response) => { + sendApiResponse(response, { + entries: + await context.rpgWorldLibraryRepository.listPublishedCustomWorldGallery(), + } satisfies CustomWorldGalleryResponse); + }), + ); + + router.get( + '/runtime/custom-world-gallery/:ownerUserId/:profileId', + routeMeta({ operation: 'runtime.customWorldGallery.detail' }), + asyncHandler(async (request, response) => { + const ownerUserId = readParam(request.params.ownerUserId); + const profileId = readParam(request.params.profileId); + if (!ownerUserId || !profileId) { + throw badRequest('ownerUserId and profileId are required'); + } + + const entry = + await context.rpgWorldLibraryRepository.getPublishedCustomWorldGalleryDetail( + ownerUserId, + profileId, + ); + if (!entry) { + throw notFound('public custom world not found'); + } + + sendApiResponse(response, { + entry, + } satisfies CustomWorldGalleryDetailResponse); + }), + ); + + router.get( + '/runtime/custom-world/works', + requireAuth, + routeMeta({ operation: 'runtime.customWorldWorks.list' }), + asyncHandler(async (request, response) => { + sendApiResponse(response, { + items: await context.rpgWorldWorkSummaryService.list(request.userId!), + }); + }), + ); + + router.get( + '/runtime/custom-world-library', + requireAuth, + routeMeta({ operation: 'runtime.customWorldLibrary.list' }), + asyncHandler(async (request, response) => { + sendApiResponse(response, { + entries: await context.rpgWorldLibraryRepository.listCustomWorldProfiles( + request.userId!, + ), + } satisfies CustomWorldLibraryResponse); + }), + ); + + router.put( + '/runtime/custom-world-library/:profileId', + requireAuth, + routeMeta({ operation: 'runtime.customWorldLibrary.upsert' }), + asyncHandler(async (request, response) => { + const profileId = readParam(request.params.profileId); + if (!profileId) { + throw badRequest('profileId is required'); + } + + const payload = customWorldProfileSchema.parse(request.body); + const authorDisplayName = await resolveAuthDisplayName( + context, + request.userId!, + ); + sendApiResponse( + response, + await context.rpgWorldLibraryRepository.upsertCustomWorldProfile( + request.userId!, + profileId, + jsonClone(payload.profile), + authorDisplayName, + ), + ); + }), + ); + + router.delete( + '/runtime/custom-world-library/:profileId', + requireAuth, + routeMeta({ operation: 'runtime.customWorldLibrary.delete' }), + asyncHandler(async (request, response) => { + const profileId = readParam(request.params.profileId); + if (!profileId) { + throw badRequest('profileId is required'); + } + + sendApiResponse(response, { + entries: await context.rpgWorldLibraryRepository.deleteCustomWorldProfile( + request.userId!, + profileId, + ), + } satisfies CustomWorldLibraryResponse); + }), + ); + + router.post( + '/runtime/custom-world-library/:profileId/publish', + requireAuth, + routeMeta({ operation: 'runtime.customWorldLibrary.publish' }), + asyncHandler(async (request, response) => { + const profileId = readParam(request.params.profileId); + if (!profileId) { + throw badRequest('profileId is required'); + } + + const authorDisplayName = await resolveAuthDisplayName( + context, + request.userId!, + ); + const agentSessionId = resolveAgentSessionIdFromProfileId(profileId); + if (agentSessionId) { + const agentSession = await context.customWorldAgentSessions.get( + request.userId!, + agentSessionId, + ); + + if (agentSession) { + try { + publishingService.buildPublishReadiness({ + sessionId: agentSessionId, + draftProfile: agentSession.draftProfile, + qualityFindings: agentSession.qualityFindings, + }); + } catch (error) { + throw conflict( + error instanceof Error + ? error.message + : '当前世界还没有通过发布校验。', + ); + } + + const publishResult = await publishingService.publishSessionDraft({ + userId: request.userId!, + authorDisplayName, + sessionId: agentSessionId, + draftProfile: + (agentSession.draftProfile ?? {}) as Record, + qualityFindings: agentSession.qualityFindings, + }); + await syncAgentSessionPublishedState({ + context, + userId: request.userId!, + sessionId: agentSessionId, + worldName: resolvePublishedWorldName(publishResult.publishedProfile), + qualityFindings: agentSession.qualityFindings, + }); + sendApiResponse( + response, + publishResult.mutation satisfies CustomWorldLibraryMutationResponse, + ); + return; + } + } + + const mutation = await context.rpgWorldLibraryRepository.publishCustomWorldProfile( + request.userId!, + profileId, + authorDisplayName, + ); + if (!mutation) { + throw notFound('custom world not found'); + } + + sendApiResponse( + response, + mutation satisfies CustomWorldLibraryMutationResponse, + ); + }), + ); + + router.post( + '/runtime/custom-world-library/:profileId/unpublish', + requireAuth, + routeMeta({ operation: 'runtime.customWorldLibrary.unpublish' }), + asyncHandler(async (request, response) => { + const profileId = readParam(request.params.profileId); + if (!profileId) { + throw badRequest('profileId is required'); + } + + const authorDisplayName = await resolveAuthDisplayName( + context, + request.userId!, + ); + const mutation = + await context.rpgWorldLibraryRepository.unpublishCustomWorldProfile( + request.userId!, + profileId, + authorDisplayName, + ); + if (!mutation) { + throw notFound('custom world not found'); + } + + sendApiResponse( + response, + mutation satisfies CustomWorldLibraryMutationResponse, + ); + }), + ); + + return router; +} diff --git a/server-node/src/routes/rpg-profile/rpgProfileRoutes.ts b/server-node/src/routes/rpg-profile/rpgProfileRoutes.ts new file mode 100644 index 00000000..e86076b5 --- /dev/null +++ b/server-node/src/routes/rpg-profile/rpgProfileRoutes.ts @@ -0,0 +1,214 @@ +import { Router } from 'express'; +import { z } from 'zod'; + +import type { + PlatformBrowseHistoryBatchSyncRequest, + PlatformBrowseHistoryResponse, + PlatformBrowseHistoryWriteEntry, + ProfileDashboardSummary, + ProfilePlayStatsResponse, + ProfileWalletLedgerResponse, + RuntimeSettings, +} from '../../../../packages/shared/src/contracts/runtime.js'; +import { PLATFORM_THEMES } from '../../../../packages/shared/src/contracts/runtime.js'; +import type { AppContext } from '../../context.js'; +import { asyncHandler, sendApiResponse } from '../../http.js'; +import { requireJwtAuth } from '../../middleware/auth.js'; +import { routeMeta } from '../../middleware/routeMeta.js'; + +const platformBrowseHistoryEntrySchema = z.object({ + ownerUserId: z.string().trim().min(1), + profileId: z.string().trim().min(1), + worldName: z.string().trim().min(1), + subtitle: z.string().trim().optional().default(''), + summaryText: z.string().trim().optional().default(''), + coverImageSrc: z.string().trim().nullable().optional().default(null), + themeMode: z.string().trim().optional().default('mythic'), + authorDisplayName: z.string().trim().optional().default('玩家'), + visitedAt: z.string().trim().optional().default(''), +}); + +const platformBrowseHistoryBatchSchema = z.object({ + entries: z.array(platformBrowseHistoryEntrySchema).max(100), +}); + +const settingsSchema = z.object({ + musicVolume: z.number().min(0).max(1), + platformTheme: z.enum(PLATFORM_THEMES), +}); + +export const RPG_PROFILE_ROUTE_BASE_PATH = '/api/runtime/profile'; +export const RPG_PROFILE_LEGACY_ROUTE_BASE_PATH = '/api/profile'; + +function routeCompatPaths(path: string) { + return [path, path.replace('runtime/', '')] as const; +} + +export function createRpgProfileRoutes(context: AppContext) { + const router = Router(); + const requireAuth = requireJwtAuth(context.config, context.userRepository); + + routeCompatPaths('/api/runtime/profile/dashboard').forEach((path, index) => { + router.get( + path.replace('/api/', '/'), + requireAuth, + routeMeta({ + operation: + index === 0 + ? 'profile.dashboard.get' + : 'profile.dashboard.get.compat', + }), + asyncHandler(async (request, response) => { + sendApiResponse( + response, + await context.rpgProfileDashboardRepository.getProfileDashboard( + request.userId!, + ), + ); + }), + ); + }); + + routeCompatPaths('/api/runtime/profile/wallet-ledger').forEach((path, index) => { + router.get( + path.replace('/api/', '/'), + requireAuth, + routeMeta({ + operation: + index === 0 + ? 'profile.walletLedger.list' + : 'profile.walletLedger.list.compat', + }), + asyncHandler(async (request, response) => { + sendApiResponse(response, { + entries: + await context.rpgProfileDashboardRepository.listProfileWalletLedger( + request.userId!, + ), + }); + }), + ); + }); + + routeCompatPaths('/api/runtime/profile/play-stats').forEach((path, index) => { + router.get( + path.replace('/api/', '/'), + requireAuth, + routeMeta({ + operation: + index === 0 + ? 'profile.playStats.get' + : 'profile.playStats.get.compat', + }), + asyncHandler(async (request, response) => { + sendApiResponse( + response, + await context.rpgProfileDashboardRepository.getProfilePlayStats( + request.userId!, + ), + ); + }), + ); + }); + + routeCompatPaths('/api/runtime/profile/browse-history').forEach((path, index) => { + router.get( + path.replace('/api/', '/'), + requireAuth, + routeMeta({ + operation: + index === 0 + ? 'profile.browseHistory.list' + : 'profile.browseHistory.list.compat', + }), + asyncHandler(async (request, response) => { + sendApiResponse(response, { + entries: await context.rpgBrowseHistoryRepository.listPlatformBrowseHistory( + request.userId!, + ), + }); + }), + ); + + router.post( + path.replace('/api/', '/'), + requireAuth, + routeMeta({ + operation: + index === 0 + ? 'profile.browseHistory.upsert' + : 'profile.browseHistory.upsert.compat', + }), + asyncHandler(async (request, response) => { + const rawBody = + request.body && typeof request.body === 'object' ? request.body : {}; + const payload = ( + 'entries' in rawBody + ? platformBrowseHistoryBatchSchema.parse(rawBody) + : platformBrowseHistoryEntrySchema.parse(rawBody) + ) as + | PlatformBrowseHistoryBatchSyncRequest + | PlatformBrowseHistoryWriteEntry; + + const entries = 'entries' in payload ? payload.entries : [payload]; + + sendApiResponse(response, { + entries: + await context.rpgBrowseHistoryRepository.upsertPlatformBrowseHistoryEntries( + request.userId!, + entries, + ), + }); + }), + ); + + router.delete( + path.replace('/api/', '/'), + requireAuth, + routeMeta({ + operation: + index === 0 + ? 'profile.browseHistory.clear' + : 'profile.browseHistory.clear.compat', + }), + asyncHandler(async (request, response) => { + await context.rpgBrowseHistoryRepository.clearPlatformBrowseHistory( + request.userId!, + ); + sendApiResponse(response, { + entries: [], + }); + }), + ); + }); + + router.get( + '/api/runtime/settings'.replace('/api/', '/'), + requireAuth, + routeMeta({ operation: 'runtime.settings.get' }), + asyncHandler(async (request, response) => { + sendApiResponse( + response, + await context.rpgProfileDashboardRepository.getSettings(request.userId!), + ); + }), + ); + + router.put( + '/api/runtime/settings'.replace('/api/', '/'), + requireAuth, + routeMeta({ operation: 'runtime.settings.put' }), + asyncHandler(async (request, response) => { + const payload = settingsSchema.parse(request.body) as RuntimeSettings; + sendApiResponse( + response, + await context.rpgProfileDashboardRepository.putSettings( + request.userId!, + payload, + ), + ); + }), + ); + + return router; +} diff --git a/server-node/src/routes/rpg-runtime/rpgRuntimeAiAssistRoutes.ts b/server-node/src/routes/rpg-runtime/rpgRuntimeAiAssistRoutes.ts new file mode 100644 index 00000000..06973c12 --- /dev/null +++ b/server-node/src/routes/rpg-runtime/rpgRuntimeAiAssistRoutes.ts @@ -0,0 +1,370 @@ +import { Router } from 'express'; +import { z } from 'zod'; + +import type { + QuestGenerationRequest, + RuntimeItemIntentRequest, +} from '../../../../packages/shared/src/contracts/rpgRuntimeQuestAssist.js'; +import type { + CharacterChatReplyRequest, + CharacterChatSuggestionsRequest, + CharacterChatSummaryRequest, + NpcChatDialogueRequest, + NpcChatTurnRequest, + NpcRecruitDialogueRequest, + StoryRequestPayload, +} from '../../../../packages/shared/src/contracts/rpgRuntimeChat.js'; +import type { GenerateCustomWorldProfileInput } from '../../../../packages/shared/src/contracts/runtime.js'; +import type { AppContext } from '../../context.js'; +import { asyncHandler, sendApiResponse } from '../../http.js'; +import { requireJwtAuth } from '../../middleware/auth.js'; +import { routeMeta } from '../../middleware/routeMeta.js'; +import { + generateCharacterChatSuggestionsFromOrchestrator, + generateCharacterChatSummaryFromOrchestrator, + streamCharacterChatReplyFromOrchestrator, + streamNpcChatDialogueFromOrchestrator, + streamNpcChatTurnFromOrchestrator, + streamNpcRecruitDialogueFromOrchestrator, +} from '../../modules/ai/chatOrchestrator.js'; +import { generateCustomWorldProfileFromOrchestrator } from '../../modules/ai/customWorldOrchestrator.js'; +import { + characterChatReplyRequestSchema, + characterChatSuggestionsRequestSchema, + characterChatSummaryRequestSchema, + npcChatDialogueRequestSchema, + npcChatTurnRequestSchema, + npcRecruitDialogueRequestSchema, +} from '../../services/chatService.js'; +import { + customWorldCoverImageSchema, + customWorldCoverUploadSchema, + generateCustomWorldCoverImage, + uploadCustomWorldCoverImage, +} from '../../services/customWorldCoverAssetService.js'; +import { generateCustomWorldEntity } from '../../services/customWorldEntityGenerationService.js'; +import { generateSceneNpcForLandmark } from '../../services/customWorldSceneNpcGenerationService.js'; +import { generateQuestForNpcEncounter } from '../../services/questService.js'; +import { generateRuntimeItemIntents } from '../../services/runtimeItemService.js'; +import { + generateSceneImage, + sceneImageSchema, +} from '../../services/sceneImageService.js'; +import { + generateHighQualityInitialStory, + generateHighQualityNextStory, + parseStoryRequest, +} from '../../services/storyService.js'; + +const jsonObjectSchema = z.record(z.string(), z.unknown()); + +const customWorldProfileGenerationSchema = z.object({ + settingText: z.string().trim().min(1), + creatorIntent: jsonObjectSchema.nullish(), + generationMode: z.enum(['fast', 'full']).optional(), +}); + +const customWorldSceneNpcSchema = z.object({ + profile: jsonObjectSchema, + landmarkId: z.string().trim().min(1), +}); + +const customWorldEntitySchema = z.object({ + profile: jsonObjectSchema, + kind: z.enum(['playable', 'story', 'landmark']), +}); + +const runtimeItemIntentSchema = z.object({ + context: jsonObjectSchema, + plans: z.array(jsonObjectSchema), +}); + +const questGenerationSchema = z.object({ + state: jsonObjectSchema, + encounter: jsonObjectSchema, +}); + +const llmProxySchema = jsonObjectSchema; + +export const RPG_RUNTIME_AI_ASSIST_ROUTE_BASE_PATH = '/api/runtime'; + +export function createRpgRuntimeAiAssistRoutes(context: AppContext) { + const router = Router(); + const requireAuth = requireJwtAuth(context.config, context.userRepository); + const handleCustomWorldEntityGeneration = asyncHandler( + async (request, response) => { + const payload = customWorldEntitySchema.parse(request.body) as { + profile: Record; + kind: 'playable' | 'story' | 'landmark'; + }; + sendApiResponse( + response, + await generateCustomWorldEntity(context.llmClient, payload), + ); + }, + ); + const handleCustomWorldSceneNpcGeneration = asyncHandler( + async (request, response) => { + const payload = customWorldSceneNpcSchema.parse(request.body) as { + profile: Record; + landmarkId: string; + }; + sendApiResponse(response, { + npc: await generateSceneNpcForLandmark(context.llmClient, payload), + }); + }, + ); + + router.post( + '/llm/chat/completions', + requireAuth, + routeMeta({ operation: 'runtime.llm.chatCompletionsProxy' }), + asyncHandler(async (request, response) => { + const body = llmProxySchema.parse(request.body); + await context.llmClient.forwardCompletion(request, body, response); + }), + ); + + router.post( + '/custom-world/cover-image', + requireAuth, + routeMeta({ operation: 'runtime.customWorld.coverImage' }), + asyncHandler(async (request, response) => { + const payload = customWorldCoverImageSchema.parse(request.body); + sendApiResponse(response, await generateCustomWorldCoverImage(context, payload)); + }), + ); + + router.post( + '/custom-world/cover-upload', + requireAuth, + routeMeta({ operation: 'runtime.customWorld.coverUpload' }), + asyncHandler(async (request, response) => { + const payload = customWorldCoverUploadSchema.parse(request.body); + sendApiResponse(response, await uploadCustomWorldCoverImage(context, payload)); + }), + ); + + router.post( + '/custom-world/scene-image', + requireAuth, + routeMeta({ operation: 'runtime.customWorld.sceneImage' }), + asyncHandler(async (request, response) => { + const payload = sceneImageSchema.parse(request.body); + sendApiResponse(response, await generateSceneImage(context, payload)); + }), + ); + + router.post( + '/custom-world/entity', + requireAuth, + routeMeta({ operation: 'runtime.customWorld.entity' }), + handleCustomWorldEntityGeneration, + ); + + router.post( + '/runtime/custom-world/entity', + requireAuth, + routeMeta({ operation: 'runtime.customWorld.entity.compat' }), + handleCustomWorldEntityGeneration, + ); + + router.post( + '/custom-world/scene-npc', + requireAuth, + routeMeta({ operation: 'runtime.customWorld.sceneNpc' }), + handleCustomWorldSceneNpcGeneration, + ); + + router.post( + '/runtime/custom-world/scene-npc', + requireAuth, + routeMeta({ operation: 'runtime.customWorld.sceneNpc.compat' }), + handleCustomWorldSceneNpcGeneration, + ); + + router.post( + '/runtime/custom-world/profile', + requireAuth, + routeMeta({ operation: 'runtime.customWorld.profile' }), + asyncHandler(async (request, response) => { + const payload = customWorldProfileGenerationSchema.parse( + request.body, + ) as GenerateCustomWorldProfileInput; + sendApiResponse( + response, + await generateCustomWorldProfileFromOrchestrator( + context.llmClient, + payload, + ), + ); + }), + ); + + router.post( + '/runtime/story/initial', + requireAuth, + routeMeta({ operation: 'runtime.story.initial' }), + asyncHandler(async (request, response) => { + const payload = parseStoryRequest(request.body) as StoryRequestPayload; + sendApiResponse( + response, + await generateHighQualityInitialStory(context.llmClient, payload), + ); + }), + ); + + router.post( + '/runtime/story/continue', + requireAuth, + routeMeta({ operation: 'runtime.story.continue' }), + asyncHandler(async (request, response) => { + const payload = parseStoryRequest(request.body) as StoryRequestPayload; + sendApiResponse( + response, + await generateHighQualityNextStory(context.llmClient, payload), + ); + }), + ); + + router.post( + '/runtime/chat/character/suggestions', + requireAuth, + routeMeta({ operation: 'runtime.chat.character.suggestions' }), + asyncHandler(async (request, response) => { + const payload = characterChatSuggestionsRequestSchema.parse( + request.body, + ) as CharacterChatSuggestionsRequest; + sendApiResponse(response, { + text: await generateCharacterChatSuggestionsFromOrchestrator( + context.llmClient, + payload, + ), + }); + }), + ); + + router.post( + '/runtime/chat/character/summary', + requireAuth, + routeMeta({ operation: 'runtime.chat.character.summary' }), + asyncHandler(async (request, response) => { + const payload = characterChatSummaryRequestSchema.parse( + request.body, + ) as CharacterChatSummaryRequest; + sendApiResponse(response, { + text: await generateCharacterChatSummaryFromOrchestrator( + context.llmClient, + payload, + ), + }); + }), + ); + + router.post( + '/runtime/chat/character/reply/stream', + requireAuth, + routeMeta({ operation: 'runtime.chat.character.replyStream' }), + asyncHandler(async (request, response) => { + const payload = characterChatReplyRequestSchema.parse( + request.body, + ) as CharacterChatReplyRequest; + await streamCharacterChatReplyFromOrchestrator(context.llmClient, { + request, + response, + payload, + }); + }), + ); + + router.post( + '/runtime/chat/npc/dialogue/stream', + requireAuth, + routeMeta({ operation: 'runtime.chat.npc.dialogueStream' }), + asyncHandler(async (request, response) => { + const payload = npcChatDialogueRequestSchema.parse( + request.body, + ) as NpcChatDialogueRequest; + await streamNpcChatDialogueFromOrchestrator(context.llmClient, { + request, + response, + payload, + }); + }), + ); + + router.post( + '/runtime/chat/npc/turn/stream', + requireAuth, + routeMeta({ operation: 'runtime.chat.npc.turnStream' }), + asyncHandler(async (request, response) => { + const payload = npcChatTurnRequestSchema.parse( + request.body, + ) as NpcChatTurnRequest; + await streamNpcChatTurnFromOrchestrator(context.llmClient, { + request, + response, + payload, + }); + }), + ); + + router.post( + '/runtime/chat/npc/recruit/stream', + requireAuth, + routeMeta({ operation: 'runtime.chat.npc.recruitStream' }), + asyncHandler(async (request, response) => { + const payload = npcRecruitDialogueRequestSchema.parse( + request.body, + ) as NpcRecruitDialogueRequest; + await streamNpcRecruitDialogueFromOrchestrator(context.llmClient, { + request, + response, + payload, + }); + }), + ); + + router.post( + '/runtime/items/runtime-intent', + requireAuth, + routeMeta({ operation: 'runtime.items.intent' }), + asyncHandler(async (request, response) => { + const payload = runtimeItemIntentSchema.parse( + request.body, + ) as RuntimeItemIntentRequest; + sendApiResponse(response, { + intents: await generateRuntimeItemIntents(context.llmClient, payload), + }); + }), + ); + + router.post( + '/runtime/quests/generate', + requireAuth, + routeMeta({ operation: 'runtime.quests.generate' }), + asyncHandler(async (request, response) => { + const payload = questGenerationSchema.parse( + request.body, + ) as QuestGenerationRequest; + sendApiResponse( + response, + await generateQuestForNpcEncounter(context.llmClient, payload), + ); + }), + ); + + router.get( + '/ws/health', + requireAuth, + routeMeta({ operation: 'runtime.ws.health' }), + (_request, response) => { + sendApiResponse(response, { + ok: true, + message: 'websocket routes reserved for future real-time support', + }); + }, + ); + + return router; +} diff --git a/server-node/src/modules/story/storyActionRoutes.test.ts b/server-node/src/routes/rpg-runtime/rpgRuntimeStoryRoutes.test.ts similarity index 90% rename from server-node/src/modules/story/storyActionRoutes.test.ts rename to server-node/src/routes/rpg-runtime/rpgRuntimeStoryRoutes.test.ts index aac1bf83..2596303d 100644 --- a/server-node/src/modules/story/storyActionRoutes.test.ts +++ b/server-node/src/routes/rpg-runtime/rpgRuntimeStoryRoutes.test.ts @@ -8,10 +8,10 @@ import test from 'node:test'; import { createApp } from '../../app.ts'; import { buildQuestForEncounter } from '../../bridges/legacyQuestProgressBridge.js'; import type { AppConfig } from '../../config.ts'; +import { applyQuestSignal } from '../../modules/quest/questProgressionService.ts'; import { createAppContext } from '../../server.ts'; import { createTestPlayerCharacter } from '../../testFixtures/runtimeCharacter.ts'; import { httpRequest, type TestRequestInit } from '../../testHttp.ts'; -import { applyQuestSignal } from '../quest/questProgressionService.ts'; function createTestConfig(testName: string): AppConfig { const tempRoot = fs.mkdtempSync( @@ -91,6 +91,11 @@ function createTestConfig(testName: string): AppConfig { mockAvatarUrl: '', }, authSession: { + accessCookieName: 'genarrative_access_session', + accessCookieTtlSeconds: 7200, + accessCookieSecure: false, + accessCookieSameSite: 'Lax', + accessCookiePath: '/', refreshCookieName: 'genarrative_refresh_session', refreshSessionTtlDays: 30, refreshCookieSecure: false, @@ -1881,6 +1886,243 @@ test('runtime story actions resolve equipment_equip and persist updated loadout' }); }); +test('runtime story actions resolve npc_recruit directly on the server', async () => { + await withTestServer('task6-recruit-direct', async ({ baseUrl }) => { + const entry = await authEntry(baseUrl, 'story_task6_recruit', 'secret123'); + + await putSnapshot( + baseUrl, + entry.token, + createTask6GameState({ + currentEncounter: { + kind: 'npc', + id: 'npc_guard_01', + npcName: '守桥人', + npcDescription: '在桥口驻守多年的旧识', + context: '桥口守卫', + characterId: 'bridge-guard', + }, + npcInteractionActive: true, + npcStates: { + npc_guard_01: { + affinity: 64, + chattedCount: 2, + helpUsed: false, + giftsGiven: 0, + inventory: [], + recruited: false, + }, + }, + companions: [], + roster: [], + }), + ); + + const response = await httpRequest( + `${baseUrl}/api/runtime/story/actions/resolve`, + withBearer(entry.token, { + method: 'POST', + body: JSON.stringify({ + sessionId: 'runtime-main', + clientVersion: 0, + action: { + type: 'story_choice', + functionId: 'npc_recruit', + payload: { + preludeText: + '守桥人:你既然想清楚了,那我就跟你走这一程。', + }, + }, + }), + }), + ); + const payload = (await response.json()) as { + presentation: { + storyText: string; + }; + snapshot: { + gameState: { + currentEncounter: unknown; + npcInteractionActive: boolean; + companions: Array<{ + npcId: string; + characterId: string; + joinedAtAffinity: number; + maxHp: number; + maxMana: number; + }>; + roster: Array; + npcStates: { + npc_guard_01: { + recruited: boolean; + firstMeaningfulContactResolved: boolean; + }; + }; + }; + }; + viewModel: { + companions: Array<{ + npcId: string; + }>; + }; + }; + + assert.equal(response.status, 200); + assert.match(payload.presentation.storyText, /守桥人/u); + assert.equal(payload.snapshot.gameState.currentEncounter, null); + assert.equal(payload.snapshot.gameState.npcInteractionActive, false); + assert.equal(payload.snapshot.gameState.companions.length, 1); + assert.equal(payload.snapshot.gameState.companions[0]?.npcId, 'npc_guard_01'); + assert.equal( + payload.snapshot.gameState.companions[0]?.joinedAtAffinity, + 64, + ); + assert.ok((payload.snapshot.gameState.companions[0]?.maxHp ?? 0) > 0); + assert.ok((payload.snapshot.gameState.companions[0]?.maxMana ?? 0) > 0); + assert.deepEqual(payload.snapshot.gameState.roster, []); + assert.equal( + payload.snapshot.gameState.npcStates.npc_guard_01.recruited, + true, + ); + assert.equal( + payload.snapshot.gameState.npcStates.npc_guard_01 + .firstMeaningfulContactResolved, + true, + ); + assert.equal(payload.viewModel.companions[0]?.npcId, 'npc_guard_01'); + }); +}); + +test('runtime story actions resolve npc_recruit with full-party replacement on the server', async () => { + await withTestServer('task6-recruit-swap', async ({ baseUrl }) => { + const entry = await authEntry( + baseUrl, + 'story_task6_recruit_swap', + 'secret123', + ); + + await putSnapshot( + baseUrl, + entry.token, + createTask6GameState({ + currentEncounter: { + kind: 'npc', + id: 'npc_scout_02', + npcName: '追迹人', + npcDescription: '擅长沿痕追人的同路者', + context: '山道追迹', + characterId: 'trail-scout', + }, + npcInteractionActive: true, + npcStates: { + npc_scout_02: { + affinity: 71, + chattedCount: 3, + helpUsed: false, + giftsGiven: 0, + inventory: [], + recruited: false, + }, + npc_old_guard: { + affinity: 48, + chattedCount: 1, + helpUsed: false, + giftsGiven: 0, + inventory: [], + recruited: true, + }, + npc_old_medic: { + affinity: 55, + chattedCount: 1, + helpUsed: false, + giftsGiven: 0, + inventory: [], + recruited: true, + }, + }, + companions: [ + { + npcId: 'npc_old_guard', + characterId: 'old-guard', + joinedAtAffinity: 48, + hp: 180, + maxHp: 180, + mana: 999, + maxMana: 999, + skillCooldowns: {}, + animationState: 'idle', + actionMode: 'idle', + offsetX: 0, + offsetY: 0, + transitionMs: 0, + }, + { + npcId: 'npc_old_medic', + characterId: 'old-medic', + joinedAtAffinity: 55, + hp: 170, + maxHp: 170, + mana: 999, + maxMana: 999, + skillCooldowns: {}, + animationState: 'idle', + actionMode: 'idle', + offsetX: 0, + offsetY: 0, + transitionMs: 0, + }, + ], + roster: [], + }), + ); + + const response = await httpRequest( + `${baseUrl}/api/runtime/story/actions/resolve`, + withBearer(entry.token, { + method: 'POST', + body: JSON.stringify({ + sessionId: 'runtime-main', + clientVersion: 0, + action: { + type: 'story_choice', + functionId: 'npc_recruit', + payload: { + releaseNpcId: 'npc_old_guard', + preludeText: + '追迹人:如果你真要带我同行,那就先把你队里的位置理顺。', + }, + }, + }), + }), + ); + const payload = (await response.json()) as { + snapshot: { + gameState: { + companions: Array<{ npcId: string }>; + roster: Array<{ npcId: string }>; + }; + }; + viewModel: { + companions: Array<{ npcId: string }>; + }; + }; + + assert.equal(response.status, 200); + assert.deepEqual( + payload.snapshot.gameState.companions.map((companion) => companion.npcId), + ['npc_scout_02', 'npc_old_medic'], + ); + assert.deepEqual( + payload.snapshot.gameState.roster.map((companion) => companion.npcId), + ['npc_old_guard'], + ); + assert.deepEqual( + payload.viewModel.companions.map((companion) => companion.npcId), + ['npc_scout_02', 'npc_old_medic'], + ); + }); +}); + test('runtime story actions resolve npc_trade buy transactions on the server', async () => { await withTestServer('task6-trade-buy', async ({ baseUrl }) => { const entry = await authEntry( diff --git a/server-node/src/modules/story/storyActionRoutes.ts b/server-node/src/routes/rpg-runtime/rpgRuntimeStoryRoutes.ts similarity index 55% rename from server-node/src/modules/story/storyActionRoutes.ts rename to server-node/src/routes/rpg-runtime/rpgRuntimeStoryRoutes.ts index f9bc124c..f9012657 100644 --- a/server-node/src/modules/story/storyActionRoutes.ts +++ b/server-node/src/routes/rpg-runtime/rpgRuntimeStoryRoutes.ts @@ -1,22 +1,24 @@ import { Router } from 'express'; import { z } from 'zod'; -import type { RuntimeStoryActionRequest } from '../../../../packages/shared/src/contracts/story.js'; +import type { + RuntimeStoryActionRequest, + RuntimeStoryStateRequest, +} from '../../../../packages/shared/src/contracts/rpgRuntimeStoryState.js'; import type { AppContext } from '../../context.js'; import { badRequest } from '../../errors.js'; import { asyncHandler, sendApiResponse } from '../../http.js'; import { requireJwtAuth } from '../../middleware/auth.js'; import { routeMeta } from '../../middleware/routeMeta.js'; -import { - getRuntimeStoryState, - resolveRuntimeStoryAction, -} from './storyActionService.js'; +import { getRuntimeStoryState } from '../../modules/rpg-runtime-story/RpgRuntimeStoryStateService.js'; +import { resolveRuntimeStoryAction } from '../../modules/rpg-runtime-story/RpgRuntimeStoryActionService.js'; const actionPayloadSchema = z.record(z.string(), z.unknown()); const runtimeStoryActionSchema = z.object({ sessionId: z.string().trim().min(1), clientVersion: z.number().int().min(0).optional(), + snapshot: z.unknown().optional(), action: z.object({ type: z.literal('story_choice'), functionId: z.string().trim().min(1), @@ -25,7 +27,15 @@ const runtimeStoryActionSchema = z.object({ }), }); -export function createStoryActionRoutes(context: AppContext) { +const runtimeStoryStateResolveSchema = z.object({ + sessionId: z.string().trim().min(1), + clientVersion: z.number().int().min(0).optional(), + snapshot: z.unknown().optional(), +}); + +export const RPG_RUNTIME_STORY_ROUTE_BASE_PATH = '/api/runtime/story'; + +export function createRpgRuntimeStoryRoutes(context: AppContext) { const router = Router(); const requireAuth = requireJwtAuth(context.config, context.userRepository); @@ -41,7 +51,7 @@ export function createStoryActionRoutes(context: AppContext) { sendApiResponse( response, await resolveRuntimeStoryAction({ - runtimeRepository: context.runtimeRepository, + snapshotRepository: context.rpgRuntimeSnapshotRepository, llmClient: context.llmClient, userId: request.userId!, request: payload, @@ -62,7 +72,7 @@ export function createStoryActionRoutes(context: AppContext) { sendApiResponse( response, await getRuntimeStoryState({ - runtimeRepository: context.runtimeRepository, + snapshotRepository: context.rpgRuntimeSnapshotRepository, userId: request.userId!, sessionId, }), @@ -70,5 +80,25 @@ export function createStoryActionRoutes(context: AppContext) { }), ); + router.post( + '/state/resolve', + routeMeta({ operation: 'runtime.story.state.resolve' }), + asyncHandler(async (request, response) => { + const payload = runtimeStoryStateResolveSchema.parse( + request.body, + ) as RuntimeStoryStateRequest; + sendApiResponse( + response, + await getRuntimeStoryState({ + snapshotRepository: context.rpgRuntimeSnapshotRepository, + userId: request.userId!, + sessionId: payload.sessionId, + clientVersion: payload.clientVersion, + snapshot: payload.snapshot, + }), + ); + }), + ); + return router; } diff --git a/server-node/src/routes/rpgRouteBoundaries.test.ts b/server-node/src/routes/rpgRouteBoundaries.test.ts new file mode 100644 index 00000000..820e317e --- /dev/null +++ b/server-node/src/routes/rpgRouteBoundaries.test.ts @@ -0,0 +1,524 @@ +import assert from 'node:assert/strict'; +import fs from 'node:fs'; +import type { AddressInfo } from 'node:net'; +import os from 'node:os'; +import path from 'node:path'; +import test from 'node:test'; + +import { createApp } from '../app.ts'; +import type { AppConfig } from '../config.ts'; +import { createAppContext } from '../server.ts'; +import { httpRequest, type TestRequestInit } from '../testHttp.ts'; + +function createTestConfig(testName: string): AppConfig { + const tempRoot = fs.mkdtempSync( + path.join(os.tmpdir(), `genarrative-rpg-routes-${testName}-`), + ); + + return { + nodeEnv: 'test', + projectRoot: tempRoot, + publicDir: path.join(tempRoot, 'public'), + logsDir: path.join(tempRoot, 'logs'), + dataDir: path.join(tempRoot, 'data'), + rawEnv: {}, + databaseUrl: `pg-mem://genarrative-rpg-routes-${testName}`, + serverAddr: ':0', + logLevel: 'silent', + editorApiEnabled: true, + assetsApiEnabled: true, + jwtSecret: 'test-secret', + jwtExpiresIn: '7d', + jwtIssuer: 'genarrative-rpg-routes-test', + llm: { + baseUrl: 'https://example.invalid', + apiKey: '', + model: 'test-model', + }, + dashScope: { + baseUrl: 'https://example.invalid', + apiKey: '', + imageModel: 'test-image-model', + requestTimeoutMs: 1000, + }, + smsAuth: { + enabled: true, + provider: 'mock', + endpoint: 'dypnsapi.aliyuncs.com', + accessKeyId: '', + accessKeySecret: '', + signName: 'Test Sign', + templateCode: '100001', + templateParamKey: 'code', + countryCode: '86', + schemeName: '', + codeLength: 6, + codeType: 1, + validTimeSeconds: 300, + intervalSeconds: 60, + duplicatePolicy: 1, + caseAuthPolicy: 1, + returnVerifyCode: false, + mockVerifyCode: '123456', + maxSendPerPhonePerDay: 20, + maxSendPerIpPerHour: 30, + maxVerifyFailuresPerPhonePerHour: 12, + maxVerifyFailuresPerIpPerHour: 24, + captchaTtlSeconds: 180, + captchaTriggerVerifyFailuresPerPhone: 3, + captchaTriggerVerifyFailuresPerIp: 5, + blockPhoneFailureThreshold: 6, + blockIpFailureThreshold: 10, + blockPhoneDurationMinutes: 30, + blockIpDurationMinutes: 30, + }, + wechatAuth: { + enabled: true, + provider: 'mock', + appId: '', + appSecret: '', + authorizeEndpoint: 'https://open.weixin.qq.com/connect/qrconnect', + accessTokenEndpoint: 'https://api.weixin.qq.com/sns/oauth2/access_token', + userInfoEndpoint: 'https://api.weixin.qq.com/sns/userinfo', + callbackPath: '/api/auth/wechat/callback', + defaultRedirectPath: '/', + mockUserId: 'mock_wechat_user', + mockUnionId: 'mock_wechat_union', + mockDisplayName: '微信旅人', + mockAvatarUrl: '', + }, + authSession: { + accessCookieName: 'genarrative_access_session', + accessCookieTtlSeconds: 7200, + accessCookieSecure: false, + accessCookieSameSite: 'Lax', + accessCookiePath: '/', + refreshCookieName: 'genarrative_refresh_session', + refreshSessionTtlDays: 30, + refreshCookieSecure: false, + refreshCookieSameSite: 'Lax', + refreshCookiePath: '/api/auth', + }, + }; +} + +async function withTestServer( + testName: string, + run: (options: { baseUrl: string }) => Promise, +) { + const context = await createAppContext(createTestConfig(testName)); + const app = createApp(context); + const server = await new Promise((resolve) => { + const nextServer = app.listen(0, '127.0.0.1', () => resolve(nextServer)); + }); + + try { + const address = server.address() as AddressInfo; + return await run({ + baseUrl: `http://127.0.0.1:${address.port}`, + }); + } finally { + await new Promise((resolve, reject) => { + server.close((error) => { + if (error) { + reject(error); + return; + } + resolve(); + }); + }); + await context.db.close(); + } +} + +async function authEntry(baseUrl: string, username: string, password: string) { + const response = await httpRequest(`${baseUrl}/api/auth/entry`, { + method: 'POST', + headers: { + 'Content-Type': 'application/json', + }, + body: JSON.stringify({ + username, + password, + }), + }); + const payload = (await response.json()) as { + token: string; + user: { + id: string; + }; + }; + + assert.equal(response.status, 200); + assert.ok(payload.token); + return payload; +} + +function withBearer(token: string, init: TestRequestInit = {}) { + return { + ...init, + headers: { + ...(init.headers ?? {}), + Authorization: `Bearer ${token}`, + 'Content-Type': 'application/json', + }, + } satisfies TestRequestInit; +} + +async function putSnapshot( + baseUrl: string, + token: string, + body: Record, +) { + const response = await httpRequest( + `${baseUrl}/api/runtime/save/snapshot`, + withBearer(token, { + method: 'PUT', + body: JSON.stringify(body), + }), + ); + + assert.equal(response.status, 200); + return response.json(); +} + +test('rpg profile routes keep new and legacy dashboard compatibility', async () => { + await withTestServer('profile-compat', async ({ baseUrl }) => { + const entry = await authEntry(baseUrl, 'rpg_profile_user', 'secret123'); + + await putSnapshot(baseUrl, entry.token, { + gameState: { + currentScene: 'Story', + worldType: 'WUXIA', + playerCharacter: { + id: 'hero-profile', + title: '试剑客', + description: '赶路的人。', + personality: '稳重', + attributes: { + strength: 8, + }, + skills: [], + }, + }, + bottomTab: 'adventure', + currentStory: { + text: '第一段记录', + options: [], + }, + savedAt: '2026-04-21T10:00:00.000Z', + }); + + const runtimeResponse = await httpRequest( + `${baseUrl}/api/runtime/profile/dashboard`, + withBearer(entry.token), + ); + const runtimePayload = (await runtimeResponse.json()) as { + walletBalance: number; + playedWorldCount: number; + }; + const legacyResponse = await httpRequest( + `${baseUrl}/api/profile/dashboard`, + withBearer(entry.token), + ); + const legacyPayload = (await legacyResponse.json()) as typeof runtimePayload; + + assert.equal(runtimeResponse.status, 200); + assert.equal(legacyResponse.status, 200); + assert.deepEqual(legacyPayload, runtimePayload); + }); +}); + +test('rpg entry save routes keep list and resume archive compatibility', async () => { + await withTestServer('save-archive-compat', async ({ baseUrl }) => { + const entry = await authEntry(baseUrl, 'rpg_save_user', 'secret123'); + + await putSnapshot(baseUrl, entry.token, { + gameState: { + currentScene: 'Story', + worldType: 'CUSTOM', + customWorldProfile: { + id: 'world-archive-a', + name: '裂潮边城', + }, + playerCharacter: { + id: 'hero-save', + title: '归乡人', + description: '带着旧信回城。', + personality: '沉静', + attributes: { + spirit: 9, + }, + skills: [], + }, + playerCurrency: 42, + }, + bottomTab: 'adventure', + currentStory: { + text: '旧灯塔还亮着。', + options: [], + }, + savedAt: '2026-04-21T10:05:00.000Z', + }); + + const listRuntime = await httpRequest( + `${baseUrl}/api/runtime/profile/save-archives`, + withBearer(entry.token), + ); + const listLegacy = await httpRequest( + `${baseUrl}/api/profile/save-archives`, + withBearer(entry.token), + ); + const runtimePayload = (await listRuntime.json()) as { + entries: Array<{ worldKey: string }>; + }; + const legacyPayload = (await listLegacy.json()) as typeof runtimePayload; + + assert.equal(listRuntime.status, 200); + assert.equal(listLegacy.status, 200); + assert.deepEqual(legacyPayload.entries, runtimePayload.entries); + assert.equal(runtimePayload.entries.length, 1); + + const worldKey = runtimePayload.entries[0]?.worldKey; + assert.ok(worldKey); + + const resumeRuntime = await httpRequest( + `${baseUrl}/api/runtime/profile/save-archives/${encodeURIComponent(worldKey!)}`, + withBearer(entry.token, { + method: 'POST', + }), + ); + const resumeLegacy = await httpRequest( + `${baseUrl}/api/profile/save-archives/${encodeURIComponent(worldKey!)}`, + withBearer(entry.token, { + method: 'POST', + }), + ); + const resumeRuntimePayload = (await resumeRuntime.json()) as { + entry: { worldKey: string }; + snapshot: { gameState: { playerCurrency: number } }; + }; + const resumeLegacyPayload = (await resumeLegacy.json()) as typeof resumeRuntimePayload; + + assert.equal(resumeRuntime.status, 200); + assert.equal(resumeLegacy.status, 200); + assert.deepEqual(resumeLegacyPayload.entry, resumeRuntimePayload.entry); + assert.equal( + resumeLegacyPayload.snapshot.bottomTab, + resumeRuntimePayload.snapshot.bottomTab, + ); + assert.equal( + resumeLegacyPayload.snapshot.currentStory.text, + resumeRuntimePayload.snapshot.currentStory.text, + ); + assert.equal(resumeRuntimePayload.snapshot.gameState.playerCurrency, 42); + assert.equal(resumeLegacyPayload.snapshot.gameState.playerCurrency, 42); + }); +}); + +test('rpg world library routes expose gallery and library through new boundaries', async () => { + await withTestServer('world-library-boundary', async ({ baseUrl }) => { + const owner = await authEntry(baseUrl, 'rpg_world_owner', 'secret123'); + + const upsertResponse = await httpRequest( + `${baseUrl}/api/runtime/custom-world-library/world-a`, + withBearer(owner.token, { + method: 'PUT', + body: JSON.stringify({ + profile: { + name: '裂桥前线', + subtitle: '雾潮压城', + summary: '守桥与沉船商盟持续拉扯。', + settingText: '一座被雾潮包住的边城。', + templateWorldType: 'WUXIA', + majorFactions: [], + coreConflicts: [], + playableNpcs: [], + storyNpcs: [], + items: [], + landmarks: [], + attributeSchema: { + slots: [], + }, + }, + }), + }), + ); + assert.equal(upsertResponse.status, 200); + + const libraryResponse = await httpRequest( + `${baseUrl}/api/runtime/custom-world-library`, + withBearer(owner.token), + ); + const libraryPayload = (await libraryResponse.json()) as { + entries: Array<{ profileId: string }>; + }; + assert.equal(libraryResponse.status, 200); + assert.deepEqual( + libraryPayload.entries.map((entry) => entry.profileId), + ['world-a'], + ); + + const publishResponse = await httpRequest( + `${baseUrl}/api/runtime/custom-world-library/world-a/publish`, + withBearer(owner.token, { + method: 'POST', + }), + ); + assert.equal(publishResponse.status, 200); + + const galleryResponse = await httpRequest( + `${baseUrl}/api/runtime/custom-world-gallery`, + ); + const galleryPayload = (await galleryResponse.json()) as { + entries: Array<{ ownerUserId: string; profileId: string }>; + }; + assert.equal(galleryResponse.status, 200); + assert.equal(galleryPayload.entries.length, 1); + + const detailResponse = await httpRequest( + `${baseUrl}/api/runtime/custom-world-gallery/${encodeURIComponent(galleryPayload.entries[0]!.ownerUserId)}/${encodeURIComponent(galleryPayload.entries[0]!.profileId)}`, + ); + const detailPayload = (await detailResponse.json()) as { + entry: { + profileId: string; + worldName: string; + }; + }; + assert.equal(detailResponse.status, 200); + assert.equal(detailPayload.entry.profileId, 'world-a'); + assert.equal(detailPayload.entry.worldName, '裂桥前线'); + }); +}); + +test('rpg runtime story routes resolve through the new route boundary', async () => { + await withTestServer('runtime-story-boundary', async ({ baseUrl }) => { + const entry = await authEntry(baseUrl, 'rpg_story_user', 'secret123'); + + await putSnapshot(baseUrl, entry.token, { + gameState: { + worldType: 'WUXIA', + playerCharacter: { + id: 'hero-story', + title: '试剑客', + description: '站在桥口的人。', + personality: '谨慎', + attributes: { + strength: 8, + spirit: 6, + }, + skills: [], + }, + runtimeStats: { + playTimeMs: 0, + lastPlayTickAt: null, + hostileNpcsDefeated: 0, + questsAccepted: 0, + itemsUsed: 0, + scenesTraveled: 0, + }, + currentScene: 'test-scene', + storyHistory: [], + characterChats: {}, + animationState: 'idle', + currentEncounter: { + kind: 'npc', + id: 'npc_merchant_01', + npcName: '沈七', + npcDescription: '腰间挂着药囊的行商', + context: '受伤行商', + }, + npcInteractionActive: true, + currentScenePreset: null, + sceneHostileNpcs: [], + playerX: 0, + playerOffsetY: 0, + playerFacing: 'right', + playerActionMode: 'idle', + scrollWorld: false, + inBattle: false, + playerHp: 31, + playerMaxHp: 40, + playerMana: 9, + playerMaxMana: 16, + playerSkillCooldowns: {}, + activeBuildBuffs: [], + activeCombatEffects: [], + playerCurrency: 90, + playerInventory: [], + playerEquipment: { + weapon: null, + armor: null, + relic: null, + }, + npcStates: { + npc_merchant_01: { + affinity: 46, + chattedCount: 0, + helpUsed: false, + giftsGiven: 0, + inventory: [], + recruited: false, + }, + }, + quests: [], + roster: [], + companions: [], + currentNpcBattleMode: null, + currentNpcBattleOutcome: null, + sparReturnEncounter: null, + sparPlayerHpBefore: null, + sparPlayerMaxHpBefore: null, + sparStoryHistoryBefore: null, + }, + bottomTab: 'adventure', + currentStory: { + text: '巡路人看着你,像在等一句开口。', + options: [], + }, + }); + + const stateResponse = await httpRequest( + `${baseUrl}/api/runtime/story/state/runtime-main`, + withBearer(entry.token), + ); + const statePayload = (await stateResponse.json()) as { + viewModel: { + availableOptions: Array<{ functionId: string }>; + }; + }; + assert.equal(stateResponse.status, 200); + assert.ok( + statePayload.viewModel.availableOptions.some( + (option) => option.functionId === 'npc_chat', + ), + ); + + const actionResponse = await httpRequest( + `${baseUrl}/api/runtime/story/actions/resolve`, + withBearer(entry.token, { + method: 'POST', + body: JSON.stringify({ + sessionId: 'runtime-main', + clientVersion: 0, + action: { + type: 'story_choice', + functionId: 'npc_chat', + }, + }), + }), + ); + const actionPayload = (await actionResponse.json()) as { + serverVersion: number; + viewModel: { + encounter: { + affinity: number; + } | null; + }; + }; + + assert.equal(actionResponse.status, 200); + assert.equal(actionPayload.serverVersion, 1); + assert.equal(actionPayload.viewModel.encounter?.affinity, 52); + }); +}); diff --git a/server-node/src/routes/runtimeRoutes.ts b/server-node/src/routes/runtimeRoutes.ts deleted file mode 100644 index 75b8dc27..00000000 --- a/server-node/src/routes/runtimeRoutes.ts +++ /dev/null @@ -1,956 +0,0 @@ -import { Router } from 'express'; -import { z } from 'zod'; - -import type { ListCustomWorldWorksResponse } from '../../../packages/shared/src/contracts/customWorldAgent.js'; -import type { - AnswerCustomWorldSessionQuestionRequest, - CreateCustomWorldSessionRequest, - CustomWorldGalleryDetailResponse, - CustomWorldGalleryResponse, - CustomWorldLibraryMutationResponse, - CustomWorldLibraryResponse, - PlatformBrowseHistoryBatchSyncRequest, - PlatformBrowseHistoryResponse, - PlatformBrowseHistoryWriteEntry, - ProfileDashboardSummary, - ProfilePlayStatsResponse, - ProfileSaveArchiveListResponse, - ProfileSaveArchiveResumeResponse, - ProfileWalletLedgerResponse, - RuntimeSettings, - SavedGameSnapshotInput, -} from '../../../packages/shared/src/contracts/runtime.js'; -import { - CUSTOM_WORLD_GENERATION_MODES, - PLATFORM_THEMES, -} from '../../../packages/shared/src/contracts/runtime.js'; -import type { - QuestGenerationRequest, - RuntimeItemIntentRequest, -} from '../../../packages/shared/src/contracts/story.js'; -import type { - CharacterChatReplyRequest, - CharacterChatSuggestionsRequest, - CharacterChatSummaryRequest, - NpcChatDialogueRequest, - NpcChatTurnRequest, - NpcRecruitDialogueRequest, -} from '../../../packages/shared/src/contracts/story.js'; -import type { AppContext } from '../context.js'; -import { badRequest, notFound } from '../errors.js'; -import { - asyncHandler, - jsonClone, - prepareEventStreamResponse, - sendApiResponse, -} from '../http.js'; -import { requireJwtAuth } from '../middleware/auth.js'; -import { routeMeta } from '../middleware/routeMeta.js'; -import { - generateCharacterChatSuggestionsFromOrchestrator, - generateCharacterChatSummaryFromOrchestrator, - streamCharacterChatReplyFromOrchestrator, - streamNpcChatDialogueFromOrchestrator, - streamNpcChatTurnFromOrchestrator, - streamNpcRecruitDialogueFromOrchestrator, -} from '../modules/ai/chatOrchestrator.js'; -import { - hydrateSavedSnapshot, - normalizeSavedSnapshotPayload, -} from '../modules/runtime/runtimeSnapshotHydration.js'; -import { - characterChatReplyRequestSchema, - characterChatSuggestionsRequestSchema, - characterChatSummaryRequestSchema, - npcChatDialogueRequestSchema, - npcChatTurnRequestSchema, - npcRecruitDialogueRequestSchema, -} from '../services/chatService.js'; -import { generateCustomWorldEntity } from '../services/customWorldEntityGenerationService.js'; -import { generateCustomWorldProfile } from '../services/customWorldGenerationService.js'; -import { generateSceneNpcForLandmark } from '../services/customWorldSceneNpcGenerationService.js'; -import { listCustomWorldWorkSummaries } from '../services/customWorldWorkSummaryService.js'; -import { generateQuestForNpcEncounter } from '../services/questService.js'; -import { generateRuntimeItemIntents } from '../services/runtimeItemService.js'; -import { - customWorldCoverImageSchema, - customWorldCoverUploadSchema, - generateCustomWorldCoverImage, - uploadCustomWorldCoverImage, -} from '../services/customWorldCoverAssetService.js'; -import { - generateSceneImage, - sceneImageSchema, -} from '../services/sceneImageService.js'; -import { - generateHighQualityInitialStory, - generateHighQualityNextStory, - parseStoryRequest, -} from '../services/storyService.js'; -import { createCustomWorldAgentRoutes } from './customWorldAgent.js'; - -const jsonObjectSchema = z.record(z.string(), z.unknown()); - -const saveSnapshotSchema = z.object({ - gameState: z.unknown(), - bottomTab: z.string().trim().min(1), - currentStory: z.unknown().nullable().optional().default(null), - savedAt: z.string().trim().optional().default(''), -}); - -const settingsSchema = z.object({ - musicVolume: z.number().min(0).max(1), - platformTheme: z.enum(PLATFORM_THEMES), -}); - -const platformBrowseHistoryEntrySchema = z.object({ - ownerUserId: z.string().trim().min(1), - profileId: z.string().trim().min(1), - worldName: z.string().trim().min(1), - subtitle: z.string().trim().optional().default(''), - summaryText: z.string().trim().optional().default(''), - coverImageSrc: z.string().trim().nullable().optional().default(null), - themeMode: z.string().trim().optional().default('mythic'), - authorDisplayName: z.string().trim().optional().default('玩家'), - visitedAt: z.string().trim().optional().default(''), -}); - -const platformBrowseHistoryBatchSchema = z.object({ - entries: z.array(platformBrowseHistoryEntrySchema).max(100), -}); - -const customWorldProfileSchema = z.object({ - profile: jsonObjectSchema, -}); - -const customWorldSceneNpcSchema = z.object({ - profile: jsonObjectSchema, - landmarkId: z.string().trim().min(1), -}); - -const customWorldEntitySchema = z.object({ - profile: jsonObjectSchema, - kind: z.enum(['playable', 'story', 'landmark']), -}); - -const customWorldSessionSchema = z.object({ - settingText: z.string().trim().min(1), - creatorIntent: jsonObjectSchema.nullable().optional().default(null), - generationMode: z.enum(CUSTOM_WORLD_GENERATION_MODES).default('fast'), -}); - -const customWorldAnswerSchema = z.object({ - questionId: z.string().trim().min(1), - answer: z.string().trim().min(1), -}); - -const runtimeItemIntentSchema = z.object({ - context: jsonObjectSchema, - plans: z.array(jsonObjectSchema), -}); - -const questGenerationSchema = z.object({ - state: jsonObjectSchema, - encounter: jsonObjectSchema, -}); - -const llmProxySchema = jsonObjectSchema; - -function readParam(param: string | string[] | undefined) { - return Array.isArray(param) ? param[0]?.trim() || '' : param?.trim() || ''; -} - -async function resolveAuthDisplayName(context: AppContext, userId: string) { - const user = await context.userRepository.findById(userId); - if (!user) { - throw notFound('user not found'); - } - - return user.displayName?.trim() || '玩家'; -} - -export function createRuntimeRoutes(context: AppContext) { - const router = Router(); - const requireAuth = requireJwtAuth(context.config, context.userRepository); - const routeCompatPaths = (path: string) => [ - path, - `/runtime${path}`, - ] as const; - const handleCustomWorldEntityGeneration = asyncHandler(async (request, response) => { - const payload = customWorldEntitySchema.parse(request.body) as { - profile: Record; - kind: 'playable' | 'story' | 'landmark'; - }; - sendApiResponse( - response, - await generateCustomWorldEntity(context.llmClient, payload), - ); - }); - const handleCustomWorldSceneNpcGeneration = asyncHandler(async (request, response) => { - const payload = customWorldSceneNpcSchema.parse(request.body) as { - profile: Record; - landmarkId: string; - }; - sendApiResponse(response, { - npc: await generateSceneNpcForLandmark(context.llmClient, payload), - }); - }); - - router.get( - '/runtime/custom-world-gallery', - routeMeta({ operation: 'runtime.customWorldGallery.list' }), - asyncHandler(async (_request, response) => { - sendApiResponse(response, { - entries: await context.runtimeRepository.listPublishedCustomWorldGallery(), - } satisfies CustomWorldGalleryResponse); - }), - ); - - router.get( - '/runtime/custom-world-gallery/:ownerUserId/:profileId', - routeMeta({ operation: 'runtime.customWorldGallery.detail' }), - asyncHandler(async (request, response) => { - const ownerUserId = readParam(request.params.ownerUserId); - const profileId = readParam(request.params.profileId); - if (!ownerUserId || !profileId) { - throw badRequest('ownerUserId and profileId are required'); - } - - const entry = - await context.runtimeRepository.getPublishedCustomWorldGalleryDetail( - ownerUserId, - profileId, - ); - if (!entry) { - throw notFound('public custom world not found'); - } - - sendApiResponse(response, { - entry, - } satisfies CustomWorldGalleryDetailResponse); - }), - ); - - router.use(requireAuth); - router.use( - '/runtime/custom-world/agent', - createCustomWorldAgentRoutes(context), - ); - - routeCompatPaths('/profile/dashboard').forEach((path, index) => { - router.get( - path, - routeMeta({ - operation: - index === 0 - ? 'profile.dashboard.get' - : 'profile.dashboard.get.compat', - }), - asyncHandler(async (request, response) => { - sendApiResponse( - response, - await context.runtimeRepository.getProfileDashboard(request.userId!), - ); - }), - ); - }); - - routeCompatPaths('/profile/wallet-ledger').forEach((path, index) => { - router.get( - path, - routeMeta({ - operation: - index === 0 - ? 'profile.walletLedger.list' - : 'profile.walletLedger.list.compat', - }), - asyncHandler(async (request, response) => { - sendApiResponse(response, { - entries: await context.runtimeRepository.listProfileWalletLedger( - request.userId!, - ), - }); - }), - ); - }); - - routeCompatPaths('/profile/play-stats').forEach((path, index) => { - router.get( - path, - routeMeta({ - operation: - index === 0 - ? 'profile.playStats.get' - : 'profile.playStats.get.compat', - }), - asyncHandler(async (request, response) => { - sendApiResponse( - response, - await context.runtimeRepository.getProfilePlayStats(request.userId!), - ); - }), - ); - }); - - routeCompatPaths('/profile/browse-history').forEach((path, index) => { - router.get( - path, - routeMeta({ - operation: - index === 0 - ? 'profile.browseHistory.list' - : 'profile.browseHistory.list.compat', - }), - asyncHandler(async (request, response) => { - sendApiResponse(response, { - entries: await context.runtimeRepository.listPlatformBrowseHistory( - request.userId!, - ), - }); - }), - ); - - router.post( - path, - routeMeta({ - operation: - index === 0 - ? 'profile.browseHistory.upsert' - : 'profile.browseHistory.upsert.compat', - }), - asyncHandler(async (request, response) => { - const rawBody = - request.body && typeof request.body === 'object' ? request.body : {}; - const payload = ( - 'entries' in rawBody - ? platformBrowseHistoryBatchSchema.parse(rawBody) - : platformBrowseHistoryEntrySchema.parse(rawBody) - ) as - | PlatformBrowseHistoryBatchSyncRequest - | PlatformBrowseHistoryWriteEntry; - - const entries = 'entries' in payload ? payload.entries : [payload]; - - sendApiResponse(response, { - entries: - await context.runtimeRepository.upsertPlatformBrowseHistoryEntries( - request.userId!, - entries, - ), - }); - }), - ); - - router.delete( - path, - routeMeta({ - operation: - index === 0 - ? 'profile.browseHistory.clear' - : 'profile.browseHistory.clear.compat', - }), - asyncHandler(async (request, response) => { - await context.runtimeRepository.clearPlatformBrowseHistory( - request.userId!, - ); - sendApiResponse(response, { - entries: [], - }); - }), - ); - }); - - routeCompatPaths('/profile/save-archives').forEach((path, index) => { - router.get( - path, - routeMeta({ - operation: - index === 0 - ? 'profile.saveArchives.list' - : 'profile.saveArchives.list.compat', - }), - asyncHandler(async (request, response) => { - sendApiResponse(response, { - entries: await context.runtimeRepository.listProfileSaveArchives( - request.userId!, - ), - }); - }), - ); - }); - - [ - '/profile/save-archives/:worldKey', - '/runtime/profile/save-archives/:worldKey', - ].forEach((path, index) => { - router.post( - path, - routeMeta({ - operation: - index === 0 - ? 'profile.saveArchives.resume' - : 'profile.saveArchives.resume.compat', - }), - asyncHandler(async (request, response) => { - const worldKey = - typeof request.params.worldKey === 'string' - ? request.params.worldKey.trim() - : ''; - - if (!worldKey) { - throw badRequest('worldKey 不能为空'); - } - - const resumedArchive = - await context.runtimeRepository.resumeProfileSaveArchive( - request.userId!, - worldKey, - ); - - if (!resumedArchive) { - throw notFound('指定存档不存在'); - } - - sendApiResponse(response, { - entry: resumedArchive.entry, - snapshot: hydrateSavedSnapshot(resumedArchive.snapshot)!, - }); - }), - ); - }); - - router.post( - '/llm/chat/completions', - routeMeta({ operation: 'runtime.llm.chatCompletionsProxy' }), - asyncHandler(async (request, response) => { - const body = llmProxySchema.parse(request.body); - await context.llmClient.forwardCompletion(request, body, response); - }), - ); - - router.post( - '/custom-world/cover-image', - routeMeta({ operation: 'runtime.customWorld.coverImage' }), - asyncHandler(async (request, response) => { - const payload = customWorldCoverImageSchema.parse(request.body); - sendApiResponse(response, await generateCustomWorldCoverImage(context, payload)); - }), - ); - - router.post( - '/custom-world/cover-upload', - routeMeta({ operation: 'runtime.customWorld.coverUpload' }), - asyncHandler(async (request, response) => { - const payload = customWorldCoverUploadSchema.parse(request.body); - sendApiResponse(response, await uploadCustomWorldCoverImage(context, payload)); - }), - ); - - router.post( - '/custom-world/scene-image', - routeMeta({ operation: 'runtime.customWorld.sceneImage' }), - asyncHandler(async (request, response) => { - const payload = sceneImageSchema.parse(request.body); - sendApiResponse(response, await generateSceneImage(context, payload)); - }), - ); - - router.post( - '/custom-world/entity', - routeMeta({ operation: 'runtime.customWorld.entity' }), - handleCustomWorldEntityGeneration, - ); - - router.post( - '/runtime/custom-world/entity', - routeMeta({ operation: 'runtime.customWorld.entity.compat' }), - handleCustomWorldEntityGeneration, - ); - - router.post( - '/custom-world/scene-npc', - routeMeta({ operation: 'runtime.customWorld.sceneNpc' }), - handleCustomWorldSceneNpcGeneration, - ); - - router.post( - '/runtime/custom-world/scene-npc', - routeMeta({ operation: 'runtime.customWorld.sceneNpc.compat' }), - handleCustomWorldSceneNpcGeneration, - ); - - router.get( - '/runtime/save/snapshot', - routeMeta({ operation: 'runtime.snapshot.get' }), - asyncHandler(async (request, response) => { - sendApiResponse( - response, - hydrateSavedSnapshot( - await context.runtimeRepository.getSnapshot(request.userId!), - ), - ); - }), - ); - - router.put( - '/runtime/save/snapshot', - routeMeta({ operation: 'runtime.snapshot.put' }), - asyncHandler(async (request, response) => { - const payload = saveSnapshotSchema.parse( - request.body, - ) as SavedGameSnapshotInput; - const normalizedSnapshot = normalizeSavedSnapshotPayload({ - savedAt: payload.savedAt || new Date().toISOString(), - gameState: payload.gameState, - bottomTab: payload.bottomTab, - currentStory: payload.currentStory ?? null, - }); - sendApiResponse( - response, - hydrateSavedSnapshot( - await context.runtimeRepository.putSnapshot( - request.userId!, - normalizedSnapshot, - ), - ), - ); - }), - ); - - router.delete( - '/runtime/save/snapshot', - routeMeta({ operation: 'runtime.snapshot.delete' }), - asyncHandler(async (request, response) => { - await context.runtimeRepository.deleteSnapshot(request.userId!); - sendApiResponse(response, { ok: true }); - }), - ); - - router.get( - '/runtime/settings', - routeMeta({ operation: 'runtime.settings.get' }), - asyncHandler(async (request, response) => { - sendApiResponse( - response, - await context.runtimeRepository.getSettings(request.userId!), - ); - }), - ); - - router.put( - '/runtime/settings', - routeMeta({ operation: 'runtime.settings.put' }), - asyncHandler(async (request, response) => { - const payload = settingsSchema.parse(request.body) as RuntimeSettings; - sendApiResponse( - response, - await context.runtimeRepository.putSettings(request.userId!, payload), - ); - }), - ); - - router.get( - '/runtime/custom-world/works', - routeMeta({ operation: 'runtime.customWorldWorks.list' }), - asyncHandler(async (request, response) => { - sendApiResponse(response, { - items: await listCustomWorldWorkSummaries(request.userId!, { - runtimeRepository: context.runtimeRepository, - customWorldAgentSessions: context.customWorldAgentSessions, - }), - }); - }), - ); - - router.get( - '/runtime/custom-world-library', - routeMeta({ operation: 'runtime.customWorldLibrary.list' }), - asyncHandler(async (request, response) => { - sendApiResponse(response, { - entries: await context.runtimeRepository.listCustomWorldProfiles( - request.userId!, - ), - } satisfies CustomWorldLibraryResponse); - }), - ); - - router.put( - '/runtime/custom-world-library/:profileId', - routeMeta({ operation: 'runtime.customWorldLibrary.upsert' }), - asyncHandler(async (request, response) => { - const profileId = readParam(request.params.profileId); - if (!profileId) { - throw badRequest('profileId is required'); - } - const payload = customWorldProfileSchema.parse(request.body); - const authorDisplayName = await resolveAuthDisplayName( - context, - request.userId!, - ); - sendApiResponse( - response, - await context.runtimeRepository.upsertCustomWorldProfile( - request.userId!, - profileId, - jsonClone(payload.profile), - authorDisplayName, - ), - ); - }), - ); - - router.delete( - '/runtime/custom-world-library/:profileId', - routeMeta({ operation: 'runtime.customWorldLibrary.delete' }), - asyncHandler(async (request, response) => { - const profileId = readParam(request.params.profileId); - if (!profileId) { - throw badRequest('profileId is required'); - } - sendApiResponse(response, { - entries: await context.runtimeRepository.deleteCustomWorldProfile( - request.userId!, - profileId, - ), - } satisfies CustomWorldLibraryResponse); - }), - ); - - router.post( - '/runtime/custom-world-library/:profileId/publish', - routeMeta({ operation: 'runtime.customWorldLibrary.publish' }), - asyncHandler(async (request, response) => { - const profileId = readParam(request.params.profileId); - if (!profileId) { - throw badRequest('profileId is required'); - } - - const authorDisplayName = await resolveAuthDisplayName( - context, - request.userId!, - ); - const mutation = - await context.runtimeRepository.publishCustomWorldProfile( - request.userId!, - profileId, - authorDisplayName, - ); - if (!mutation) { - throw notFound('custom world not found'); - } - - sendApiResponse( - response, - mutation satisfies CustomWorldLibraryMutationResponse, - ); - }), - ); - - router.post( - '/runtime/custom-world-library/:profileId/unpublish', - routeMeta({ operation: 'runtime.customWorldLibrary.unpublish' }), - asyncHandler(async (request, response) => { - const profileId = readParam(request.params.profileId); - if (!profileId) { - throw badRequest('profileId is required'); - } - - const authorDisplayName = await resolveAuthDisplayName( - context, - request.userId!, - ); - const mutation = - await context.runtimeRepository.unpublishCustomWorldProfile( - request.userId!, - profileId, - authorDisplayName, - ); - if (!mutation) { - throw notFound('custom world not found'); - } - - sendApiResponse( - response, - mutation satisfies CustomWorldLibraryMutationResponse, - ); - }), - ); - - router.post( - '/runtime/story/initial', - routeMeta({ operation: 'runtime.story.initial' }), - asyncHandler(async (request, response) => { - const payload = parseStoryRequest(request.body); - sendApiResponse( - response, - await generateHighQualityInitialStory(context.llmClient, payload), - ); - }), - ); - - router.post( - '/runtime/story/continue', - routeMeta({ operation: 'runtime.story.continue' }), - asyncHandler(async (request, response) => { - const payload = parseStoryRequest(request.body); - sendApiResponse( - response, - await generateHighQualityNextStory(context.llmClient, payload), - ); - }), - ); - - router.post( - '/runtime/chat/character/suggestions', - routeMeta({ operation: 'runtime.chat.character.suggestions' }), - asyncHandler(async (request, response) => { - const payload = characterChatSuggestionsRequestSchema.parse( - request.body, - ) as CharacterChatSuggestionsRequest; - sendApiResponse(response, { - text: await generateCharacterChatSuggestionsFromOrchestrator( - context.llmClient, - payload, - ), - }); - }), - ); - - router.post( - '/runtime/chat/character/summary', - routeMeta({ operation: 'runtime.chat.character.summary' }), - asyncHandler(async (request, response) => { - const payload = characterChatSummaryRequestSchema.parse( - request.body, - ) as CharacterChatSummaryRequest; - sendApiResponse(response, { - text: await generateCharacterChatSummaryFromOrchestrator( - context.llmClient, - payload, - ), - }); - }), - ); - - router.post( - '/runtime/chat/character/reply/stream', - routeMeta({ operation: 'runtime.chat.character.replyStream' }), - asyncHandler(async (request, response) => { - const payload = characterChatReplyRequestSchema.parse( - request.body, - ) as CharacterChatReplyRequest; - await streamCharacterChatReplyFromOrchestrator(context.llmClient, { - request, - response, - payload, - }); - }), - ); - - router.post( - '/runtime/chat/npc/dialogue/stream', - routeMeta({ operation: 'runtime.chat.npc.dialogueStream' }), - asyncHandler(async (request, response) => { - const payload = npcChatDialogueRequestSchema.parse( - request.body, - ) as NpcChatDialogueRequest; - await streamNpcChatDialogueFromOrchestrator(context.llmClient, { - request, - response, - payload, - }); - }), - ); - - router.post( - '/runtime/chat/npc/turn/stream', - routeMeta({ operation: 'runtime.chat.npc.turnStream' }), - asyncHandler(async (request, response) => { - const payload = npcChatTurnRequestSchema.parse( - request.body, - ) as NpcChatTurnRequest; - await streamNpcChatTurnFromOrchestrator(context.llmClient, { - request, - response, - payload, - }); - }), - ); - - router.post( - '/runtime/chat/npc/recruit/stream', - routeMeta({ operation: 'runtime.chat.npc.recruitStream' }), - asyncHandler(async (request, response) => { - const payload = npcRecruitDialogueRequestSchema.parse( - request.body, - ) as NpcRecruitDialogueRequest; - await streamNpcRecruitDialogueFromOrchestrator(context.llmClient, { - request, - response, - payload, - }); - }), - ); - - router.post( - '/runtime/custom-world/sessions', - routeMeta({ operation: 'runtime.customWorldSession.create' }), - asyncHandler(async (request, response) => { - const payload = customWorldSessionSchema.parse( - request.body, - ) as CreateCustomWorldSessionRequest; - sendApiResponse( - response, - await context.customWorldSessions.create( - request.userId!, - payload.settingText, - payload.creatorIntent, - payload.generationMode, - ), - ); - }), - ); - - router.get( - '/runtime/custom-world/sessions/:sessionId', - routeMeta({ operation: 'runtime.customWorldSession.get' }), - asyncHandler(async (request, response) => { - const session = await context.customWorldSessions.get( - request.userId!, - readParam(request.params.sessionId), - ); - if (!session) { - throw notFound('custom world session not found'); - } - sendApiResponse(response, session); - }), - ); - - router.post( - '/runtime/custom-world/sessions/:sessionId/answers', - routeMeta({ operation: 'runtime.customWorldSession.answer' }), - asyncHandler(async (request, response) => { - const payload = customWorldAnswerSchema.parse( - request.body, - ) as AnswerCustomWorldSessionQuestionRequest; - const session = await context.customWorldSessions.answer( - request.userId!, - readParam(request.params.sessionId), - payload.questionId, - payload.answer, - ); - if (!session) { - throw notFound('custom world session not found'); - } - sendApiResponse(response, session); - }), - ); - - router.get( - '/runtime/custom-world/sessions/:sessionId/generate/stream', - routeMeta({ operation: 'runtime.customWorldSession.generateStream' }), - asyncHandler(async (request, response) => { - const session = await context.customWorldSessions.get( - request.userId!, - readParam(request.params.sessionId), - ); - if (!session) { - throw notFound('custom world session not found'); - } - - prepareEventStreamResponse(request, response); - const controller = new AbortController(); - - request.on('close', () => { - controller.abort(); - }); - - const writeEvent = (event: string, payload: Record) => { - response.write(`event: ${event}\n`); - response.write(`data: ${JSON.stringify(payload)}\n\n`); - }; - - writeEvent('progress', { phase: 'preparing', progress: 10 }); - await context.customWorldSessions.updateStatus( - request.userId!, - readParam(request.params.sessionId), - 'generating', - ); - writeEvent('progress', { phase: 'requesting_llm', progress: 45 }); - - try { - const profile = await generateCustomWorldProfile(context, session, { - signal: controller.signal, - onProgress: (progress) => { - writeEvent( - 'progress', - progress as unknown as Record, - ); - }, - }); - await context.customWorldSessions.setResult( - request.userId!, - readParam(request.params.sessionId), - profile, - ); - writeEvent('progress', { phase: 'completed', progress: 100 }); - writeEvent('result', { profile }); - writeEvent('done', { ok: true }); - } catch (error) { - const message = - error instanceof Error - ? error.message - : 'custom world generation failed'; - await context.customWorldSessions.updateStatus( - request.userId!, - readParam(request.params.sessionId), - 'generation_error', - message, - ); - writeEvent('error', { message }); - } finally { - response.end(); - } - }), - ); - - router.post( - '/runtime/items/runtime-intent', - routeMeta({ operation: 'runtime.items.intent' }), - asyncHandler(async (request, response) => { - const payload = runtimeItemIntentSchema.parse( - request.body, - ) as RuntimeItemIntentRequest; - sendApiResponse(response, { - intents: await generateRuntimeItemIntents(context.llmClient, payload), - }); - }), - ); - - router.post( - '/runtime/quests/generate', - routeMeta({ operation: 'runtime.quests.generate' }), - asyncHandler(async (request, response) => { - const payload = questGenerationSchema.parse( - request.body, - ) as QuestGenerationRequest; - sendApiResponse( - response, - await generateQuestForNpcEncounter(context.llmClient, payload), - ); - }), - ); - - router.get( - '/ws/health', - routeMeta({ operation: 'runtime.ws.health' }), - (_request, response) => { - sendApiResponse(response, { - ok: true, - message: 'websocket routes reserved for future real-time support', - }); - }, - ); - - return router; -} diff --git a/server-node/src/server.ts b/server-node/src/server.ts index 3c303014..62c05fa5 100644 --- a/server-node/src/server.ts +++ b/server-node/src/server.ts @@ -8,6 +8,13 @@ import { createLogger } from './logging.js'; import { AuthAuditLogRepository } from './repositories/authAuditLogRepository.js'; import { AuthIdentityRepository } from './repositories/authIdentityRepository.js'; import { AuthRiskBlockRepository } from './repositories/authRiskBlockRepository.js'; +import { RpgAgentSessionRepository } from './repositories/RpgAgentSessionRepository.js'; +import { RpgWorldProfileRepository } from './repositories/RpgWorldProfileRepository.js'; +import { RpgSaveArchiveRepository } from './repositories/rpg-entry/RpgSaveArchiveRepository.js'; +import { RpgWorldLibraryRepository } from './repositories/rpg-entry/RpgWorldLibraryRepository.js'; +import { RpgBrowseHistoryRepository } from './repositories/rpg-profile/RpgBrowseHistoryRepository.js'; +import { RpgProfileDashboardRepository } from './repositories/rpg-profile/RpgProfileDashboardRepository.js'; +import { RpgRuntimeSnapshotRepository } from './repositories/rpg-runtime/RpgRuntimeSnapshotRepository.js'; import { RuntimeRepository } from './repositories/runtimeRepository.js'; import { SmsAuthEventRepository } from './repositories/smsAuthEventRepository.js'; import { UserRepository } from './repositories/userRepository.js'; @@ -16,8 +23,8 @@ import { CaptchaChallengeStore } from './services/captchaChallengeStore.js'; import { CustomWorldAgentAutoAssetService } from './services/customWorldAgentAutoAssetService.js'; import { CustomWorldAgentOrchestrator } from './services/customWorldAgentOrchestrator.js'; import { CustomWorldAgentSessionStore } from './services/customWorldAgentSessionStore.js'; -import { CustomWorldSessionStore } from './services/customWorldSessionStore.js'; import { UpstreamLlmClient } from './services/llmClient.js'; +import { RpgWorldWorkSummaryService } from './services/RpgWorldWorkSummaryService.js'; import { createSmsVerificationService } from './services/smsVerificationService.js'; import { createWechatAuthService } from './services/wechatAuthService.js'; import { WechatAuthStateStore } from './services/wechatAuthStateStore.js'; @@ -80,10 +87,32 @@ function describeDatabase(databaseUrl: string) { export async function createAppContext(config: AppConfig = loadConfig()) { const logger = createLogger(config); const db = await createDatabase(config); + const rpgAgentSessionRepository = new RpgAgentSessionRepository(db); + const rpgWorldProfileRepository = new RpgWorldProfileRepository(db); const runtimeRepository = new RuntimeRepository(db); - const customWorldAgentSessions = new CustomWorldAgentSessionStore( + const rpgProfileDashboardRepository = new RpgProfileDashboardRepository( runtimeRepository, ); + const rpgBrowseHistoryRepository = new RpgBrowseHistoryRepository( + runtimeRepository, + ); + const rpgSaveArchiveRepository = new RpgSaveArchiveRepository( + runtimeRepository, + ); + const rpgWorldLibraryRepository = new RpgWorldLibraryRepository( + runtimeRepository, + ); + const rpgRuntimeSnapshotRepository = new RpgRuntimeSnapshotRepository( + runtimeRepository, + ); + const userRepository = new UserRepository(db); + const customWorldAgentSessions = new CustomWorldAgentSessionStore( + rpgAgentSessionRepository, + ); + const rpgWorldWorkSummaryService = new RpgWorldWorkSummaryService( + rpgWorldProfileRepository, + customWorldAgentSessions, + ); const autoAssetService = new CustomWorldAgentAutoAssetService( config, config.dashScope.apiKey.trim() @@ -105,15 +134,21 @@ export async function createAppContext(config: AppConfig = loadConfig()) { config, logger, db, - userRepository: new UserRepository(db), + userRepository, authIdentityRepository: new AuthIdentityRepository(db), authAuditLogRepository: new AuthAuditLogRepository(db), authRiskBlockRepository: new AuthRiskBlockRepository(db), smsAuthEventRepository: new SmsAuthEventRepository(db), userSessionRepository: new UserSessionRepository(db), + rpgAgentSessionRepository, + rpgWorldProfileRepository, + rpgProfileDashboardRepository, + rpgBrowseHistoryRepository, + rpgSaveArchiveRepository, + rpgWorldLibraryRepository, + rpgRuntimeSnapshotRepository, runtimeRepository, llmClient: new UpstreamLlmClient(config, logger), - customWorldSessions: new CustomWorldSessionStore(runtimeRepository), customWorldAgentSessions, customWorldAgentOrchestrator: new CustomWorldAgentOrchestrator( customWorldAgentSessions, @@ -122,8 +157,11 @@ export async function createAppContext(config: AppConfig = loadConfig()) { : null, { autoAssetService, + rpgWorldProfileRepository, + userRepository, }, ), + rpgWorldWorkSummaryService, smsVerificationService: createSmsVerificationService(config, logger), wechatAuthService: createWechatAuthService(config, logger), wechatAuthStates: new WechatAuthStateStore(), diff --git a/server-node/src/services/RpgWorldPreviewCompiler.fixture.test.ts b/server-node/src/services/RpgWorldPreviewCompiler.fixture.test.ts new file mode 100644 index 00000000..ad73d3be --- /dev/null +++ b/server-node/src/services/RpgWorldPreviewCompiler.fixture.test.ts @@ -0,0 +1,80 @@ +import assert from 'node:assert/strict'; +import test from 'node:test'; + +import { + createRpgAgentFoundationDraftProfileFixture, + createRpgCreationPublishedProfileFixture, +} from '../../../packages/shared/src/contracts/rpgCreationFixtures.js'; +import { + buildRpgWorldPreviewEnvelope, + normalizeRpgWorldPreviewEnvelope, +} from './RpgWorldPreviewCompiler.js'; + +test('rpg world preview compiler can consume shared published profile fixture as a stable unit baseline', () => { + const publishedProfile = createRpgCreationPublishedProfileFixture(); + const previewEnvelope = buildRpgWorldPreviewEnvelope( + publishedProfile, + String(publishedProfile.settingText ?? ''), + ); + + assert.equal(previewEnvelope.source, 'session_preview'); + assert.equal(previewEnvelope.preview.name, publishedProfile.name); + assert.equal( + (previewEnvelope.preview.playableNpcs as Array<{ generatedAnimationSetId?: string }>)[0] + ?.generatedAnimationSetId, + 'animation-set-playable-1', + ); + assert.equal( + ( + previewEnvelope.preview.sceneChapterBlueprints as Array<{ + acts?: Array<{ backgroundImageSrc?: string }>; + }> + )[0]?.acts?.[0]?.backgroundImageSrc, + '/generated-custom-world-scenes/landmark-1/scene-act-1/scene.png', + ); +}); + +test('regression: foundation-like shared fixture fields are preserved after normalize + preview compile chain', () => { + const foundationDraft = createRpgAgentFoundationDraftProfileFixture(); + const normalizedPreviewEnvelope = normalizeRpgWorldPreviewEnvelope( + { + name: foundationDraft.name, + subtitle: foundationDraft.subtitle, + summary: foundationDraft.summary, + tone: foundationDraft.tone, + playerGoal: foundationDraft.playerGoal, + templateWorldType: 'WUXIA', + majorFactions: foundationDraft.majorFactions, + coreConflicts: foundationDraft.coreConflicts, + playableNpcs: foundationDraft.playableNpcs, + storyNpcs: foundationDraft.storyNpcs, + camp: foundationDraft.camp, + landmarks: foundationDraft.landmarks, + sceneChapterBlueprints: foundationDraft.sceneChapters, + themePack: foundationDraft.themePack, + storyGraph: foundationDraft.storyGraph, + }, + foundationDraft.worldHook, + ); + + assert.equal(normalizedPreviewEnvelope.source, 'session_preview'); + assert.equal( + (normalizedPreviewEnvelope.preview.playableNpcs as Array<{ imageSrc?: string }>)[0] + ?.imageSrc, + '/generated-characters/playable-1/visual/asset-runtime/master.png', + ); + assert.equal( + (normalizedPreviewEnvelope.preview.playableNpcs as Array<{ + animationMap?: { attack?: { basePath?: string } }; + }>)[0]?.animationMap?.attack?.basePath, + '/generated-characters/playable-1/animations/attack', + ); + assert.equal( + ( + normalizedPreviewEnvelope.preview.sceneChapterBlueprints as Array<{ + acts?: Array<{ backgroundAssetId?: string }>; + }> + )[0]?.acts?.[0]?.backgroundAssetId, + 'scene-asset-runtime', + ); +}); diff --git a/server-node/src/services/RpgWorldPreviewCompiler.test.ts b/server-node/src/services/RpgWorldPreviewCompiler.test.ts new file mode 100644 index 00000000..f4899792 --- /dev/null +++ b/server-node/src/services/RpgWorldPreviewCompiler.test.ts @@ -0,0 +1,269 @@ +import assert from 'node:assert/strict'; +import test from 'node:test'; + +import { + buildRpgWorldPreviewEnvelope, + buildRpgWorldPreviewProfile, + normalizeRpgWorldPreviewEnvelope, +} from './RpgWorldPreviewCompiler.js'; +import { buildRpgCreationPreviewProfileFromDraftProfile } from './rpgCreationPreviewProfileBuilder.js'; + +function createPreviewFixture() { + const storyNpcs = Array.from({ length: 25 }, (_, index) => ({ + name: `场景角色${index + 1}`, + title: `头衔${index + 1}`, + role: `职责${index + 1}`, + description: `场景角色描述${index + 1}`, + backstory: `场景角色背景${index + 1}`, + personality: `场景角色性格${index + 1}`, + motivation: `场景角色动机${index + 1}`, + combatStyle: `场景角色战斗风格${index + 1}`, + initialAffinity: index % 4 === 0 ? -10 : 6, + relationshipHooks: [`关系${index + 1}`], + tags: [`线索${index + 1}`], + })); + + return { + id: 'preview-world', + name: '预览测试世界', + subtitle: '预览副标题', + summary: '服务端预览编译的兼容结果。', + tone: '压抑、潮湿', + playerGoal: '先确认谁在推动局势,再决定站位。', + templateWorldType: 'WUXIA', + majorFactions: ['守灯会', '潮线商盟'], + coreConflicts: ['旧航道解释权正在被重写'], + playableNpcs: Array.from({ length: 5 }, (_, index) => ({ + name: `角色${index + 1}`, + title: `称号${index + 1}`, + role: `身份${index + 1}`, + description: `角色描述${index + 1}`, + backstory: `角色背景${index + 1}`, + personality: `角色性格${index + 1}`, + motivation: `角色动机${index + 1}`, + combatStyle: `战斗风格${index + 1}`, + initialAffinity: 18, + relationshipHooks: [`接触点${index + 1}`], + tags: [`标签${index + 1}`], + })), + storyNpcs, + landmarks: Array.from({ length: 10 }, (_, index) => ({ + name: `场景${index + 1}`, + description: `场景描述${index + 1}`, + dangerLevel: 'medium', + sceneNpcNames: [ + storyNpcs[index % storyNpcs.length]?.name ?? `场景角色${index + 1}`, + storyNpcs[(index + 1) % storyNpcs.length]?.name ?? + `场景角色${index + 2}`, + storyNpcs[(index + 2) % storyNpcs.length]?.name ?? + `场景角色${index + 3}`, + ], + connections: [ + { + targetLandmarkName: `场景${((index + 1) % 10) + 1}`, + relativePosition: 'forward', + summary: '沿主路前行', + }, + { + targetLandmarkName: `场景${((index + 9) % 10) + 1}`, + relativePosition: 'back', + summary: '回身可返', + }, + ], + })), + }; +} + +test('rpg world preview compiler builds a legacy-compatible preview envelope on the server', () => { + const settingText = '一个被潮雾反复切开的边境世界。'; + const rawProfile = createPreviewFixture(); + + const previewProfile = buildRpgWorldPreviewProfile(rawProfile, settingText); + const previewEnvelope = buildRpgWorldPreviewEnvelope(rawProfile, settingText); + const normalizedEnvelope = normalizeRpgWorldPreviewEnvelope( + rawProfile, + settingText, + ); + + assert.equal(previewProfile.name, '预览测试世界'); + assert.equal(previewProfile.playableNpcs.length, 5); + assert.equal(previewEnvelope.source, 'session_preview'); + assert.equal(normalizedEnvelope.source, 'session_preview'); + assert.equal(previewEnvelope.preview.name, '预览测试世界'); + assert.equal(previewEnvelope.preview.scenarioPackId, 'scenario-pack:预览测试世界'); + assert.equal( + normalizedEnvelope.preview.campaignPackId, + 'campaign-pack:预览测试世界', + ); +}); + +test('phase5 preview builder keeps legacy runtime-rich fields while merging latest draft assets', () => { + const previewProfile = buildRpgCreationPreviewProfileFromDraftProfile({ + sessionId: 'session-phase5-preview', + draftProfile: { + name: '潮雾列岛', + subtitle: '旧灯塔与失控航路', + summary: '第一版世界底稿已经整理完成。', + tone: '压抑、潮湿、悬疑', + playerGoal: '查清沉船与禁航区异动的真相。', + majorFactions: ['守灯会', '航运公会'], + coreConflicts: ['守灯会与航运公会争夺旧航路控制权'], + playableNpcs: [ + { + id: 'playable-1', + name: '沈砺', + imageSrc: '/generated-characters/playable-1/visual/asset-runtime/master.png', + generatedVisualAssetId: 'asset-runtime-playable', + generatedAnimationSetId: 'animation-set-runtime-playable', + animationMap: { + attack: { + basePath: '/generated-characters/playable-1/animations/attack', + }, + }, + }, + ], + storyNpcs: [ + { + id: 'story-1', + name: '顾潮音', + imageSrc: '/generated-characters/story-1/visual/asset-runtime/master.png', + generatedVisualAssetId: 'asset-runtime-story', + }, + ], + landmarks: [ + { + id: 'landmark-1', + name: '回潮旧灯塔', + imageSrc: '/generated-custom-world-scenes/landmark-1/scene.png', + generatedSceneAssetId: 'scene-asset-runtime', + }, + ], + sceneChapters: [ + { + id: 'scene-chapter-1', + sceneId: 'landmark-1', + title: '灯塔初章', + acts: [ + { + id: 'scene-act-1', + title: '第一幕', + backgroundImageSrc: + '/generated-custom-world-scenes/landmark-1/scene-act-1/scene.png', + backgroundAssetId: 'scene-act-runtime', + }, + ], + }, + ], + legacyResultProfile: { + id: 'agent-draft-session-phase5-preview', + settingText: '被海雾吞没的旧航路群岛', + name: '潮雾列岛·结果页精修版', + subtitle: '旧灯塔与失控航路', + summary: '服务端 preview 需要保留结果页富字段。', + tone: '压抑、潮湿、悬疑', + playerGoal: '查清沉船夜与假航灯的真正操盘者。', + templateWorldType: 'WUXIA', + majorFactions: ['守灯会', '航运公会'], + coreConflicts: ['守灯会与航运公会争夺旧航路控制权'], + playableNpcs: [ + { + id: 'playable-1', + name: '沈砺', + title: '旧航路引路人', + role: '关键同行者', + description: '最熟悉旧航路的人。', + backstory: '曾在沉船夜里带着半支船队逃出海雾。', + personality: '表面沉稳,心里一直在算退路。', + motivation: '想赶在守灯会封航前查清真相。', + combatStyle: '借地形和潮路换位,先拉扯再压近。', + initialAffinity: 18, + relationshipHooks: ['旧友', '沉船旧案'], + tags: ['潮路', '引路'], + narrativeProfile: { + publicMask: '像个只想把旧路再走通一次的熟路人。', + }, + }, + ], + storyNpcs: [ + { + id: 'story-1', + name: '顾潮音', + title: '守灯会值夜人', + role: '场景关键角色', + description: '夜里巡灯与封锁禁航区的人。', + backstory: '在失控海雾第一次吞船那夜,她是最后一个还留在灯塔顶的人。', + personality: '冷静克制,但提到旧灯册时会显得过分警觉。', + motivation: '想守住灯塔记录,也想找到谁先改动了禁航信号。', + combatStyle: '借塔顶视角和风向压制,再用灯火错位扰乱。', + initialAffinity: 8, + relationshipHooks: ['禁航记录', '灯塔值夜'], + tags: ['守灯会', '灯塔'], + }, + ], + items: [ + { + id: 'item-world-1', + name: '潮雾罗盘', + category: '饰品', + }, + ], + landmarks: [ + { + id: 'landmark-1', + name: '回潮旧灯塔', + description: '被海雾啃旧的石塔仍在夜里维持着微弱灯火。', + dangerLevel: 'high', + sceneNpcIds: ['story-1'], + connections: [], + }, + ], + themePack: { + id: 'theme-pack:tide', + }, + knowledgeFacts: [ + { + id: 'fact-1', + title: '高处潮痕', + }, + ], + threadContracts: [ + { + id: 'contract-1', + threadId: 'thread-visible-1', + }, + ], + sceneChapterBlueprints: [ + { + id: 'scene-chapter-1', + sceneId: 'landmark-1', + title: '灯塔初章', + acts: [ + { + id: 'scene-act-1', + title: '第一幕', + }, + ], + }, + ], + generationMode: 'full', + generationStatus: 'complete', + }, + }, + }); + + assert.equal(previewProfile.name, '潮雾列岛'); + assert.equal(previewProfile.playerGoal, '查清沉船与禁航区异动的真相。'); + assert.equal(previewProfile.themePack?.id, 'theme-pack:tide'); + assert.equal(previewProfile.knowledgeFacts?.[0]?.id, 'fact-1'); + assert.equal(previewProfile.threadContracts?.[0]?.id, 'contract-1'); + assert.equal(previewProfile.playableNpcs[0]?.imageSrc, '/generated-characters/playable-1/visual/asset-runtime/master.png'); + assert.equal(previewProfile.playableNpcs[0]?.generatedAnimationSetId, 'animation-set-runtime-playable'); + assert.equal( + previewProfile.playableNpcs[0]?.narrativeProfile?.publicMask, + '像个只想把旧路再走通一次的熟路人。', + ); + assert.equal( + previewProfile.sceneChapterBlueprints?.[0]?.acts?.[0]?.backgroundAssetId, + 'scene-act-runtime', + ); +}); diff --git a/server-node/src/services/RpgWorldPreviewCompiler.ts b/server-node/src/services/RpgWorldPreviewCompiler.ts new file mode 100644 index 00000000..483b488b --- /dev/null +++ b/server-node/src/services/RpgWorldPreviewCompiler.ts @@ -0,0 +1,65 @@ +import { + buildCompiledCustomWorldProfile, + normalizeCustomWorldProfile, +} from '../modules/custom-world/runtime-profile/index.js'; +import type { + RpgCreationPreview, + RpgCreationPreviewEnvelope, + RpgCreationPreviewSource, +} from '../../../packages/shared/src/contracts/rpgCreationPreview.js'; +import type { CustomWorldProfile } from '../modules/custom-world/runtimeTypes.js'; + +/** + * 工作包 G 把服务端结果预览编译入口收口到这里。 + * Phase 5 后当前 preview 正式作为 session_preview 主链输出, + * 编译边界已经从 foundation draft 流程中抽离。 + */ +export type RpgWorldPreviewProfile = CustomWorldProfile; + +const RPG_WORLD_PREVIEW_SOURCE: RpgCreationPreviewSource = + 'session_preview'; + +function toRpgCreationPreview( + profile: RpgWorldPreviewProfile, +): RpgCreationPreview { + return profile as unknown as RpgCreationPreview; +} + +export function buildRpgWorldPreviewProfile( + raw: unknown, + settingText: string, +): RpgWorldPreviewProfile { + return buildCompiledCustomWorldProfile(raw, settingText); +} + +export function normalizeRpgWorldPreviewProfile( + raw: unknown, + settingText: string, +): RpgWorldPreviewProfile { + return normalizeCustomWorldProfile(raw, settingText); +} + +export function buildRpgWorldPreviewEnvelope( + raw: unknown, + settingText: string, +): RpgCreationPreviewEnvelope { + return { + preview: toRpgCreationPreview(buildRpgWorldPreviewProfile(raw, settingText)), + source: RPG_WORLD_PREVIEW_SOURCE, + }; +} + +export function normalizeRpgWorldPreviewEnvelope( + raw: unknown, + settingText: string, +): RpgCreationPreviewEnvelope { + return { + preview: toRpgCreationPreview( + buildRpgWorldPreviewProfile( + normalizeRpgWorldPreviewProfile(raw, settingText), + settingText, + ), + ), + source: RPG_WORLD_PREVIEW_SOURCE, + }; +} diff --git a/server-node/src/services/RpgWorldWorkCoverResolver.ts b/server-node/src/services/RpgWorldWorkCoverResolver.ts new file mode 100644 index 00000000..186825cf --- /dev/null +++ b/server-node/src/services/RpgWorldWorkCoverResolver.ts @@ -0,0 +1,46 @@ +import type { + CustomWorldLibraryEntry, + CustomWorldProfileRecord, +} from '../../../packages/shared/src/contracts/runtime.js'; +import { resolveCustomWorldCoverPresentation } from '../repositories/customWorldLibraryMetadata.js'; +import type { CustomWorldAgentSessionRecord } from './customWorldAgentSessionStore.js'; + +function toRecord(value: unknown) { + return value && typeof value === 'object' && !Array.isArray(value) + ? (value as Record) + : null; +} + +/** + * 作品封面解析统一收口在这里,避免 works 聚合服务重复理解草稿态与发布态的封面规则。 + */ +export function resolveRpgWorldDraftWorkCover( + session: CustomWorldAgentSessionRecord, +) { + const draftProfile = toRecord(session.draftProfile); + if (!draftProfile) { + return { + imageSrc: null, + renderMode: 'image' as const, + characterImageSrcs: [], + }; + } + + return resolveCustomWorldCoverPresentation( + draftProfile as CustomWorldProfileRecord, + ); +} + +export function resolveRpgWorldPublishedWorkCover( + libraryEntry: CustomWorldLibraryEntry, +) { + const coverPresentation = resolveCustomWorldCoverPresentation( + libraryEntry.profile, + ); + + return { + imageSrc: libraryEntry.coverImageSrc || coverPresentation.imageSrc, + renderMode: coverPresentation.renderMode, + characterImageSrcs: coverPresentation.characterImageSrcs, + }; +} diff --git a/server-node/src/services/RpgWorldWorkSummaryAssembler.fixture.test.ts b/server-node/src/services/RpgWorldWorkSummaryAssembler.fixture.test.ts new file mode 100644 index 00000000..9aeb0771 --- /dev/null +++ b/server-node/src/services/RpgWorldWorkSummaryAssembler.fixture.test.ts @@ -0,0 +1,96 @@ +import assert from 'node:assert/strict'; +import test from 'node:test'; + +import { + createRpgAgentSessionFixture, + createRpgCreationWorksResponseFixture, + createRpgWorldLibraryEntryFixture, +} from '../../../packages/shared/src/contracts/rpgCreationFixtures.js'; +import { RpgWorldWorkSummaryAssembler } from './RpgWorldWorkSummaryAssembler.js'; + +test('rpg world work summary assembler can consume shared fixture baselines as a unit test', () => { + const assembler = new RpgWorldWorkSummaryAssembler(); + const session = createRpgAgentSessionFixture(); + const libraryEntry = createRpgWorldLibraryEntryFixture(); + const [draftItem] = assembler.assembleDraftItems([ + { + ...JSON.parse(JSON.stringify(session)), + userId: 'fixture-user', + seedText: '被海雾吞没的旧航路群岛', + operations: [], + checkpoints: [], + createdAt: session.updatedAt, + }, + ]); + const [publishedItem] = assembler.assemblePublishedItems([libraryEntry]); + + assert.equal(draftItem.sourceType, 'agent_session'); + assert.equal(draftItem.roleVisualReadyCount, 2); + assert.equal(draftItem.roleAnimationReadyCount, 2); + assert.equal(draftItem.roleAssetSummaryLabel, '沈砺 · 动作已就绪'); + assert.equal(draftItem.canEnterWorld, false); + assert.equal(draftItem.publishReady, true); + assert.equal(draftItem.blockerCount, 0); + assert.equal(publishedItem.sourceType, 'published_profile'); + assert.equal(publishedItem.canEnterWorld, true); + assert.equal(publishedItem.publishReady, true); + assert.equal(publishedItem.blockerCount, 0); + assert.equal(publishedItem.roleAnimationReadyCount, 1); +}); + +test('regression: assembler output stays aligned with shared works response fixture', () => { + const assembler = new RpgWorldWorkSummaryAssembler(); + const session = createRpgAgentSessionFixture(); + const libraryEntry = createRpgWorldLibraryEntryFixture(); + const expected = createRpgCreationWorksResponseFixture(); + + const [draftItem] = assembler.assembleDraftItems([ + { + ...JSON.parse(JSON.stringify(session)), + userId: 'fixture-user', + seedText: '被海雾吞没的旧航路群岛', + operations: [], + checkpoints: [], + createdAt: session.updatedAt, + }, + ]); + const [publishedItem] = assembler.assemblePublishedItems([libraryEntry]); + const expectedDraft = expected.items.find((entry) => entry.sourceType === 'agent_session'); + const expectedPublished = expected.items.find( + (entry) => entry.sourceType === 'published_profile', + ); + + assert.ok(expectedDraft); + assert.ok(expectedPublished); + assert.equal(draftItem.coverImageSrc, expectedDraft.coverImageSrc); + assert.deepEqual( + draftItem.coverCharacterImageSrcs, + expectedDraft.coverCharacterImageSrcs, + ); + assert.equal(draftItem.stageLabel, expectedDraft.stageLabel); + assert.equal(draftItem.publishReady, expectedDraft.publishReady); + assert.equal(draftItem.blockerCount, expectedDraft.blockerCount); + assert.equal(publishedItem.coverImageSrc, expectedPublished.coverImageSrc); + assert.equal( + publishedItem.roleAssetSummaryLabel, + expectedPublished.roleAssetSummaryLabel, + ); +}); + +test('published sessions do not leak back into draft work summaries', () => { + const assembler = new RpgWorldWorkSummaryAssembler(); + const session = createRpgAgentSessionFixture(); + const draftItems = assembler.assembleDraftItems([ + { + ...JSON.parse(JSON.stringify(session)), + userId: 'fixture-user', + seedText: '被海雾吞没的旧航路群岛', + operations: [], + checkpoints: [], + createdAt: session.updatedAt, + stage: 'published', + }, + ]); + + assert.equal(draftItems.length, 0); +}); diff --git a/server-node/src/services/RpgWorldWorkSummaryAssembler.ts b/server-node/src/services/RpgWorldWorkSummaryAssembler.ts new file mode 100644 index 00000000..7942c629 --- /dev/null +++ b/server-node/src/services/RpgWorldWorkSummaryAssembler.ts @@ -0,0 +1,301 @@ +import type { + CustomWorldAgentStage, + CustomWorldWorkSummary, +} from '../../../packages/shared/src/contracts/customWorldAgent.js'; +import type { + CustomWorldLibraryEntry, + CustomWorldProfileRecord, +} from '../../../packages/shared/src/contracts/runtime.js'; +import { normalizeFoundationDraftProfile } from './customWorldAgentDraftCompiler.js'; +import { + buildDraftSummaryFromIntent, + buildDraftTitleFromIntent, + normalizeCreatorIntentRecord, +} from './customWorldAgentIntentExtractionService.js'; +import { + rebuildRoleAssetCoverage, + resolveRoleAssetStatusLabel, +} from './customWorldAgentRoleAssetStateService.js'; +import type { CustomWorldAgentSessionRecord } from './customWorldAgentSessionStore.js'; +import { + buildDraftSummaryFromEightAnchorContent, + buildDraftTitleFromEightAnchorContent, +} from './eightAnchorCompatibilityService.js'; +import { + resolveRpgWorldDraftWorkCover, + resolveRpgWorldPublishedWorkCover, +} from './RpgWorldWorkCoverResolver.js'; +import { CustomWorldAgentPublishingService } from './customWorldAgentPublishingService.js'; + +function toText(value: unknown) { + return typeof value === 'string' ? value.trim() : ''; +} + +function toRecord(value: unknown) { + return value && typeof value === 'object' && !Array.isArray(value) + ? (value as Record) + : null; +} + +function toRecordArray(value: unknown) { + return Array.isArray(value) + ? value.filter((item) => item && typeof item === 'object') + : []; +} + +function truncateText(value: string, maxLength: number) { + if (value.length <= maxLength) { + return value; + } + + return `${value.slice(0, Math.max(0, maxLength - 1)).trim()}…`; +} + +function formatDraftStageLabel(stage: CustomWorldAgentStage) { + if (stage === 'collecting_intent') return '收集世界锚点'; + if (stage === 'clarifying') return '补齐关键锚点'; + if (stage === 'foundation_review') return '准备整理底稿'; + if (stage === 'object_refining') return '待完善草稿'; + if (stage === 'visual_refining') return '视觉工坊'; + if (stage === 'long_tail_review') return '扩展长尾'; + if (stage === 'ready_to_publish') return '准备发布'; + if (stage === 'published') return '已发布'; + return '发生错误'; +} + +function resolveDraftTitle(session: CustomWorldAgentSessionRecord) { + const intent = normalizeCreatorIntentRecord(session.creatorIntent); + const draftProfile = normalizeFoundationDraftProfile(session.draftProfile); + + return ( + draftProfile?.name || + buildDraftTitleFromEightAnchorContent(session.anchorContent) || + buildDraftTitleFromIntent(intent) || + toText(session.draftProfile?.title) || + truncateText(session.seedText, 18) || + '未命名草稿' + ); +} + +function resolveDraftSummary(session: CustomWorldAgentSessionRecord) { + const intent = normalizeCreatorIntentRecord(session.creatorIntent); + const compiledSummary = buildDraftSummaryFromIntent(intent); + const draftProfile = normalizeFoundationDraftProfile(session.draftProfile); + + return ( + draftProfile?.summary || + buildDraftSummaryFromEightAnchorContent(session.anchorContent) || + compiledSummary || + toText(session.draftProfile?.summary) || + truncateText(session.seedText, 72) || + '还在收集你的世界锚点。' + ); +} + +function resolveDraftCounts(session: CustomWorldAgentSessionRecord) { + const draftProfile = normalizeFoundationDraftProfile(session.draftProfile); + if (draftProfile) { + // 草稿作品卡需要展示当前可编辑的全部角色数量,而不是仅统计可扮演角色。 + const totalRoleCount = [ + ...new Set( + [...draftProfile.playableNpcs, ...draftProfile.storyNpcs].map( + (entry) => entry.id, + ), + ), + ].length; + + return { + playableNpcCount: totalRoleCount, + landmarkCount: draftProfile.landmarks.length, + }; + } + + const playableNpcCount = session.draftCards.filter( + (card) => card.kind === 'character', + ).length; + const landmarkCount = session.draftCards.filter( + (card) => card.kind === 'landmark' || card.kind === 'camp', + ).length; + + return { + playableNpcCount, + landmarkCount, + }; +} + +function resolveDraftRoleAssetProgress(session: CustomWorldAgentSessionRecord) { + const coverage = rebuildRoleAssetCoverage(session.draftProfile); + const roleVisualReadyCount = coverage.roleAssets.filter( + (entry) => entry.status !== 'missing', + ).length; + const roleAnimationReadyCount = coverage.roleAssets.filter( + (entry) => entry.status === 'complete', + ).length; + const leadRole = coverage.roleAssets[0]; + + return { + roleVisualReadyCount, + roleAnimationReadyCount, + roleAssetSummaryLabel: leadRole + ? `${leadRole.roleName} · ${resolveRoleAssetStatusLabel(leadRole.status)}` + : coverage.roleAssets.length > 0 + ? '角色资产进行中' + : null, + }; +} + +function isLibraryEntry( + value: unknown, +): value is CustomWorldLibraryEntry { + const record = toRecord(value); + return ( + record !== null && + typeof record.ownerUserId === 'string' && + typeof record.profileId === 'string' && + Boolean(toRecord(record.profile)) + ); +} + +function isPublishedLibraryEntry( + value: unknown, +): value is CustomWorldLibraryEntry { + return isLibraryEntry(value) && value.visibility === 'published'; +} + +/** + * works 组装器只负责把 session/profile 转成稳定读模型,不直接发起仓储读取。 + */ +export class RpgWorldWorkSummaryAssembler { + private readonly publishGateService = new CustomWorldAgentPublishingService({ + listOwnProfiles: async () => [], + upsertOwnProfile: async () => { + throw new Error('publish repository is unavailable in work summary assembler'); + }, + syncProfileFromSnapshot: async () => undefined, + softDeleteOwnProfile: async () => [], + publishOwnProfile: async () => null, + unpublishOwnProfile: async () => null, + listPublishedGallery: async () => [], + getPublishedGalleryDetail: async () => null, + }); + + assembleDraftItems(sessions: CustomWorldAgentSessionRecord[]) { + return sessions + .filter((session) => session.stage !== 'published') + .map((session) => { + const counts = resolveDraftCounts(session); + const roleAssetProgress = resolveDraftRoleAssetProgress(session); + const coverPresentation = resolveRpgWorldDraftWorkCover(session); + const publishState = this.publishGateService.summarizePublishGate({ + sessionId: session.sessionId, + stage: session.stage, + draftProfile: session.draftProfile, + qualityFindings: session.qualityFindings, + }); + + return { + workId: `draft:${session.sessionId}`, + sourceType: 'agent_session', + status: 'draft', + title: resolveDraftTitle(session), + subtitle: + normalizeFoundationDraftProfile(session.draftProfile)?.subtitle || + formatDraftStageLabel(session.stage), + summary: resolveDraftSummary(session), + coverImageSrc: coverPresentation.imageSrc, + coverRenderMode: coverPresentation.renderMode, + coverCharacterImageSrcs: coverPresentation.characterImageSrcs, + updatedAt: session.updatedAt, + publishedAt: null, + stage: session.stage, + stageLabel: formatDraftStageLabel(session.stage), + playableNpcCount: counts.playableNpcCount, + landmarkCount: counts.landmarkCount, + roleVisualReadyCount: roleAssetProgress.roleVisualReadyCount, + roleAnimationReadyCount: roleAssetProgress.roleAnimationReadyCount, + roleAssetSummaryLabel: roleAssetProgress.roleAssetSummaryLabel, + sessionId: session.sessionId, + profileId: null, + canResume: true, + canEnterWorld: publishState.canEnterWorld, + blockerCount: publishState.blockerCount, + publishReady: publishState.publishReady, + } satisfies CustomWorldWorkSummary; + }); + } + + assemblePublishedItems( + profiles: Array>, + ) { + return profiles.filter(isPublishedLibraryEntry).map((libraryEntry) => { + const profileRecord = libraryEntry.profile as CustomWorldProfileRecord & + Record; + const playableNpcs = toRecordArray(profileRecord.playableNpcs); + const landmarks = toRecordArray(profileRecord.landmarks); + const updatedAt = + toText(libraryEntry.updatedAt) || + toText(profileRecord.updatedAt) || + new Date().toISOString(); + const coverPresentation = resolveRpgWorldPublishedWorkCover(libraryEntry); + const roleVisualReadyCount = playableNpcs.filter( + (entry) => + Boolean(toText(entry.imageSrc)) && + Boolean(toText(entry.generatedVisualAssetId)), + ).length; + const roleAnimationReadyCount = playableNpcs.filter((entry) => + Boolean(toText(entry.generatedAnimationSetId)), + ).length; + + return { + workId: `published:${toText(profileRecord.id) || updatedAt}`, + sourceType: 'published_profile', + status: 'published', + title: + toText(libraryEntry.worldName) || + toText(profileRecord.name) || + '未命名世界', + subtitle: + toText(libraryEntry.subtitle) || + toText(profileRecord.subtitle) || + '已保存作品', + summary: + toText(libraryEntry.summaryText) || + toText(profileRecord.summary) || + '这个世界已经可以直接进入体验。', + coverImageSrc: coverPresentation.imageSrc, + coverRenderMode: coverPresentation.renderMode, + coverCharacterImageSrcs: coverPresentation.characterImageSrcs, + updatedAt, + publishedAt: + toText(libraryEntry.publishedAt) || + toText(profileRecord.publishedAt) || + updatedAt, + stage: 'published', + stageLabel: '已发布', + playableNpcCount: + libraryEntry.playableNpcCount > 0 + ? libraryEntry.playableNpcCount + : playableNpcs.length, + landmarkCount: + libraryEntry.landmarkCount > 0 + ? libraryEntry.landmarkCount + : landmarks.length, + roleVisualReadyCount, + roleAnimationReadyCount, + roleAssetSummaryLabel: + roleAnimationReadyCount > 0 + ? `动作已就绪 ${roleAnimationReadyCount}` + : roleVisualReadyCount > 0 + ? `主图已就绪 ${roleVisualReadyCount}` + : null, + sessionId: null, + profileId: + toText(libraryEntry.profileId) || toText(profileRecord.id) || null, + canResume: false, + canEnterWorld: true, + blockerCount: 0, + publishReady: true, + } satisfies CustomWorldWorkSummary; + }); + } +} diff --git a/server-node/src/services/RpgWorldWorkSummaryService.ts b/server-node/src/services/RpgWorldWorkSummaryService.ts new file mode 100644 index 00000000..06cd2df1 --- /dev/null +++ b/server-node/src/services/RpgWorldWorkSummaryService.ts @@ -0,0 +1,44 @@ +import type { CustomWorldWorkSummary } from '../../../packages/shared/src/contracts/customWorldAgent.js'; +import type { RpgWorldProfileRepositoryPort } from '../repositories/RpgWorldProfileRepository.js'; +import type { CustomWorldAgentSessionStore } from './customWorldAgentSessionStore.js'; +import { RpgWorldWorkSummaryAssembler } from './RpgWorldWorkSummaryAssembler.js'; + +/** + * RPG 作品卡服务只负责组织“草稿 session + 已发布作品”两类读模型, + * 不再直接承担读库 SQL 或封面字段推导细节。 + */ +export class RpgWorldWorkSummaryService { + private readonly assembler: RpgWorldWorkSummaryAssembler; + + constructor( + private readonly rpgWorldProfiles: RpgWorldProfileRepositoryPort, + private readonly customWorldAgentSessions: CustomWorldAgentSessionStore, + assembler: RpgWorldWorkSummaryAssembler = new RpgWorldWorkSummaryAssembler(), + ) { + this.assembler = assembler; + } + + async list(userId: string): Promise { + const [sessions, profiles] = await Promise.all([ + this.customWorldAgentSessions.list(userId), + this.rpgWorldProfiles.listOwnProfiles(userId), + ]); + + const draftItems = this.assembler.assembleDraftItems(sessions); + const publishedItems = this.assembler.assemblePublishedItems(profiles); + + return [...draftItems, ...publishedItems].sort((left, right) => { + const updatedAtDiff = + new Date(right.updatedAt).getTime() - new Date(left.updatedAt).getTime(); + if (updatedAtDiff !== 0) { + return updatedAtDiff; + } + + if (left.sourceType !== right.sourceType) { + return left.sourceType === 'agent_session' ? -1 : 1; + } + + return left.workId.localeCompare(right.workId); + }); + } +} diff --git a/server-node/src/services/chatService.ts b/server-node/src/services/chatService.ts index cc7a35a7..4242155e 100644 --- a/server-node/src/services/chatService.ts +++ b/server-node/src/services/chatService.ts @@ -7,7 +7,7 @@ import type { NpcChatDialogueRequest, NpcChatTurnRequest, NpcRecruitDialogueRequest, -} from '../../../packages/shared/src/contracts/story.js'; +} from '../../../packages/shared/src/contracts/rpgRuntimeChat.js'; const jsonObjectSchema = z.record(z.string(), z.unknown()); diff --git a/server-node/src/services/customWorldAgentActionExecutors/draftFoundationExecutor.ts b/server-node/src/services/customWorldAgentActionExecutors/draftFoundationExecutor.ts new file mode 100644 index 00000000..9cfd0031 --- /dev/null +++ b/server-node/src/services/customWorldAgentActionExecutors/draftFoundationExecutor.ts @@ -0,0 +1,145 @@ +import { + buildCreatorIntentFromEightAnchorContent, + buildAnchorPackFromEightAnchorContent, +} from '../eightAnchorCompatibilityService.js'; +import { rebuildRoleAssetCoverage } from '../customWorldAgentRoleAssetStateService.js'; +import type { CustomWorldAgentActionExecutor } from './types.js'; +import type { CustomWorldAgentFoundationDraftService } from '../customWorldAgentFoundationDraftService.js'; +import type { CustomWorldAgentAutoAssetService } from '../customWorldAgentAutoAssetService.js'; +import type { CustomWorldAgentSnapshotBuilder } from '../customWorldAgentSnapshotBuilder.js'; +import type { CustomWorldAgentSessionStore } from '../customWorldAgentSessionStore.js'; +import { buildFoundationDraftAssistantMessage } from './helpers.js'; +import { + buildCheckpointSnapshot, + createOperationUpdater, + getRequiredSession, +} from './executorShared.js'; + +export function createDraftFoundationExecutor(params: { + sessionStore: CustomWorldAgentSessionStore; + foundationDraftService: CustomWorldAgentFoundationDraftService; + autoAssetService: CustomWorldAgentAutoAssetService | null; + snapshotBuilder: CustomWorldAgentSnapshotBuilder; +}): CustomWorldAgentActionExecutor<'draft_foundation'> { + return async ({ userId, sessionId, operationId }) => { + const updateOperation = createOperationUpdater({ + sessionStore: params.sessionStore, + userId, + sessionId, + operationId, + }); + + try { + await updateOperation({ + status: 'running', + phaseLabel: '整理世界骨架', + phaseDetail: '正在校验已确认锚点,并准备第一版世界框架生成链路。', + progress: 12, + }); + + const latestSession = await getRequiredSession({ + sessionStore: params.sessionStore, + userId, + sessionId, + }); + if (latestSession.progressPercent < 100) { + throw new Error('session progressPercent is below 100'); + } + + const creatorIntent = buildCreatorIntentFromEightAnchorContent( + latestSession.anchorContent, + ); + const anchorPack = buildAnchorPackFromEightAnchorContent( + latestSession.anchorContent, + latestSession.progressPercent, + ); + const draftProfile = await params.foundationDraftService.generate({ + creatorIntent, + anchorPack, + anchorContent: latestSession.anchorContent, + onProgress: async (progress) => { + await updateOperation({ + status: 'running', + phaseLabel: progress.phaseLabel, + phaseDetail: progress.phaseDetail, + progress: progress.progress, + }); + }, + }); + + const draftWithAssets = params.autoAssetService + ? await params.autoAssetService.populateDraftAssets({ + draftProfile, + onProgress: async (progress) => { + await updateOperation({ + status: 'running', + phaseLabel: progress.phaseLabel, + phaseDetail: progress.phaseDetail, + progress: progress.progress, + }); + }, + }) + : { + draftProfile, + assetCoverage: rebuildRoleAssetCoverage(draftProfile), + warnings: [], + }; + + await updateOperation({ + phaseLabel: '编译草稿卡', + phaseDetail: '正在把世界底稿整理成可浏览的卡片摘要和详情结构。', + progress: 98, + }); + + const nextState = params.snapshotBuilder.buildFoundationDraftState({ + creatorIntent, + anchorPack, + draftProfile: + draftWithAssets.draftProfile as unknown as Record, + assetCoverage: draftWithAssets.assetCoverage, + }); + + await params.sessionStore.replaceDerivedState(userId, sessionId, nextState); + await params.sessionStore.appendCheckpoint(userId, sessionId, { + label: '世界底稿 V1', + snapshot: buildCheckpointSnapshot(latestSession, nextState), + }); + await params.sessionStore.appendMessage( + userId, + sessionId, + buildFoundationDraftAssistantMessage({ + relatedOperationId: operationId, + draftProfile: draftWithAssets.draftProfile, + warnings: draftWithAssets.warnings, + }), + ); + + await updateOperation({ + status: 'completed', + phaseLabel: '世界底稿已生成', + phaseDetail: + draftWithAssets.warnings.length > 0 + ? `第一版世界底稿和 ${(nextState.draftCards ?? []).length} 张草稿卡已经整理完成,另有 ${draftWithAssets.warnings.length} 项资产补齐待后续处理。` + : `第一版世界底稿和 ${(nextState.draftCards ?? []).length} 张草稿卡已经整理完成。`, + progress: 100, + error: null, + }); + } catch (error) { + const currentOperation = await params.sessionStore.getOperation( + userId, + sessionId, + operationId, + ); + await updateOperation({ + status: 'failed', + phaseLabel: currentOperation?.phaseLabel?.trim() || '底稿生成失败', + phaseDetail: + currentOperation?.phaseDetail?.trim() || + '这一轮没有成功把设定编成世界底稿。', + progress: 100, + error: + error instanceof Error ? error.message : 'draft foundation failed', + }); + } + }; +} diff --git a/server-node/src/services/customWorldAgentActionExecutors/executorShared.ts b/server-node/src/services/customWorldAgentActionExecutors/executorShared.ts new file mode 100644 index 00000000..6c046b2c --- /dev/null +++ b/server-node/src/services/customWorldAgentActionExecutors/executorShared.ts @@ -0,0 +1,108 @@ +import type { CustomWorldAgentOperationRecord } from '../../../../packages/shared/src/contracts/customWorldAgent.js'; +import type { CustomWorldAgentSessionRecord, CustomWorldAgentSessionStore } from '../customWorldAgentSessionStore.js'; + +export type UpdateExecutorOperation = ( + patch: Partial, +) => Promise; + +export async function getRequiredSession(params: { + sessionStore: CustomWorldAgentSessionStore; + userId: string; + sessionId: string; +}) { + const session = (await params.sessionStore.get( + params.userId, + params.sessionId, + )) as CustomWorldAgentSessionRecord | null; + if (!session) { + throw new Error('custom world agent session not found'); + } + + return session; +} + +export function createOperationUpdater(params: { + sessionStore: CustomWorldAgentSessionStore; + userId: string; + sessionId: string; + operationId: string; +}): UpdateExecutorOperation { + return (patch) => + params.sessionStore.updateOperation( + params.userId, + params.sessionId, + params.operationId, + patch, + ); +} + +// checkpoint 恢复依赖这份最小可回放快照,统一由 executor 共享,避免每个动作手写字段集合。 +export function buildCheckpointSnapshot( + session: CustomWorldAgentSessionRecord, + patch: Partial< + Pick< + CustomWorldAgentSessionRecord, + | 'currentTurn' + | 'anchorContent' + | 'progressPercent' + | 'lastAssistantReply' + | 'stage' + | 'focusCardId' + | 'creatorIntent' + | 'creatorIntentReadiness' + | 'anchorPack' + | 'lockState' + | 'draftProfile' + | 'pendingClarifications' + | 'suggestedActions' + | 'recommendedReplies' + | 'draftCards' + | 'qualityFindings' + | 'assetCoverage' + > + >, +) { + return { + currentTurn: patch.currentTurn ?? session.currentTurn, + anchorContent: patch.anchorContent ?? session.anchorContent, + progressPercent: patch.progressPercent ?? session.progressPercent, + lastAssistantReply: + patch.lastAssistantReply !== undefined + ? patch.lastAssistantReply + : session.lastAssistantReply, + stage: patch.stage ?? session.stage, + focusCardId: + patch.focusCardId !== undefined ? patch.focusCardId : session.focusCardId, + creatorIntent: + patch.creatorIntent !== undefined + ? patch.creatorIntent + : session.creatorIntent, + creatorIntentReadiness: + patch.creatorIntentReadiness ?? session.creatorIntentReadiness, + anchorPack: patch.anchorPack !== undefined ? patch.anchorPack : session.anchorPack, + lockState: patch.lockState !== undefined ? patch.lockState : session.lockState, + draftProfile: + patch.draftProfile !== undefined ? patch.draftProfile : session.draftProfile, + pendingClarifications: + patch.pendingClarifications !== undefined + ? patch.pendingClarifications + : session.pendingClarifications, + suggestedActions: + patch.suggestedActions !== undefined + ? patch.suggestedActions + : session.suggestedActions, + recommendedReplies: + patch.recommendedReplies !== undefined + ? patch.recommendedReplies + : session.recommendedReplies, + draftCards: patch.draftCards !== undefined ? patch.draftCards : session.draftCards, + qualityFindings: + patch.qualityFindings !== undefined + ? patch.qualityFindings + : session.qualityFindings, + assetCoverage: + patch.assetCoverage !== undefined + ? patch.assetCoverage + : session.assetCoverage, + }; +} diff --git a/server-node/src/services/customWorldAgentActionExecutors/expandLongTailExecutor.ts b/server-node/src/services/customWorldAgentActionExecutors/expandLongTailExecutor.ts new file mode 100644 index 00000000..343473fb --- /dev/null +++ b/server-node/src/services/customWorldAgentActionExecutors/expandLongTailExecutor.ts @@ -0,0 +1,116 @@ +import { getWorldFoundationCardId } from '../customWorldAgentDraftCompiler.js'; +import type { CustomWorldAgentActionExecutor } from './types.js'; +import type { CustomWorldAgentEntityGenerationService } from '../customWorldAgentEntityGenerationService.js'; +import type { CustomWorldAgentSnapshotBuilder } from '../customWorldAgentSnapshotBuilder.js'; +import type { CustomWorldAgentSessionStore } from '../customWorldAgentSessionStore.js'; +import { buildActionResultMessage } from './helpers.js'; +import { + buildCheckpointSnapshot, + createOperationUpdater, + getRequiredSession, +} from './executorShared.js'; + +export function createExpandLongTailExecutor(params: { + sessionStore: CustomWorldAgentSessionStore; + entityGenerationService: CustomWorldAgentEntityGenerationService; + snapshotBuilder: CustomWorldAgentSnapshotBuilder; +}): CustomWorldAgentActionExecutor<'expand_long_tail'> { + return async ({ userId, sessionId, operationId }) => { + const updateOperation = createOperationUpdater({ + sessionStore: params.sessionStore, + userId, + sessionId, + operationId, + }); + + try { + await updateOperation({ + status: 'running', + phaseLabel: '扩展长尾内容', + phaseDetail: '正在补充边缘角色与次级地点,让世界草稿更完整可玩。', + progress: 28, + }); + + const latestSession = await getRequiredSession({ + sessionStore: params.sessionStore, + userId, + sessionId, + }); + const baseDraftProfile = + (latestSession.draftProfile ?? {}) as Record; + const characterResult = + await params.entityGenerationService.generateAdditionalCharacters({ + creatorIntent: latestSession.creatorIntent, + anchorPack: latestSession.anchorPack, + draftProfile: baseDraftProfile, + count: 2, + anchorCardIds: + latestSession.focusCardId && latestSession.focusCardId.trim() + ? [latestSession.focusCardId] + : [getWorldFoundationCardId()], + }); + + await updateOperation({ + phaseLabel: '补充次级地点', + phaseDetail: '正在围绕新线索补齐可承接支线与长尾内容的地点。', + progress: 62, + }); + + const landmarkResult = + await params.entityGenerationService.generateAdditionalLandmarks({ + creatorIntent: latestSession.creatorIntent, + anchorPack: latestSession.anchorPack, + draftProfile: characterResult.draftProfile, + count: 2, + anchorCardIds: + characterResult.generatedCharacters.length > 0 + ? [characterResult.generatedCharacters[0]!.id] + : latestSession.focusCardId && latestSession.focusCardId.trim() + ? [latestSession.focusCardId] + : [getWorldFoundationCardId()], + }); + + const focusCardId = + landmarkResult.generatedLandmarks[0]?.id ?? + characterResult.generatedCharacters[0]?.id ?? + latestSession.focusCardId; + const nextState = params.snapshotBuilder.buildRefiningState({ + previousStage: latestSession.stage, + nextStage: 'long_tail_review', + draftProfile: landmarkResult.draftProfile, + focusCardId, + }); + + await params.sessionStore.replaceDerivedState(userId, sessionId, nextState); + await params.sessionStore.appendCheckpoint(userId, sessionId, { + label: `扩展长尾 ${characterResult.generatedCharacters.length} 角色 / ${landmarkResult.generatedLandmarks.length} 地点`, + snapshot: buildCheckpointSnapshot(latestSession, nextState), + }); + await params.sessionStore.appendMessage( + userId, + sessionId, + buildActionResultMessage({ + relatedOperationId: operationId, + text: `已补出 ${characterResult.generatedCharacters.length} 个长尾角色和 ${landmarkResult.generatedLandmarks.length} 个次级地点,当前阶段进入补全长尾内容。`, + }), + ); + + await updateOperation({ + status: 'completed', + phaseLabel: '长尾内容已扩展', + phaseDetail: '长尾角色与次级地点已经补回草稿,可继续收口后进入发布前检查。', + progress: 100, + error: null, + }); + } catch (error) { + await updateOperation({ + status: 'failed', + phaseLabel: '扩展长尾失败', + phaseDetail: '这一轮没有成功补出长尾内容。', + progress: 100, + error: + error instanceof Error ? error.message : 'expand long tail failed', + }); + } + }; +} diff --git a/server-node/src/services/customWorldAgentActionExecutors/generateCharactersExecutor.ts b/server-node/src/services/customWorldAgentActionExecutors/generateCharactersExecutor.ts new file mode 100644 index 00000000..4931f0e5 --- /dev/null +++ b/server-node/src/services/customWorldAgentActionExecutors/generateCharactersExecutor.ts @@ -0,0 +1,110 @@ +import { getWorldFoundationCardId } from '../customWorldAgentDraftCompiler.js'; +import type { CustomWorldAgentActionExecutor } from './types.js'; +import type { CustomWorldAgentEntityGenerationService } from '../customWorldAgentEntityGenerationService.js'; +import type { CustomWorldAgentChangeSummaryService } from '../customWorldAgentChangeSummaryService.js'; +import type { CustomWorldAgentSnapshotBuilder } from '../customWorldAgentSnapshotBuilder.js'; +import type { CustomWorldAgentSessionStore } from '../customWorldAgentSessionStore.js'; +import { buildActionResultMessage } from './helpers.js'; +import { + buildCheckpointSnapshot, + createOperationUpdater, + getRequiredSession, +} from './executorShared.js'; + +export function createGenerateCharactersExecutor(params: { + sessionStore: CustomWorldAgentSessionStore; + entityGenerationService: CustomWorldAgentEntityGenerationService; + changeSummaryService: CustomWorldAgentChangeSummaryService; + snapshotBuilder: CustomWorldAgentSnapshotBuilder; +}): CustomWorldAgentActionExecutor<'generate_characters'> { + return async ({ userId, sessionId, operationId, payload }) => { + const updateOperation = createOperationUpdater({ + sessionStore: params.sessionStore, + userId, + sessionId, + operationId, + }); + + try { + await updateOperation({ + status: 'running', + phaseLabel: '生成新角色', + phaseDetail: '正在围绕当前世界底稿补出新角色。', + progress: 32, + }); + + const latestSession = await getRequiredSession({ + sessionStore: params.sessionStore, + userId, + sessionId, + }); + const generationResult = + await params.entityGenerationService.generateAdditionalCharacters({ + creatorIntent: latestSession.creatorIntent, + anchorPack: latestSession.anchorPack, + draftProfile: (latestSession.draftProfile ?? {}) as Record< + string, + unknown + >, + count: payload.count, + promptText: payload.promptText, + anchorCardIds: + payload.anchorCardIds && payload.anchorCardIds.length > 0 + ? payload.anchorCardIds + : latestSession.focusCardId + ? [latestSession.focusCardId] + : [getWorldFoundationCardId()], + }); + + await updateOperation({ + phaseLabel: '插入新角色卡', + phaseDetail: '正在把新角色插回草稿并刷新卡片列表。', + progress: 74, + }); + + const focusCardId = generationResult.generatedCharacters[0]?.id ?? null; + const nextState = params.snapshotBuilder.buildRefiningState({ + previousStage: latestSession.stage, + draftProfile: generationResult.draftProfile, + focusCardId, + }); + + await params.sessionStore.replaceDerivedState(userId, sessionId, nextState); + await params.sessionStore.appendCheckpoint(userId, sessionId, { + label: `新增角色 ${generationResult.generatedCharacters.length} 个`, + snapshot: buildCheckpointSnapshot(latestSession, nextState), + }); + await params.sessionStore.appendMessage( + userId, + sessionId, + buildActionResultMessage({ + relatedOperationId: operationId, + text: params.changeSummaryService.buildSummary({ + action: 'generate_characters', + names: generationResult.generatedCharacters.map( + (entry) => entry.name, + ), + draftProfile: generationResult.draftProfile, + }), + }), + ); + + await updateOperation({ + status: 'completed', + phaseLabel: '新角色已加入草稿', + phaseDetail: `已补出 ${generationResult.generatedCharacters.length} 个新角色。`, + progress: 100, + error: null, + }); + } catch (error) { + await updateOperation({ + status: 'failed', + phaseLabel: '角色生成失败', + phaseDetail: '这一轮没有成功补出新角色。', + progress: 100, + error: + error instanceof Error ? error.message : 'generate characters failed', + }); + } + }; +} diff --git a/server-node/src/services/customWorldAgentActionExecutors/generateLandmarksExecutor.ts b/server-node/src/services/customWorldAgentActionExecutors/generateLandmarksExecutor.ts new file mode 100644 index 00000000..1277ff4e --- /dev/null +++ b/server-node/src/services/customWorldAgentActionExecutors/generateLandmarksExecutor.ts @@ -0,0 +1,110 @@ +import { getWorldFoundationCardId } from '../customWorldAgentDraftCompiler.js'; +import type { CustomWorldAgentActionExecutor } from './types.js'; +import type { CustomWorldAgentEntityGenerationService } from '../customWorldAgentEntityGenerationService.js'; +import type { CustomWorldAgentChangeSummaryService } from '../customWorldAgentChangeSummaryService.js'; +import type { CustomWorldAgentSnapshotBuilder } from '../customWorldAgentSnapshotBuilder.js'; +import type { CustomWorldAgentSessionStore } from '../customWorldAgentSessionStore.js'; +import { buildActionResultMessage } from './helpers.js'; +import { + buildCheckpointSnapshot, + createOperationUpdater, + getRequiredSession, +} from './executorShared.js'; + +export function createGenerateLandmarksExecutor(params: { + sessionStore: CustomWorldAgentSessionStore; + entityGenerationService: CustomWorldAgentEntityGenerationService; + changeSummaryService: CustomWorldAgentChangeSummaryService; + snapshotBuilder: CustomWorldAgentSnapshotBuilder; +}): CustomWorldAgentActionExecutor<'generate_landmarks'> { + return async ({ userId, sessionId, operationId, payload }) => { + const updateOperation = createOperationUpdater({ + sessionStore: params.sessionStore, + userId, + sessionId, + operationId, + }); + + try { + await updateOperation({ + status: 'running', + phaseLabel: '生成新地点', + phaseDetail: '正在围绕当前世界底稿补出新地点。', + progress: 32, + }); + + const latestSession = await getRequiredSession({ + sessionStore: params.sessionStore, + userId, + sessionId, + }); + const generationResult = + await params.entityGenerationService.generateAdditionalLandmarks({ + creatorIntent: latestSession.creatorIntent, + anchorPack: latestSession.anchorPack, + draftProfile: (latestSession.draftProfile ?? {}) as Record< + string, + unknown + >, + count: payload.count, + promptText: payload.promptText, + anchorCardIds: + payload.anchorCardIds && payload.anchorCardIds.length > 0 + ? payload.anchorCardIds + : latestSession.focusCardId + ? [latestSession.focusCardId] + : [getWorldFoundationCardId()], + }); + + await updateOperation({ + phaseLabel: '插入新地点卡', + phaseDetail: '正在把新地点插回草稿并刷新卡片列表。', + progress: 74, + }); + + const focusCardId = generationResult.generatedLandmarks[0]?.id ?? null; + const nextState = params.snapshotBuilder.buildRefiningState({ + previousStage: latestSession.stage, + draftProfile: generationResult.draftProfile, + focusCardId, + }); + + await params.sessionStore.replaceDerivedState(userId, sessionId, nextState); + await params.sessionStore.appendCheckpoint(userId, sessionId, { + label: `新增地点 ${generationResult.generatedLandmarks.length} 个`, + snapshot: buildCheckpointSnapshot(latestSession, nextState), + }); + await params.sessionStore.appendMessage( + userId, + sessionId, + buildActionResultMessage({ + relatedOperationId: operationId, + text: params.changeSummaryService.buildSummary({ + action: 'generate_landmarks', + names: generationResult.generatedLandmarks.map( + (entry) => entry.name, + ), + draftProfile: generationResult.draftProfile, + }), + }), + ); + + await updateOperation({ + status: 'completed', + phaseLabel: '新地点已加入草稿', + phaseDetail: `已补出 ${generationResult.generatedLandmarks.length} 个新地点。`, + progress: 100, + error: null, + }); + } catch (error) { + await updateOperation({ + status: 'failed', + phaseLabel: '地点生成失败', + phaseDetail: '这一轮没有成功补出新地点。', + progress: 100, + error: + error instanceof Error ? error.message : 'generate landmarks failed', + }); + } + }; +} diff --git a/server-node/src/services/customWorldAgentActionExecutors/generateRoleAssetsExecutor.ts b/server-node/src/services/customWorldAgentActionExecutors/generateRoleAssetsExecutor.ts new file mode 100644 index 00000000..2daea938 --- /dev/null +++ b/server-node/src/services/customWorldAgentActionExecutors/generateRoleAssetsExecutor.ts @@ -0,0 +1,82 @@ +import type { CustomWorldAgentActionExecutor } from './types.js'; +import type { CustomWorldAgentAssetBridgeService } from '../customWorldAgentAssetBridgeService.js'; +import type { CustomWorldAgentSnapshotBuilder } from '../customWorldAgentSnapshotBuilder.js'; +import type { CustomWorldAgentSessionStore } from '../customWorldAgentSessionStore.js'; +import { buildActionResultMessage } from './helpers.js'; +import { + createOperationUpdater, + getRequiredSession, +} from './executorShared.js'; + +export function createGenerateRoleAssetsExecutor(params: { + sessionStore: CustomWorldAgentSessionStore; + assetBridgeService: CustomWorldAgentAssetBridgeService; + snapshotBuilder: CustomWorldAgentSnapshotBuilder; +}): CustomWorldAgentActionExecutor<'generate_role_assets'> { + return async ({ userId, sessionId, operationId, payload }) => { + const updateOperation = createOperationUpdater({ + sessionStore: params.sessionStore, + userId, + sessionId, + operationId, + }); + + try { + await updateOperation({ + status: 'running', + phaseLabel: '准备角色资产工坊', + phaseDetail: '正在校验角色并整理工坊上下文。', + progress: 40, + }); + + const latestSession = await getRequiredSession({ + sessionStore: params.sessionStore, + userId, + sessionId, + }); + const roleId = payload.roleIds[0]!; + const studioContext = params.assetBridgeService.buildRoleAssetStudioContext( + latestSession.draftProfile, + roleId, + ); + const nextState = params.snapshotBuilder.buildRefiningState({ + previousStage: latestSession.stage, + nextStage: 'visual_refining', + draftProfile: + (latestSession.draftProfile ?? {}) as Record, + draftCards: latestSession.draftCards, + assetCoverage: latestSession.assetCoverage, + focusCardId: roleId, + }); + + await params.sessionStore.replaceDerivedState(userId, sessionId, nextState); + await params.sessionStore.appendMessage( + userId, + sessionId, + buildActionResultMessage({ + relatedOperationId: operationId, + text: `已为「${studioContext.roleName}」准备好角色资产工坊,先生成主图候选,再补核心动作。`, + }), + ); + + await updateOperation({ + status: 'completed', + phaseLabel: '角色资产工坊已就绪', + phaseDetail: `「${studioContext.roleName}」现在可以开始生成主图和动作。`, + progress: 100, + error: null, + }); + } catch (error) { + await updateOperation({ + status: 'failed', + phaseLabel: '角色资产工坊准备失败', + phaseDetail: '这一轮没有成功进入角色资产工坊。', + progress: 100, + error: + error instanceof Error + ? error.message + : 'generate role assets failed', + }); + } + }; +} diff --git a/server-node/src/services/customWorldAgentActionExecutors/generateSceneAssetsExecutor.ts b/server-node/src/services/customWorldAgentActionExecutors/generateSceneAssetsExecutor.ts new file mode 100644 index 00000000..e2e69ad6 --- /dev/null +++ b/server-node/src/services/customWorldAgentActionExecutors/generateSceneAssetsExecutor.ts @@ -0,0 +1,88 @@ +import type { CustomWorldAgentActionExecutor } from './types.js'; +import type { CustomWorldAgentAssetBridgeService } from '../customWorldAgentAssetBridgeService.js'; +import type { CustomWorldAgentSnapshotBuilder } from '../customWorldAgentSnapshotBuilder.js'; +import type { CustomWorldAgentSessionStore } from '../customWorldAgentSessionStore.js'; +import { buildActionResultMessage } from './helpers.js'; +import { + createOperationUpdater, + getRequiredSession, +} from './executorShared.js'; + +export function createGenerateSceneAssetsExecutor(params: { + sessionStore: CustomWorldAgentSessionStore; + assetBridgeService: CustomWorldAgentAssetBridgeService; + snapshotBuilder: CustomWorldAgentSnapshotBuilder; +}): CustomWorldAgentActionExecutor<'generate_scene_assets'> { + return async ({ userId, sessionId, operationId, payload }) => { + const updateOperation = createOperationUpdater({ + sessionStore: params.sessionStore, + userId, + sessionId, + operationId, + }); + + try { + await updateOperation({ + status: 'running', + phaseLabel: '准备场景资产工坊', + phaseDetail: '正在校验目标场景并整理场景图工坊上下文。', + progress: 40, + }); + + const latestSession = await getRequiredSession({ + sessionStore: params.sessionStore, + userId, + sessionId, + }); + const sceneId = payload.sceneIds[0]!; + const sceneKind = + latestSession.draftCards.find((entry) => entry.id === sceneId)?.kind === + 'camp' + ? 'camp' + : 'landmark'; + const sceneContext = params.assetBridgeService.buildSceneAssetStudioContext( + latestSession.draftProfile, + sceneId, + sceneKind, + ); + const nextState = params.snapshotBuilder.buildRefiningState({ + previousStage: latestSession.stage, + nextStage: 'visual_refining', + draftProfile: + (latestSession.draftProfile ?? {}) as Record, + draftCards: latestSession.draftCards, + assetCoverage: latestSession.assetCoverage, + focusCardId: sceneId, + }); + + await params.sessionStore.replaceDerivedState(userId, sessionId, nextState); + await params.sessionStore.appendMessage( + userId, + sessionId, + buildActionResultMessage({ + relatedOperationId: operationId, + text: `已为「${sceneContext.sceneName}」准备好场景图工坊,保存生成结果后会自动同步回当前草稿。`, + }), + ); + + await updateOperation({ + status: 'completed', + phaseLabel: '场景资产工坊已就绪', + phaseDetail: `「${sceneContext.sceneName}」现在可以继续生成和确认正式场景图。`, + progress: 100, + error: null, + }); + } catch (error) { + await updateOperation({ + status: 'failed', + phaseLabel: '场景资产工坊准备失败', + phaseDetail: '这一轮没有成功进入场景资产工坊。', + progress: 100, + error: + error instanceof Error + ? error.message + : 'generate scene assets failed', + }); + } + }; +} diff --git a/server-node/src/services/customWorldAgentActionExecutors/helpers.ts b/server-node/src/services/customWorldAgentActionExecutors/helpers.ts new file mode 100644 index 00000000..1c838f4f --- /dev/null +++ b/server-node/src/services/customWorldAgentActionExecutors/helpers.ts @@ -0,0 +1,58 @@ +import crypto from 'node:crypto'; + +import type { CustomWorldAgentMessage } from '../../../../packages/shared/src/contracts/customWorldAgent.js'; +import { + normalizeFoundationDraftProfile, +} from '../customWorldAgentDraftCompiler.js'; + +export function buildRoleAssetSyncResultText(params: { + roleName: string; + assetStatusLabel: string; +}) { + return `已把「${params.roleName}」的角色资产写回草稿,当前状态:${params.assetStatusLabel}。`; +} + +export function buildFoundationDraftAssistantMessage(params: { + relatedOperationId: string; + draftProfile: unknown; + warnings?: string[]; +}) { + const profile = normalizeFoundationDraftProfile(params.draftProfile); + const leadCharacter = profile?.playableNpcs[0]; + const leadLandmark = profile?.landmarks[0]; + const warnings = (params.warnings ?? []).filter(Boolean); + + return { + id: `message-${crypto.randomBytes(8).toString('hex')}`, + role: 'assistant', + kind: 'summary', + text: [ + `我先把第一版世界底稿整理出来了:${profile?.summary || '底稿已经生成完成。'}`, + '', + `当前已经落下来的第一批对象数量是:关键角色 ${profile?.playableNpcs.length ?? 0} 个,关键地点 ${profile?.landmarks.length ?? 0} 个,势力 ${profile?.factions.length ?? 0} 个。`, + `建议你先从“${profile?.name || '世界总卡'}”这张世界总卡看起${leadCharacter ? `,再顺着角色「${leadCharacter.name}」往下细修` : ''}${leadLandmark ? `,地点可以先看「${leadLandmark.name}」` : ''}。`, + ...(warnings.length > 0 + ? [ + '', + `这一轮有 ${warnings.length} 项资产补齐未完成,但不影响世界底稿继续精修。`, + ] + : []), + ].join('\n'), + createdAt: new Date().toISOString(), + relatedOperationId: params.relatedOperationId, + } satisfies CustomWorldAgentMessage; +} + +export function buildActionResultMessage(params: { + relatedOperationId: string; + text: string; +}) { + return { + id: `message-${crypto.randomBytes(8).toString('hex')}`, + role: 'assistant', + kind: 'action_result', + text: params.text, + createdAt: new Date().toISOString(), + relatedOperationId: params.relatedOperationId, + } satisfies CustomWorldAgentMessage; +} diff --git a/server-node/src/services/customWorldAgentActionExecutors/index.ts b/server-node/src/services/customWorldAgentActionExecutors/index.ts new file mode 100644 index 00000000..0dd62e53 --- /dev/null +++ b/server-node/src/services/customWorldAgentActionExecutors/index.ts @@ -0,0 +1,105 @@ +import type { CustomWorldAgentAutoAssetService } from '../customWorldAgentAutoAssetService.js'; +import type { CustomWorldAgentAssetBridgeService } from '../customWorldAgentAssetBridgeService.js'; +import type { CustomWorldAgentChangeSummaryService } from '../customWorldAgentChangeSummaryService.js'; +import type { CustomWorldAgentDraftCompiler } from '../customWorldAgentDraftCompiler.js'; +import type { CustomWorldAgentEntityGenerationService } from '../customWorldAgentEntityGenerationService.js'; +import type { CustomWorldAgentFoundationDraftService } from '../customWorldAgentFoundationDraftService.js'; +import type { CustomWorldAgentPublishingService } from '../customWorldAgentPublishingService.js'; +import type { CustomWorldAgentResultSyncService } from '../customWorldAgentResultSyncService.js'; +import type { CustomWorldAgentSessionStore } from '../customWorldAgentSessionStore.js'; +import type { CustomWorldAgentSnapshotBuilder } from '../customWorldAgentSnapshotBuilder.js'; +import { createDraftFoundationExecutor } from './draftFoundationExecutor.js'; +import { createExpandLongTailExecutor } from './expandLongTailExecutor.js'; +import { createGenerateCharactersExecutor } from './generateCharactersExecutor.js'; +import { createGenerateLandmarksExecutor } from './generateLandmarksExecutor.js'; +import { createGenerateRoleAssetsExecutor } from './generateRoleAssetsExecutor.js'; +import { createGenerateSceneAssetsExecutor } from './generateSceneAssetsExecutor.js'; +import { createPublishWorldExecutor } from './publishWorldExecutor.js'; +import { createRevertCheckpointExecutor } from './revertCheckpointExecutor.js'; +import { createSyncResultProfileExecutor } from './syncResultProfileExecutor.js'; +import { createSyncRoleAssetsExecutor } from './syncRoleAssetsExecutor.js'; +import { createSyncSceneAssetsExecutor } from './syncSceneAssetsExecutor.js'; +import type { CustomWorldAgentActionExecutorMap } from './types.js'; +import { createUpdateDraftCardExecutor } from './updateDraftCardExecutor.js'; + +export * from './types.js'; + +export function createCustomWorldAgentActionExecutorMap(params: { + sessionStore: CustomWorldAgentSessionStore; + foundationDraftService: CustomWorldAgentFoundationDraftService; + draftCompiler: CustomWorldAgentDraftCompiler; + entityGenerationService: CustomWorldAgentEntityGenerationService; + changeSummaryService: CustomWorldAgentChangeSummaryService; + assetBridgeService: CustomWorldAgentAssetBridgeService; + autoAssetService: CustomWorldAgentAutoAssetService | null; + snapshotBuilder: CustomWorldAgentSnapshotBuilder; + resultSyncService: CustomWorldAgentResultSyncService; + publishingService: CustomWorldAgentPublishingService; + resolveAuthorDisplayName?: ((userId: string) => Promise) | null; +}): CustomWorldAgentActionExecutorMap { + return { + draft_foundation: createDraftFoundationExecutor({ + sessionStore: params.sessionStore, + foundationDraftService: params.foundationDraftService, + autoAssetService: params.autoAssetService, + snapshotBuilder: params.snapshotBuilder, + }), + update_draft_card: createUpdateDraftCardExecutor({ + sessionStore: params.sessionStore, + draftCompiler: params.draftCompiler, + changeSummaryService: params.changeSummaryService, + snapshotBuilder: params.snapshotBuilder, + }), + sync_result_profile: createSyncResultProfileExecutor({ + sessionStore: params.sessionStore, + resultSyncService: params.resultSyncService, + snapshotBuilder: params.snapshotBuilder, + }), + generate_characters: createGenerateCharactersExecutor({ + sessionStore: params.sessionStore, + entityGenerationService: params.entityGenerationService, + changeSummaryService: params.changeSummaryService, + snapshotBuilder: params.snapshotBuilder, + }), + generate_landmarks: createGenerateLandmarksExecutor({ + sessionStore: params.sessionStore, + entityGenerationService: params.entityGenerationService, + changeSummaryService: params.changeSummaryService, + snapshotBuilder: params.snapshotBuilder, + }), + generate_role_assets: createGenerateRoleAssetsExecutor({ + sessionStore: params.sessionStore, + assetBridgeService: params.assetBridgeService, + snapshotBuilder: params.snapshotBuilder, + }), + sync_role_assets: createSyncRoleAssetsExecutor({ + sessionStore: params.sessionStore, + assetBridgeService: params.assetBridgeService, + snapshotBuilder: params.snapshotBuilder, + }), + generate_scene_assets: createGenerateSceneAssetsExecutor({ + sessionStore: params.sessionStore, + assetBridgeService: params.assetBridgeService, + snapshotBuilder: params.snapshotBuilder, + }), + sync_scene_assets: createSyncSceneAssetsExecutor({ + sessionStore: params.sessionStore, + assetBridgeService: params.assetBridgeService, + snapshotBuilder: params.snapshotBuilder, + }), + expand_long_tail: createExpandLongTailExecutor({ + sessionStore: params.sessionStore, + entityGenerationService: params.entityGenerationService, + snapshotBuilder: params.snapshotBuilder, + }), + publish_world: createPublishWorldExecutor({ + sessionStore: params.sessionStore, + publishingService: params.publishingService, + resolveAuthorDisplayName: params.resolveAuthorDisplayName ?? null, + }), + revert_checkpoint: createRevertCheckpointExecutor({ + sessionStore: params.sessionStore, + snapshotBuilder: params.snapshotBuilder, + }), + }; +} diff --git a/server-node/src/services/customWorldAgentActionExecutors/publishWorldExecutor.ts b/server-node/src/services/customWorldAgentActionExecutors/publishWorldExecutor.ts new file mode 100644 index 00000000..b3b4694f --- /dev/null +++ b/server-node/src/services/customWorldAgentActionExecutors/publishWorldExecutor.ts @@ -0,0 +1,166 @@ +import type { CustomWorldAgentActionExecutor } from './types.js'; +import type { CustomWorldAgentPublishingService } from '../customWorldAgentPublishingService.js'; +import type { CustomWorldAgentSessionStore } from '../customWorldAgentSessionStore.js'; +import { buildActionResultMessage } from './helpers.js'; +import { + buildCheckpointSnapshot, + createOperationUpdater, + getRequiredSession, +} from './executorShared.js'; + +function toText(value: unknown) { + return typeof value === 'string' ? value.trim() : ''; +} + +function extractPublishBlockerMessages(message: string) { + const normalized = message.trim(); + if (!normalized) { + return []; + } + + const detailText = normalized.includes(':') + ? normalized.split(':').slice(1).join(':').trim() + : normalized; + + return detailText + .split(';') + .map((entry) => entry.trim()) + .filter(Boolean); +} + +function buildGateFailureMessage(errorMessage: string) { + return [ + '当前世界还不能发布,先把这些阻断项补齐:', + ...(extractPublishBlockerMessages(errorMessage).length > 0 + ? extractPublishBlockerMessages(errorMessage) + : [errorMessage.trim()] + ) + .slice(0, 4) + .map((entry, index) => `${index + 1}. ${entry}`), + ].join('\n'); +} + +function resolvePublishedWorldName(profile: unknown) { + const profileRecord = + profile && typeof profile === 'object' && !Array.isArray(profile) + ? (profile as Record) + : null; + + return toText(profileRecord?.name) || '当前世界'; +} + +export function createPublishWorldExecutor(params: { + sessionStore: CustomWorldAgentSessionStore; + publishingService: CustomWorldAgentPublishingService; + resolveAuthorDisplayName?: ((userId: string) => Promise) | null; +}): CustomWorldAgentActionExecutor<'publish_world'> { + return async ({ userId, sessionId, operationId }) => { + const updateOperation = createOperationUpdater({ + sessionStore: params.sessionStore, + userId, + sessionId, + operationId, + }); + + try { + await updateOperation({ + status: 'running', + phaseLabel: '执行发布校验', + phaseDetail: '正在检查角色资产、场景图和主线草稿是否满足发布门槛。', + progress: 28, + }); + + const latestSession = await getRequiredSession({ + sessionStore: params.sessionStore, + userId, + sessionId, + }); + try { + params.publishingService.buildPublishReadiness({ + sessionId, + draftProfile: latestSession.draftProfile, + qualityFindings: latestSession.qualityFindings, + }); + } catch (error) { + const errorMessage = + error instanceof Error ? error.message : 'publish world failed'; + await params.sessionStore.appendMessage( + userId, + sessionId, + { + id: `message-${Date.now().toString(36)}-publish-warning`, + role: 'assistant', + kind: 'warning', + text: buildGateFailureMessage(errorMessage), + createdAt: new Date().toISOString(), + relatedOperationId: operationId, + }, + ); + throw error; + } + + await updateOperation({ + phaseLabel: '发布正式世界', + phaseDetail: '正在把当前草稿编译成正式世界档案并写入作品库。', + progress: 68, + }); + + const authorDisplayName = params.resolveAuthorDisplayName + ? await params.resolveAuthorDisplayName(userId) + : '玩家'; + const publishResult = await params.publishingService.publishSessionDraft({ + userId, + authorDisplayName: authorDisplayName.trim() || '玩家', + sessionId, + draftProfile: + (latestSession.draftProfile ?? {}) as Record, + qualityFindings: latestSession.qualityFindings, + }); + const worldName = resolvePublishedWorldName(publishResult.publishedProfile); + const publishedQualityFindings = latestSession.qualityFindings.filter( + (entry) => entry.severity !== 'blocker', + ); + const publishedState = { + stage: 'published' as const, + qualityFindings: publishedQualityFindings, + }; + + await params.sessionStore.replaceDerivedState( + userId, + sessionId, + publishedState, + ); + await params.sessionStore.appendCheckpoint(userId, sessionId, { + label: `发布世界 ${worldName}`, + snapshot: buildCheckpointSnapshot(latestSession, publishedState), + }); + await params.sessionStore.appendMessage( + userId, + sessionId, + buildActionResultMessage({ + relatedOperationId: operationId, + text: + publishedQualityFindings.length > 0 + ? `世界「${worldName}」已发布,并保留 ${publishedQualityFindings.length} 条 warning 供后续继续优化。` + : `世界「${worldName}」已正式发布,可以进入作品库与世界入口。`, + }), + ); + + await updateOperation({ + status: 'completed', + phaseLabel: '世界已发布', + phaseDetail: `正式世界档案已写入作品库:${publishResult.profileId}。`, + progress: 100, + error: null, + }); + } catch (error) { + await updateOperation({ + status: 'failed', + phaseLabel: '发布失败', + phaseDetail: '当前世界还没有通过发布校验或写入作品库失败。', + progress: 100, + error: error instanceof Error ? error.message : 'publish world failed', + }); + } + }; +} diff --git a/server-node/src/services/customWorldAgentActionExecutors/revertCheckpointExecutor.ts b/server-node/src/services/customWorldAgentActionExecutors/revertCheckpointExecutor.ts new file mode 100644 index 00000000..0c70585c --- /dev/null +++ b/server-node/src/services/customWorldAgentActionExecutors/revertCheckpointExecutor.ts @@ -0,0 +1,95 @@ +import type { CustomWorldAgentActionExecutor } from './types.js'; +import type { CustomWorldAgentSnapshotBuilder } from '../customWorldAgentSnapshotBuilder.js'; +import type { CustomWorldAgentSessionStore } from '../customWorldAgentSessionStore.js'; +import { buildActionResultMessage } from './helpers.js'; +import { + createOperationUpdater, + getRequiredSession, +} from './executorShared.js'; + +export function createRevertCheckpointExecutor(params: { + sessionStore: CustomWorldAgentSessionStore; + snapshotBuilder: CustomWorldAgentSnapshotBuilder; +}): CustomWorldAgentActionExecutor<'revert_checkpoint'> { + return async ({ userId, sessionId, operationId, payload }) => { + const updateOperation = createOperationUpdater({ + sessionStore: params.sessionStore, + userId, + sessionId, + operationId, + }); + + try { + await updateOperation({ + status: 'running', + phaseLabel: '恢复历史检查点', + phaseDetail: '正在把指定检查点的草稿状态恢复到当前会话。', + progress: 36, + }); + + const latestSession = await getRequiredSession({ + sessionStore: params.sessionStore, + userId, + sessionId, + }); + const checkpoint = latestSession.checkpoints.find( + (entry) => entry.checkpointId === payload.checkpointId, + ); + if (!checkpoint?.snapshot) { + throw new Error('目标检查点不存在,或当前检查点还没有可恢复快照。'); + } + + await params.sessionStore.restoreCheckpoint( + userId, + sessionId, + payload.checkpointId, + ); + const restoredSession = await getRequiredSession({ + sessionStore: params.sessionStore, + userId, + sessionId, + }); + const nextState = params.snapshotBuilder.buildRefiningState({ + previousStage: restoredSession.stage, + nextStage: + restoredSession.stage === 'visual_refining' || + restoredSession.stage === 'long_tail_review' || + restoredSession.stage === 'ready_to_publish' + ? restoredSession.stage + : 'object_refining', + draftProfile: + (restoredSession.draftProfile ?? {}) as Record, + focusCardId: restoredSession.focusCardId, + }); + + await params.sessionStore.replaceDerivedState(userId, sessionId, nextState); + await params.sessionStore.appendMessage( + userId, + sessionId, + buildActionResultMessage({ + relatedOperationId: operationId, + text: `已恢复到检查点「${checkpoint.label}」,当前草稿和卡片摘要已经回滚到对应版本。`, + }), + ); + + await updateOperation({ + status: 'completed', + phaseLabel: '检查点已恢复', + phaseDetail: `已恢复到「${checkpoint.label}」。`, + progress: 100, + error: null, + }); + } catch (error) { + await updateOperation({ + status: 'failed', + phaseLabel: '恢复检查点失败', + phaseDetail: '这一轮没有成功恢复历史检查点。', + progress: 100, + error: + error instanceof Error + ? error.message + : 'revert checkpoint failed', + }); + } + }; +} diff --git a/server-node/src/services/customWorldAgentActionExecutors/syncResultProfileExecutor.ts b/server-node/src/services/customWorldAgentActionExecutors/syncResultProfileExecutor.ts new file mode 100644 index 00000000..2058fc3b --- /dev/null +++ b/server-node/src/services/customWorldAgentActionExecutors/syncResultProfileExecutor.ts @@ -0,0 +1,87 @@ +import type { CustomWorldAgentActionExecutor } from './types.js'; +import type { CustomWorldAgentResultSyncService } from '../customWorldAgentResultSyncService.js'; +import type { CustomWorldAgentSnapshotBuilder } from '../customWorldAgentSnapshotBuilder.js'; +import type { CustomWorldAgentSessionStore } from '../customWorldAgentSessionStore.js'; +import { buildActionResultMessage } from './helpers.js'; +import { + buildCheckpointSnapshot, + createOperationUpdater, + getRequiredSession, +} from './executorShared.js'; + +export function createSyncResultProfileExecutor(params: { + sessionStore: CustomWorldAgentSessionStore; + resultSyncService: CustomWorldAgentResultSyncService; + snapshotBuilder: CustomWorldAgentSnapshotBuilder; +}): CustomWorldAgentActionExecutor<'sync_result_profile'> { + return async ({ userId, sessionId, operationId, payload }) => { + const updateOperation = createOperationUpdater({ + sessionStore: params.sessionStore, + userId, + sessionId, + operationId, + }); + + try { + await updateOperation({ + status: 'running', + phaseLabel: '同步结果页快照', + phaseDetail: '正在把结果页里的最新世界结构写回当前草稿。', + progress: 36, + }); + + const latestSession = await getRequiredSession({ + sessionStore: params.sessionStore, + userId, + sessionId, + }); + const nextDraftProfile = + params.resultSyncService.syncResultProfileIntoDraftProfile({ + currentDraftProfile: latestSession.draftProfile, + resultProfile: payload.profile as never, + }); + + await updateOperation({ + phaseLabel: '重编译草稿摘要', + phaseDetail: '正在刷新草稿卡摘要、资产覆盖和结果页恢复快照。', + progress: 72, + }); + + const nextState = params.snapshotBuilder.buildRefiningState({ + previousStage: latestSession.stage, + draftProfile: nextDraftProfile, + }); + + await params.sessionStore.replaceDerivedState(userId, sessionId, nextState); + await params.sessionStore.appendCheckpoint(userId, sessionId, { + label: '同步结果页编辑', + snapshot: buildCheckpointSnapshot(latestSession, nextState), + }); + await params.sessionStore.appendMessage( + userId, + sessionId, + buildActionResultMessage({ + relatedOperationId: operationId, + text: '结果页里的最新世界结构已经同步回当前草稿。', + }), + ); + + await updateOperation({ + status: 'completed', + phaseLabel: '结果页快照已同步', + phaseDetail: '当前结果页编辑内容已经并回 Agent 草稿主链。', + progress: 100, + error: null, + }); + } catch (error) { + await updateOperation({ + status: 'failed', + phaseLabel: '结果页同步失败', + phaseDetail: '这一轮结果页编辑没有成功写回当前草稿。', + progress: 100, + error: + error instanceof Error ? error.message : 'sync result profile failed', + }); + } + }; +} diff --git a/server-node/src/services/customWorldAgentActionExecutors/syncRoleAssetsExecutor.ts b/server-node/src/services/customWorldAgentActionExecutors/syncRoleAssetsExecutor.ts new file mode 100644 index 00000000..5cd581c3 --- /dev/null +++ b/server-node/src/services/customWorldAgentActionExecutors/syncRoleAssetsExecutor.ts @@ -0,0 +1,97 @@ +import { resolveRoleAssetStatusLabel } from '../customWorldAgentRoleAssetStateService.js'; +import type { CustomWorldAgentActionExecutor } from './types.js'; +import type { CustomWorldAgentAssetBridgeService } from '../customWorldAgentAssetBridgeService.js'; +import type { CustomWorldAgentSnapshotBuilder } from '../customWorldAgentSnapshotBuilder.js'; +import type { CustomWorldAgentSessionStore } from '../customWorldAgentSessionStore.js'; +import { + buildActionResultMessage, + buildRoleAssetSyncResultText, +} from './helpers.js'; +import { + buildCheckpointSnapshot, + createOperationUpdater, + getRequiredSession, +} from './executorShared.js'; + +export function createSyncRoleAssetsExecutor(params: { + sessionStore: CustomWorldAgentSessionStore; + assetBridgeService: CustomWorldAgentAssetBridgeService; + snapshotBuilder: CustomWorldAgentSnapshotBuilder; +}): CustomWorldAgentActionExecutor<'sync_role_assets'> { + return async ({ userId, sessionId, operationId, payload }) => { + const updateOperation = createOperationUpdater({ + sessionStore: params.sessionStore, + userId, + sessionId, + operationId, + }); + + try { + await updateOperation({ + status: 'running', + phaseLabel: '同步角色资产', + phaseDetail: '正在把主图与动作结果写回当前世界草稿。', + progress: 36, + }); + + const latestSession = await getRequiredSession({ + sessionStore: params.sessionStore, + userId, + sessionId, + }); + const syncResult = params.assetBridgeService.applyRoleAssetPublishResult( + latestSession.draftProfile, + payload, + ); + + await updateOperation({ + phaseLabel: '刷新角色卡摘要', + phaseDetail: '正在同步更新角色卡状态与资产覆盖。', + progress: 72, + }); + + const nextState = params.snapshotBuilder.buildRefiningState({ + previousStage: latestSession.stage, + nextStage: 'visual_refining', + draftProfile: syncResult.draftProfile, + focusCardId: payload.roleId, + }); + + await params.sessionStore.replaceDerivedState(userId, sessionId, nextState); + await params.sessionStore.appendCheckpoint(userId, sessionId, { + label: `同步角色资产 ${syncResult.updatedAssetSummary.roleName}`, + snapshot: buildCheckpointSnapshot(latestSession, nextState), + }); + await params.sessionStore.appendMessage( + userId, + sessionId, + buildActionResultMessage({ + relatedOperationId: operationId, + text: buildRoleAssetSyncResultText({ + roleName: syncResult.updatedAssetSummary.roleName, + assetStatusLabel: resolveRoleAssetStatusLabel( + syncResult.updatedAssetSummary.status, + ), + }), + }), + ); + + await updateOperation({ + status: 'completed', + phaseLabel: '角色资产已同步', + phaseDetail: `「${syncResult.updatedAssetSummary.roleName}」的资产状态已更新为${resolveRoleAssetStatusLabel(syncResult.updatedAssetSummary.status)}。`, + progress: 100, + error: null, + }); + } catch (error) { + await updateOperation({ + status: 'failed', + phaseLabel: '角色资产同步失败', + phaseDetail: '这一轮没有成功把角色资产写回草稿。', + progress: 100, + error: + error instanceof Error ? error.message : 'sync role assets failed', + }); + } + }; +} diff --git a/server-node/src/services/customWorldAgentActionExecutors/syncSceneAssetsExecutor.ts b/server-node/src/services/customWorldAgentActionExecutors/syncSceneAssetsExecutor.ts new file mode 100644 index 00000000..6dc24354 --- /dev/null +++ b/server-node/src/services/customWorldAgentActionExecutors/syncSceneAssetsExecutor.ts @@ -0,0 +1,88 @@ +import type { CustomWorldAgentActionExecutor } from './types.js'; +import type { CustomWorldAgentAssetBridgeService } from '../customWorldAgentAssetBridgeService.js'; +import type { CustomWorldAgentSnapshotBuilder } from '../customWorldAgentSnapshotBuilder.js'; +import type { CustomWorldAgentSessionStore } from '../customWorldAgentSessionStore.js'; +import { buildActionResultMessage } from './helpers.js'; +import { + buildCheckpointSnapshot, + createOperationUpdater, + getRequiredSession, +} from './executorShared.js'; + +export function createSyncSceneAssetsExecutor(params: { + sessionStore: CustomWorldAgentSessionStore; + assetBridgeService: CustomWorldAgentAssetBridgeService; + snapshotBuilder: CustomWorldAgentSnapshotBuilder; +}): CustomWorldAgentActionExecutor<'sync_scene_assets'> { + return async ({ userId, sessionId, operationId, payload }) => { + const updateOperation = createOperationUpdater({ + sessionStore: params.sessionStore, + userId, + sessionId, + operationId, + }); + + try { + await updateOperation({ + status: 'running', + phaseLabel: '同步场景资产', + phaseDetail: '正在把营地/地点场景图写回当前世界草稿。', + progress: 38, + }); + + const latestSession = await getRequiredSession({ + sessionStore: params.sessionStore, + userId, + sessionId, + }); + const syncResult = params.assetBridgeService.applySceneAssetPublishResult( + latestSession.draftProfile, + payload, + ); + + await updateOperation({ + phaseLabel: '刷新场景卡摘要', + phaseDetail: '正在更新地点卡、幕背景摘要和场景资产覆盖率。', + progress: 72, + }); + + const nextState = params.snapshotBuilder.buildRefiningState({ + previousStage: latestSession.stage, + nextStage: 'visual_refining', + draftProfile: syncResult.draftProfile, + focusCardId: payload.sceneId, + }); + + await params.sessionStore.replaceDerivedState(userId, sessionId, nextState); + await params.sessionStore.appendCheckpoint(userId, sessionId, { + label: `同步场景资产 ${String(syncResult.updatedScene.name ?? payload.sceneId)}`, + snapshot: buildCheckpointSnapshot(latestSession, nextState), + }); + await params.sessionStore.appendMessage( + userId, + sessionId, + buildActionResultMessage({ + relatedOperationId: operationId, + text: `已把「${String(syncResult.updatedScene.name ?? '当前场景')}」的场景图写回草稿,并同步刷新地点卡与幕背景状态。`, + }), + ); + + await updateOperation({ + status: 'completed', + phaseLabel: '场景资产已同步', + phaseDetail: `「${String(syncResult.updatedScene.name ?? '当前场景')}」的场景图已经进入当前草稿。`, + progress: 100, + error: null, + }); + } catch (error) { + await updateOperation({ + status: 'failed', + phaseLabel: '场景资产同步失败', + phaseDetail: '这一轮没有成功把场景图写回当前草稿。', + progress: 100, + error: + error instanceof Error ? error.message : 'sync scene assets failed', + }); + } + }; +} diff --git a/server-node/src/services/customWorldAgentActionExecutors/types.ts b/server-node/src/services/customWorldAgentActionExecutors/types.ts new file mode 100644 index 00000000..93d10e91 --- /dev/null +++ b/server-node/src/services/customWorldAgentActionExecutors/types.ts @@ -0,0 +1,29 @@ +import type { CustomWorldAgentActionRequest } from '../../../../packages/shared/src/contracts/customWorldAgent.js'; + +export type CustomWorldAgentActionPayload< + K extends CustomWorldAgentActionRequest['action'], +> = Extract; + +export type CustomWorldAgentActionExecutor< + K extends CustomWorldAgentActionRequest['action'], +> = (params: { + userId: string; + sessionId: string; + operationId: string; + payload: CustomWorldAgentActionPayload; +}) => Promise; + +export type CustomWorldAgentActionExecutorMap = { + draft_foundation: CustomWorldAgentActionExecutor<'draft_foundation'>; + update_draft_card: CustomWorldAgentActionExecutor<'update_draft_card'>; + sync_result_profile: CustomWorldAgentActionExecutor<'sync_result_profile'>; + generate_characters: CustomWorldAgentActionExecutor<'generate_characters'>; + generate_landmarks: CustomWorldAgentActionExecutor<'generate_landmarks'>; + generate_role_assets: CustomWorldAgentActionExecutor<'generate_role_assets'>; + sync_role_assets: CustomWorldAgentActionExecutor<'sync_role_assets'>; + generate_scene_assets: CustomWorldAgentActionExecutor<'generate_scene_assets'>; + sync_scene_assets: CustomWorldAgentActionExecutor<'sync_scene_assets'>; + expand_long_tail: CustomWorldAgentActionExecutor<'expand_long_tail'>; + publish_world: CustomWorldAgentActionExecutor<'publish_world'>; + revert_checkpoint: CustomWorldAgentActionExecutor<'revert_checkpoint'>; +}; diff --git a/server-node/src/services/customWorldAgentActionExecutors/updateDraftCardExecutor.ts b/server-node/src/services/customWorldAgentActionExecutors/updateDraftCardExecutor.ts new file mode 100644 index 00000000..a3a5fcc2 --- /dev/null +++ b/server-node/src/services/customWorldAgentActionExecutors/updateDraftCardExecutor.ts @@ -0,0 +1,111 @@ +import { updateDraftCardSections } from '../customWorldAgentDraftEditService.js'; +import type { CustomWorldAgentActionExecutor } from './types.js'; +import type { CustomWorldAgentChangeSummaryService } from '../customWorldAgentChangeSummaryService.js'; +import type { CustomWorldAgentDraftCompiler } from '../customWorldAgentDraftCompiler.js'; +import type { CustomWorldAgentSnapshotBuilder } from '../customWorldAgentSnapshotBuilder.js'; +import type { CustomWorldAgentSessionStore } from '../customWorldAgentSessionStore.js'; +import { buildActionResultMessage } from './helpers.js'; +import { + buildCheckpointSnapshot, + createOperationUpdater, + getRequiredSession, +} from './executorShared.js'; + +export function createUpdateDraftCardExecutor(params: { + sessionStore: CustomWorldAgentSessionStore; + draftCompiler: CustomWorldAgentDraftCompiler; + changeSummaryService: CustomWorldAgentChangeSummaryService; + snapshotBuilder: CustomWorldAgentSnapshotBuilder; +}): CustomWorldAgentActionExecutor<'update_draft_card'> { + return async ({ userId, sessionId, operationId, payload }) => { + const updateOperation = createOperationUpdater({ + sessionStore: params.sessionStore, + userId, + sessionId, + operationId, + }); + + try { + await updateOperation({ + status: 'running', + phaseLabel: '写回草稿设定', + phaseDetail: '正在把这次编辑内容写回当前世界底稿。', + progress: 34, + }); + + const latestSession = await getRequiredSession({ + sessionStore: params.sessionStore, + userId, + sessionId, + }); + const nextDraftProfile = updateDraftCardSections({ + draftProfile: (latestSession.draftProfile ?? {}) as Record< + string, + unknown + >, + cardId: payload.cardId, + sections: payload.sections, + }); + + await updateOperation({ + phaseLabel: '重编译草稿卡', + phaseDetail: '正在同步更新草稿摘要和详情内容。', + progress: 72, + }); + + const nextState = params.snapshotBuilder.buildRefiningState({ + previousStage: latestSession.stage, + draftProfile: nextDraftProfile, + focusCardId: payload.cardId, + }); + const updatedDetail = params.draftCompiler.getDraftCardDetail( + nextDraftProfile, + payload.cardId, + ); + const changedSectionIds = new Set( + payload.sections + .map((section) => section.sectionId.trim()) + .filter(Boolean), + ); + + await params.sessionStore.replaceDerivedState(userId, sessionId, nextState); + await params.sessionStore.appendCheckpoint(userId, sessionId, { + label: `编辑 ${updatedDetail?.title || '草稿卡'}`, + snapshot: buildCheckpointSnapshot(latestSession, nextState), + }); + await params.sessionStore.appendMessage( + userId, + sessionId, + buildActionResultMessage({ + relatedOperationId: operationId, + text: params.changeSummaryService.buildSummary({ + action: 'update_draft_card', + cardId: payload.cardId, + changedLabels: + updatedDetail?.sections + .filter((section) => changedSectionIds.has(section.id)) + .map((section) => section.label) ?? [], + draftProfile: nextDraftProfile, + }), + }), + ); + + await updateOperation({ + status: 'completed', + phaseLabel: '草稿设定已保存', + phaseDetail: `「${updatedDetail?.title || '当前卡片'}」的设定已经同步更新。`, + progress: 100, + error: null, + }); + } catch (error) { + await updateOperation({ + status: 'failed', + phaseLabel: '保存失败', + phaseDetail: '这次草稿编辑没有成功写回到底稿。', + progress: 100, + error: + error instanceof Error ? error.message : 'update draft card failed', + }); + } + }; +} diff --git a/server-node/src/services/customWorldAgentActionRegistry.test.ts b/server-node/src/services/customWorldAgentActionRegistry.test.ts new file mode 100644 index 00000000..00b0ca89 --- /dev/null +++ b/server-node/src/services/customWorldAgentActionRegistry.test.ts @@ -0,0 +1,260 @@ +import assert from 'node:assert/strict'; +import test from 'node:test'; + +import type { CustomWorldAgentActionExecutorMap } from './customWorldAgentActionExecutors/index.js'; +import { CustomWorldAgentActionRegistry } from './customWorldAgentActionRegistry.js'; +import { createRpgAgentSessionFixture } from '../../../packages/shared/src/contracts/rpgCreationFixtures.js'; + +function createExecutorLog() { + const calls: Array<{ + action: keyof CustomWorldAgentActionExecutorMap; + payload: unknown; + userId: string; + sessionId: string; + operationId: string; + }> = []; + + const createExecutor = ( + action: K, + ): CustomWorldAgentActionExecutorMap[K] => { + return (async (params) => { + calls.push({ + action, + payload: params.payload, + userId: params.userId, + sessionId: params.sessionId, + operationId: params.operationId, + }); + }) as CustomWorldAgentActionExecutorMap[K]; + }; + + return { + calls, + executors: { + draft_foundation: createExecutor('draft_foundation'), + update_draft_card: createExecutor('update_draft_card'), + sync_result_profile: createExecutor('sync_result_profile'), + generate_characters: createExecutor('generate_characters'), + generate_landmarks: createExecutor('generate_landmarks'), + generate_role_assets: createExecutor('generate_role_assets'), + sync_role_assets: createExecutor('sync_role_assets'), + generate_scene_assets: createExecutor('generate_scene_assets'), + sync_scene_assets: createExecutor('sync_scene_assets'), + expand_long_tail: createExecutor('expand_long_tail'), + publish_world: createExecutor('publish_world'), + revert_checkpoint: createExecutor('revert_checkpoint'), + } satisfies CustomWorldAgentActionExecutorMap, + }; +} + +function createSessionRecord(overrides: Partial> = {}) { + const session = createRpgAgentSessionFixture(); + + return { + ...JSON.parse(JSON.stringify(session)), + userId: 'fixture-user', + seedText: '被海雾吞没的旧航路群岛', + operations: [], + checkpoints: [], + createdAt: session.updatedAt, + updatedAt: session.updatedAt, + ...overrides, + }; +} + +test('action registry exposes supported actions with stage-aware enablement and disabled reasons', () => { + const { executors } = createExecutorLog(); + const registry = new CustomWorldAgentActionRegistry(executors); + const session = createSessionRecord({ + stage: 'foundation_review', + progressPercent: 80, + }); + const supportedActions = registry.buildSupportedActions(session as never); + const draftFoundation = supportedActions.find( + (entry) => entry.action === 'draft_foundation', + ); + const syncResultProfile = supportedActions.find( + (entry) => entry.action === 'sync_result_profile', + ); + const publishWorld = supportedActions.find( + (entry) => entry.action === 'publish_world', + ); + const expandLongTail = supportedActions.find( + (entry) => entry.action === 'expand_long_tail', + ); + const revertCheckpoint = supportedActions.find( + (entry) => entry.action === 'revert_checkpoint', + ); + + assert.equal(draftFoundation?.enabled, false); + assert.match(draftFoundation?.reason ?? '', /progressPercent >= 100/u); + assert.equal(syncResultProfile?.enabled, false); + assert.match( + syncResultProfile?.reason ?? '', + /object_refining or visual_refining/u, + ); + assert.equal(publishWorld?.enabled, false); + assert.match( + publishWorld?.reason ?? '', + /object_refining, visual_refining, long_tail_review or ready_to_publish/u, + ); + assert.equal(expandLongTail?.enabled, false); + assert.match( + expandLongTail?.reason ?? '', + /object_refining, visual_refining, long_tail_review or ready_to_publish/u, + ); + assert.equal(revertCheckpoint?.enabled, false); + assert.match( + revertCheckpoint?.reason ?? '', + /requires at least one restorable checkpoint snapshot/u, + ); +}); + +test('action registry enables long-tail and publish actions in late stages, and exposes revert when restorable checkpoint exists', () => { + const { executors } = createExecutorLog(); + const registry = new CustomWorldAgentActionRegistry(executors); + const session = createSessionRecord({ + stage: 'ready_to_publish', + checkpoints: [ + { + checkpointId: 'checkpoint-1', + createdAt: '2026-04-21T12:00:00.000Z', + label: '可回滚版本', + snapshot: { + currentTurn: 2, + anchorContent: createSessionRecord().anchorContent, + progressPercent: 100, + lastAssistantReply: '已生成草稿。', + stage: 'object_refining', + focusCardId: 'world-foundation', + creatorIntent: {}, + creatorIntentReadiness: { + isReady: true, + completedKeys: [], + missingKeys: [], + }, + anchorPack: {}, + lockState: {}, + draftProfile: createSessionRecord().draftProfile, + pendingClarifications: [], + suggestedActions: [], + recommendedReplies: [], + draftCards: createSessionRecord().draftCards, + qualityFindings: [], + assetCoverage: createSessionRecord().assetCoverage, + }, + }, + ], + }); + + const supportedActions = registry.buildSupportedActions(session as never); + + assert.equal( + supportedActions.find((entry) => entry.action === 'expand_long_tail')?.enabled, + true, + ); + assert.equal( + supportedActions.find((entry) => entry.action === 'publish_world')?.enabled, + true, + ); + assert.equal( + supportedActions.find((entry) => entry.action === 'revert_checkpoint')?.enabled, + true, + ); +}); + +test('action registry validates sync_scene_assets required payload and dispatches scene action executors', async () => { + const { calls, executors } = createExecutorLog(); + const registry = new CustomWorldAgentActionRegistry(executors); + const session = createSessionRecord({ + stage: 'visual_refining', + }); + + assert.throws( + () => + registry.prepareExecution(session as never, { + action: 'sync_scene_assets', + sceneId: 'camp-home', + sceneKind: 'camp', + imageSrc: '', + generatedSceneAssetId: 'scene-asset-1', + }), + /imageSrc and generatedSceneAssetId/u, + ); + + const prepared = registry.prepareExecution(session as never, { + action: 'generate_scene_assets', + sceneIds: ['camp-home'], + }); + + assert.equal(prepared.operationType, 'generate_scene_assets'); + + await prepared.execute({ + userId: 'fixture-user', + sessionId: 'fixture-session', + operationId: 'operation-scene-1', + }); + + assert.equal(calls.at(-1)?.action, 'generate_scene_assets'); +}); + +test('action registry normalizes sync_result_profile payload before dispatching executor', async () => { + const { calls, executors } = createExecutorLog(); + const registry = new CustomWorldAgentActionRegistry(executors); + const session = createSessionRecord({ + stage: 'object_refining', + }); + const prepared = registry.prepareExecution(session as never, { + action: 'sync_result_profile', + profile: { + id: 'profile-1', + settingText: '潮雾列岛', + name: '潮雾列岛', + subtitle: '旧灯塔与失控航路', + summary: '结果页确认版。', + tone: '压抑、潮湿、悬疑', + playerGoal: '查清真相。', + templateWorldType: 'WUXIA', + majorFactions: ['守灯会'], + coreConflicts: ['争夺旧航路控制权'], + playableNpcs: [], + storyNpcs: [], + items: [], + landmarks: [], + generationMode: 'full', + generationStatus: 'complete', + }, + }); + + assert.equal(prepared.operationType, 'sync_result_profile'); + + await prepared.execute({ + userId: 'fixture-user', + sessionId: 'fixture-session', + operationId: 'operation-1', + }); + + assert.equal(calls.length, 1); + assert.equal(calls[0]?.action, 'sync_result_profile'); + assert.equal( + (calls[0]?.payload as { profile?: { name?: string } })?.profile?.name, + '潮雾列岛', + ); +}); + +test('action registry rejects invalid generate_role_assets payload with unit-level validation', () => { + const { executors } = createExecutorLog(); + const registry = new CustomWorldAgentActionRegistry(executors); + const session = createSessionRecord({ + stage: 'object_refining', + }); + + assert.throws( + () => + registry.prepareExecution(session as never, { + action: 'generate_role_assets', + roleIds: ['playable-1', 'story-1'], + }), + /exactly one roleId/u, + ); +}); diff --git a/server-node/src/services/customWorldAgentActionRegistry.ts b/server-node/src/services/customWorldAgentActionRegistry.ts new file mode 100644 index 00000000..07f7f133 --- /dev/null +++ b/server-node/src/services/customWorldAgentActionRegistry.ts @@ -0,0 +1,403 @@ +import type { + CustomWorldAgentActionRequest, + CustomWorldAgentOperationRecord, + CustomWorldSupportedAction, +} from '../../../packages/shared/src/contracts/customWorldAgent.js'; +import { badRequest } from '../errors.js'; +import { normalizeCustomWorldProfile } from '../modules/custom-world/runtimeProfile.js'; +import { normalizeFoundationDraftProfile } from './customWorldAgentDraftCompiler.js'; +import type { + CustomWorldAgentActionExecutorMap, + CustomWorldAgentActionPayload, +} from './customWorldAgentActionExecutors/index.js'; +import type { CustomWorldAgentSessionRecord } from './customWorldAgentSessionStore.js'; + +type EnabledAction = keyof CustomWorldAgentActionExecutorMap; +type EnabledDescriptor = { + operationType: CustomWorldAgentOperationRecord['type']; + normalizePayload?: ( + payload: CustomWorldAgentActionPayload, + ) => CustomWorldAgentActionPayload; + validate?: ( + session: CustomWorldAgentSessionRecord, + payload: CustomWorldAgentActionPayload, + ) => void; + execute: CustomWorldAgentActionExecutorMap[K]; +}; +type DisabledAction = Exclude; +type DisabledDescriptor = { + disabledReason: string; +}; + +type ActionCapabilityState = { + enabled: boolean; + reason?: string; +}; + +function assertDraftRefiningActionAvailable( + session: CustomWorldAgentSessionRecord, + action: string, +) { + if ( + session.stage !== 'object_refining' && + session.stage !== 'visual_refining' + ) { + throw badRequest( + `${action} is only available during object_refining or visual_refining`, + ); + } + + const hasDraftFoundation = Boolean( + normalizeFoundationDraftProfile(session.draftProfile) && + session.draftCards.length > 0, + ); + if (!hasDraftFoundation) { + throw badRequest(`${action} requires an existing draft foundation`); + } +} + +function assertLongTailActionAvailable( + session: CustomWorldAgentSessionRecord, + action: string, +) { + if ( + session.stage !== 'object_refining' && + session.stage !== 'visual_refining' && + session.stage !== 'long_tail_review' && + session.stage !== 'ready_to_publish' + ) { + throw badRequest( + `${action} is only available during object_refining, visual_refining, long_tail_review or ready_to_publish`, + ); + } +} + +function assertPublishActionAvailable( + session: CustomWorldAgentSessionRecord, + action: string, +) { + assertLongTailActionAvailable(session, action); + if (!normalizeFoundationDraftProfile(session.draftProfile)) { + throw badRequest(`${action} requires an existing draft foundation`); + } +} + +export type PreparedCustomWorldAgentActionExecution = { + operationType: CustomWorldAgentOperationRecord['type']; + execute: (params: { + userId: string; + sessionId: string; + operationId: string; + }) => Promise; +}; + +export class CustomWorldAgentActionRegistry { + private readonly descriptors: Record< + CustomWorldAgentActionRequest['action'], + EnabledDescriptor | DisabledDescriptor + >; + + constructor(executors: CustomWorldAgentActionExecutorMap) { + this.descriptors = { + draft_foundation: { + operationType: 'draft_foundation', + validate: (session) => { + if (session.progressPercent < 100) { + throw badRequest('draft_foundation requires progressPercent >= 100'); + } + }, + execute: executors.draft_foundation, + }, + update_draft_card: { + operationType: 'update_draft_card', + validate: (session, payload) => { + assertDraftRefiningActionAvailable(session, payload.action); + if (!payload.cardId.trim()) { + throw badRequest('update_draft_card requires cardId'); + } + if (!Array.isArray(payload.sections) || payload.sections.length === 0) { + throw badRequest('update_draft_card requires sections'); + } + }, + execute: executors.update_draft_card, + }, + sync_result_profile: { + operationType: 'sync_result_profile', + normalizePayload: (payload) => { + const normalizedProfile = normalizeCustomWorldProfile(payload.profile, ''); + if (!normalizedProfile) { + throw badRequest('sync_result_profile requires a valid profile'); + } + + return { + ...payload, + profile: normalizedProfile as unknown as Record, + }; + }, + validate: (session, payload) => { + assertDraftRefiningActionAvailable(session, payload.action); + }, + execute: executors.sync_result_profile, + }, + generate_characters: { + operationType: 'generate_characters', + validate: (session, payload) => { + assertDraftRefiningActionAvailable(session, payload.action); + if (payload.count < 1 || payload.count > 3) { + throw badRequest( + 'generate_characters count must be between 1 and 3', + ); + } + }, + execute: executors.generate_characters, + }, + generate_landmarks: { + operationType: 'generate_landmarks', + validate: (session, payload) => { + assertDraftRefiningActionAvailable(session, payload.action); + if (payload.count < 1 || payload.count > 3) { + throw badRequest( + 'generate_landmarks count must be between 1 and 3', + ); + } + }, + execute: executors.generate_landmarks, + }, + generate_role_assets: { + operationType: 'generate_role_assets', + validate: (session, payload) => { + assertDraftRefiningActionAvailable(session, payload.action); + if (!Array.isArray(payload.roleIds) || payload.roleIds.length !== 1) { + throw badRequest( + 'generate_role_assets currently requires exactly one roleId', + ); + } + }, + execute: executors.generate_role_assets, + }, + sync_role_assets: { + operationType: 'sync_role_assets', + validate: (session, payload) => { + assertDraftRefiningActionAvailable(session, payload.action); + if (!payload.roleId.trim()) { + throw badRequest('sync_role_assets requires roleId'); + } + if ( + !payload.portraitPath.trim() || + !payload.generatedVisualAssetId.trim() + ) { + throw badRequest( + 'sync_role_assets requires portraitPath and generatedVisualAssetId', + ); + } + }, + execute: executors.sync_role_assets, + }, + generate_scene_assets: { + operationType: 'generate_scene_assets', + validate: (session, payload) => { + assertDraftRefiningActionAvailable(session, payload.action); + if (!Array.isArray(payload.sceneIds) || payload.sceneIds.length !== 1) { + throw badRequest( + 'generate_scene_assets currently requires exactly one sceneId', + ); + } + }, + execute: executors.generate_scene_assets, + }, + sync_scene_assets: { + operationType: 'sync_scene_assets', + validate: (session, payload) => { + assertDraftRefiningActionAvailable(session, payload.action); + if (!payload.sceneId.trim()) { + throw badRequest('sync_scene_assets requires sceneId'); + } + if (!payload.imageSrc.trim() || !payload.generatedSceneAssetId.trim()) { + throw badRequest( + 'sync_scene_assets requires imageSrc and generatedSceneAssetId', + ); + } + }, + execute: executors.sync_scene_assets, + }, + expand_long_tail: { + operationType: 'expand_long_tail', + validate: (session, payload) => { + assertLongTailActionAvailable(session, payload.action); + if (!normalizeFoundationDraftProfile(session.draftProfile)) { + throw badRequest('expand_long_tail requires an existing draft foundation'); + } + }, + execute: executors.expand_long_tail, + }, + publish_world: { + operationType: 'publish_world', + validate: (session, payload) => { + assertPublishActionAvailable(session, payload.action); + }, + execute: executors.publish_world, + }, + revert_checkpoint: { + operationType: 'revert_checkpoint', + validate: (session, payload) => { + assertLongTailActionAvailable(session, payload.action); + if (!payload.checkpointId.trim()) { + throw badRequest('revert_checkpoint requires checkpointId'); + } + const checkpoint = session.checkpoints.find( + (entry) => entry.checkpointId === payload.checkpointId, + ); + if (!checkpoint) { + throw badRequest('revert_checkpoint target checkpoint does not exist'); + } + if (!checkpoint.snapshot) { + throw badRequest( + 'revert_checkpoint target checkpoint does not contain a restorable snapshot', + ); + } + }, + execute: executors.revert_checkpoint, + }, + }; + } + + // orchestrator 只关心“拿到一个已校验的动作执行计划”,不再自己维护所有 action 分支。 + prepareExecution( + session: CustomWorldAgentSessionRecord, + payload: CustomWorldAgentActionRequest, + ): PreparedCustomWorldAgentActionExecution { + const descriptor = this.descriptors[payload.action]; + if ('disabledReason' in descriptor) { + throw badRequest(descriptor.disabledReason); + } + + const normalizedPayload = descriptor.normalizePayload + ? descriptor.normalizePayload(payload as never) + : payload; + + descriptor.validate?.(session, normalizedPayload as never); + + return { + operationType: descriptor.operationType, + execute: ({ userId, sessionId, operationId }) => + descriptor.execute({ + userId, + sessionId, + operationId, + payload: normalizedPayload as never, + }), + }; + } + + buildSupportedActions( + session: CustomWorldAgentSessionRecord, + ): CustomWorldSupportedAction[] { + return ( + Object.entries(this.descriptors) as Array< + [ + CustomWorldAgentActionRequest['action'], + EnabledDescriptor | DisabledDescriptor, + ] + > + ).map(([action, descriptor]) => { + const capability = this.resolveCapabilityState(session, action, descriptor); + + return { + action, + enabled: capability.enabled, + reason: capability.reason ?? null, + } satisfies CustomWorldSupportedAction; + }); + } + + private resolveCapabilityState( + session: CustomWorldAgentSessionRecord, + action: CustomWorldAgentActionRequest['action'], + descriptor: EnabledDescriptor | DisabledDescriptor, + ): ActionCapabilityState { + if ('disabledReason' in descriptor) { + return { + enabled: false, + reason: descriptor.disabledReason, + }; + } + + if (action === 'draft_foundation') { + return session.progressPercent >= 100 + ? { enabled: true } + : { + enabled: false, + reason: 'draft_foundation requires progressPercent >= 100', + }; + } + + if ( + action === 'update_draft_card' || + action === 'sync_result_profile' || + action === 'generate_characters' || + action === 'generate_landmarks' || + action === 'generate_role_assets' || + action === 'sync_role_assets' || + action === 'generate_scene_assets' || + action === 'sync_scene_assets' + ) { + try { + assertDraftRefiningActionAvailable(session, action); + return { enabled: true }; + } catch (error) { + return { + enabled: false, + reason: error instanceof Error ? error.message : 'action unavailable', + }; + } + } + + if (action === 'expand_long_tail') { + try { + assertLongTailActionAvailable(session, action); + return { enabled: true }; + } catch (error) { + return { + enabled: false, + reason: error instanceof Error ? error.message : 'action unavailable', + }; + } + } + + if (action === 'publish_world') { + try { + assertPublishActionAvailable(session, action); + return { enabled: true }; + } catch (error) { + return { + enabled: false, + reason: error instanceof Error ? error.message : 'action unavailable', + }; + } + } + + if (action === 'revert_checkpoint') { + const restorableCheckpoint = session.checkpoints.find( + (entry) => Boolean(entry.snapshot), + ); + if (!restorableCheckpoint) { + return { + enabled: false, + reason: 'revert_checkpoint requires at least one restorable checkpoint snapshot', + }; + } + + try { + assertLongTailActionAvailable(session, action); + return { enabled: true }; + } catch (error) { + return { + enabled: false, + reason: error instanceof Error ? error.message : 'action unavailable', + }; + } + } + + return { enabled: true }; + } +} diff --git a/server-node/src/services/customWorldAgentAssetBridgeService.ts b/server-node/src/services/customWorldAgentAssetBridgeService.ts index 21de5ab5..6f1d4378 100644 --- a/server-node/src/services/customWorldAgentAssetBridgeService.ts +++ b/server-node/src/services/customWorldAgentAssetBridgeService.ts @@ -1,6 +1,10 @@ -import type { CustomWorldRoleAssetSummary } from '../../../packages/shared/src/contracts/customWorldAgent.js'; +import type { + CustomWorldRoleAssetSummary, + CustomWorldSceneAssetSummary, +} from '../../../packages/shared/src/contracts/customWorldAgent.js'; import { getRoleAssetSummaryById, + rebuildRoleAssetCoverage, mergeRoleAssetIntoDraftProfile, } from './customWorldAgentRoleAssetStateService.js'; @@ -31,6 +35,17 @@ type SyncRoleAssetsPayload = { animationMap?: Record | null; }; +type SceneKind = 'camp' | 'landmark'; + +type SyncSceneAssetsPayload = { + sceneId: string; + sceneKind: SceneKind; + imageSrc: string; + generatedSceneAssetId: string; + generatedScenePrompt?: string | null; + generatedSceneModel?: string | null; +}; + export type SyncRoleAssetsResult = { roleId: string; updatedRole: Record; @@ -38,6 +53,97 @@ export type SyncRoleAssetsResult = { draftProfile: Record; }; +export type SceneAssetStudioContext = { + sceneId: string; + sceneKind: SceneKind; + sceneName: string; + sceneDescription: string; + imageSrc: string | null; + readyActCount: number; + missingActCount: number; +}; + +export type SyncSceneAssetsResult = { + sceneId: string; + sceneKind: SceneKind; + updatedScene: Record; + updatedAssetSummaries: CustomWorldSceneAssetSummary[]; + draftProfile: Record; +}; + +function cloneRecord>(value: T): T { + return JSON.parse(JSON.stringify(value)) as T; +} + +function toSceneDescription(scene: Record, sceneKind: SceneKind) { + if (sceneKind === 'camp') { + return ( + toText(scene.description) || + toText(scene.summary) || + toText(scene.mood) + ); + } + + return ( + toText(scene.description) || + toText(scene.summary) || + toText(scene.purpose) || + toText(scene.mood) + ); +} + +function findSceneActsBySceneId( + draftProfile: Record, + sceneId: string, +) { + return toRecordArray(draftProfile.sceneChapters) + .filter((chapter) => toText(chapter.sceneId) === sceneId) + .flatMap((chapter) => toRecordArray(chapter.acts)); +} + +function updateSceneChapterActsForScene(params: { + draftProfile: Record; + sceneId: string; + imageSrc: string; + generatedSceneAssetId: string; +}) { + return toRecordArray(params.draftProfile.sceneChapters).map((chapter) => { + if (toText(chapter.sceneId) !== params.sceneId) { + return chapter; + } + + return { + ...chapter, + acts: toRecordArray(chapter.acts).map((act) => ({ + ...act, + backgroundImageSrc: params.imageSrc, + backgroundAssetId: params.generatedSceneAssetId, + })), + } satisfies Record; + }); +} + +function buildSceneAssetFallbackSummary(params: { + sceneId: string; + sceneKind: SceneKind; + updatedScene: Record; + imageSrc: string; + generatedSceneAssetId: string; +}) { + return { + sceneId: params.sceneId, + sceneName: + toText(params.updatedScene.name) || + (params.sceneKind === 'camp' ? '开局营地' : '未命名场景'), + actId: null, + actTitle: params.sceneKind === 'camp' ? '营地正式背景图' : '场景正式背景图', + imageSrc: params.imageSrc, + assetId: params.generatedSceneAssetId, + status: 'ready', + nextPointCost: 0, + } satisfies CustomWorldSceneAssetSummary; +} + export class CustomWorldAgentAssetBridgeService { buildRoleAssetStudioContext(snapshot: unknown, roleId: string) { const profile = toRecord(snapshot); @@ -96,4 +202,123 @@ export class CustomWorldAgentAssetBridgeService { draftProfile, }; } + + buildSceneAssetStudioContext( + snapshot: unknown, + sceneId: string, + sceneKind: SceneKind, + ): SceneAssetStudioContext { + const profile = toRecord(snapshot); + if (!profile) { + throw new Error('当前世界草稿为空,无法打开场景资产工坊。'); + } + + const scene = + sceneKind === 'camp' + ? toRecord(profile.camp) + : toRecordArray(profile.landmarks).find( + (item) => toText(item.id) === sceneId, + ) ?? null; + if (!scene) { + throw new Error('未找到目标场景,无法进入场景资产工坊。'); + } + + const sceneActs = findSceneActsBySceneId(profile, sceneId); + const readyActCount = sceneActs.filter((act) => + Boolean(toText(act.backgroundImageSrc) || toText(act.backgroundAssetId)), + ).length; + + return { + sceneId, + sceneKind, + sceneName: + toText(scene.name) || (sceneKind === 'camp' ? '开局营地' : '未命名场景'), + sceneDescription: toSceneDescription(scene, sceneKind), + imageSrc: toText(scene.imageSrc) || null, + readyActCount, + missingActCount: Math.max(0, sceneActs.length - readyActCount), + }; + } + + applySceneAssetPublishResult( + snapshot: unknown, + payload: SyncSceneAssetsPayload, + ): SyncSceneAssetsResult { + const profile = toRecord(snapshot); + if (!profile) { + throw new Error('当前世界草稿为空,无法同步场景资产。'); + } + + const nextDraftProfile = cloneRecord(profile); + let updatedScene: Record | null = null; + + if (payload.sceneKind === 'camp') { + const currentCamp = toRecord(nextDraftProfile.camp); + if (!currentCamp || toText(currentCamp.id) !== payload.sceneId) { + throw new Error('目标营地不存在,无法同步场景资产。'); + } + + updatedScene = { + ...currentCamp, + imageSrc: payload.imageSrc, + generatedSceneAssetId: payload.generatedSceneAssetId, + generatedScenePrompt: payload.generatedScenePrompt ?? null, + generatedSceneModel: payload.generatedSceneModel ?? null, + }; + nextDraftProfile.camp = updatedScene; + } else { + let touched = false; + nextDraftProfile.landmarks = toRecordArray(nextDraftProfile.landmarks).map( + (item) => { + if (toText(item.id) !== payload.sceneId) { + return item; + } + + touched = true; + updatedScene = { + ...item, + imageSrc: payload.imageSrc, + generatedSceneAssetId: payload.generatedSceneAssetId, + generatedScenePrompt: payload.generatedScenePrompt ?? null, + generatedSceneModel: payload.generatedSceneModel ?? null, + }; + return updatedScene; + }, + ); + + if (!touched || !updatedScene) { + throw new Error('目标地点不存在,无法同步场景资产。'); + } + } + + nextDraftProfile.sceneChapters = updateSceneChapterActsForScene({ + draftProfile: nextDraftProfile, + sceneId: payload.sceneId, + imageSrc: payload.imageSrc, + generatedSceneAssetId: payload.generatedSceneAssetId, + }); + + const updatedAssetSummaries = rebuildRoleAssetCoverage( + nextDraftProfile, + ).sceneAssets.filter((entry) => entry.sceneId === payload.sceneId); + + return { + sceneId: payload.sceneId, + sceneKind: payload.sceneKind, + updatedScene: updatedScene ?? {}, + updatedAssetSummaries: + updatedAssetSummaries.length > 0 + ? updatedAssetSummaries + : [ + buildSceneAssetFallbackSummary({ + sceneId: payload.sceneId, + sceneKind: payload.sceneKind, + updatedScene: updatedScene ?? {}, + imageSrc: payload.imageSrc, + generatedSceneAssetId: payload.generatedSceneAssetId, + }), + ], + draftProfile: nextDraftProfile, + }; + } } diff --git a/server-node/src/services/customWorldAgentAutoAssetService.test.ts b/server-node/src/services/customWorldAgentAutoAssetService.test.ts index 8f508b2c..537b8e1f 100644 --- a/server-node/src/services/customWorldAgentAutoAssetService.test.ts +++ b/server-node/src/services/customWorldAgentAutoAssetService.test.ts @@ -87,6 +87,11 @@ function createTestConfig(testName: string): AppConfig { mockAvatarUrl: '', }, authSession: { + accessCookieName: 'genarrative_access_session', + accessCookieTtlSeconds: 7200, + accessCookieSecure: false, + accessCookieSameSite: 'Lax', + accessCookiePath: '/', refreshCookieName: 'refresh_token', refreshSessionTtlDays: 30, refreshCookieSecure: false, diff --git a/server-node/src/services/customWorldAgentDraftCompiler.ts b/server-node/src/services/customWorldAgentDraftCompiler.ts index 6911e061..42f2abd8 100644 --- a/server-node/src/services/customWorldAgentDraftCompiler.ts +++ b/server-node/src/services/customWorldAgentDraftCompiler.ts @@ -377,6 +377,9 @@ function normalizeLandmark( secret: secret || '玩家第一次抵达就会意识到它不只是背景', dangerLevel: dangerLevel || '中', imageSrc: toText(record.imageSrc) || null, + generatedSceneAssetId: toText(record.generatedSceneAssetId) || null, + generatedScenePrompt: toText(record.generatedScenePrompt) || null, + generatedSceneModel: toText(record.generatedSceneModel) || null, characterIds: toStringArray(record.characterIds, 8), threadIds: toStringArray(record.threadIds, 8), summary: @@ -501,6 +504,9 @@ function normalizeCamp(value: unknown): CustomWorldFoundationDraftCamp | null { mood: dangerLevel || '克制、紧绷,但还能暂时收拢局势', dangerLevel: dangerLevel || '克制、紧绷,但还能暂时收拢局势', imageSrc: toText(record.imageSrc) || null, + generatedSceneAssetId: toText(record.generatedSceneAssetId) || null, + generatedScenePrompt: toText(record.generatedScenePrompt) || null, + generatedSceneModel: toText(record.generatedSceneModel) || null, summary: summary || clampText( @@ -1060,6 +1066,9 @@ function buildLandmarkWarnings(landmark: CustomWorldFoundationDraftLandmark) { if (landmark.threadIds.length === 0) { warnings.push('这个地点还缺少更清楚的线程挂钩。'); } + if (!landmark.imageSrc || !landmark.generatedSceneAssetId) { + warnings.push('这个地点还没有绑定正式场景图。'); + } return warnings; } @@ -1163,8 +1172,12 @@ function buildSceneChapterWarnings(params: { return warnings; } -function buildCampWarnings() { - return [] as string[]; +function buildCampWarnings(camp: CustomWorldFoundationDraftCamp) { + const warnings: string[] = []; + if (!camp.imageSrc || !camp.generatedSceneAssetId) { + warnings.push('营地还没有绑定正式场景图。'); + } + return warnings; } function buildCharacterAssetHeadline(character: CustomWorldFoundationDraftCharacter) { @@ -1332,12 +1345,22 @@ export class CustomWorldAgentDraftCompiler { }); if (profile.camp) { - const campWarnings = buildCampWarnings(); + const campWarnings = buildCampWarnings(profile.camp); pushCard({ id: profile.camp.id, kind: 'camp', title: profile.camp.name, - subtitle: clampText(profile.camp.mood || '开局落脚处', 28), + subtitle: clampText( + [ + profile.camp.mood || '开局落脚处', + profile.camp.imageSrc && profile.camp.generatedSceneAssetId + ? '背景图已就绪' + : '待生成背景图', + ] + .filter(Boolean) + .join(' / '), + 28, + ), summary: profile.camp.summary, linkedIds: [ ...profile.landmarks.slice(0, 2).map((entry) => entry.id), @@ -1347,14 +1370,21 @@ export class CustomWorldAgentDraftCompiler { sections: [ buildSection('name', '营地名称', profile.camp.name), buildSection('description', '当前定位', profile.camp.description), - buildSection( - 'dangerLevel', - '危险等级', - profile.camp.dangerLevel || profile.camp.mood, - ), - buildSection( - 'linkedObjects', - '关联对象', + buildSection( + 'dangerLevel', + '危险等级', + profile.camp.dangerLevel || profile.camp.mood, + ), + buildSection( + 'sceneAsset', + '场景资产', + profile.camp.imageSrc || profile.camp.generatedSceneAssetId + ? '正式场景图已就绪' + : '待生成正式场景图', + ), + buildSection( + 'linkedObjects', + '关联对象', [ resolveLandmarkNames( profile.landmarks.slice(0, 2).map((entry) => entry.id), @@ -1490,22 +1520,39 @@ export class CustomWorldAgentDraftCompiler { profile.landmarks.forEach((landmark) => { const warnings = buildLandmarkWarnings(landmark); - pushCard({ - id: landmark.id, - kind: 'landmark', - title: landmark.name, - subtitle: clampText(landmark.purpose || landmark.mood, 28), - summary: landmark.summary, - linkedIds: [...landmark.characterIds, ...landmark.threadIds].slice(0, 8), - sections: [ - buildSection('name', '地点名', landmark.name), - buildSection('purpose', '地点定位', landmark.purpose), - buildSection('mood', '场景情绪', landmark.mood), - buildSection('secret', '隐藏秘密', landmark.secret || landmark.importance), - buildSection('summary', '地点摘要', landmark.summary), - buildSection( - 'characterIds', - '关联角色', + pushCard({ + id: landmark.id, + kind: 'landmark', + title: landmark.name, + subtitle: clampText( + [ + landmark.purpose || landmark.mood, + landmark.imageSrc && landmark.generatedSceneAssetId + ? '背景图已就绪' + : '待生成背景图', + ] + .filter(Boolean) + .join(' / '), + 28, + ), + summary: landmark.summary, + linkedIds: [...landmark.characterIds, ...landmark.threadIds].slice(0, 8), + sections: [ + buildSection('name', '地点名', landmark.name), + buildSection('purpose', '地点定位', landmark.purpose), + buildSection('mood', '场景情绪', landmark.mood), + buildSection('secret', '隐藏秘密', landmark.secret || landmark.importance), + buildSection('summary', '地点摘要', landmark.summary), + buildSection( + 'sceneAsset', + '场景资产', + landmark.imageSrc || landmark.generatedSceneAssetId + ? '正式场景图已就绪' + : '待生成正式场景图', + ), + buildSection( + 'characterIds', + '关联角色', resolveCharacterNames(landmark.characterIds), ), buildSection( diff --git a/server-node/src/services/customWorldAgentFoundationDraftService.test.ts b/server-node/src/services/customWorldAgentFoundationDraftService.test.ts new file mode 100644 index 00000000..45674c11 --- /dev/null +++ b/server-node/src/services/customWorldAgentFoundationDraftService.test.ts @@ -0,0 +1,324 @@ +import assert from 'node:assert/strict'; +import test from 'node:test'; + +import type { UpstreamLlmClient } from './llmClient.js'; +import { CustomWorldAgentFoundationDraftService } from './customWorldAgentFoundationDraftService.js'; +import { normalizeFoundationDraftProfile } from './customWorldAgentDraftCompiler.js'; + +function createFoundationDraftLlmClient(): UpstreamLlmClient { + let roleOutlineBatch = 0; + let landmarkSeedBatch = 0; + let landmarkNetworkBatch = 0; + let playableNarrativeBatch = 0; + let playableDossierBatch = 0; + let storyNarrativeBatch = 0; + let storyDossierBatch = 0; + + return { + requestMessageContent: async (params) => { + const debugLabel = params.debugLabel ?? ''; + + if (debugLabel === 'agent-foundation-framework') { + return JSON.stringify({ + name: '潮雾列岛', + subtitle: '盐火灯塔与失控航路', + summary: '潮雾列岛正在被假航灯和沉船商盟重新切开。', + tone: '冷峻、潮湿、悬疑', + playerGoal: '先确认谁在操盘假航灯,再决定自己站在哪一边。', + templateWorldType: 'WUXIA', + majorFactions: ['守灯会', '沉船商盟'], + coreConflicts: ['守灯会与沉船商盟争夺旧航路解释权'], + camp: { + name: '雾湾前哨', + description: '玩家在盐火灯塔下方临时收束线索的地方。', + dangerLevel: 'medium', + }, + playableNpcs: [], + storyNpcs: [], + landmarks: [], + }); + } + + if (debugLabel.startsWith('agent-foundation-playable-outline-batch-')) { + roleOutlineBatch += 1; + return JSON.stringify({ + playableNpcs: [ + { + name: '返灯人', + title: '失职守灯人', + role: '玩家前线身份', + description: '被迫返乡的守灯人,刚站回快要熄灭的旧灯塔。', + visualDescription: '风衣上沾着盐霜,眼底始终没有睡意。', + actionDescription: '先守住灯塔,再判断该不该相信旧友。', + sceneVisualDescription: '每次钟声响起时,他都像被过去拽住。', + initialAffinity: 18, + relationshipHooks: ['这是玩家贴近世界的第一切口'], + tags: ['玩家视角', '守灯'], + }, + ], + }); + } + + if (debugLabel.startsWith('agent-foundation-story-outline-batch-')) { + roleOutlineBatch += 1; + return JSON.stringify({ + storyNpcs: [ + { + name: '沈砺', + title: '旧友兼宿敌', + role: '沉船商盟引路人', + description: '他像旧友,也像最早知道假航灯秘密的人。', + visualDescription: '衣角总带着潮水味,像是刚从夜雾里走出来。', + actionDescription: '会不断试探玩家到底愿不愿意回到旧航路。', + sceneVisualDescription: '总在钟声停下后的空隙里现身。', + initialAffinity: 6, + relationshipHooks: ['和玩家共享一段无法翻篇的旧灯塔往事'], + tags: ['旧友', '宿敌'], + }, + { + name: '岚珀', + title: '守灯会巡夜官', + role: '守灯会前台接口人', + description: '她负责把守灯会的怀疑与命令直接压到玩家面前。', + visualDescription: '披着潮湿斗篷,眼神总先看灯芯再看人。', + actionDescription: '要求玩家立刻证明自己还配站回灯塔。', + sceneVisualDescription: '总把巡夜灯举得很高,不给人躲闪空间。', + initialAffinity: 6, + relationshipHooks: ['会逼玩家更早站队'], + tags: ['守灯会', '巡夜'], + }, + ], + }); + } + + if (debugLabel.startsWith('agent-foundation-landmark-seed-batch-')) { + landmarkSeedBatch += 1; + return JSON.stringify({ + landmarks: [ + { + name: '盐火灯塔', + description: '旧灯塔正在熄灭边缘摇晃,所有人都盯着它的火。', + visualDescription: '塔身被盐霜和旧火痕反复覆盖。', + dangerLevel: 'high', + sceneNpcNames: ['沈砺', '岚珀'], + connections: [], + }, + { + name: '沉船码头', + description: '假航灯把沉船和黑市都引到了这片雾港。', + visualDescription: '破碎船骨和黑帆在潮雾里若隐若现。', + dangerLevel: 'high', + sceneNpcNames: ['沈砺'], + connections: [], + }, + ], + }); + } + + if (debugLabel.startsWith('agent-foundation-landmark-network-batch-')) { + landmarkNetworkBatch += 1; + return JSON.stringify({ + landmarks: [ + { + name: '盐火灯塔', + description: '旧灯塔正在熄灭边缘摇晃,所有人都盯着它的火。', + visualDescription: '塔身被盐霜和旧火痕反复覆盖。', + dangerLevel: 'high', + sceneNpcNames: ['沈砺', '岚珀'], + connections: [ + { + targetLandmarkName: '沉船码头', + relativePosition: 'forward', + summary: '顺着残灯下的潮道走,就会被拖进沉船码头。', + }, + ], + }, + { + name: '沉船码头', + description: '假航灯把沉船和黑市都引到了这片雾港。', + visualDescription: '破碎船骨和黑帆在潮雾里若隐若现。', + dangerLevel: 'high', + sceneNpcNames: ['沈砺'], + connections: [ + { + targetLandmarkName: '盐火灯塔', + relativePosition: 'back', + summary: '码头所有线头最终都会重新指回灯塔。', + }, + ], + }, + ], + }); + } + + if (debugLabel.startsWith('agent-foundation-playable-narrative-batch-')) { + playableNarrativeBatch += 1; + return JSON.stringify({ + playableNpcs: [ + { + name: '返灯人', + title: '失职守灯人', + role: '玩家前线身份', + description: '被迫返乡的守灯人,刚站回快要熄灭的旧灯塔。', + visualDescription: '风衣上沾着盐霜,眼底始终没有睡意。', + actionDescription: '先守住灯塔,再判断该不该相信旧友。', + sceneVisualDescription: '每次钟声响起时,他都像被过去拽住。', + relationshipHooks: ['这是玩家贴近世界的第一切口'], + tags: ['玩家视角', '守灯'], + }, + ], + }); + } + + if (debugLabel.startsWith('agent-foundation-playable-dossier-batch-')) { + playableDossierBatch += 1; + return JSON.stringify({ + playableNpcs: [ + { + name: '返灯人', + title: '失职守灯人', + role: '玩家前线身份', + description: '被迫返乡的守灯人,刚站回快要熄灭的旧灯塔。', + visualDescription: '风衣上沾着盐霜,眼底始终没有睡意。', + actionDescription: '先守住灯塔,再判断该不该相信旧友。', + sceneVisualDescription: '每次钟声响起时,他都像被过去拽住。', + relationshipHooks: ['这是玩家贴近世界的第一切口'], + tags: ['玩家视角', '守灯'], + }, + ], + }); + } + + if (debugLabel.startsWith('agent-foundation-story-narrative-batch-')) { + storyNarrativeBatch += 1; + return JSON.stringify({ + storyNpcs: [ + { + name: '沈砺', + title: '旧友兼宿敌', + role: '沉船商盟引路人', + description: '他像旧友,也像最早知道假航灯秘密的人。', + visualDescription: '衣角总带着潮水味,像是刚从夜雾里走出来。', + actionDescription: '会不断试探玩家到底愿不愿意回到旧航路。', + sceneVisualDescription: '总在钟声停下后的空隙里现身。', + relationshipHooks: ['和玩家共享一段无法翻篇的旧灯塔往事'], + tags: ['旧友', '宿敌'], + }, + { + name: '岚珀', + title: '守灯会巡夜官', + role: '守灯会前台接口人', + description: '她负责把守灯会的怀疑与命令直接压到玩家面前。', + visualDescription: '披着潮湿斗篷,眼神总先看灯芯再看人。', + actionDescription: '要求玩家立刻证明自己还配站回灯塔。', + sceneVisualDescription: '总把巡夜灯举得很高,不给人躲闪空间。', + relationshipHooks: ['会逼玩家更早站队'], + tags: ['守灯会', '巡夜'], + }, + ], + }); + } + + if (debugLabel.startsWith('agent-foundation-story-dossier-batch-')) { + storyDossierBatch += 1; + return JSON.stringify({ + storyNpcs: [ + { + name: '沈砺', + title: '旧友兼宿敌', + role: '沉船商盟引路人', + description: '他像旧友,也像最早知道假航灯秘密的人。', + visualDescription: '衣角总带着潮水味,像是刚从夜雾里走出来。', + actionDescription: '会不断试探玩家到底愿不愿意回到旧航路。', + sceneVisualDescription: '总在钟声停下后的空隙里现身。', + relationshipHooks: ['和玩家共享一段无法翻篇的旧灯塔往事'], + tags: ['旧友', '宿敌'], + }, + { + name: '岚珀', + title: '守灯会巡夜官', + role: '守灯会前台接口人', + description: '她负责把守灯会的怀疑与命令直接压到玩家面前。', + visualDescription: '披着潮湿斗篷,眼神总先看灯芯再看人。', + actionDescription: '要求玩家立刻证明自己还配站回灯塔。', + sceneVisualDescription: '总把巡夜灯举得很高,不给人躲闪空间。', + relationshipHooks: ['会逼玩家更早站队'], + tags: ['守灯会', '巡夜'], + }, + ], + }); + } + + throw new Error(`未覆盖的测试 debugLabel: ${debugLabel}`); + }, + streamMessageContent: async () => { + throw new Error('这个测试不应该走流式接口'); + }, + } as UpstreamLlmClient; +} + +test('foundation draft service builds draft fields directly from framework instead of reusing preview compiler output', async () => { + const service = new CustomWorldAgentFoundationDraftService( + createFoundationDraftLlmClient(), + ); + + const draft = await service.generate({ + creatorIntent: { + sourceMode: 'freeform', + rawSettingText: '被海雾反复切开的列岛世界。', + worldHook: '旧灯塔、假航灯与失控航路重新把列岛撕开。', + themeKeywords: ['海岛', '悬疑'], + toneDirectives: ['冷峻', '潮湿'], + playerPremise: '玩家是被迫返乡的失职守灯人', + openingSituation: '开局时正站在即将熄灭的旧灯塔上', + coreConflicts: ['守灯会与沉船商盟争夺旧航路解释权'], + keyFactions: [], + keyCharacters: [], + keyLandmarks: [], + iconicElements: ['潮雾钟声', '盐火灯塔'], + forbiddenDirectives: [], + }, + anchorPack: { + creatorIntentSummary: '潮雾、旧灯塔、假航灯和被迫返乡的守灯人。', + }, + }); + + const normalized = normalizeFoundationDraftProfile(draft); + const legacyResultProfile = (draft as Record) + .legacyResultProfile as Record | undefined; + const legacyStoryNpcs = Array.isArray(legacyResultProfile?.storyNpcs) + ? (legacyResultProfile?.storyNpcs as Array>) + : []; + + assert.ok(normalized); + assert.equal(normalized?.name, '潮雾列岛'); + assert.equal( + normalized?.summary, + '潮雾列岛正在被假航灯和沉船商盟重新切开。', + ); + assert.equal(normalized?.playableNpcs.length, 1); + assert.equal(normalized?.storyNpcs.length, 2); + assert.equal(normalized?.storyNpcs[0]?.name, '沈砺'); + assert.match( + normalized?.storyNpcs[0]?.summary ?? '', + /旧友|假航灯|灯塔/u, + ); + assert.equal( + normalized?.storyNpcs[0]?.publicMask, + '衣角总带着潮水味,像是刚从夜雾里走出来。', + ); + assert.equal(normalized?.landmarks.length, 2); + assert.equal(normalized?.landmarks[0]?.name, '盐火灯塔'); + assert.equal(normalized?.sceneChapters.length, 2); + assert.equal(legacyResultProfile?.name, '潮雾列岛'); + assert.equal( + legacyResultProfile?.scenarioPackId, + 'scenario-pack:潮雾列岛', + ); + assert.equal( + legacyResultProfile?.campaignPackId, + 'campaign-pack:潮雾列岛', + ); + assert.equal(legacyStoryNpcs[0]?.name, '沈砺'); + assert.equal(legacyStoryNpcs[0]?.backstory, undefined); +}); diff --git a/server-node/src/services/customWorldAgentFoundationDraftService.ts b/server-node/src/services/customWorldAgentFoundationDraftService.ts index b13a1896..fe6b53b0 100644 --- a/server-node/src/services/customWorldAgentFoundationDraftService.ts +++ b/server-node/src/services/customWorldAgentFoundationDraftService.ts @@ -26,7 +26,6 @@ import { buildCustomWorldRoleOutlineBatchPrompt, } from '../prompts/customWorldPrompts.js'; import { - buildCompiledCustomWorldProfile, buildCustomWorldRawProfileFromFramework, type CustomWorldGenerationFramework, type CustomWorldGenerationLandmarkOutline, @@ -36,8 +35,7 @@ import { normalizeCustomWorldGenerationFramework, normalizeCustomWorldGenerationLandmarkOutlineBatch, normalizeCustomWorldGenerationRoleOutlineBatch, -} from '../modules/custom-world/runtimeProfile.js'; -import type { CustomWorldProfile } from '../modules/custom-world/runtimeTypes.js'; +} from '../modules/custom-world/runtime-profile/index.js'; import { buildDraftSummaryFromIntent, type CreatorCharacterSeedRecord, @@ -961,6 +959,379 @@ function buildSceneChaptersFromDraft(params: { }); } +function buildDraftFactionsFromFramework(params: { + majorFactions: string[]; + coreConflicts: string[]; + fallbackSummary: string; +}): CustomWorldFoundationDraftFaction[] { + const names = dedupeStrings(params.majorFactions, 4); + const fallbackConflict = + params.coreConflicts[0] || params.fallbackSummary || '局势仍在持续升温'; + + return names.map((name, index) => { + const relatedConflict = + params.coreConflicts[index % Math.max(1, params.coreConflicts.length)] || + fallbackConflict; + const conflictTarget = extractConflictTarget(relatedConflict); + + return { + id: createId('faction', name, index), + name, + title: name, + publicGoal: clampText( + conflictTarget + ? `拿下${conflictTarget}的主动解释权` + : '在失衡局势里先一步抢到主动权', + 28, + ), + relatedConflict, + tension: clampText(relatedConflict, 48), + playerRelation: clampText( + index === 0 + ? '它会先一步影响玩家的开局站位' + : '玩家迟早要和它发生正面碰撞', + 32, + ), + summary: clampText( + `${name}围绕“${buildCompactLabel(relatedConflict, '核心冲突', 16)}”持续施压,也会直接改变玩家的开局判断。`, + 120, + ), + } satisfies CustomWorldFoundationDraftFaction; + }); +} + +function buildDraftCharactersFromGenerationRoles(params: { + roles: CustomWorldGenerationRoleOutline[]; + roleKind: 'playable' | 'story'; + threads: CustomWorldFoundationDraftThread[]; + maxCount: number; + fallbackPressure: string; +}): CustomWorldFoundationDraftCharacter[] { + const threadCount = Math.max(1, params.threads.length); + + return params.roles.slice(0, params.maxCount).map((role, index) => { + const primaryThreadId = params.threads[index % threadCount]?.id ?? ''; + const secondaryThreadId = + params.threads[ + (index + (params.roleKind === 'playable' ? 2 : 1)) % threadCount + ]?.id ?? ''; + const fallbackRelation = + params.roleKind === 'playable' + ? '这是玩家当前最贴近世界的切入口' + : '会直接改变玩家的下一步选择'; + const publicIdentity = + clampText(role.description, 36) || '站在当前局势前台的人'; + const currentPressure = + clampText( + role.actionDescription || + role.sceneVisualDescription || + params.fallbackPressure, + 48, + ) || '正在被当前局势不断加压'; + const publicMask = clampText(role.visualDescription, 36) || undefined; + const hiddenHook = + clampText(role.sceneVisualDescription || role.tags[0] || '', 48) || + undefined; + const relationToPlayer = + clampText(role.relationshipHooks[0] || fallbackRelation, 36) || + fallbackRelation; + + return { + id: createId( + 'character', + `${params.roleKind}-${role.name || role.role || index + 1}`, + index, + ), + name: + clampText(role.name, 16) || + buildCompactLabel(role.role || role.title, '关键角色', 10), + title: clampText(role.title || role.role, 18) || '关键角色', + role: clampText(role.role || role.title, 28) || '关键角色', + publicIdentity, + publicMask, + currentPressure, + hiddenHook, + relationToPlayer, + threadIds: dedupeStrings([primaryThreadId, secondaryThreadId], 3), + summary: clampText( + [ + publicIdentity, + currentPressure ? `眼下压力是${currentPressure}` : '', + relationToPlayer ? `与玩家的关系是${relationToPlayer}` : '', + ] + .filter(Boolean) + .join(';'), + 130, + ), + } satisfies CustomWorldFoundationDraftCharacter; + }); +} + +function buildDraftLandmarksFromFramework(params: { + landmarks: CustomWorldGenerationLandmarkOutline[]; + threads: CustomWorldFoundationDraftThread[]; + storyNpcs: CustomWorldFoundationDraftCharacter[]; + maxCount: number; + fallbackConflict: string; +}): CustomWorldFoundationDraftLandmark[] { + const threadCount = Math.max(1, params.threads.length); + const storyNpcIdByName = new Map( + params.storyNpcs.map((role) => [role.name.trim(), role.id] as const), + ); + + return params.landmarks.slice(0, params.maxCount).map((landmark, index) => { + const threadIds = dedupeStrings( + [ + params.threads[index % threadCount]?.id ?? '', + params.threads[(index + 1) % threadCount]?.id ?? '', + ], + 3, + ); + const characterIds = dedupeStrings( + landmark.sceneNpcNames.map( + (name) => storyNpcIdByName.get(name.trim()) ?? '', + ), + 4, + ); + + return { + id: createId('landmark', landmark.name, index), + name: clampText(landmark.name, 16) || `关键地点${index + 1}`, + description: + clampText(landmark.visualDescription || landmark.description, 48) || + undefined, + purpose: clampText(landmark.description, 28) || '承接关键剧情推进', + mood: + clampText(landmark.visualDescription || landmark.dangerLevel, 24) || + '带着明显风险的关键地点', + importance: clampText( + `${landmark.name}和“${buildCompactLabel(params.fallbackConflict, '主线冲突', 16)}”直接勾连,第一次抵达时就会意识到它不只是背景。`, + 60, + ), + secret: + clampText(landmark.connections[0]?.summary || '', 36) || undefined, + dangerLevel: clampText(landmark.dangerLevel, 24) || undefined, + characterIds, + threadIds, + summary: clampText( + landmark.description || + landmark.visualDescription || + `${landmark.name}会把当前局势的压力直接抬到台前。`, + 120, + ), + } satisfies CustomWorldFoundationDraftLandmark; + }); +} + +/** + * 工作包 G 的最小收口实现: + * foundation draft 主字段直接由 framework / role detail / landmark detail 组装, + * 不再通过 preview compiler 先转成 legacy runtime profile 再反解回 draft。 + */ +function buildFoundationDraftProfileFromFramework(params: { + framework: CustomWorldGenerationFramework; + playableDetailed: CustomWorldGenerationRoleOutline[]; + storyDetailed: CustomWorldGenerationRoleOutline[]; + creatorIntent: CustomWorldCreatorIntentRecord; + anchorPack: unknown; + anchorContent?: EightAnchorContent | null; + settingText: string; +}) { + const normalizedAnchorContent = normalizeEightAnchorContent( + params.anchorContent, + ); + const coreConflicts = + dedupeStrings(params.framework.coreConflicts, 4).length > 0 + ? dedupeStrings(params.framework.coreConflicts, 4) + : dedupeStrings(params.creatorIntent.coreConflicts, 4).length > 0 + ? dedupeStrings(params.creatorIntent.coreConflicts, 4) + : [params.framework.summary || '旧秩序与新力量正在争夺这个世界的解释权']; + const iconicElements = dedupeStrings(params.creatorIntent.iconicElements, 6); + const playerPremise = + clampText(params.creatorIntent.playerPremise, 72) || + '玩家是一名被卷进局势中心的行动者'; + const openingSituation = + clampText(params.creatorIntent.openingSituation, 72) || + '故事开局时,玩家已经站在必须立刻选边的位置上'; + const worldHook = + clampText( + params.creatorIntent.worldHook || + params.creatorIntent.rawSettingText || + params.framework.summary, + 72, + ) || '一个仍在失衡边缘不断扩张的世界'; + const fallbackFactions = buildFactions({ + intent: params.creatorIntent, + coreConflicts, + playerPremise, + iconicElements, + }); + const factions = + dedupeStrings(params.framework.majorFactions, 4).length > 0 + ? buildDraftFactionsFromFramework({ + majorFactions: params.framework.majorFactions, + coreConflicts, + fallbackSummary: params.framework.summary, + }) + : fallbackFactions; + const baseThreads = buildBaseThreads({ + intent: params.creatorIntent, + coreConflicts, + playerPremise, + openingSituation, + iconicElements, + }); + const playableNpcs = buildDraftCharactersFromGenerationRoles({ + roles: params.playableDetailed, + roleKind: 'playable', + threads: baseThreads, + maxCount: FOUNDATION_DRAFT_PLAYABLE_COUNT, + fallbackPressure: coreConflicts[0] || params.framework.summary, + }); + const storyNpcs = buildDraftCharactersFromGenerationRoles({ + roles: params.storyDetailed, + roleKind: 'story', + threads: baseThreads, + maxCount: FOUNDATION_DRAFT_STORY_COUNT, + fallbackPressure: coreConflicts[0] || params.framework.summary, + }); + const landmarks = buildDraftLandmarksFromFramework({ + landmarks: params.framework.landmarks, + threads: baseThreads, + storyNpcs, + maxCount: FOUNDATION_DRAFT_LANDMARK_COUNT, + fallbackConflict: coreConflicts[0] || params.framework.summary, + }); + const threads = finalizeThreads({ + threads: baseThreads.slice(0, 4), + characters: [...playableNpcs, ...storyNpcs], + landmarks, + }); + const chapter = buildChapter({ + worldName: params.framework.name, + openingSituation, + playerGoal: params.framework.playerGoal, + characters: [...playableNpcs, ...storyNpcs], + landmarks, + threads, + }); + const sceneChapters = buildSceneChaptersFromDraft({ + landmarks, + playableNpcs, + storyNpcs, + threads, + }); + + const legacyFramework: CustomWorldGenerationFramework = { + ...params.framework, + playableNpcs: params.playableDetailed, + storyNpcs: params.storyDetailed, + }; + const legacyResultProfile = buildCustomWorldRawProfileFromFramework( + legacyFramework, + ) as Record; + legacyResultProfile.id = + legacyResultProfile.id ?? + `agent-draft-${slugify(params.framework.name || 'world')}`; + legacyResultProfile.settingText = params.settingText; + legacyResultProfile.sceneChapterBlueprints = sceneChapters; + legacyResultProfile.generationMode = 'fast'; + legacyResultProfile.generationStatus = 'key_only'; + legacyResultProfile.scenarioPackId = + legacyResultProfile.scenarioPackId ?? + `scenario-pack:${slugify(params.framework.name || 'world')}`; + legacyResultProfile.campaignPackId = + legacyResultProfile.campaignPackId ?? + `campaign-pack:${slugify(params.framework.name || 'world')}`; + legacyResultProfile.creatorIntent = + legacyResultProfile.creatorIntent ?? + (params.creatorIntent as unknown as Record); + legacyResultProfile.anchorPack = + legacyResultProfile.anchorPack ?? + (toRecord(params.anchorPack) ?? + ({ value: params.anchorPack } as Record)); + if (normalizedAnchorContent) { + legacyResultProfile.anchorContent = + legacyResultProfile.anchorContent ?? + (normalizedAnchorContent as unknown as Record); + } + + return { + name: clampText(params.framework.name, 40) || '未命名世界底稿', + subtitle: + clampText(params.framework.subtitle, 40) || + clampText( + [ + buildCompactLabel(playerPremise, '玩家视角', 12), + buildCompactLabel(coreConflicts[0] || '', '核心冲突', 16), + ] + .filter(Boolean) + .join(' · '), + 40, + ) || + '第一版世界底稿', + summary: + clampText(params.framework.summary, 180) || + clampText( + `${worldHook} 玩家会以“${playerPremise}”切入这个世界,眼下最直接的冲突是“${coreConflicts[0]}”。`, + 180, + ) || + '第一版世界底稿已经整理完成。', + tone: + clampText(params.framework.tone, 72) || + buildTone(params.creatorIntent), + playerGoal: + clampText(params.framework.playerGoal, 72) || + buildPlayerGoal({ + playerPremise, + openingSituation, + coreConflict: coreConflicts[0] || '', + }), + majorFactions: + dedupeStrings(params.framework.majorFactions, 6).length > 0 + ? dedupeStrings(params.framework.majorFactions, 6) + : factions.map((entry) => entry.name), + coreConflicts, + playableNpcs, + storyNpcs, + landmarks, + camp: { + id: 'camp-home', + name: clampText(params.framework.camp.name, 16) || '开局据点', + description: + clampText(params.framework.camp.description, 72) || + '玩家暂时还能整顿情报、换口气并决定下一步站位的落脚处。', + mood: + clampText(params.framework.tone, 36) || '紧绷但还可暂时收住局势', + dangerLevel: + clampText(params.framework.camp.dangerLevel, 24) || undefined, + summary: clampText( + params.framework.camp.description || + `${params.framework.camp.name}仍是玩家在风暴边缘还能勉强站稳的一块地方。`, + 88, + ), + } satisfies CustomWorldFoundationDraftCamp, + themePack: null, + storyGraph: null, + factions, + threads, + chapters: [chapter], + sceneChapters, + worldHook, + playerPremise, + openingSituation, + iconicElements, + sourceAnchorSummary: + buildDraftSummaryFromEightAnchorContent(normalizedAnchorContent) || + toText(toRecord(params.anchorPack)?.creatorIntentSummary) || + buildDraftSummaryFromIntent(params.creatorIntent) || + params.framework.summary, + legacyResultProfile, + } satisfies CustomWorldFoundationDraftProfile & { + legacyResultProfile: Record; + }; +} + function getNamedRecordKey(value: unknown) { return toText(value).replace(/\s+/gu, ''); } @@ -1496,271 +1867,6 @@ async function expandFoundationRoleEntries(params: { return mergedEntries; } -function buildDraftFactionsFromRuntimeProfile(profile: CustomWorldProfile) { - const factionNames = dedupeStrings(profile.majorFactions, 4); - const firstConflict = profile.coreConflicts[0] || profile.summary; - - return factionNames.slice(0, 4).map((name, index) => { - const relatedConflict = - profile.coreConflicts[ - index % Math.max(1, profile.coreConflicts.length) - ] || firstConflict; - return { - id: createId('faction', name, index), - name, - title: name, - publicGoal: clampText( - extractConflictTarget(relatedConflict) - ? `拿下${extractConflictTarget(relatedConflict)}的主导权` - : '在失衡局势里先抢到主动权', - 28, - ), - relatedConflict, - tension: clampText(relatedConflict, 48), - playerRelation: clampText( - index === 0 - ? '它会主动影响玩家的第一步站位' - : '玩家迟早要和它发生直接交集', - 32, - ), - summary: clampText( - `${name}围绕“${buildCompactLabel(relatedConflict, '核心冲突', 16)}”持续施压,也会直接影响玩家的开局判断。`, - 120, - ), - } satisfies CustomWorldFoundationDraftFaction; - }); -} - -function buildDraftThreadsFromRuntimeProfile( - profile: CustomWorldProfile, -): CustomWorldFoundationDraftThread[] { - const graphThreads = [ - ...(profile.storyGraph?.visibleThreads ?? []).slice(0, 2), - ...(profile.storyGraph?.hiddenThreads ?? []).slice(0, 2), - ]; - - if (graphThreads.length > 0) { - return graphThreads.map( - (thread, index) => - ({ - id: thread.id || createId('thread', thread.title, index), - title: clampText(thread.title, 18), - type: thread.visibility === 'hidden' ? 'hidden' : 'main', - conflictType: clampText(thread.conflictType, 18), - conflict: clampText(thread.summary || thread.stakes, 72), - stakes: clampText(thread.stakes, 48), - characterIds: thread.involvedActorIds.slice(0, 4), - landmarkIds: thread.relatedLocationIds.slice(0, 4), - summary: clampText(thread.summary, 120), - }) satisfies CustomWorldFoundationDraftThread, - ); - } - - return profile.coreConflicts.slice(0, 3).map((conflict, index) => ({ - id: createId('thread', conflict, index), - title: buildCompactLabel(conflict, `主线${index + 1}`, 16), - type: index === 1 ? 'hidden' : 'main', - conflict, - characterIds: [], - landmarkIds: [], - summary: clampText(`这条线围绕“${conflict}”持续推进。`, 80), - })); -} - -function buildDraftCharactersFromRuntimeProfile( - roles: CustomWorldProfile['playableNpcs'] | CustomWorldProfile['storyNpcs'], - fallbackThreadIds: string[], -) { - return roles.map((role) => ({ - id: role.id, - name: role.name, - title: clampText(role.title || role.role, 18) || '关键角色', - role: clampText(role.role || role.title, 28) || '关键角色', - publicIdentity: - clampText( - role.narrativeProfile?.publicMask || - role.backstoryReveal.publicSummary || - role.description, - 36, - ) || '站在局势前台的人', - publicMask: - clampText( - role.narrativeProfile?.firstContactMask || role.personality, - 36, - ) || undefined, - currentPressure: - clampText( - role.narrativeProfile?.immediatePressure || - role.motivation || - role.backstory, - 48, - ) || '正在被当前局势不断加压', - hiddenHook: - clampText( - role.narrativeProfile?.hiddenLine || - role.backstoryReveal.chapters[2]?.content || - role.backstory, - 48, - ) || undefined, - relationToPlayer: - clampText( - role.relationshipHooks[0] || - role.narrativeProfile?.visibleLine || - role.motivation, - 36, - ) || '会直接改变玩家的下一步选择', - threadIds: - role.narrativeProfile?.relatedThreadIds?.slice(0, 3) ?? - fallbackThreadIds.slice(0, 3), - summary: - clampText(role.description || role.backstoryReveal.publicSummary, 120) || - '这个角色会持续推动当前世界底稿继续展开。', - })) satisfies CustomWorldFoundationDraftCharacter[]; -} - -function buildDraftLandmarksFromRuntimeProfile( - profile: CustomWorldProfile, - threads: CustomWorldFoundationDraftThread[], -) { - return profile.landmarks - .slice(0, FOUNDATION_DRAFT_LANDMARK_COUNT) - .map((landmark) => { - const relatedThreadIds = threads - .filter((thread) => thread.landmarkIds.includes(landmark.id)) - .map((thread) => thread.id) - .slice(0, 3); - - return { - id: landmark.id, - name: landmark.name, - description: clampText(landmark.description, 48) || undefined, - purpose: clampText(landmark.description, 28) || '承接关键剧情推进', - mood: - clampText( - landmark.narrativeResidues?.[0]?.summary || - landmark.dangerLevel || - '带着明显风险的关键地点', - 24, - ) || '带着明显风险的关键地点', - importance: clampText( - landmark.narrativeResidues?.[0]?.changeHint || - landmark.description || - '和当前主线冲突直接勾连的关键地点', - 60, - ), - secret: - clampText( - landmark.narrativeResidues?.[0]?.hiddenTruth || - landmark.connections[0]?.summary || - '', - 36, - ) || undefined, - dangerLevel: landmark.dangerLevel, - characterIds: landmark.sceneNpcIds.slice(0, 4), - threadIds: relatedThreadIds, - summary: clampText( - landmark.description || - landmark.narrativeResidues?.[0]?.summary || - '', - 120, - ), - } satisfies CustomWorldFoundationDraftLandmark; - }); -} - -function convertRuntimeProfileToFoundationDraft(params: { - profile: CustomWorldProfile; - intent: CustomWorldCreatorIntentRecord; - anchorPack: unknown; -}) { - const factions = buildDraftFactionsFromRuntimeProfile(params.profile); - const threads = buildDraftThreadsFromRuntimeProfile(params.profile); - const playableNpcs = buildDraftCharactersFromRuntimeProfile( - params.profile.playableNpcs.slice(0, FOUNDATION_DRAFT_PLAYABLE_COUNT), - threads.slice(0, 2).map((entry) => entry.id), - ); - const storyNpcs = buildDraftCharactersFromRuntimeProfile( - params.profile.storyNpcs.slice(0, FOUNDATION_DRAFT_STORY_COUNT), - threads.slice(1, 3).map((entry) => entry.id), - ); - const landmarks = buildDraftLandmarksFromRuntimeProfile( - params.profile, - threads, - ); - const chapter = buildChapter({ - worldName: params.profile.name, - openingSituation: - clampText(params.intent.openingSituation, 60) || params.profile.summary, - playerGoal: params.profile.playerGoal, - characters: [...playableNpcs, ...storyNpcs], - landmarks, - threads, - }); - const sceneChapters = buildSceneChaptersFromDraft({ - landmarks, - playableNpcs, - storyNpcs, - threads, - }); - const anchorRecord = toRecord(params.anchorPack); - - return { - name: params.profile.name, - subtitle: params.profile.subtitle, - summary: params.profile.summary, - tone: params.profile.tone, - playerGoal: params.profile.playerGoal, - majorFactions: - params.profile.majorFactions.length > 0 - ? params.profile.majorFactions - : factions.map((entry) => entry.name), - coreConflicts: - params.profile.coreConflicts.length > 0 - ? params.profile.coreConflicts - : [params.profile.summary], - playableNpcs, - storyNpcs, - landmarks, - camp: params.profile.camp - ? ({ - id: 'camp-home', - name: params.profile.camp.name, - description: params.profile.camp.description, - mood: clampText(params.profile.tone, 36) || '紧绷但还可暂时收住局势', - dangerLevel: params.profile.camp.dangerLevel, - summary: clampText(params.profile.camp.description, 88), - } satisfies CustomWorldFoundationDraftCamp) - : null, - themePack: - (params.profile.themePack as unknown as Record | null) ?? - null, - storyGraph: - (params.profile.storyGraph as unknown as Record | null) ?? - null, - factions, - threads, - chapters: [chapter], - sceneChapters, - worldHook: - clampText(params.intent.worldHook || params.profile.summary, 72) || - params.profile.summary, - playerPremise: - clampText(params.intent.playerPremise, 72) || - '玩家是一名被卷进局势中心的行动者', - openingSituation: - clampText(params.intent.openingSituation, 72) || - '故事开局时,玩家已经站在必须立刻选边的位置上', - iconicElements: dedupeStrings(params.intent.iconicElements, 6), - sourceAnchorSummary: - toText(anchorRecord?.creatorIntentSummary) || - buildDraftSummaryFromIntent(params.intent) || - params.profile.summary, - legacyResultProfile: params.profile as unknown as Record, - } satisfies CustomWorldFoundationDraftProfile & { - legacyResultProfile: Record; - }; -} - async function buildFoundationDraftProfileWithLlm(params: { llmClient: UpstreamLlmClient; creatorIntent: CustomWorldCreatorIntentRecord; @@ -1885,25 +1991,19 @@ async function buildFoundationDraftProfileWithLlm(params: { await emitDraftProgress(params.onProgress, { phaseLabel: '编译世界底稿', - phaseDetail: '正在把分批生成结果整理成旧版世界结果结构,再编成草稿卡底稿。', + phaseDetail: + '正在把分批生成结果直接整理成第一版 foundation draft,并同步兼容结果快照。', progress: 97, }); - const rawProfile = buildCustomWorldRawProfileFromFramework( + return buildFoundationDraftProfileFromFramework({ framework, - ) as Record; - rawProfile.playableNpcs = playableDetailed; - rawProfile.storyNpcs = storyDetailed; - rawProfile.landmarks = framework.landmarks; - - const runtimeProfile = buildCompiledCustomWorldProfile( - rawProfile, - settingText, - ); - return convertRuntimeProfileToFoundationDraft({ - profile: runtimeProfile, - intent: params.creatorIntent, + playableDetailed, + storyDetailed, + creatorIntent: params.creatorIntent, anchorPack: params.anchorPack, + anchorContent: params.anchorContent, + settingText, }); } diff --git a/server-node/src/services/customWorldAgentMessageTurnService.ts b/server-node/src/services/customWorldAgentMessageTurnService.ts new file mode 100644 index 00000000..bf2843e4 --- /dev/null +++ b/server-node/src/services/customWorldAgentMessageTurnService.ts @@ -0,0 +1,196 @@ +import crypto from 'node:crypto'; + +import type { + CreatorIntentReadiness, + CustomWorldAgentMessage, + CustomWorldAgentSessionSnapshot, +} from '../../../packages/shared/src/contracts/customWorldAgent.js'; +import { + buildPendingClarifications, + evaluateCreatorIntentReadiness, + resolveCreatorIntentStage, +} from './customWorldAgentClarificationService.js'; +import { + buildAnchorPackFromIntent, + buildDraftSummaryFromIntent, + buildDraftTitleFromIntent, + type CustomWorldCreatorIntentRecord, +} from './customWorldAgentIntentExtractionService.js'; +import type { + CustomWorldAgentSessionRecord, + CustomWorldAgentSessionStore, +} from './customWorldAgentSessionStore.js'; +import type { CustomWorldAgentSuggestedActionService } from './customWorldAgentSuggestedActionService.js'; +import { CustomWorldAgentSnapshotBuilder } from './customWorldAgentSnapshotBuilder.js'; +import { + buildCreatorIntentFromEightAnchorContent, + buildEightAnchorContentFromCreatorIntent, +} from './eightAnchorCompatibilityService.js'; +import { EightAnchorSingleTurnService } from './eightAnchorSingleTurnService.js'; + +function buildDerivedState( + intent: CustomWorldCreatorIntentRecord, + hasUserInput: boolean, + suggestedActionService: CustomWorldAgentSuggestedActionService, +) { + const readiness = evaluateCreatorIntentReadiness(intent); + const pendingClarifications = buildPendingClarifications(intent, readiness); + const stage = resolveCreatorIntentStage({ + hasUserInput, + readiness, + }); + + return { + readiness, + pendingClarifications, + stage, + anchorPack: buildAnchorPackFromIntent(intent, { + completedKeys: readiness.completedKeys, + missingKeys: readiness.missingKeys, + }), + draftProfile: { + title: buildDraftTitleFromIntent(intent), + summary: buildDraftSummaryFromIntent(intent), + }, + suggestedActions: suggestedActionService.buildSuggestedActions({ + stage, + isReady: readiness.isReady, + }), + }; +} + +export class CustomWorldAgentMessageTurnService { + constructor( + private readonly sessionStore: CustomWorldAgentSessionStore, + private readonly eightAnchorSingleTurnService: EightAnchorSingleTurnService, + private readonly suggestedActionService: CustomWorldAgentSuggestedActionService, + private readonly snapshotBuilder: CustomWorldAgentSnapshotBuilder, + ) {} + + async applyMessageTurn(params: { + userId: string; + sessionId: string; + latestUserText: string; + quickFillRequested: boolean; + relatedOperationId?: string | null; + onReplyUpdate?: (text: string) => void; + }) { + const latestSession = (await this.sessionStore.get( + params.userId, + params.sessionId, + )) as CustomWorldAgentSessionRecord | null; + if (!latestSession) { + throw new Error('custom world agent session not found'); + } + + const shouldPreserveDraftStage = + (latestSession.stage === 'object_refining' || + latestSession.stage === 'visual_refining') && + latestSession.draftCards.length > 0; + + const assistantTurn = await this.eightAnchorSingleTurnService.streamTurn( + { + currentTurn: latestSession.currentTurn + 1, + progressPercent: latestSession.progressPercent, + quickFillRequested: params.quickFillRequested, + currentAnchorContent: latestSession.anchorContent, + chatHistory: latestSession.messages + .filter( + (message): message is CustomWorldAgentMessage => + (message.role === 'user' || message.role === 'assistant') && + Boolean(message.text.trim()), + ) + .map((message) => ({ + role: message.role, + content: message.text, + })), + }, + { + onReplyUpdate: params.onReplyUpdate, + }, + ); + const nextCreatorIntent = buildCreatorIntentFromEightAnchorContent( + assistantTurn.nextAnchorContent, + ); + const progressPercent = Math.max( + 0, + Math.min(100, Math.round(assistantTurn.progressPercent)), + ); + const creatorIntentReadiness: CreatorIntentReadiness = + progressPercent >= 100 + ? { + isReady: true, + completedKeys: [ + 'world_hook', + 'player_premise', + 'theme_and_tone', + 'core_conflict', + 'relationship_seed', + 'iconic_element', + ], + missingKeys: [], + } + : evaluateCreatorIntentReadiness(nextCreatorIntent); + const derivedState = buildDerivedState( + nextCreatorIntent, + true, + this.suggestedActionService, + ); + const shouldStayInDraftStage = + shouldPreserveDraftStage && progressPercent >= 100; + const assistantMessage = { + id: `message-${crypto.randomBytes(8).toString('hex')}`, + role: 'assistant', + kind: 'chat', + text: assistantTurn.replyText, + createdAt: new Date().toISOString(), + relatedOperationId: params.relatedOperationId ?? null, + } satisfies CustomWorldAgentMessage; + + await this.sessionStore.replaceDerivedState( + params.userId, + params.sessionId, + this.snapshotBuilder.buildMessageTurnState({ + latestSession, + nextAnchorContent: assistantTurn.nextAnchorContent, + progressPercent, + replyText: assistantTurn.replyText, + nextCreatorIntent, + creatorIntentReadiness, + derivedDraftProfile: derivedState.draftProfile, + derivedPendingClarifications: derivedState.pendingClarifications, + derivedStage: derivedState.stage, + shouldStayInDraftStage, + }), + ); + await this.sessionStore.appendMessage( + params.userId, + params.sessionId, + assistantMessage, + ); + + return (await this.sessionStore.getSnapshot( + params.userId, + params.sessionId, + )) as CustomWorldAgentSessionSnapshot; + } + + deriveInitialSessionState(params: { + seedText: string; + creatorIntent: CustomWorldCreatorIntentRecord; + }) { + const anchorContent = buildEightAnchorContentFromCreatorIntent( + params.creatorIntent, + ); + const derivedState = buildDerivedState( + params.creatorIntent, + Boolean(params.seedText), + this.suggestedActionService, + ); + + return { + anchorContent, + ...derivedState, + }; + } +} diff --git a/server-node/src/services/customWorldAgentOrchestrator.ts b/server-node/src/services/customWorldAgentOrchestrator.ts index 956f80d4..6910d45b 100644 --- a/server-node/src/services/customWorldAgentOrchestrator.ts +++ b/server-node/src/services/customWorldAgentOrchestrator.ts @@ -8,57 +8,50 @@ import type { CustomWorldAgentMessage, CustomWorldAgentOperationRecord, CustomWorldAgentSessionSnapshot, - CustomWorldDraftCardSummary, CustomWorldPendingClarification, - CustomWorldSuggestedAction, SendCustomWorldAgentMessageRequest, SendCustomWorldAgentMessageResponse, } from '../../../packages/shared/src/contracts/customWorldAgent.js'; -import { badRequest, notFound } from '../errors.js'; +import { notFound } from '../errors.js'; import { prepareEventStreamResponse } from '../http.js'; +import { CustomWorldAgentActionRegistry } from './customWorldAgentActionRegistry.js'; +import { createCustomWorldAgentActionExecutorMap } from './customWorldAgentActionExecutors/index.js'; import { CustomWorldAgentAssetBridgeService } from './customWorldAgentAssetBridgeService.js'; import { CustomWorldAgentAutoAssetService } from './customWorldAgentAutoAssetService.js'; import { CustomWorldAgentChangeSummaryService } from './customWorldAgentChangeSummaryService.js'; -import { - buildPendingClarifications, - evaluateCreatorIntentReadiness, - resolveCreatorIntentStage, -} from './customWorldAgentClarificationService.js'; +import { CustomWorldAgentMessageTurnService } from './customWorldAgentMessageTurnService.js'; import { CustomWorldAgentDraftCompiler, - getWorldFoundationCardId, normalizeFoundationDraftProfile, } from './customWorldAgentDraftCompiler.js'; -import { updateDraftCardSections } from './customWorldAgentDraftEditService.js'; import { CustomWorldAgentEntityGenerationService } from './customWorldAgentEntityGenerationService.js'; import { CustomWorldAgentFoundationDraftService } from './customWorldAgentFoundationDraftService.js'; import { - buildAnchorPackFromIntent, - buildDraftSummaryFromIntent, - buildDraftTitleFromIntent, createEmptyCreatorIntentRecord, type CustomWorldCreatorIntentRecord, extractCreatorIntentPatch, hasMeaningfulCreatorIntentRecord, mergeCreatorIntentRecord, - normalizeCreatorIntentRecord, } from './customWorldAgentIntentExtractionService.js'; -import { - rebuildRoleAssetCoverage, - resolveRoleAssetStatusLabel, -} from './customWorldAgentRoleAssetStateService.js'; +import { CustomWorldAgentPublishingService } from './customWorldAgentPublishingService.js'; +import { CustomWorldAgentQualityGateService } from './customWorldAgentQualityGateService.js'; +import { CustomWorldAgentResultSyncService } from './customWorldAgentResultSyncService.js'; +import { CustomWorldAgentSnapshotBuilder } from './customWorldAgentSnapshotBuilder.js'; import { type CustomWorldAgentSessionRecord, CustomWorldAgentSessionStore, } from './customWorldAgentSessionStore.js'; +import { CustomWorldAgentSuggestedActionService } from './customWorldAgentSuggestedActionService.js'; import { - buildAnchorPackFromEightAnchorContent, - buildCreatorIntentFromEightAnchorContent, buildEightAnchorContentFromCreatorIntent, estimateProgressPercentFromAnchorContent, } from './eightAnchorCompatibilityService.js'; import { EightAnchorSingleTurnService } from './eightAnchorSingleTurnService.js'; +import { buildRpgWorldPreviewEnvelope } from './RpgWorldPreviewCompiler.js'; +import { buildRpgCreationPreviewProfileFromDraftProfile } from './rpgCreationPreviewProfileBuilder.js'; import type { UpstreamLlmClient } from './llmClient.js'; +import type { RpgWorldProfileRepositoryPort } from '../repositories/RpgWorldProfileRepository.js'; +import type { UserRepositoryPort } from '../repositories/userRepository.js'; const PHASE2_FORCE_FAIL_TOKEN = '__phase1_force_fail__'; function truncateText(value: string, maxLength: number) { @@ -75,81 +68,14 @@ function sleep(ms: number) { }); } -function buildSuggestedActions( - params: { - stage?: CustomWorldAgentSessionRecord['stage']; - isReady?: boolean; - draftProfile?: unknown; - draftCards?: CustomWorldDraftCardSummary[]; - } = {}, -): CustomWorldSuggestedAction[] { - const profile = normalizeFoundationDraftProfile(params.draftProfile); - const actions: CustomWorldSuggestedAction[] = [ - { - id: 'request_summary', - type: 'request_summary', - label: - params.stage === 'object_refining' || params.stage === 'visual_refining' - ? '总结当前世界底稿' - : '总结当前设定', - }, - ]; - - if (params.stage === 'foundation_review' && params.isReady) { - actions.push({ - id: 'draft_foundation', - type: 'draft_foundation', - label: '整理一版世界底稿', - }); - return actions; - } - - if ( - (params.stage === 'object_refining' || - params.stage === 'visual_refining') && - profile - ) { - const worldCardId = - params.draftCards?.find((entry) => entry.kind === 'world')?.id ?? - getWorldFoundationCardId(); - const firstCharacter = [...profile.playableNpcs, ...profile.storyNpcs][0]; - const firstLandmark = profile.landmarks[0]; - - actions.push({ - id: 'refine_world', - type: 'refine_focus_target', - label: '先看世界总卡', - targetId: worldCardId, - }); - - if (firstCharacter) { - actions.push({ - id: `refine-character-${firstCharacter.id}`, - type: 'refine_focus_target', - label: `精修角色:${firstCharacter.name}`, - targetId: firstCharacter.id, - }); - } - - if (firstLandmark) { - actions.push({ - id: `refine-landmark-${firstLandmark.id}`, - type: 'refine_focus_target', - label: `继续补地点:${firstLandmark.name}`, - targetId: firstLandmark.id, - }); - } - } - - return actions; -} - function buildOperation(type: CustomWorldAgentOperationRecord['type']) { const phaseDetail = type === 'draft_foundation' ? '正在把已确认设定编成第一版世界底稿。' : type === 'update_draft_card' ? '正在把这次设定改动写回草稿。' + : type === 'sync_result_profile' + ? '正在把结果页里的世界快照同步回当前草稿。' : type === 'generate_characters' ? '正在围绕当前底稿补出新角色。' : type === 'generate_landmarks' @@ -216,36 +142,6 @@ function composeAssistantReply(params: { ].join('\n'); } -function buildDerivedState( - intent: CustomWorldCreatorIntentRecord, - hasUserInput: boolean, -) { - const readiness = evaluateCreatorIntentReadiness(intent); - const pendingClarifications = buildPendingClarifications(intent, readiness); - const stage = resolveCreatorIntentStage({ - hasUserInput, - readiness, - }); - - return { - readiness, - pendingClarifications, - stage, - anchorPack: buildAnchorPackFromIntent(intent, { - completedKeys: readiness.completedKeys, - missingKeys: readiness.missingKeys, - }), - draftProfile: { - title: buildDraftTitleFromIntent(intent), - summary: buildDraftSummaryFromIntent(intent), - }, - suggestedActions: buildSuggestedActions({ - stage, - isReady: readiness.isReady, - }), - }; -} - function buildWelcomeMessage(params: { seedText: string; intent: CustomWorldCreatorIntentRecord; @@ -272,51 +168,6 @@ function buildWelcomeMessage(params: { }); } -function buildFoundationDraftAssistantMessage(params: { - relatedOperationId: string; - draftProfile: unknown; - warnings?: string[]; -}) { - const profile = normalizeFoundationDraftProfile(params.draftProfile); - const leadCharacter = profile?.playableNpcs[0]; - const leadLandmark = profile?.landmarks[0]; - const warnings = (params.warnings ?? []).filter(Boolean); - - return { - id: `message-${crypto.randomBytes(8).toString('hex')}`, - role: 'assistant', - kind: 'summary', - text: [ - `我先把第一版世界底稿整理出来了:${profile?.summary || '底稿已经生成完成。'}`, - '', - `当前已经落下来的第一批对象数量是:关键角色 ${profile?.playableNpcs.length ?? 0} 个,关键地点 ${profile?.landmarks.length ?? 0} 个,势力 ${profile?.factions.length ?? 0} 个。`, - `建议你先从“${profile?.name || '世界总卡'}”这张世界总卡看起${leadCharacter ? `,再顺着角色「${leadCharacter.name}」往下细修` : ''}${leadLandmark ? `,地点可以先看「${leadLandmark.name}」` : ''}。`, - ...(warnings.length > 0 - ? [ - '', - `这一轮有 ${warnings.length} 项资产补齐未完成,但不影响世界底稿继续精修。`, - ] - : []), - ].join('\n'), - createdAt: new Date().toISOString(), - relatedOperationId: params.relatedOperationId, - } satisfies CustomWorldAgentMessage; -} - -function buildActionResultMessage(params: { - relatedOperationId: string; - text: string; -}) { - return { - id: `message-${crypto.randomBytes(8).toString('hex')}`, - role: 'assistant', - kind: 'action_result', - text: params.text, - createdAt: new Date().toISOString(), - relatedOperationId: params.relatedOperationId, - } satisfies CustomWorldAgentMessage; -} - function writeSseEvent( response: Response, event: string, @@ -330,6 +181,27 @@ function writeSseEvent( response.write(`data: ${JSON.stringify(data)}\n\n`); } +/** + * 发布 readiness 校验和正式写库复用同一个服务。 + * 当运行环境还没接入真实作品仓储时,真正执行发布动作会在这里统一抛出明确错误。 + */ +function createUnavailablePublishingRepository(): RpgWorldProfileRepositoryPort { + const throwUnavailable = async () => { + throw new Error('当前环境还没有注入发布仓储,暂时无法执行世界发布。'); + }; + + return { + listOwnProfiles: throwUnavailable, + upsertOwnProfile: throwUnavailable, + syncProfileFromSnapshot: throwUnavailable, + softDeleteOwnProfile: throwUnavailable, + publishOwnProfile: throwUnavailable, + unpublishOwnProfile: throwUnavailable, + listPublishedGallery: throwUnavailable, + getPublishedGalleryDetail: throwUnavailable, + }; +} + export class CustomWorldAgentOrchestrator { private readonly foundationDraftService: CustomWorldAgentFoundationDraftService; @@ -345,12 +217,29 @@ export class CustomWorldAgentOrchestrator { private readonly eightAnchorSingleTurnService: EightAnchorSingleTurnService; + private readonly suggestedActionService: CustomWorldAgentSuggestedActionService; + + private readonly qualityGateService: CustomWorldAgentQualityGateService; + + private readonly snapshotBuilder: CustomWorldAgentSnapshotBuilder; + + private readonly resultSyncService: CustomWorldAgentResultSyncService; + + private readonly publishingService: CustomWorldAgentPublishingService; + + private readonly actionRegistry: CustomWorldAgentActionRegistry; + + private readonly messageTurnService: CustomWorldAgentMessageTurnService; + constructor( private readonly sessionStore: CustomWorldAgentSessionStore, llmClient: UpstreamLlmClient | null = null, options: { singleTurnLlmClient?: UpstreamLlmClient | null; autoAssetService?: CustomWorldAgentAutoAssetService | null; + userRepository?: UserRepositoryPort | null; + resolveAuthorDisplayName?: ((userId: string) => Promise) | null; + rpgWorldProfileRepository?: RpgWorldProfileRepositoryPort | null; } = {}, ) { this.foundationDraftService = new CustomWorldAgentFoundationDraftService( @@ -362,11 +251,50 @@ export class CustomWorldAgentOrchestrator { ); this.changeSummaryService = new CustomWorldAgentChangeSummaryService(); this.assetBridgeService = new CustomWorldAgentAssetBridgeService(); - this.autoAssetService = - options.autoAssetService ?? null; + this.autoAssetService = options.autoAssetService ?? null; this.eightAnchorSingleTurnService = new EightAnchorSingleTurnService( (options.singleTurnLlmClient ?? llmClient) ?? undefined, ); + this.suggestedActionService = new CustomWorldAgentSuggestedActionService(); + this.qualityGateService = new CustomWorldAgentQualityGateService(); + this.snapshotBuilder = new CustomWorldAgentSnapshotBuilder( + this.draftCompiler, + this.suggestedActionService, + this.qualityGateService, + ); + this.resultSyncService = new CustomWorldAgentResultSyncService(); + this.publishingService = new CustomWorldAgentPublishingService( + options.rpgWorldProfileRepository ?? createUnavailablePublishingRepository(), + ); + const resolveAuthorDisplayName = + options.resolveAuthorDisplayName ?? + (options.userRepository + ? async (userId: string) => { + const user = await options.userRepository?.findById(userId); + return user?.displayName?.trim() || '玩家'; + } + : null); + this.messageTurnService = new CustomWorldAgentMessageTurnService( + this.sessionStore, + this.eightAnchorSingleTurnService, + this.suggestedActionService, + this.snapshotBuilder, + ); + this.actionRegistry = new CustomWorldAgentActionRegistry( + createCustomWorldAgentActionExecutorMap({ + sessionStore: this.sessionStore, + foundationDraftService: this.foundationDraftService, + draftCompiler: this.draftCompiler, + entityGenerationService: this.entityGenerationService, + changeSummaryService: this.changeSummaryService, + assetBridgeService: this.assetBridgeService, + autoAssetService: this.autoAssetService, + snapshotBuilder: this.snapshotBuilder, + resultSyncService: this.resultSyncService, + publishingService: this.publishingService, + resolveAuthorDisplayName, + }), + ); } async createSession( @@ -382,46 +310,50 @@ export class CustomWorldAgentOrchestrator { }) : {}; const creatorIntent = mergeCreatorIntentRecord(baseIntent, seedPatch); - const derivedState = buildDerivedState(creatorIntent, Boolean(seedText)); - const anchorContent = buildEightAnchorContentFromCreatorIntent(creatorIntent); + const initialState = this.messageTurnService.deriveInitialSessionState({ + seedText, + creatorIntent, + }); const progressPercent = seedText - ? estimateProgressPercentFromAnchorContent(anchorContent) + ? estimateProgressPercentFromAnchorContent(initialState.anchorContent) : 0; const fallbackWelcomeMessage = buildWelcomeMessage({ seedText, intent: creatorIntent, - pendingClarifications: derivedState.pendingClarifications, - isReady: derivedState.readiness.isReady, + pendingClarifications: initialState.pendingClarifications, + isReady: initialState.readiness.isReady, }); const record = await this.sessionStore.create(userId, { seedText, welcomeMessage: fallbackWelcomeMessage, currentTurn: 0, - anchorContent, + anchorContent: initialState.anchorContent, progressPercent, lastAssistantReply: fallbackWelcomeMessage, creatorIntent, - creatorIntentReadiness: derivedState.readiness, - anchorPack: buildAnchorPackFromEightAnchorContent( - anchorContent, - progressPercent, - ), - draftProfile: derivedState.draftProfile, - pendingClarifications: derivedState.pendingClarifications, + creatorIntentReadiness: initialState.readiness, + anchorPack: initialState.anchorPack, + draftProfile: initialState.draftProfile, + pendingClarifications: initialState.pendingClarifications, stage: progressPercent >= 100 ? 'foundation_review' : 'collecting_intent', - suggestedActions: derivedState.suggestedActions, + suggestedActions: initialState.suggestedActions, recommendedReplies: [], }); - return (await this.sessionStore.getSnapshot( + return (await this.getSessionSnapshot( userId, record.sessionId, )) as CustomWorldAgentSessionSnapshot; } async getSessionSnapshot(userId: string, sessionId: string) { - return this.sessionStore.getSnapshot(userId, sessionId); + const sessionRecord = await this.sessionStore.get(userId, sessionId); + if (!sessionRecord) { + return null; + } + + return this.buildSessionSnapshot(sessionRecord); } async submitMessage( @@ -528,164 +460,21 @@ export class CustomWorldAgentOrchestrator { throw notFound('custom world agent session not found'); } - if (payload.action === 'draft_foundation') { - if (session.progressPercent < 100) { - throw badRequest('draft_foundation requires progressPercent >= 100'); - } + const preparedExecution = this.actionRegistry.prepareExecution( + session, + payload, + ); + const operation = buildOperation(preparedExecution.operationType); + await this.sessionStore.createOperation(userId, sessionId, operation); + void preparedExecution.execute({ + userId, + sessionId, + operationId: operation.operationId, + }); - const operation = buildOperation('draft_foundation'); - await this.sessionStore.createOperation(userId, sessionId, operation); - void this.processDraftFoundationOperation({ - userId, - sessionId, - operationId: operation.operationId, - }); - - return { - operation, - }; - } - - if ( - payload.action === 'update_draft_card' || - payload.action === 'generate_characters' || - payload.action === 'generate_landmarks' || - payload.action === 'generate_role_assets' || - payload.action === 'sync_role_assets' - ) { - if ( - session.stage !== 'object_refining' && - session.stage !== 'visual_refining' - ) { - throw badRequest( - `${payload.action} is only available during object_refining or visual_refining`, - ); - } - - const hasDraftFoundation = Boolean( - normalizeFoundationDraftProfile(session.draftProfile) && - session.draftCards.length > 0, - ); - if (!hasDraftFoundation) { - throw badRequest( - `${payload.action} requires an existing draft foundation`, - ); - } - } - - if (payload.action === 'update_draft_card') { - if (!payload.cardId.trim()) { - throw badRequest('update_draft_card requires cardId'); - } - if (!Array.isArray(payload.sections) || payload.sections.length === 0) { - throw badRequest('update_draft_card requires sections'); - } - - const operation = buildOperation('update_draft_card'); - await this.sessionStore.createOperation(userId, sessionId, operation); - void this.processUpdateDraftCardOperation({ - userId, - sessionId, - operationId: operation.operationId, - payload, - }); - - return { - operation, - }; - } - - if (payload.action === 'generate_characters') { - if (payload.count < 1 || payload.count > 3) { - throw badRequest('generate_characters count must be between 1 and 3'); - } - - const operation = buildOperation('generate_characters'); - await this.sessionStore.createOperation(userId, sessionId, operation); - void this.processGenerateCharactersOperation({ - userId, - sessionId, - operationId: operation.operationId, - payload, - }); - - return { - operation, - }; - } - - if (payload.action === 'generate_landmarks') { - if (payload.count < 1 || payload.count > 3) { - throw badRequest('generate_landmarks count must be between 1 and 3'); - } - - const operation = buildOperation('generate_landmarks'); - await this.sessionStore.createOperation(userId, sessionId, operation); - void this.processGenerateLandmarksOperation({ - userId, - sessionId, - operationId: operation.operationId, - payload, - }); - - return { - operation, - }; - } - - if (payload.action === 'generate_role_assets') { - if (!Array.isArray(payload.roleIds) || payload.roleIds.length !== 1) { - throw badRequest( - 'generate_role_assets currently requires exactly one roleId', - ); - } - - const operation = buildOperation('generate_role_assets'); - await this.sessionStore.createOperation(userId, sessionId, operation); - void this.processGenerateRoleAssetsOperation({ - userId, - sessionId, - operationId: operation.operationId, - payload, - }); - - return { - operation, - }; - } - - if (payload.action === 'sync_role_assets') { - if (!payload.roleId.trim()) { - throw badRequest('sync_role_assets requires roleId'); - } - if ( - !payload.portraitPath.trim() || - !payload.generatedVisualAssetId.trim() - ) { - throw badRequest( - 'sync_role_assets requires portraitPath and generatedVisualAssetId', - ); - } - - const operation = buildOperation('sync_role_assets'); - await this.sessionStore.createOperation(userId, sessionId, operation); - void this.processSyncRoleAssetsOperation({ - userId, - sessionId, - operationId: operation.operationId, - payload, - }); - - return { - operation, - }; - } - - if (payload.action === 'publish_world') { - throw badRequest('publish_world is not available in phase5'); - } - - throw badRequest(`${payload.action} is not available in phase5`); + return { + operation, + }; } async getOperation(userId: string, sessionId: string, operationId: string) { @@ -709,807 +498,103 @@ export class CustomWorldAgentOrchestrator { relatedOperationId?: string | null; onReplyUpdate?: (text: string) => void; }) { - const latestSession = (await this.sessionStore.get( - params.userId, - params.sessionId, - )) as CustomWorldAgentSessionRecord | null; - if (!latestSession) { - throw new Error('custom world agent session not found'); - } - - const shouldPreserveDraftStage = - (latestSession.stage === 'object_refining' || - latestSession.stage === 'visual_refining') && - latestSession.draftCards.length > 0; - - const assistantTurn = await this.eightAnchorSingleTurnService.streamTurn( - { - currentTurn: latestSession.currentTurn + 1, - progressPercent: latestSession.progressPercent, - quickFillRequested: params.quickFillRequested, - currentAnchorContent: latestSession.anchorContent, - chatHistory: latestSession.messages - .filter( - (message): message is CustomWorldAgentMessage => - (message.role === 'user' || message.role === 'assistant') && - Boolean(message.text.trim()), - ) - .map((message) => ({ - role: message.role, - content: message.text, - })), - }, - { - onReplyUpdate: params.onReplyUpdate, - }, - ); - const nextCreatorIntent = buildCreatorIntentFromEightAnchorContent( - assistantTurn.nextAnchorContent, - ); - const progressPercent = Math.max( - 0, - Math.min(100, Math.round(assistantTurn.progressPercent)), - ); - const creatorIntentReadiness = - progressPercent >= 100 - ? { - isReady: true, - completedKeys: [ - 'world_hook', - 'player_premise', - 'theme_and_tone', - 'core_conflict', - 'relationship_seed', - 'iconic_element', - ], - missingKeys: [], - } - : evaluateCreatorIntentReadiness(nextCreatorIntent); - const derivedState = buildDerivedState(nextCreatorIntent, true); - const preservedStage = - latestSession.stage === 'visual_refining' - ? ('visual_refining' as const) - : ('object_refining' as const); - const shouldStayInDraftStage = - shouldPreserveDraftStage && progressPercent >= 100; - const nextStage = shouldStayInDraftStage - ? preservedStage - : derivedState.stage; - const assistantMessage = { - id: `message-${crypto.randomBytes(8).toString('hex')}`, - role: 'assistant', - kind: 'chat', - text: assistantTurn.replyText, - createdAt: new Date().toISOString(), - relatedOperationId: params.relatedOperationId ?? null, - } satisfies CustomWorldAgentMessage; - - await this.sessionStore.replaceDerivedState(params.userId, params.sessionId, { - currentTurn: latestSession.currentTurn + 1, - anchorContent: assistantTurn.nextAnchorContent, - progressPercent, - lastAssistantReply: assistantTurn.replyText, - stage: nextStage, - focusCardId: shouldStayInDraftStage ? latestSession.focusCardId : null, - creatorIntent: nextCreatorIntent, - creatorIntentReadiness, - anchorPack: buildAnchorPackFromEightAnchorContent( - assistantTurn.nextAnchorContent, - progressPercent, - ), - draftProfile: shouldStayInDraftStage - ? latestSession.draftProfile - : progressPercent >= 100 - ? { - title: buildDraftTitleFromIntent(nextCreatorIntent), - summary: buildDraftSummaryFromIntent(nextCreatorIntent), - } - : derivedState.draftProfile, - draftCards: shouldStayInDraftStage ? latestSession.draftCards : [], - assetCoverage: shouldStayInDraftStage - ? latestSession.assetCoverage - : rebuildRoleAssetCoverage( - progressPercent >= 100 - ? { - title: buildDraftTitleFromIntent(nextCreatorIntent), - summary: buildDraftSummaryFromIntent(nextCreatorIntent), - } - : derivedState.draftProfile, - ), - pendingClarifications: - progressPercent >= 100 ? [] : derivedState.pendingClarifications, - suggestedActions: shouldStayInDraftStage - ? buildSuggestedActions({ - stage: preservedStage, - isReady: true, - draftProfile: latestSession.draftProfile, - draftCards: latestSession.draftCards, - }) - : progressPercent >= 100 - ? [ - { - id: 'draft_foundation', - type: 'draft_foundation', - label: '生成游戏设定草稿', - }, - ] - : [], - recommendedReplies: [], - }); - await this.sessionStore.appendMessage( - params.userId, - params.sessionId, - assistantMessage, - ); - - return (await this.sessionStore.getSnapshot( - params.userId, - params.sessionId, - )) as CustomWorldAgentSessionSnapshot; + await this.messageTurnService.applyMessageTurn(params); + return this.getSessionSnapshot(params.userId, params.sessionId); } - private async processDraftFoundationOperation(params: { - userId: string; - sessionId: string; - operationId: string; - }) { - const { userId, sessionId, operationId } = params; + /** + * 统一 session snapshot 的读模型装配口径,避免普通拉取、SSE 流和内部调用返回不同字段集合。 + */ + private buildSessionSnapshot( + sessionRecord: CustomWorldAgentSessionRecord, + ): CustomWorldAgentSessionSnapshot { + const snapshot = { + sessionId: sessionRecord.sessionId, + currentTurn: sessionRecord.currentTurn, + anchorContent: sessionRecord.anchorContent, + progressPercent: sessionRecord.progressPercent, + lastAssistantReply: sessionRecord.lastAssistantReply, + stage: sessionRecord.stage, + focusCardId: sessionRecord.focusCardId, + creatorIntent: sessionRecord.creatorIntent, + creatorIntentReadiness: sessionRecord.creatorIntentReadiness, + anchorPack: sessionRecord.anchorPack, + lockState: sessionRecord.lockState, + draftProfile: sessionRecord.draftProfile, + messages: sessionRecord.messages, + draftCards: sessionRecord.draftCards, + pendingClarifications: sessionRecord.pendingClarifications, + suggestedActions: sessionRecord.suggestedActions, + recommendedReplies: sessionRecord.recommendedReplies, + qualityFindings: sessionRecord.qualityFindings, + assetCoverage: sessionRecord.assetCoverage, + checkpoints: sessionRecord.checkpoints.map((checkpoint) => ({ + checkpointId: checkpoint.checkpointId, + createdAt: checkpoint.createdAt, + label: checkpoint.label, + })), + supportedActions: this.actionRegistry.buildSupportedActions(sessionRecord), + resultPreview: this.buildResultPreview(sessionRecord), + updatedAt: sessionRecord.updatedAt, + } satisfies CustomWorldAgentSessionSnapshot; - try { - await this.sessionStore.updateOperation(userId, sessionId, operationId, { - status: 'running', - phaseLabel: '整理世界骨架', - phaseDetail: '正在校验已确认锚点,并准备第一版世界框架生成链路。', - progress: 12, - }); - - await sleep(30); - - const latestSession = (await this.sessionStore.get( - userId, - sessionId, - )) as CustomWorldAgentSessionRecord | null; - if (!latestSession) { - throw new Error('custom world agent session not found'); - } - - if (latestSession.progressPercent < 100) { - throw new Error('session progressPercent is below 100'); - } - - const creatorIntent = buildCreatorIntentFromEightAnchorContent( - latestSession.anchorContent, - ); - const anchorPack = buildAnchorPackFromEightAnchorContent( - latestSession.anchorContent, - latestSession.progressPercent, - ); - - const draftProfile = await this.foundationDraftService.generate({ - creatorIntent, - anchorPack, - anchorContent: latestSession.anchorContent, - onProgress: async (progress) => { - await this.sessionStore.updateOperation( - userId, - sessionId, - operationId, - { - status: 'running', - phaseLabel: progress.phaseLabel, - phaseDetail: progress.phaseDetail, - progress: progress.progress, - }, - ); - }, - }); - - const draftWithAssets = this.autoAssetService - ? await this.autoAssetService.populateDraftAssets({ - draftProfile, - onProgress: async (progress) => { - await this.sessionStore.updateOperation( - userId, - sessionId, - operationId, - { - status: 'running', - phaseLabel: progress.phaseLabel, - phaseDetail: progress.phaseDetail, - progress: progress.progress, - }, - ); - }, - }) - : { - draftProfile, - assetCoverage: rebuildRoleAssetCoverage(draftProfile), - warnings: [], - }; - - await this.sessionStore.updateOperation(userId, sessionId, operationId, { - phaseLabel: '编译草稿卡', - phaseDetail: '正在把世界底稿整理成可浏览的卡片摘要和详情结构。', - progress: 98, - }); - - const draftCards = this.draftCompiler.compileDraftCards( - draftWithAssets.draftProfile, - ); - const assetCoverage = draftWithAssets.assetCoverage; - const nextStage = 'object_refining' as const; - const nextSuggestedActions = buildSuggestedActions({ - stage: nextStage, - isReady: true, - draftProfile: draftWithAssets.draftProfile, - draftCards, - }); - - await this.sessionStore.replaceDerivedState(userId, sessionId, { - stage: nextStage, - creatorIntent, - anchorPack, - draftProfile: - draftWithAssets.draftProfile as unknown as Record, - draftCards, - assetCoverage, - pendingClarifications: [], - suggestedActions: nextSuggestedActions, - recommendedReplies: [], - }); - await this.sessionStore.appendCheckpoint(userId, sessionId, { - label: '世界底稿 V1', - }); - await this.sessionStore.appendMessage( - userId, - sessionId, - buildFoundationDraftAssistantMessage({ - relatedOperationId: operationId, - draftProfile: draftWithAssets.draftProfile, - warnings: draftWithAssets.warnings, - }), - ); - - await this.sessionStore.updateOperation(userId, sessionId, operationId, { - status: 'completed', - phaseLabel: '世界底稿已生成', - phaseDetail: - draftWithAssets.warnings.length > 0 - ? `第一版世界底稿和 ${draftCards.length} 张草稿卡已经整理完成,另有 ${draftWithAssets.warnings.length} 项资产补齐待后续处理。` - : `第一版世界底稿和 ${draftCards.length} 张草稿卡已经整理完成。`, - progress: 100, - error: null, - }); - } catch (error) { - const currentOperation = await this.sessionStore.getOperation( - userId, - sessionId, - operationId, - ); - await this.sessionStore.updateOperation(userId, sessionId, operationId, { - status: 'failed', - phaseLabel: - currentOperation?.phaseLabel?.trim() || '底稿生成失败', - phaseDetail: - currentOperation?.phaseDetail?.trim() || - '这一轮没有成功把设定编成世界底稿。', - progress: 100, - error: - error instanceof Error ? error.message : 'draft foundation failed', - }); - } + return snapshot; } - private async processUpdateDraftCardOperation(params: { - userId: string; - sessionId: string; - operationId: string; - payload: Extract< - CustomWorldAgentActionRequest, - { action: 'update_draft_card' } - >; - }) { - const { userId, sessionId, operationId, payload } = params; + /** + * 当前仍输出 legacy-compatible preview envelope,但正式把它接入 session snapshot 主链, + * 为后续结果页切换到服务端 preview 数据源提供稳定入口。 + */ + private buildResultPreview( + sessionRecord: CustomWorldAgentSessionRecord, + ): CustomWorldAgentSessionSnapshot['resultPreview'] { + const draftProfile = + sessionRecord.draftProfile && + typeof sessionRecord.draftProfile === 'object' && + !Array.isArray(sessionRecord.draftProfile) + ? (sessionRecord.draftProfile as Record) + : null; - try { - await this.sessionStore.updateOperation(userId, sessionId, operationId, { - status: 'running', - phaseLabel: '写回草稿设定', - phaseDetail: '正在把这次编辑内容写回当前世界底稿。', - progress: 34, - }); - - const latestSession = (await this.sessionStore.get( - userId, - sessionId, - )) as CustomWorldAgentSessionRecord | null; - if (!latestSession) { - throw new Error('custom world agent session not found'); - } - - const nextDraftProfile = updateDraftCardSections({ - draftProfile: (latestSession.draftProfile ?? {}) as Record< - string, - unknown - >, - cardId: payload.cardId, - sections: payload.sections, - }); - - await this.sessionStore.updateOperation(userId, sessionId, operationId, { - phaseLabel: '重编译草稿卡', - phaseDetail: '正在同步更新草稿摘要和详情内容。', - progress: 72, - }); - - const nextDraftCards = - this.draftCompiler.compileDraftCards(nextDraftProfile); - const assetCoverage = rebuildRoleAssetCoverage(nextDraftProfile); - const nextStage = - latestSession.stage === 'visual_refining' - ? ('visual_refining' as const) - : ('object_refining' as const); - const nextSuggestedActions = buildSuggestedActions({ - stage: nextStage, - isReady: true, - draftProfile: nextDraftProfile, - draftCards: nextDraftCards, - }); - const updatedDetail = this.draftCompiler.getDraftCardDetail( - nextDraftProfile, - payload.cardId, - ); - const changedSectionIds = new Set( - payload.sections - .map((section) => section.sectionId.trim()) - .filter(Boolean), - ); - - await this.sessionStore.replaceDerivedState(userId, sessionId, { - stage: nextStage, - draftProfile: nextDraftProfile, - draftCards: nextDraftCards, - assetCoverage, - focusCardId: payload.cardId, - suggestedActions: nextSuggestedActions, - recommendedReplies: [], - }); - await this.sessionStore.appendCheckpoint(userId, sessionId, { - label: `编辑 ${updatedDetail?.title || '草稿卡'}`, - }); - await this.sessionStore.appendMessage( - userId, - sessionId, - buildActionResultMessage({ - relatedOperationId: operationId, - text: this.changeSummaryService.buildSummary({ - action: 'update_draft_card', - cardId: payload.cardId, - changedLabels: - updatedDetail?.sections - .filter((section) => changedSectionIds.has(section.id)) - .map((section) => section.label) ?? [], - draftProfile: nextDraftProfile, - }), - }), - ); - - await this.sessionStore.updateOperation(userId, sessionId, operationId, { - status: 'completed', - phaseLabel: '草稿设定已保存', - phaseDetail: `「${updatedDetail?.title || '当前卡片'}」的设定已经同步更新。`, - progress: 100, - error: null, - }); - } catch (error) { - await this.sessionStore.updateOperation(userId, sessionId, operationId, { - status: 'failed', - phaseLabel: '保存失败', - phaseDetail: '这次草稿编辑没有成功写回到底稿。', - progress: 100, - error: - error instanceof Error ? error.message : 'update draft card failed', - }); + if (!draftProfile) { + return null; } - } - private async processGenerateCharactersOperation(params: { - userId: string; - sessionId: string; - operationId: string; - payload: Extract< - CustomWorldAgentActionRequest, - { action: 'generate_characters' } - >; - }) { - const { userId, sessionId, operationId, payload } = params; - - try { - await this.sessionStore.updateOperation(userId, sessionId, operationId, { - status: 'running', - phaseLabel: '生成新角色', - phaseDetail: '正在围绕当前世界底稿补出新角色。', - progress: 32, - }); - - const latestSession = (await this.sessionStore.get( - userId, - sessionId, - )) as CustomWorldAgentSessionRecord | null; - if (!latestSession) { - throw new Error('custom world agent session not found'); - } - - const generationResult = - await this.entityGenerationService.generateAdditionalCharacters({ - creatorIntent: latestSession.creatorIntent, - anchorPack: latestSession.anchorPack, - draftProfile: (latestSession.draftProfile ?? {}) as Record< - string, - unknown - >, - count: payload.count, - promptText: payload.promptText, - anchorCardIds: - payload.anchorCardIds && payload.anchorCardIds.length > 0 - ? payload.anchorCardIds - : latestSession.focusCardId - ? [latestSession.focusCardId] - : [getWorldFoundationCardId()], - }); - - await this.sessionStore.updateOperation(userId, sessionId, operationId, { - phaseLabel: '插入新角色卡', - phaseDetail: '正在把新角色插回草稿并刷新卡片列表。', - progress: 74, - }); - - const nextDraftCards = this.draftCompiler.compileDraftCards( - generationResult.draftProfile, - ); - const assetCoverage = rebuildRoleAssetCoverage( - generationResult.draftProfile, - ); - const nextStage = - latestSession.stage === 'visual_refining' - ? ('visual_refining' as const) - : ('object_refining' as const); - const nextSuggestedActions = buildSuggestedActions({ - stage: nextStage, - isReady: true, - draftProfile: generationResult.draftProfile, - draftCards: nextDraftCards, - }); - const focusCardId = generationResult.generatedCharacters[0]?.id ?? null; - - await this.sessionStore.replaceDerivedState(userId, sessionId, { - stage: nextStage, - draftProfile: generationResult.draftProfile, - draftCards: nextDraftCards, - assetCoverage, - focusCardId, - suggestedActions: nextSuggestedActions, - recommendedReplies: [], - }); - await this.sessionStore.appendCheckpoint(userId, sessionId, { - label: `新增角色 ${generationResult.generatedCharacters.length} 个`, - }); - await this.sessionStore.appendMessage( - userId, - sessionId, - buildActionResultMessage({ - relatedOperationId: operationId, - text: this.changeSummaryService.buildSummary({ - action: 'generate_characters', - names: generationResult.generatedCharacters.map( - (entry) => entry.name, - ), - draftProfile: generationResult.draftProfile, - }), - }), - ); - - await this.sessionStore.updateOperation(userId, sessionId, operationId, { - status: 'completed', - phaseLabel: '新角色已加入草稿', - phaseDetail: `已补出 ${generationResult.generatedCharacters.length} 个新角色。`, - progress: 100, - error: null, - }); - } catch (error) { - await this.sessionStore.updateOperation(userId, sessionId, operationId, { - status: 'failed', - phaseLabel: '角色生成失败', - phaseDetail: '这一轮没有成功补出新角色。', - progress: 100, - error: - error instanceof Error ? error.message : 'generate characters failed', - }); + if (!normalizeFoundationDraftProfile(draftProfile)) { + return null; } - } - - private async processGenerateLandmarksOperation(params: { - userId: string; - sessionId: string; - operationId: string; - payload: Extract< - CustomWorldAgentActionRequest, - { action: 'generate_landmarks' } - >; - }) { - const { userId, sessionId, operationId, payload } = params; try { - await this.sessionStore.updateOperation(userId, sessionId, operationId, { - status: 'running', - phaseLabel: '生成新地点', - phaseDetail: '正在围绕当前世界底稿补出新地点。', - progress: 32, + const publishGate = this.publishingService.summarizePublishGate({ + sessionId: sessionRecord.sessionId, + stage: sessionRecord.stage, + draftProfile, + qualityFindings: sessionRecord.qualityFindings, + }); + const previewProfile = buildRpgCreationPreviewProfileFromDraftProfile({ + sessionId: sessionRecord.sessionId, + draftProfile, + profileId: publishGate.profileId, }); - const latestSession = (await this.sessionStore.get( - userId, - sessionId, - )) as CustomWorldAgentSessionRecord | null; - if (!latestSession) { - throw new Error('custom world agent session not found'); - } - - const generationResult = - await this.entityGenerationService.generateAdditionalLandmarks({ - creatorIntent: latestSession.creatorIntent, - anchorPack: latestSession.anchorPack, - draftProfile: (latestSession.draftProfile ?? {}) as Record< - string, - unknown - >, - count: payload.count, - promptText: payload.promptText, - anchorCardIds: - payload.anchorCardIds && payload.anchorCardIds.length > 0 - ? payload.anchorCardIds - : latestSession.focusCardId - ? [latestSession.focusCardId] - : [getWorldFoundationCardId()], - }); - - await this.sessionStore.updateOperation(userId, sessionId, operationId, { - phaseLabel: '插入新地点卡', - phaseDetail: '正在把新地点插回草稿并刷新卡片列表。', - progress: 74, - }); - - const nextDraftCards = this.draftCompiler.compileDraftCards( - generationResult.draftProfile, - ); - const assetCoverage = rebuildRoleAssetCoverage( - generationResult.draftProfile, - ); - const nextStage = - latestSession.stage === 'visual_refining' - ? ('visual_refining' as const) - : ('object_refining' as const); - const nextSuggestedActions = buildSuggestedActions({ - stage: nextStage, - isReady: true, - draftProfile: generationResult.draftProfile, - draftCards: nextDraftCards, - }); - const focusCardId = generationResult.generatedLandmarks[0]?.id ?? null; - - await this.sessionStore.replaceDerivedState(userId, sessionId, { - stage: nextStage, - draftProfile: generationResult.draftProfile, - draftCards: nextDraftCards, - assetCoverage, - focusCardId, - suggestedActions: nextSuggestedActions, - recommendedReplies: [], - }); - await this.sessionStore.appendCheckpoint(userId, sessionId, { - label: `新增地点 ${generationResult.generatedLandmarks.length} 个`, - }); - await this.sessionStore.appendMessage( - userId, - sessionId, - buildActionResultMessage({ - relatedOperationId: operationId, - text: this.changeSummaryService.buildSummary({ - action: 'generate_landmarks', - names: generationResult.generatedLandmarks.map( - (entry) => entry.name, - ), - draftProfile: generationResult.draftProfile, - }), - }), - ); - - await this.sessionStore.updateOperation(userId, sessionId, operationId, { - status: 'completed', - phaseLabel: '新地点已加入草稿', - phaseDetail: `已补出 ${generationResult.generatedLandmarks.length} 个新地点。`, - progress: 100, - error: null, - }); - } catch (error) { - await this.sessionStore.updateOperation(userId, sessionId, operationId, { - status: 'failed', - phaseLabel: '地点生成失败', - phaseDetail: '这一轮没有成功补出新地点。', - progress: 100, - error: - error instanceof Error ? error.message : 'generate landmarks failed', - }); - } - } - - private async processGenerateRoleAssetsOperation(params: { - userId: string; - sessionId: string; - operationId: string; - payload: Extract< - CustomWorldAgentActionRequest, - { action: 'generate_role_assets' } - >; - }) { - const { userId, sessionId, operationId, payload } = params; - - try { - await this.sessionStore.updateOperation(userId, sessionId, operationId, { - status: 'running', - phaseLabel: '准备角色资产工坊', - phaseDetail: '正在校验角色并整理工坊上下文。', - progress: 40, - }); - - const latestSession = (await this.sessionStore.get( - userId, - sessionId, - )) as CustomWorldAgentSessionRecord | null; - if (!latestSession) { - throw new Error('custom world agent session not found'); - } - - const roleId = payload.roleIds[0]!; - const studioContext = this.assetBridgeService.buildRoleAssetStudioContext( - latestSession.draftProfile, - roleId, - ); - const nextStage = 'visual_refining' as const; - const nextSuggestedActions = buildSuggestedActions({ - stage: nextStage, - isReady: true, - draftProfile: latestSession.draftProfile, - draftCards: latestSession.draftCards, - }); - - await this.sessionStore.replaceDerivedState(userId, sessionId, { - stage: nextStage, - focusCardId: roleId, - suggestedActions: nextSuggestedActions, - recommendedReplies: [], - }); - await this.sessionStore.appendMessage( - userId, - sessionId, - buildActionResultMessage({ - relatedOperationId: operationId, - text: `已为「${studioContext.roleName}」准备好角色资产工坊,先生成主图候选,再补核心动作。`, - }), - ); - - await this.sessionStore.updateOperation(userId, sessionId, operationId, { - status: 'completed', - phaseLabel: '角色资产工坊已就绪', - phaseDetail: `「${studioContext.roleName}」现在可以开始生成主图和动作。`, - progress: 100, - error: null, - }); - } catch (error) { - await this.sessionStore.updateOperation(userId, sessionId, operationId, { - status: 'failed', - phaseLabel: '角色资产工坊准备失败', - phaseDetail: '这一轮没有成功进入角色资产工坊。', - progress: 100, - error: - error instanceof Error - ? error.message - : 'generate role assets failed', - }); - } - } - - private async processSyncRoleAssetsOperation(params: { - userId: string; - sessionId: string; - operationId: string; - payload: Extract< - CustomWorldAgentActionRequest, - { action: 'sync_role_assets' } - >; - }) { - const { userId, sessionId, operationId, payload } = params; - - try { - await this.sessionStore.updateOperation(userId, sessionId, operationId, { - status: 'running', - phaseLabel: '同步角色资产', - phaseDetail: '正在把主图与动作结果写回当前世界草稿。', - progress: 36, - }); - - const latestSession = (await this.sessionStore.get( - userId, - sessionId, - )) as CustomWorldAgentSessionRecord | null; - if (!latestSession) { - throw new Error('custom world agent session not found'); - } - - const syncResult = this.assetBridgeService.applyRoleAssetPublishResult( - latestSession.draftProfile, - payload, - ); - - await this.sessionStore.updateOperation(userId, sessionId, operationId, { - phaseLabel: '刷新角色卡摘要', - phaseDetail: '正在同步更新角色卡状态与资产覆盖。', - progress: 72, - }); - - const nextDraftCards = this.draftCompiler.compileDraftCards( - syncResult.draftProfile, - ); - const assetCoverage = rebuildRoleAssetCoverage(syncResult.draftProfile); - const nextSuggestedActions = buildSuggestedActions({ - stage: 'visual_refining', - isReady: true, - draftProfile: syncResult.draftProfile, - draftCards: nextDraftCards, - }); - - await this.sessionStore.replaceDerivedState(userId, sessionId, { - stage: 'visual_refining', - draftProfile: syncResult.draftProfile, - draftCards: nextDraftCards, - assetCoverage, - focusCardId: payload.roleId, - suggestedActions: nextSuggestedActions, - recommendedReplies: [], - }); - await this.sessionStore.appendCheckpoint(userId, sessionId, { - label: `同步角色资产 ${syncResult.updatedAssetSummary.roleName}`, - }); - await this.sessionStore.appendMessage( - userId, - sessionId, - buildActionResultMessage({ - relatedOperationId: operationId, - text: buildRoleAssetSyncResultText({ - roleName: syncResult.updatedAssetSummary.roleName, - assetStatusLabel: resolveRoleAssetStatusLabel( - syncResult.updatedAssetSummary.status, - ), - }), - }), - ); - - await this.sessionStore.updateOperation(userId, sessionId, operationId, { - status: 'completed', - phaseLabel: '角色资产已同步', - phaseDetail: `「${syncResult.updatedAssetSummary.roleName}」的资产状态已更新为${resolveRoleAssetStatusLabel(syncResult.updatedAssetSummary.status)}。`, - progress: 100, - error: null, - }); - } catch (error) { - await this.sessionStore.updateOperation(userId, sessionId, operationId, { - status: 'failed', - phaseLabel: '角色资产同步失败', - phaseDetail: '这一轮没有成功把角色资产写回草稿。', - progress: 100, - error: - error instanceof Error ? error.message : 'sync role assets failed', - }); + return { + ...buildRpgWorldPreviewEnvelope( + previewProfile, + String(previewProfile.settingText ?? ''), + ), + generatedAt: sessionRecord.updatedAt, + qualityFindings: sessionRecord.qualityFindings.map((finding) => ({ + id: finding.id, + severity: finding.severity, + code: finding.code, + targetId: finding.targetId ?? null, + message: finding.message, + })), + blockers: publishGate.blockers, + publishReady: publishGate.publishReady, + canEnterWorld: publishGate.canEnterWorld, + }; + } catch { + return null; } } diff --git a/server-node/src/services/customWorldAgentPhase2.test.ts b/server-node/src/services/customWorldAgentPhase2.test.ts index a083ceb4..f3087757 100644 --- a/server-node/src/services/customWorldAgentPhase2.test.ts +++ b/server-node/src/services/customWorldAgentPhase2.test.ts @@ -1,8 +1,6 @@ import assert from 'node:assert/strict'; import test from 'node:test'; -import type { CustomWorldSessionRecord } from '../../../packages/shared/src/contracts/runtime.js'; -import type { RuntimeRepositoryPort } from '../repositories/runtimeRepository.js'; import { buildPendingClarifications, evaluateCreatorIntentReadiness, @@ -12,89 +10,10 @@ import { mergeCreatorIntentRecord, } from './customWorldAgentIntentExtractionService.js'; import { CustomWorldAgentOrchestrator } from './customWorldAgentOrchestrator.js'; +import { createInMemoryRpgWorldRepositoryPorts } from './customWorldAgentRepositoryTestHelpers.js'; import { CustomWorldAgentSessionStore } from './customWorldAgentSessionStore.js'; import { createTestCustomWorldAgentSingleTurnLlmClient } from './customWorldAgentTestHelpers.js'; -import { listCustomWorldWorkSummaries } from './customWorldWorkSummaryService.js'; - -function createRuntimeRepositoryStub(): RuntimeRepositoryPort { - const sessionsByUser = new Map< - string, - Map - >(); - const profilesByUser = new Map[]>(); - - const getSessionBucket = (userId: string) => { - const existing = sessionsByUser.get(userId); - if (existing) { - return existing; - } - - const nextBucket = new Map(); - sessionsByUser.set(userId, nextBucket); - return nextBucket; - }; - - return { - async getSnapshot(_userId) { - return null; - }, - async putSnapshot(_userId, _payload) { - throw new Error('not implemented'); - }, - async deleteSnapshot(_userId) { - return undefined; - }, - async getSettings() { - return { - musicVolume: 0.42, - platformTheme: 'light', - }; - }, - async putSettings(_userId, settings) { - return settings; - }, - async listCustomWorldProfiles(userId) { - return [...(profilesByUser.get(userId) ?? [])]; - }, - async upsertCustomWorldProfile(userId, profileId, profile) { - const current = [...(profilesByUser.get(userId) ?? [])].filter( - (item) => String(item.id ?? '') !== profileId, - ); - current.unshift({ - ...profile, - id: profileId, - }); - profilesByUser.set(userId, current); - return current; - }, - async deleteCustomWorldProfile(userId, profileId) { - const current = [...(profilesByUser.get(userId) ?? [])].filter( - (item) => String(item.id ?? '') !== profileId, - ); - profilesByUser.set(userId, current); - return current; - }, - async listProfileSaveArchives() { - return []; - }, - async resumeProfileSaveArchive() { - return null; - }, - async listCustomWorldSessions(userId) { - return [...getSessionBucket(userId).values()]; - }, - async getCustomWorldSession(userId, sessionId) { - return getSessionBucket(userId).get(sessionId) ?? null; - }, - async upsertCustomWorldSession(userId, sessionId, session) { - getSessionBucket(userId).set( - sessionId, - JSON.parse(JSON.stringify(session)), - ); - return JSON.parse(JSON.stringify(session)); - }, - }; -} +import { RpgWorldWorkSummaryService } from './RpgWorldWorkSummaryService.js'; async function waitForOperation( orchestrator: CustomWorldAgentOrchestrator, @@ -184,8 +103,10 @@ test('phase2 clarification service only keeps the top highest leverage gap', () }); test('phase2 orchestrator advances session to foundation_review when minimal anchors are complete', async () => { - const runtimeRepository = createRuntimeRepositoryStub(); - const sessionStore = new CustomWorldAgentSessionStore(runtimeRepository); + const { rpgAgentSessionRepository } = createInMemoryRpgWorldRepositoryPorts(); + const sessionStore = new CustomWorldAgentSessionStore( + rpgAgentSessionRepository, + ); const orchestrator = new CustomWorldAgentOrchestrator(sessionStore, null, { singleTurnLlmClient: createTestCustomWorldAgentSingleTurnLlmClient(), }); @@ -265,8 +186,11 @@ test('phase2 orchestrator advances session to foundation_review when minimal anc }); test('phase2 work summaries compile draft title and summary from creator intent', async () => { - const runtimeRepository = createRuntimeRepositoryStub(); - const sessionStore = new CustomWorldAgentSessionStore(runtimeRepository); + const { rpgAgentSessionRepository, rpgWorldProfileRepository } = + createInMemoryRpgWorldRepositoryPorts(); + const sessionStore = new CustomWorldAgentSessionStore( + rpgAgentSessionRepository, + ); const orchestrator = new CustomWorldAgentOrchestrator(sessionStore, null, { singleTurnLlmClient: createTestCustomWorldAgentSingleTurnLlmClient(), }); @@ -293,10 +217,10 @@ test('phase2 work summaries compile draft title and summary from creator intent' update.operation.operationId, ); - const items = await listCustomWorldWorkSummaries(userId, { - runtimeRepository, - customWorldAgentSessions: sessionStore, - }); + const items = await new RpgWorldWorkSummaryService( + rpgWorldProfileRepository, + sessionStore, + ).list(userId); const draft = items.find( (item) => item.sessionId === createdSession.sessionId, ); diff --git a/server-node/src/services/customWorldAgentPhase3.test.ts b/server-node/src/services/customWorldAgentPhase3.test.ts index 00ac0328..e572f998 100644 --- a/server-node/src/services/customWorldAgentPhase3.test.ts +++ b/server-node/src/services/customWorldAgentPhase3.test.ts @@ -4,95 +4,14 @@ import os from 'node:os'; import path from 'node:path'; import test from 'node:test'; -import type { CustomWorldSessionRecord } from '../../../packages/shared/src/contracts/runtime.js'; import type { AppConfig } from '../config.js'; -import type { RuntimeRepositoryPort } from '../repositories/runtimeRepository.js'; import { CustomWorldAgentAutoAssetService } from './customWorldAgentAutoAssetService.js'; import { normalizeFoundationDraftProfile } from './customWorldAgentDraftCompiler.js'; import { CustomWorldAgentOrchestrator } from './customWorldAgentOrchestrator.js'; +import { createInMemoryRpgWorldRepositoryPorts } from './customWorldAgentRepositoryTestHelpers.js'; import { CustomWorldAgentSessionStore } from './customWorldAgentSessionStore.js'; import { createTestCustomWorldAgentSingleTurnLlmClient } from './customWorldAgentTestHelpers.js'; -import { listCustomWorldWorkSummaries } from './customWorldWorkSummaryService.js'; - -function createRuntimeRepositoryStub(): RuntimeRepositoryPort { - const sessionsByUser = new Map< - string, - Map - >(); - const profilesByUser = new Map[]>(); - - const getSessionBucket = (userId: string) => { - const existing = sessionsByUser.get(userId); - if (existing) { - return existing; - } - - const nextBucket = new Map(); - sessionsByUser.set(userId, nextBucket); - return nextBucket; - }; - - return { - async getSnapshot(_userId) { - return null; - }, - async putSnapshot(_userId, _payload) { - throw new Error('not implemented'); - }, - async deleteSnapshot(_userId) { - return undefined; - }, - async getSettings() { - return { - musicVolume: 0.42, - platformTheme: 'light', - }; - }, - async putSettings(_userId, settings) { - return settings; - }, - async listCustomWorldProfiles(userId) { - return [...(profilesByUser.get(userId) ?? [])]; - }, - async upsertCustomWorldProfile(userId, profileId, profile) { - const current = [...(profilesByUser.get(userId) ?? [])].filter( - (item) => String(item.id ?? '') !== profileId, - ); - current.unshift({ - ...profile, - id: profileId, - }); - profilesByUser.set(userId, current); - return current; - }, - async deleteCustomWorldProfile(userId, profileId) { - const current = [...(profilesByUser.get(userId) ?? [])].filter( - (item) => String(item.id ?? '') !== profileId, - ); - profilesByUser.set(userId, current); - return current; - }, - async listProfileSaveArchives() { - return []; - }, - async resumeProfileSaveArchive() { - return null; - }, - async listCustomWorldSessions(userId) { - return [...getSessionBucket(userId).values()]; - }, - async getCustomWorldSession(userId, sessionId) { - return getSessionBucket(userId).get(sessionId) ?? null; - }, - async upsertCustomWorldSession(userId, sessionId, session) { - getSessionBucket(userId).set( - sessionId, - JSON.parse(JSON.stringify(session)), - ); - return JSON.parse(JSON.stringify(session)); - }, - }; -} +import { RpgWorldWorkSummaryService } from './RpgWorldWorkSummaryService.js'; function createAutoAssetTestConfig(testName: string): AppConfig { const projectRoot = fs.mkdtempSync( @@ -172,6 +91,11 @@ function createAutoAssetTestConfig(testName: string): AppConfig { mockAvatarUrl: '', }, authSession: { + accessCookieName: 'genarrative_access_session', + accessCookieTtlSeconds: 7200, + accessCookieSecure: false, + accessCookieSameSite: 'Lax', + accessCookiePath: '/', refreshCookieName: 'refresh_token', refreshSessionTtlDays: 30, refreshCookieSecure: false, @@ -254,13 +178,28 @@ async function createReadySession( assert.equal(readySession?.stage, 'foundation_review'); assert.equal(readySession?.creatorIntentReadiness.isReady, true); + assert.equal(readySession?.resultPreview, null); + assert.equal( + readySession?.supportedActions?.find( + (entry) => entry.action === 'draft_foundation', + )?.enabled, + true, + ); + assert.equal( + readySession?.supportedActions?.find( + (entry) => entry.action === 'sync_result_profile', + )?.enabled, + false, + ); return readySession!; } test('phase3 ready session can execute draft_foundation and expose card detail', async () => { - const runtimeRepository = createRuntimeRepositoryStub(); - const sessionStore = new CustomWorldAgentSessionStore(runtimeRepository); + const { rpgAgentSessionRepository } = createInMemoryRpgWorldRepositoryPorts(); + const sessionStore = new CustomWorldAgentSessionStore( + rpgAgentSessionRepository, + ); const orchestrator = new CustomWorldAgentOrchestrator(sessionStore, null, { singleTurnLlmClient: createTestCustomWorldAgentSingleTurnLlmClient(), autoAssetService: createFallbackAutoAssetService('draft'), @@ -296,6 +235,21 @@ test('phase3 ready session can execute draft_foundation and expose card detail', assert.equal(operation?.status, 'completed'); assert.equal(snapshot?.stage, 'object_refining'); assert.ok(snapshot?.draftCards.length); + assert.equal(snapshot?.resultPreview?.source, 'session_preview'); + assert.equal( + snapshot?.resultPreview?.preview.name, + typeof (snapshot?.draftProfile as Record)?.name === 'string' + ? ((snapshot?.draftProfile as Record).name as string) + : '未命名世界底稿', + ); + assert.ok(Array.isArray(snapshot?.resultPreview?.blockers)); + assert.ok((snapshot?.resultPreview?.blockers?.length ?? 0) >= 0); + assert.equal(snapshot?.resultPreview?.publishReady, false); + assert.equal(snapshot?.resultPreview?.canEnterWorld, false); + assert.equal( + snapshot?.resultPreview?.qualityFindings?.length, + snapshot?.qualityFindings.length, + ); assert.ok(snapshot?.draftCards.some((card) => card.kind === 'world')); assert.ok(snapshot?.draftCards.some((card) => card.kind === 'faction')); assert.ok(snapshot?.draftCards.some((card) => card.kind === 'character')); @@ -330,6 +284,24 @@ test('phase3 ready session can execute draft_foundation and expose card detail', message.text.includes('第一版世界底稿整理出来了'), ), ); + assert.equal( + snapshot?.supportedActions?.find( + (entry) => entry.action === 'update_draft_card', + )?.enabled, + true, + ); + assert.equal( + snapshot?.supportedActions?.find( + (entry) => entry.action === 'generate_role_assets', + )?.enabled, + true, + ); + assert.equal( + snapshot?.supportedActions?.find( + (entry) => entry.action === 'publish_world', + )?.enabled, + true, + ); const worldCard = snapshot?.draftCards.find((card) => card.kind === 'world'); assert.ok(worldCard); @@ -347,8 +319,10 @@ test('phase3 ready session can execute draft_foundation and expose card detail', }); test('phase3 draft_foundation rejects not-ready session', async () => { - const runtimeRepository = createRuntimeRepositoryStub(); - const sessionStore = new CustomWorldAgentSessionStore(runtimeRepository); + const { rpgAgentSessionRepository } = createInMemoryRpgWorldRepositoryPorts(); + const sessionStore = new CustomWorldAgentSessionStore( + rpgAgentSessionRepository, + ); const orchestrator = new CustomWorldAgentOrchestrator(sessionStore, null, { singleTurnLlmClient: createTestCustomWorldAgentSingleTurnLlmClient(), autoAssetService: createFallbackAutoAssetService('not-ready'), @@ -368,8 +342,11 @@ test('phase3 draft_foundation rejects not-ready session', async () => { }); test('phase3 work summaries prefer compiled foundation draft fields', async () => { - const runtimeRepository = createRuntimeRepositoryStub(); - const sessionStore = new CustomWorldAgentSessionStore(runtimeRepository); + const { rpgAgentSessionRepository, rpgWorldProfileRepository } = + createInMemoryRpgWorldRepositoryPorts(); + const sessionStore = new CustomWorldAgentSessionStore( + rpgAgentSessionRepository, + ); const orchestrator = new CustomWorldAgentOrchestrator(sessionStore, null, { singleTurnLlmClient: createTestCustomWorldAgentSingleTurnLlmClient(), autoAssetService: createFallbackAutoAssetService('summary'), @@ -391,10 +368,10 @@ test('phase3 work summaries prefer compiled foundation draft fields', async () = response.operation.operationId, ); - const items = await listCustomWorldWorkSummaries(userId, { - runtimeRepository, - customWorldAgentSessions: sessionStore, - }); + const items = await new RpgWorldWorkSummaryService( + rpgWorldProfileRepository, + sessionStore, + ).list(userId); const draft = items.find((item) => item.sessionId === readySession.sessionId); const compiledProfile = normalizeFoundationDraftProfile( ( @@ -418,8 +395,10 @@ test('phase3 work summaries prefer compiled foundation draft fields', async () = }); test('phase3 draft foundation still completes when auto asset generation fails', async () => { - const runtimeRepository = createRuntimeRepositoryStub(); - const sessionStore = new CustomWorldAgentSessionStore(runtimeRepository); + const { rpgAgentSessionRepository } = createInMemoryRpgWorldRepositoryPorts(); + const sessionStore = new CustomWorldAgentSessionStore( + rpgAgentSessionRepository, + ); const autoAssetService = new CustomWorldAgentAutoAssetService( createAutoAssetTestConfig('asset-failure'), async () => { diff --git a/server-node/src/services/customWorldAgentPhase4.test.ts b/server-node/src/services/customWorldAgentPhase4.test.ts index b5e347f0..b2b1453c 100644 --- a/server-node/src/services/customWorldAgentPhase4.test.ts +++ b/server-node/src/services/customWorldAgentPhase4.test.ts @@ -1,93 +1,12 @@ import assert from 'node:assert/strict'; import test from 'node:test'; -import type { CustomWorldSessionRecord } from '../../../packages/shared/src/contracts/runtime.js'; -import type { RuntimeRepositoryPort } from '../repositories/runtimeRepository.js'; import { normalizeFoundationDraftProfile } from './customWorldAgentDraftCompiler.js'; import { CustomWorldAgentOrchestrator } from './customWorldAgentOrchestrator.js'; +import { createInMemoryRpgWorldRepositoryPorts } from './customWorldAgentRepositoryTestHelpers.js'; import { CustomWorldAgentSessionStore } from './customWorldAgentSessionStore.js'; import { createTestCustomWorldAgentSingleTurnLlmClient } from './customWorldAgentTestHelpers.js'; -import { listCustomWorldWorkSummaries } from './customWorldWorkSummaryService.js'; - -function createRuntimeRepositoryStub(): RuntimeRepositoryPort { - const sessionsByUser = new Map< - string, - Map - >(); - const profilesByUser = new Map[]>(); - - const getSessionBucket = (userId: string) => { - const existing = sessionsByUser.get(userId); - if (existing) { - return existing; - } - - const nextBucket = new Map(); - sessionsByUser.set(userId, nextBucket); - return nextBucket; - }; - - return { - async getSnapshot(_userId) { - return null; - }, - async putSnapshot(_userId, _payload) { - throw new Error('not implemented'); - }, - async deleteSnapshot(_userId) { - return undefined; - }, - async getSettings() { - return { - musicVolume: 0.42, - platformTheme: 'light', - }; - }, - async putSettings(_userId, settings) { - return settings; - }, - async listCustomWorldProfiles(userId) { - return [...(profilesByUser.get(userId) ?? [])]; - }, - async upsertCustomWorldProfile(userId, profileId, profile) { - const current = [...(profilesByUser.get(userId) ?? [])].filter( - (item) => String(item.id ?? '') !== profileId, - ); - current.unshift({ - ...profile, - id: profileId, - }); - profilesByUser.set(userId, current); - return current; - }, - async deleteCustomWorldProfile(userId, profileId) { - const current = [...(profilesByUser.get(userId) ?? [])].filter( - (item) => String(item.id ?? '') !== profileId, - ); - profilesByUser.set(userId, current); - return current; - }, - async listProfileSaveArchives() { - return []; - }, - async resumeProfileSaveArchive() { - return null; - }, - async listCustomWorldSessions(userId) { - return [...getSessionBucket(userId).values()]; - }, - async getCustomWorldSession(userId, sessionId) { - return getSessionBucket(userId).get(sessionId) ?? null; - }, - async upsertCustomWorldSession(userId, sessionId, session) { - getSessionBucket(userId).set( - sessionId, - JSON.parse(JSON.stringify(session)), - ); - return JSON.parse(JSON.stringify(session)); - }, - }; -} +import { RpgWorldWorkSummaryService } from './RpgWorldWorkSummaryService.js'; async function waitForOperation( orchestrator: CustomWorldAgentOrchestrator, @@ -167,8 +86,10 @@ async function createObjectRefiningSession( } test('phase4 update_draft_card writes back draft profile and recompiles summaries', async () => { - const runtimeRepository = createRuntimeRepositoryStub(); - const sessionStore = new CustomWorldAgentSessionStore(runtimeRepository); + const { rpgAgentSessionRepository } = createInMemoryRpgWorldRepositoryPorts(); + const sessionStore = new CustomWorldAgentSessionStore( + rpgAgentSessionRepository, + ); const orchestrator = new CustomWorldAgentOrchestrator(sessionStore, null, { singleTurnLlmClient: createTestCustomWorldAgentSingleTurnLlmClient(), }); @@ -227,9 +148,377 @@ test('phase4 update_draft_card writes back draft profile and recompiles summarie ); }); +test('phase4 sync_result_profile writes result-page snapshot back into session draft chain', async () => { + const { rpgAgentSessionRepository } = createInMemoryRpgWorldRepositoryPorts(); + const sessionStore = new CustomWorldAgentSessionStore( + rpgAgentSessionRepository, + ); + const orchestrator = new CustomWorldAgentOrchestrator(sessionStore, null, { + singleTurnLlmClient: createTestCustomWorldAgentSingleTurnLlmClient(), + }); + const userId = 'user-phase4-sync-result-profile'; + const session = await createObjectRefiningSession(orchestrator, userId); + + const response = await orchestrator.executeAction(userId, session.sessionId, { + action: 'sync_result_profile', + profile: { + id: `agent-draft-${session.sessionId}`, + settingText: '被海雾吞没的旧航路群岛', + name: '潮雾列岛·结果页精修版', + subtitle: '旧灯塔与失控航路', + summary: '结果页已经把世界概述继续往沉船夜暗线收紧。', + tone: '压抑、潮湿、悬疑', + playerGoal: '查清沉船夜与假航灯的真正操盘者。', + templateWorldType: 'WUXIA', + majorFactions: ['守灯会', '航运公会'], + coreConflicts: ['守灯会与航运公会争夺旧航路控制权'], + attributeSchema: { + id: 'schema:test', + worldId: 'CUSTOM', + schemaVersion: 1, + schemaName: '测试', + generatedFrom: { + worldType: 'CUSTOM', + worldName: '潮雾列岛·结果页精修版', + settingSummary: '测试', + tone: '测试', + conflictCore: '测试', + }, + slots: [], + }, + playableNpcs: [], + storyNpcs: [], + items: [], + landmarks: [], + generationMode: 'full', + generationStatus: 'complete', + }, + }); + const operation = await waitForOperation( + orchestrator, + userId, + session.sessionId, + response.operation.operationId, + ); + const snapshot = await orchestrator.getSessionSnapshot(userId, session.sessionId); + const profile = normalizeFoundationDraftProfile(snapshot?.draftProfile); + const draftRecord = snapshot?.draftProfile as Record | null; + const legacyResultProfile = draftRecord?.legacyResultProfile as + | Record + | undefined; + + assert.equal(operation?.status, 'completed'); + assert.equal(profile?.name, '潮雾列岛·结果页精修版'); + assert.equal( + profile?.summary, + '结果页已经把世界概述继续往沉船夜暗线收紧。', + ); + assert.equal(snapshot?.resultPreview?.source, 'session_preview'); + assert.equal( + snapshot?.resultPreview?.preview.name, + '潮雾列岛·结果页精修版', + ); + assert.equal( + snapshot?.resultPreview?.preview.playerGoal, + '查清沉船夜与假航灯的真正操盘者。', + ); + assert.equal(legacyResultProfile?.name, '潮雾列岛·结果页精修版'); + assert.equal( + legacyResultProfile?.playerGoal, + '查清沉船夜与假航灯的真正操盘者。', + ); + assert.ok( + snapshot?.messages.some( + (message) => + message.kind === 'action_result' && + message.text.includes('结果页里的最新世界结构已经同步回当前草稿'), + ), + ); +}); + +test('phase4 sync_result_profile keeps existing foundation structure while updating summary snapshot', async () => { + const { rpgAgentSessionRepository } = createInMemoryRpgWorldRepositoryPorts(); + const sessionStore = new CustomWorldAgentSessionStore( + rpgAgentSessionRepository, + ); + const orchestrator = new CustomWorldAgentOrchestrator(sessionStore, null, { + singleTurnLlmClient: createTestCustomWorldAgentSingleTurnLlmClient(), + }); + const userId = 'user-phase4-sync-result-profile-structure'; + const session = await createObjectRefiningSession(orchestrator, userId); + const baselineProfile = normalizeFoundationDraftProfile(session.draftProfile); + const baselinePlayableName = baselineProfile?.playableNpcs[0]?.name; + const baselineStoryName = baselineProfile?.storyNpcs[0]?.name; + const baselineLandmarkName = baselineProfile?.landmarks[0]?.name; + + assert.ok(baselinePlayableName); + assert.ok(baselineStoryName); + assert.ok(baselineLandmarkName); + + const response = await orchestrator.executeAction(userId, session.sessionId, { + action: 'sync_result_profile', + profile: { + id: `agent-draft-${session.sessionId}`, + settingText: '被海雾吞没的旧航路群岛', + name: '潮雾列岛·结果页精修版', + subtitle: '旧灯塔与失控航路', + summary: '结果页已经把世界概述继续往沉船夜暗线收紧。', + tone: '压抑、潮湿、悬疑', + playerGoal: '查清沉船夜与假航灯的真正操盘者。', + templateWorldType: 'WUXIA', + majorFactions: ['守灯会', '航运公会'], + coreConflicts: ['守灯会与航运公会争夺旧航路控制权'], + attributeSchema: { + id: 'schema:test', + worldId: 'CUSTOM', + schemaVersion: 1, + schemaName: '测试', + generatedFrom: { + worldType: 'CUSTOM', + worldName: '潮雾列岛·结果页精修版', + settingSummary: '测试', + tone: '测试', + conflictCore: '测试', + }, + slots: [], + }, + playableNpcs: [ + { + id: 'playable-runtime-only', + name: '结果页临时角色', + title: '运行时角色', + role: '测试角色', + description: '不应该直接覆盖 foundation draft。', + backstory: '仅用于验证 sync 边界。', + personality: '谨慎', + motivation: '验证同步边界', + combatStyle: '观察', + initialAffinity: 0, + relationshipHooks: [], + tags: [], + }, + ], + storyNpcs: [ + { + id: 'story-runtime-only', + name: '结果页临时场景角色', + title: '运行时场景角色', + role: '测试角色', + description: '不应该直接覆盖 foundation draft。', + backstory: '仅用于验证 sync 边界。', + personality: '克制', + motivation: '验证同步边界', + combatStyle: '观察', + initialAffinity: 0, + relationshipHooks: [], + tags: [], + }, + ], + items: [], + landmarks: [ + { + id: 'landmark-runtime-only', + name: '结果页临时地点', + description: '不应该直接覆盖 foundation draft。', + dangerLevel: '低', + sceneNpcIds: [], + connections: [], + }, + ], + generationMode: 'full', + generationStatus: 'complete', + }, + }); + const operation = await waitForOperation( + orchestrator, + userId, + session.sessionId, + response.operation.operationId, + ); + const snapshot = await orchestrator.getSessionSnapshot(userId, session.sessionId); + const profile = normalizeFoundationDraftProfile(snapshot?.draftProfile); + const draftRecord = snapshot?.draftProfile as Record | null; + const legacyResultProfile = draftRecord?.legacyResultProfile as + | Record + | undefined; + + assert.equal(operation?.status, 'completed'); + assert.equal(profile?.name, '潮雾列岛·结果页精修版'); + assert.equal(profile?.playableNpcs[0]?.name, baselinePlayableName); + assert.equal(profile?.storyNpcs[0]?.name, baselineStoryName); + assert.equal(profile?.landmarks[0]?.name, baselineLandmarkName); + assert.equal(legacyResultProfile?.name, '潮雾列岛·结果页精修版'); + assert.equal( + (legacyResultProfile?.playableNpcs as Array<{ name?: string }> | undefined)?.[0] + ?.name, + '结果页临时角色', + ); +}); + +test('phase4 sync_result_profile also writes latest role and scene assets back into draft profile', async () => { + const { rpgAgentSessionRepository } = createInMemoryRpgWorldRepositoryPorts(); + const sessionStore = new CustomWorldAgentSessionStore( + rpgAgentSessionRepository, + ); + const orchestrator = new CustomWorldAgentOrchestrator(sessionStore, null, { + singleTurnLlmClient: createTestCustomWorldAgentSingleTurnLlmClient(), + }); + const userId = 'user-phase4-sync-result-profile-assets'; + const session = await createObjectRefiningSession(orchestrator, userId); + const baselineProfile = normalizeFoundationDraftProfile(session.draftProfile)!; + const playableRole = baselineProfile.playableNpcs[0]!; + const storyRole = baselineProfile.storyNpcs[0]!; + const landmark = baselineProfile.landmarks[0]!; + + const response = await orchestrator.executeAction(userId, session.sessionId, { + action: 'sync_result_profile', + profile: { + id: `agent-draft-${session.sessionId}`, + settingText: '被海雾吞没的旧航路群岛', + name: '潮雾列岛·结果页精修版', + subtitle: '旧灯塔与失控航路', + summary: '结果页已经把最新图与动作一起确认。 ', + tone: '压抑、潮湿、悬疑', + playerGoal: '查清沉船夜与假航灯的真正操盘者。', + templateWorldType: 'WUXIA', + majorFactions: ['守灯会', '航运公会'], + coreConflicts: ['守灯会与航运公会争夺旧航路控制权'], + attributeSchema: { + id: 'schema:test', + worldId: 'CUSTOM', + schemaVersion: 1, + schemaName: '测试', + generatedFrom: { + worldType: 'CUSTOM', + worldName: '潮雾列岛·结果页精修版', + settingSummary: '测试', + tone: '测试', + conflictCore: '测试', + }, + slots: [], + }, + playableNpcs: [ + { + id: playableRole.id, + name: playableRole.name, + title: '结果页角色', + role: '关键同行者', + description: '结果页确认的最新角色资产。', + backstory: '测试', + personality: '冷静', + motivation: '验证资产回写', + combatStyle: '观察', + initialAffinity: 12, + relationshipHooks: [], + tags: [], + imageSrc: '/generated/playable/latest-master.png', + generatedVisualAssetId: 'visual-playable-latest', + generatedAnimationSetId: 'anim-playable-latest', + animationMap: { + idle: { + spriteSheetPath: '/generated/playable/idle.png', + }, + }, + }, + ], + storyNpcs: [ + { + id: storyRole.id, + name: storyRole.name, + title: '结果页场景角色', + role: '场景关键角色', + description: '结果页确认的最新场景角色资产。', + backstory: '测试', + personality: '克制', + motivation: '验证资产回写', + combatStyle: '观察', + initialAffinity: 6, + relationshipHooks: [], + tags: [], + imageSrc: '/generated/story/latest-master.png', + generatedVisualAssetId: 'visual-story-latest', + }, + ], + items: [], + landmarks: [ + { + id: landmark.id, + name: landmark.name, + description: '结果页确认的最新地点图。', + dangerLevel: '中', + sceneNpcIds: [], + connections: [], + imageSrc: '/generated/landmark/latest-scene.png', + }, + ], + sceneChapterBlueprints: [ + { + id: 'scene-chapter-1', + sceneId: landmark.id, + title: '灯塔初章', + summary: '结果页确认最新分幕图。', + linkedThreadIds: [], + linkedLandmarkIds: [landmark.id], + acts: [ + { + id: `${landmark.id}-act-1`, + sceneId: landmark.id, + title: '第一幕', + summary: '第一幕', + stageCoverage: ['opening'], + backgroundImageSrc: '/generated/scene/act-1-latest.png', + backgroundAssetId: 'scene-asset-latest', + encounterNpcIds: [], + primaryNpcId: '', + linkedThreadIds: [], + advanceRule: 'after_primary_contact', + actGoal: '验证分幕图回写', + transitionHook: '进入下一幕', + }, + ], + }, + ], + generationMode: 'full', + generationStatus: 'complete', + }, + }); + const operation = await waitForOperation( + orchestrator, + userId, + session.sessionId, + response.operation.operationId, + ); + const snapshot = await orchestrator.getSessionSnapshot(userId, session.sessionId); + const profile = normalizeFoundationDraftProfile(snapshot?.draftProfile)!; + const syncedPlayable = profile.playableNpcs.find( + (entry) => entry.id === playableRole.id, + ); + const syncedStory = profile.storyNpcs.find((entry) => entry.id === storyRole.id); + const syncedLandmark = profile.landmarks.find((entry) => entry.id === landmark.id); + const syncedSceneAct = profile.sceneChapters[0]?.acts[0]; + + assert.equal(operation?.status, 'completed'); + assert.equal(syncedPlayable?.imageSrc, '/generated/playable/latest-master.png'); + assert.equal(syncedPlayable?.generatedVisualAssetId, 'visual-playable-latest'); + assert.equal(syncedPlayable?.generatedAnimationSetId, 'anim-playable-latest'); + assert.deepEqual(syncedPlayable?.animationMap, { + idle: { + spriteSheetPath: '/generated/playable/idle.png', + }, + }); + assert.equal(syncedStory?.imageSrc, '/generated/story/latest-master.png'); + assert.equal(syncedStory?.generatedVisualAssetId, 'visual-story-latest'); + assert.equal(syncedLandmark?.imageSrc, '/generated/landmark/latest-scene.png'); + assert.equal(syncedSceneAct?.backgroundImageSrc, '/generated/scene/act-1-latest.png'); + assert.equal(syncedSceneAct?.backgroundAssetId, 'scene-asset-latest'); +}); + test('phase4 generate_characters appends story npcs and updates work summary counts', async () => { - const runtimeRepository = createRuntimeRepositoryStub(); - const sessionStore = new CustomWorldAgentSessionStore(runtimeRepository); + const { rpgAgentSessionRepository, rpgWorldProfileRepository } = + createInMemoryRpgWorldRepositoryPorts(); + const sessionStore = new CustomWorldAgentSessionStore( + rpgAgentSessionRepository, + ); const orchestrator = new CustomWorldAgentOrchestrator(sessionStore, null, { singleTurnLlmClient: createTestCustomWorldAgentSingleTurnLlmClient(), }); @@ -263,10 +552,10 @@ test('phase4 generate_characters appends story npcs and updates work summary cou [...profile.playableNpcs, ...profile.storyNpcs].map((entry) => entry.id), ), ].length; - const workItems = await listCustomWorldWorkSummaries(userId, { - runtimeRepository, - customWorldAgentSessions: sessionStore, - }); + const workItems = await new RpgWorldWorkSummaryService( + rpgWorldProfileRepository, + sessionStore, + ).list(userId); const draftItem = workItems.find((item) => item.sessionId === session.sessionId); assert.equal(operation?.status, 'completed'); @@ -284,8 +573,10 @@ test('phase4 generate_characters appends story npcs and updates work summary cou }); test('phase4 generate_landmarks appends new landmark cards and checkpoints', async () => { - const runtimeRepository = createRuntimeRepositoryStub(); - const sessionStore = new CustomWorldAgentSessionStore(runtimeRepository); + const { rpgAgentSessionRepository } = createInMemoryRpgWorldRepositoryPorts(); + const sessionStore = new CustomWorldAgentSessionStore( + rpgAgentSessionRepository, + ); const orchestrator = new CustomWorldAgentOrchestrator(sessionStore, null, { singleTurnLlmClient: createTestCustomWorldAgentSingleTurnLlmClient(), }); @@ -323,3 +614,92 @@ test('phase4 generate_landmarks appends new landmark cards and checkpoints', asy ); assert.ok((latestSessionRecord?.checkpoints.length ?? 0) >= 2); }); + +test('phase4 work summaries exclude library draft entries after phase3 downgrade', async () => { + const { rpgAgentSessionRepository, rpgWorldProfileRepository } = + createInMemoryRpgWorldRepositoryPorts(); + const sessionStore = new CustomWorldAgentSessionStore( + rpgAgentSessionRepository, + ); + const orchestrator = new CustomWorldAgentOrchestrator(sessionStore, null, { + singleTurnLlmClient: createTestCustomWorldAgentSingleTurnLlmClient(), + }); + const userId = 'user-phase4-work-summary-phase3'; + const session = await createObjectRefiningSession(orchestrator, userId); + + await rpgWorldProfileRepository.upsertOwnProfile( + userId, + 'library-draft-1', + { + id: 'library-draft-1', + name: '旧兼容草稿', + subtitle: '仍保留在作品库', + summary: '不应该继续出现在创作中心 works 聚合里。', + playableNpcs: [], + landmarks: [], + }, + '玩家', + ); + + const workItems = await new RpgWorldWorkSummaryService( + rpgWorldProfileRepository, + sessionStore, + ).list(userId); + + assert.ok(workItems.some((item) => item.sessionId === session.sessionId)); + assert.equal( + workItems.some((item) => item.profileId === 'library-draft-1'), + false, + ); +}); + +test('phase4 work summaries hide published agent sessions from draft lane and keep published entry enterable', async () => { + const { rpgAgentSessionRepository, rpgWorldProfileRepository } = + createInMemoryRpgWorldRepositoryPorts(); + const sessionStore = new CustomWorldAgentSessionStore( + rpgAgentSessionRepository, + ); + const orchestrator = new CustomWorldAgentOrchestrator(sessionStore, null, { + singleTurnLlmClient: createTestCustomWorldAgentSingleTurnLlmClient(), + }); + const userId = 'user-phase4-work-summary-published'; + const session = await createObjectRefiningSession(orchestrator, userId); + + await sessionStore.replaceDerivedState(userId, session.sessionId, { + stage: 'published', + qualityFindings: [], + }); + await rpgWorldProfileRepository.upsertOwnProfile( + userId, + `agent-draft-${session.sessionId}`, + { + id: `agent-draft-${session.sessionId}`, + name: '潮雾列岛', + subtitle: '旧灯塔与失控航路', + summary: '已发布版本。', + playableNpcs: [], + landmarks: [], + }, + '玩家', + ); + await rpgWorldProfileRepository.publishOwnProfile( + userId, + `agent-draft-${session.sessionId}`, + '玩家', + ); + + const workItems = await new RpgWorldWorkSummaryService( + rpgWorldProfileRepository, + sessionStore, + ).list(userId); + const draftItem = workItems.find((item) => item.sessionId === session.sessionId); + const publishedItem = workItems.find( + (item) => item.profileId === `agent-draft-${session.sessionId}`, + ); + + assert.equal(draftItem, undefined); + assert.equal(publishedItem?.status, 'published'); + assert.equal(publishedItem?.canEnterWorld, true); + assert.equal(publishedItem?.publishReady, true); + assert.equal(publishedItem?.blockerCount, 0); +}); diff --git a/server-node/src/services/customWorldAgentPhase5.test.ts b/server-node/src/services/customWorldAgentPhase5.test.ts index 49a1adc5..8ad74ed2 100644 --- a/server-node/src/services/customWorldAgentPhase5.test.ts +++ b/server-node/src/services/customWorldAgentPhase5.test.ts @@ -4,95 +4,15 @@ import os from 'node:os'; import path from 'node:path'; import test from 'node:test'; -import type { CustomWorldSessionRecord } from '../../../packages/shared/src/contracts/runtime.js'; import type { AppConfig } from '../config.js'; -import type { RuntimeRepositoryPort } from '../repositories/runtimeRepository.js'; +import type { UserRepositoryPort } from '../repositories/userRepository.js'; import { CustomWorldAgentAutoAssetService } from './customWorldAgentAutoAssetService.js'; import { normalizeFoundationDraftProfile } from './customWorldAgentDraftCompiler.js'; import { CustomWorldAgentOrchestrator } from './customWorldAgentOrchestrator.js'; +import { createInMemoryRpgWorldRepositoryPorts } from './customWorldAgentRepositoryTestHelpers.js'; import { CustomWorldAgentSessionStore } from './customWorldAgentSessionStore.js'; import { createTestCustomWorldAgentSingleTurnLlmClient } from './customWorldAgentTestHelpers.js'; -function createRuntimeRepositoryStub(): RuntimeRepositoryPort { - const sessionsByUser = new Map< - string, - Map - >(); - const profilesByUser = new Map[]>(); - - const getSessionBucket = (userId: string) => { - const existing = sessionsByUser.get(userId); - if (existing) { - return existing; - } - - const nextBucket = new Map(); - sessionsByUser.set(userId, nextBucket); - return nextBucket; - }; - - return { - async getSnapshot() { - return null; - }, - async putSnapshot(_userId, payload) { - return payload; - }, - async deleteSnapshot() { - return undefined; - }, - async getSettings() { - return { - musicVolume: 0.42, - platformTheme: 'light', - }; - }, - async putSettings(_userId, settings) { - return settings; - }, - async listCustomWorldProfiles(userId) { - return [...(profilesByUser.get(userId) ?? [])]; - }, - async upsertCustomWorldProfile(userId, profileId, profile) { - const current = [...(profilesByUser.get(userId) ?? [])].filter( - (item) => String(item.id ?? '') !== profileId, - ); - current.unshift({ - ...profile, - id: profileId, - }); - profilesByUser.set(userId, current); - return current; - }, - async deleteCustomWorldProfile(userId, profileId) { - const current = [...(profilesByUser.get(userId) ?? [])].filter( - (item) => String(item.id ?? '') !== profileId, - ); - profilesByUser.set(userId, current); - return current; - }, - async listProfileSaveArchives() { - return []; - }, - async resumeProfileSaveArchive() { - return null; - }, - async listCustomWorldSessions(userId) { - return [...getSessionBucket(userId).values()]; - }, - async getCustomWorldSession(userId, sessionId) { - return getSessionBucket(userId).get(sessionId) ?? null; - }, - async upsertCustomWorldSession(userId, sessionId, session) { - getSessionBucket(userId).set( - sessionId, - JSON.parse(JSON.stringify(session)), - ); - return JSON.parse(JSON.stringify(session)); - }, - }; -} - function createAutoAssetTestConfig(testName: string): AppConfig { const projectRoot = fs.mkdtempSync( path.join(os.tmpdir(), `genarrative-agent-phase5-${testName}-`), @@ -171,6 +91,11 @@ function createAutoAssetTestConfig(testName: string): AppConfig { mockAvatarUrl: '', }, authSession: { + accessCookieName: 'genarrative_access_session', + accessCookieTtlSeconds: 7200, + accessCookieSecure: false, + accessCookieSameSite: 'Lax', + accessCookiePath: '/', refreshCookieName: 'refresh_token', refreshSessionTtlDays: 30, refreshCookieSecure: false, @@ -189,6 +114,36 @@ function createFallbackAutoAssetService(testName: string) { ); } +// 发布执行器当前通过 userRepository 读取作者展示名,这里用内存 stub 对齐主链接口。 +function createUserRepository(displayName = '测试玩家'): UserRepositoryPort { + const now = '2026-04-21T00:00:00.000Z'; + + return { + findByUsername: async () => null, + findByPhoneNumber: async () => null, + findById: async (userId) => ({ + id: userId, + username: null, + passwordHash: '', + tokenVersion: 1, + displayName, + loginProvider: 'password', + accountStatus: 'active', + phoneNumber: null, + phoneVerifiedAt: null, + createdAt: now, + updatedAt: now, + }), + create: async () => null, + createPhoneUser: async () => null, + createWechatPendingUser: async () => null, + activatePendingWechatUser: async () => null, + updatePhoneInfo: async () => null, + deleteUser: async () => undefined, + incrementTokenVersion: async () => null, + }; +} + async function waitForOperation( orchestrator: CustomWorldAgentOrchestrator, userId: string, @@ -274,9 +229,119 @@ async function createObjectRefiningSession( ))!; } +async function createPublishReadySession( + orchestrator: CustomWorldAgentOrchestrator, + sessionStore: CustomWorldAgentSessionStore, + userId: string, +) { + const session = await createObjectRefiningSession(orchestrator, userId); + const profile = normalizeFoundationDraftProfile(session.draftProfile); + + assert.ok(profile); + assert.ok(profile.playableNpcs.length > 0); + assert.ok(profile.storyNpcs.length > 0); + assert.ok(profile.landmarks.length > 0); + assert.ok(profile.sceneChapters.length > 0); + + const publishReadyProfile = { + ...(session.draftProfile as Record), + camp: { + ...(profile.camp ?? {}), + id: profile.camp?.id ?? 'camp-home', + name: profile.camp?.name ?? '归潮营地', + description: profile.camp?.description ?? '可供玩家整理线索的临时据点。', + imageSrc: '/generated/camp/publish-ready.png', + generatedSceneAssetId: 'scene-camp-publish-ready', + generatedScenePrompt: '潮雾营地发布正式图', + generatedSceneModel: 'test-scene-model', + }, + playableNpcs: profile.playableNpcs.map((entry, index) => ({ + ...entry, + imageSrc: + entry.imageSrc || `/generated/playable/publish-ready-${index + 1}.png`, + generatedVisualAssetId: + entry.generatedVisualAssetId || `visual-playable-publish-${index + 1}`, + generatedAnimationSetId: + entry.generatedAnimationSetId || `anim-playable-publish-${index + 1}`, + })), + storyNpcs: profile.storyNpcs.map((entry, index) => ({ + ...entry, + imageSrc: + entry.imageSrc || `/generated/story/publish-ready-${index + 1}.png`, + generatedVisualAssetId: + entry.generatedVisualAssetId || `visual-story-publish-${index + 1}`, + generatedAnimationSetId: + entry.generatedAnimationSetId || `anim-story-publish-${index + 1}`, + })), + landmarks: profile.landmarks.map((entry, index) => ({ + ...entry, + imageSrc: + entry.imageSrc || `/generated/landmark/publish-ready-${index + 1}.png`, + generatedSceneAssetId: + entry.generatedSceneAssetId || `scene-landmark-publish-${index + 1}`, + generatedScenePrompt: + entry.generatedScenePrompt || `地点 ${entry.name} 的正式场景图`, + generatedSceneModel: + entry.generatedSceneModel || 'test-scene-model', + })), + sceneChapters: profile.sceneChapters.map((chapter) => ({ + ...chapter, + linkedThreadIds: + chapter.linkedThreadIds.length > 0 + ? chapter.linkedThreadIds + : [profile.threads[0]?.id ?? 'thread-publish-ready'], + acts: chapter.acts.map((act, index) => ({ + ...act, + encounterNpcIds: + act.encounterNpcIds.length > 0 + ? act.encounterNpcIds + : [profile.storyNpcs[0]?.id ?? profile.playableNpcs[0]?.id ?? 'role-publish-ready'], + primaryNpcId: + act.primaryNpcId || + act.encounterNpcIds[0] || + profile.storyNpcs[0]?.id || + profile.playableNpcs[0]?.id || + 'role-publish-ready', + backgroundImageSrc: + act.backgroundImageSrc || + `/generated/scene/publish-ready-${chapter.id}-${index + 1}.png`, + backgroundAssetId: + act.backgroundAssetId || `scene-act-publish-${chapter.id}-${index + 1}`, + })), + })), + chapters: profile.chapters, + } satisfies Record; + + await sessionStore.replaceDerivedState(userId, session.sessionId, { + stage: 'ready_to_publish', + draftProfile: publishReadyProfile, + draftCards: session.draftCards, + qualityFindings: [], + focusCardId: session.focusCardId, + assetCoverage: session.assetCoverage, + }); + + const publishReadySession = await orchestrator.getSessionSnapshot( + userId, + session.sessionId, + ); + + assert.equal(publishReadySession?.stage, 'ready_to_publish'); + assert.equal( + publishReadySession?.supportedActions.find( + (entry) => entry.action === 'publish_world', + )?.enabled, + true, + ); + + return publishReadySession!; +} + test('phase5 generate_role_assets only allows a single role and moves session into visual_refining', async () => { - const runtimeRepository = createRuntimeRepositoryStub(); - const sessionStore = new CustomWorldAgentSessionStore(runtimeRepository); + const { rpgAgentSessionRepository } = createInMemoryRpgWorldRepositoryPorts(); + const sessionStore = new CustomWorldAgentSessionStore( + rpgAgentSessionRepository, + ); const orchestrator = new CustomWorldAgentOrchestrator(sessionStore, null, { singleTurnLlmClient: createTestCustomWorldAgentSingleTurnLlmClient(), autoAssetService: createFallbackAutoAssetService('generate-role-assets'), @@ -312,6 +377,18 @@ test('phase5 generate_role_assets only allows a single role and moves session in assert.equal(operation?.status, 'completed'); assert.equal(snapshot?.stage, 'visual_refining'); assert.equal(snapshot?.focusCardId, characterIds[0]); + assert.equal( + snapshot?.supportedActions?.find( + (entry) => entry.action === 'generate_role_assets', + )?.enabled, + true, + ); + assert.equal( + snapshot?.supportedActions?.find( + (entry) => entry.action === 'sync_role_assets', + )?.enabled, + true, + ); assert.ok( snapshot?.messages.some( (message) => @@ -326,8 +403,10 @@ test('phase5 generate_role_assets only allows a single role and moves session in }); test('phase5 sync_role_assets writes fields back, updates coverage and recompiles character cards', async () => { - const runtimeRepository = createRuntimeRepositoryStub(); - const sessionStore = new CustomWorldAgentSessionStore(runtimeRepository); + const { rpgAgentSessionRepository } = createInMemoryRpgWorldRepositoryPorts(); + const sessionStore = new CustomWorldAgentSessionStore( + rpgAgentSessionRepository, + ); const orchestrator = new CustomWorldAgentOrchestrator(sessionStore, null, { singleTurnLlmClient: createTestCustomWorldAgentSingleTurnLlmClient(), autoAssetService: createFallbackAutoAssetService('sync-role-assets'), @@ -404,7 +483,7 @@ test('phase5 sync_role_assets writes fields back, updates coverage and recompile ?.idle?.basePath, '/generated/characters/shenli/idle', ); - const syncedSkillIds = syncedRole?.skills.map((skill) => skill.id) ?? []; + const syncedSkillIds = syncedRole?.skills?.map((skill) => skill.id) ?? []; assert.ok(syncedSkillIds.length > 0); assert.equal(syncedAssetSummary?.status, 'animations_ready'); assert.deepEqual( @@ -421,3 +500,484 @@ test('phase5 sync_role_assets writes fields back, updates coverage and recompile ); assert.ok((latestRecord?.checkpoints.length ?? 0) >= 2); }); + +test('phase5 publish_world persists published profile and moves session into published stage', async () => { + const { rpgAgentSessionRepository, rpgWorldProfileRepository } = + createInMemoryRpgWorldRepositoryPorts(); + const sessionStore = new CustomWorldAgentSessionStore( + rpgAgentSessionRepository, + ); + const orchestrator = new CustomWorldAgentOrchestrator(sessionStore, null, { + singleTurnLlmClient: createTestCustomWorldAgentSingleTurnLlmClient(), + autoAssetService: createFallbackAutoAssetService('publish-world'), + rpgWorldProfileRepository, + userRepository: createUserRepository('发布测试玩家'), + }); + const userId = 'user-phase5-publish-world'; + const session = await createPublishReadySession( + orchestrator, + sessionStore, + userId, + ); + + const response = await orchestrator.executeAction(userId, session.sessionId, { + action: 'publish_world', + }); + const operation = await waitForOperation( + orchestrator, + userId, + session.sessionId, + response.operation.operationId, + ); + const snapshot = await orchestrator.getSessionSnapshot( + userId, + session.sessionId, + ); + const profiles = await rpgWorldProfileRepository.listOwnProfiles(userId); + const publishedEntry = profiles.find( + (entry) => entry.profileId === `agent-draft-${session.sessionId}`, + ); + const latestRecord = await sessionStore.get(userId, session.sessionId); + + assert.equal(operation?.status, 'completed'); + assert.equal(snapshot?.stage, 'published'); + assert.equal(snapshot?.resultPreview?.publishReady, true); + assert.equal(snapshot?.resultPreview?.canEnterWorld, true); + assert.deepEqual(snapshot?.resultPreview?.blockers ?? [], []); + assert.equal( + snapshot?.supportedActions.find( + (entry) => entry.action === 'publish_world', + )?.enabled, + false, + ); + assert.equal(publishedEntry?.visibility, 'published'); + assert.equal(publishedEntry?.authorDisplayName, '发布测试玩家'); + assert.equal(publishedEntry?.profile.id, `agent-draft-${session.sessionId}`); + assert.ok( + snapshot?.messages.some( + (message) => + message.kind === 'action_result' && + message.text.includes('已正式发布'), + ), + ); + assert.ok( + latestRecord?.checkpoints.some( + (checkpoint) => + checkpoint.label.includes('发布世界') && + checkpoint.snapshot?.stage === 'published', + ), + ); +}); + +test('phase5 generate_scene_assets prepares scene studio and sync_scene_assets writes back camp asset fields', async () => { + const { rpgAgentSessionRepository } = createInMemoryRpgWorldRepositoryPorts(); + const sessionStore = new CustomWorldAgentSessionStore( + rpgAgentSessionRepository, + ); + const orchestrator = new CustomWorldAgentOrchestrator(sessionStore, null, { + singleTurnLlmClient: createTestCustomWorldAgentSingleTurnLlmClient(), + autoAssetService: createFallbackAutoAssetService('sync-scene-assets'), + }); + const userId = 'user-phase5-sync-scene-assets'; + const session = await createObjectRefiningSession(orchestrator, userId); + const campCard = session.draftCards.find((card) => card.kind === 'camp'); + + assert.ok(campCard); + + const prepareResponse = await orchestrator.executeAction( + userId, + session.sessionId, + { + action: 'generate_scene_assets', + sceneIds: [campCard!.id], + }, + ); + const prepareOperation = await waitForOperation( + orchestrator, + userId, + session.sessionId, + prepareResponse.operation.operationId, + ); + const preparedSnapshot = await orchestrator.getSessionSnapshot( + userId, + session.sessionId, + ); + + assert.equal(prepareOperation?.status, 'completed'); + assert.equal(preparedSnapshot?.stage, 'visual_refining'); + assert.equal(preparedSnapshot?.focusCardId, campCard!.id); + assert.ok( + preparedSnapshot?.messages.some( + (message) => + message.kind === 'action_result' && + message.text.includes('场景图工坊'), + ), + ); + + const response = await orchestrator.executeAction(userId, session.sessionId, { + action: 'sync_scene_assets', + sceneId: campCard!.id, + sceneKind: 'camp', + imageSrc: '/generated/scenes/camp-home.png', + generatedSceneAssetId: 'scene-camp-home-1', + generatedScenePrompt: '潮雾中的灯塔营地', + generatedSceneModel: 'test-scene-model', + }); + const operation = await waitForOperation( + orchestrator, + userId, + session.sessionId, + response.operation.operationId, + ); + const snapshot = await orchestrator.getSessionSnapshot( + userId, + session.sessionId, + ); + const profile = normalizeFoundationDraftProfile(snapshot?.draftProfile); + const latestRecord = await sessionStore.get(userId, session.sessionId); + + assert.equal(operation?.status, 'completed'); + assert.equal(snapshot?.stage, 'visual_refining'); + assert.equal(snapshot?.focusCardId, campCard!.id); + assert.equal(profile?.camp?.imageSrc, '/generated/scenes/camp-home.png'); + assert.equal(profile?.camp?.generatedSceneAssetId, 'scene-camp-home-1'); + assert.equal(profile?.camp?.generatedScenePrompt, '潮雾中的灯塔营地'); + assert.equal(profile?.camp?.generatedSceneModel, 'test-scene-model'); + assert.ok( + profile?.sceneChapters.every((chapter) => + chapter.sceneId === campCard!.id + ? chapter.acts.every( + (act) => + act.backgroundImageSrc === '/generated/scenes/camp-home.png' && + act.backgroundAssetId === 'scene-camp-home-1', + ) + : true, + ), + ); + assert.ok( + snapshot?.messages.some( + (message) => + message.kind === 'action_result' && + message.text.includes('场景图写回草稿'), + ), + ); + assert.ok( + latestRecord?.checkpoints.some( + (checkpoint) => + checkpoint.label.includes('同步场景资产') && Boolean(checkpoint.snapshot), + ), + ); +}); + +test('phase5 expand_long_tail appends characters and landmarks then moves into long_tail_review', async () => { + const { rpgAgentSessionRepository } = createInMemoryRpgWorldRepositoryPorts(); + const sessionStore = new CustomWorldAgentSessionStore( + rpgAgentSessionRepository, + ); + const orchestrator = new CustomWorldAgentOrchestrator(sessionStore, null, { + singleTurnLlmClient: createTestCustomWorldAgentSingleTurnLlmClient(), + autoAssetService: createFallbackAutoAssetService('expand-long-tail'), + }); + const userId = 'user-phase5-expand-long-tail'; + const session = await createObjectRefiningSession(orchestrator, userId); + const baselineProfile = normalizeFoundationDraftProfile(session.draftProfile)!; + const baselineCharacterCount = [ + ...new Set( + [...baselineProfile.playableNpcs, ...baselineProfile.storyNpcs].map( + (entry) => entry.id, + ), + ), + ].length; + const baselineLandmarkCount = baselineProfile.landmarks.length; + + const response = await orchestrator.executeAction(userId, session.sessionId, { + action: 'expand_long_tail', + }); + const operation = await waitForOperation( + orchestrator, + userId, + session.sessionId, + response.operation.operationId, + ); + const snapshot = await orchestrator.getSessionSnapshot( + userId, + session.sessionId, + ); + const profile = normalizeFoundationDraftProfile(snapshot?.draftProfile)!; + const nextCharacterCount = [ + ...new Set( + [...profile.playableNpcs, ...profile.storyNpcs].map((entry) => entry.id), + ), + ].length; + const latestRecord = await sessionStore.get(userId, session.sessionId); + + assert.equal(operation?.status, 'completed'); + assert.equal(snapshot?.stage, 'long_tail_review'); + assert.ok(nextCharacterCount >= baselineCharacterCount + 2); + assert.ok(profile.landmarks.length >= baselineLandmarkCount + 2); + assert.ok( + snapshot?.messages.some( + (message) => + message.kind === 'action_result' && + message.text.includes('长尾角色'), + ), + ); + assert.ok( + latestRecord?.checkpoints.some( + (checkpoint) => + checkpoint.label.includes('扩展长尾') && Boolean(checkpoint.snapshot), + ), + ); +}); + +test('phase5 publish_world blocks incomplete draft and publishes complete world into repository', async () => { + const { rpgAgentSessionRepository, rpgWorldProfileRepository } = + createInMemoryRpgWorldRepositoryPorts(); + const sessionStore = new CustomWorldAgentSessionStore( + rpgAgentSessionRepository, + ); + const orchestrator = new CustomWorldAgentOrchestrator(sessionStore, null, { + singleTurnLlmClient: createTestCustomWorldAgentSingleTurnLlmClient(), + autoAssetService: createFallbackAutoAssetService('publish-world'), + rpgWorldProfileRepository, + userRepository: createUserRepository(), + }); + const userId = 'user-phase5-publish-world'; + const session = await createObjectRefiningSession(orchestrator, userId); + + const blockedResponse = await orchestrator.executeAction( + userId, + session.sessionId, + { + action: 'publish_world', + }, + ); + const blockedOperation = await waitForOperation( + orchestrator, + userId, + session.sessionId, + blockedResponse.operation.operationId, + ); + const blockedSnapshot = await orchestrator.getSessionSnapshot( + userId, + session.sessionId, + ); + + assert.equal(blockedOperation?.status, 'failed'); + assert.ok( + blockedSnapshot?.messages.some( + (message) => + message.kind === 'warning' && message.text.includes('当前世界还不能发布'), + ), + ); + + const profile = normalizeFoundationDraftProfile(blockedSnapshot?.draftProfile)!; + const roleIds = [...profile.playableNpcs, ...profile.storyNpcs].map( + (entry) => entry.id, + ); + + for (const roleId of roleIds) { + const syncRoleResponse = await orchestrator.executeAction( + userId, + session.sessionId, + { + action: 'sync_role_assets', + roleId, + portraitPath: `/generated/characters/${roleId}.png`, + generatedVisualAssetId: `visual-${roleId}`, + generatedAnimationSetId: `animation-${roleId}`, + animationMap: { + run: { basePath: `/generated/characters/${roleId}/run` }, + attack: { basePath: `/generated/characters/${roleId}/attack` }, + }, + }, + ); + await waitForOperation( + orchestrator, + userId, + session.sessionId, + syncRoleResponse.operation.operationId, + ); + } + + const latestSnapshot = await orchestrator.getSessionSnapshot( + userId, + session.sessionId, + ); + const latestProfile = normalizeFoundationDraftProfile(latestSnapshot?.draftProfile)!; + const sceneTargets = [ + { + sceneId: latestProfile.camp?.id ?? 'camp-home', + sceneKind: 'camp' as const, + }, + ...latestProfile.landmarks.map((entry) => ({ + sceneId: entry.id, + sceneKind: 'landmark' as const, + })), + ]; + + for (const sceneTarget of sceneTargets) { + const syncSceneResponse = await orchestrator.executeAction( + userId, + session.sessionId, + { + action: 'sync_scene_assets', + sceneId: sceneTarget.sceneId, + sceneKind: sceneTarget.sceneKind, + imageSrc: `/generated/scenes/${sceneTarget.sceneId}.png`, + generatedSceneAssetId: `scene-${sceneTarget.sceneId}`, + generatedScenePrompt: `${sceneTarget.sceneId} 场景图`, + generatedSceneModel: 'test-scene-model', + }, + ); + await waitForOperation( + orchestrator, + userId, + session.sessionId, + syncSceneResponse.operation.operationId, + ); + } + + const publishResponse = await orchestrator.executeAction( + userId, + session.sessionId, + { + action: 'publish_world', + }, + ); + const publishOperation = await waitForOperation( + orchestrator, + userId, + session.sessionId, + publishResponse.operation.operationId, + ); + const publishedSnapshot = await orchestrator.getSessionSnapshot( + userId, + session.sessionId, + ); + const libraryEntries = await rpgWorldProfileRepository.listOwnProfiles(userId); + const publishedEntry = libraryEntries.find( + (entry) => entry.visibility === 'published', + ); + const latestRecord = await sessionStore.get(userId, session.sessionId); + + assert.equal(blockedOperation?.status, 'failed'); + assert.match(blockedOperation?.error ?? '', /缺少正式主图|缺少正式场景图|缺少章节草稿/u); + assert.equal(blockedSnapshot?.resultPreview?.publishReady, false); + assert.ok((blockedSnapshot?.resultPreview?.blockers?.length ?? 0) > 0); + assert.equal(publishOperation?.status, 'completed'); + assert.equal(publishedSnapshot?.stage, 'published'); + assert.equal(publishedSnapshot?.resultPreview?.publishReady, true); + assert.equal(publishedSnapshot?.resultPreview?.canEnterWorld, true); + assert.ok(publishedSnapshot?.qualityFindings.every((entry) => entry.severity !== 'blocker')); + assert.ok( + publishedSnapshot?.messages.some( + (message) => + message.kind === 'action_result' && + message.text.includes('已正式发布'), + ), + ); + assert.ok(publishedEntry); + assert.equal(publishedEntry?.profileId, `agent-draft-${session.sessionId}`); + assert.equal(publishedEntry?.authorDisplayName, '测试玩家'); + assert.ok( + latestRecord?.checkpoints.some( + (checkpoint) => + checkpoint.label.includes('发布世界') && + checkpoint.snapshot?.stage === 'published', + ), + ); +}); + +test('phase5 revert_checkpoint restores previous draft snapshot', async () => { + const { rpgAgentSessionRepository } = createInMemoryRpgWorldRepositoryPorts(); + const sessionStore = new CustomWorldAgentSessionStore( + rpgAgentSessionRepository, + ); + const orchestrator = new CustomWorldAgentOrchestrator(sessionStore, null, { + singleTurnLlmClient: createTestCustomWorldAgentSingleTurnLlmClient(), + autoAssetService: createFallbackAutoAssetService('revert-checkpoint'), + }); + const userId = 'user-phase5-revert-checkpoint'; + const session = await createObjectRefiningSession(orchestrator, userId); + + const updateResponse = await orchestrator.executeAction( + userId, + session.sessionId, + { + action: 'update_draft_card', + cardId: 'world-foundation', + sections: [ + { + sectionId: 'summary', + value: '回滚测试摘要版本', + }, + ], + }, + ); + await waitForOperation( + orchestrator, + userId, + session.sessionId, + updateResponse.operation.operationId, + ); + + const afterUpdateRecord = await sessionStore.get(userId, session.sessionId); + const restorableCheckpoint = [...(afterUpdateRecord?.checkpoints ?? [])] + .reverse() + .find((checkpoint) => Boolean(checkpoint.snapshot)); + + assert.ok(restorableCheckpoint); + + const syncRoleResponse = await orchestrator.executeAction( + userId, + session.sessionId, + { + action: 'sync_role_assets', + roleId: + normalizeFoundationDraftProfile( + (await orchestrator.getSessionSnapshot(userId, session.sessionId)) + ?.draftProfile, + )?.playableNpcs[0]?.id ?? 'unknown-role', + portraitPath: '/generated/characters/revert-test.png', + generatedVisualAssetId: 'visual-revert-test', + generatedAnimationSetId: 'animation-revert-test', + animationMap: { + run: { basePath: '/generated/characters/revert-test/run' }, + attack: { basePath: '/generated/characters/revert-test/attack' }, + }, + }, + ); + await waitForOperation( + orchestrator, + userId, + session.sessionId, + syncRoleResponse.operation.operationId, + ); + + const response = await orchestrator.executeAction(userId, session.sessionId, { + action: 'revert_checkpoint', + checkpointId: restorableCheckpoint!.checkpointId, + }); + const operation = await waitForOperation( + orchestrator, + userId, + session.sessionId, + response.operation.operationId, + ); + const snapshot = await orchestrator.getSessionSnapshot( + userId, + session.sessionId, + ); + const profile = normalizeFoundationDraftProfile(snapshot?.draftProfile); + + assert.equal(operation?.status, 'completed'); + assert.equal(profile?.summary, '回滚测试摘要版本'); + assert.ok( + snapshot?.messages.some( + (message) => + message.kind === 'action_result' && + message.text.includes('已恢复到检查点'), + ), + ); +}); diff --git a/server-node/src/services/customWorldAgentPublishingService.ts b/server-node/src/services/customWorldAgentPublishingService.ts new file mode 100644 index 00000000..4577f38e --- /dev/null +++ b/server-node/src/services/customWorldAgentPublishingService.ts @@ -0,0 +1,256 @@ +import type { CustomWorldProfileRecord } from '../../../packages/shared/src/contracts/runtime.js'; +import type { RpgWorldProfileRepositoryPort } from '../repositories/RpgWorldProfileRepository.js'; +import { buildRpgCreationPreviewProfileFromDraftProfile } from './rpgCreationPreviewProfileBuilder.js'; +import { normalizeFoundationDraftProfile } from './customWorldAgentDraftCompiler.js'; + +function toText(value: unknown) { + return typeof value === 'string' ? value.trim() : ''; +} + +function isRecord(value: unknown): value is Record { + return Boolean(value) && typeof value === 'object' && !Array.isArray(value); +} + +function hasGeneratedSceneAsset( + value: unknown, +) { + return Boolean(toText((value as Record | null)?.generatedSceneAssetId)); +} + +export class CustomWorldAgentPublishingService { + constructor( + private readonly rpgWorldProfileRepository: RpgWorldProfileRepositoryPort, + ) {} + + /** + * Phase4 需要把“能不能发布”收成可读的后端真相, + * 这样结果页、works 和 publish executor 才能共享同一套 blocker 语义。 + */ + evaluatePublishReadiness(params: { + sessionId: string; + draftProfile: unknown; + qualityFindings?: Array<{ + severity: 'info' | 'warning' | 'blocker'; + code?: string; + targetId?: string | null; + message: string; + }>; + }) { + const draftProfile = normalizeFoundationDraftProfile(params.draftProfile); + if (!draftProfile) { + return { + profileId: `agent-draft-${params.sessionId}`, + blockers: [ + { + severity: 'blocker' as const, + code: 'publish_empty_draft', + message: '当前世界草稿为空,无法发布。', + }, + ], + }; + } + + const findings = params.qualityFindings ?? []; + const blockers = findings.filter((entry) => entry.severity === 'blocker'); + const readinessBlockers = [...blockers]; + + if (!draftProfile.worldHook.trim()) { + readinessBlockers.push({ + severity: 'blocker', + code: 'publish_missing_world_hook', + message: '当前世界缺少 world hook,发布前需要先补齐世界一句话钩子。', + }); + } + + if (!draftProfile.playerPremise.trim()) { + readinessBlockers.push({ + severity: 'blocker', + code: 'publish_missing_player_premise', + message: '当前世界缺少玩家身份与切入前提,发布前需要先补齐玩家 premise。', + }); + } + + if ( + draftProfile.coreConflicts.length <= 0 || + !draftProfile.coreConflicts.some((entry) => toText(entry)) + ) { + readinessBlockers.push({ + severity: 'blocker', + code: 'publish_missing_core_conflict', + message: '当前世界缺少核心冲突,发布前需要先补齐核心冲突。', + }); + } + + if ((draftProfile.chapters?.length ?? 0) <= 0) { + readinessBlockers.push({ + severity: 'blocker', + code: 'publish_missing_main_chapter', + message: '当前世界还没有主线章节草稿,发布前至少要保留主线第一幕。', + }); + } + + const firstSceneActExists = draftProfile.sceneChapters.some( + (chapter) => chapter.acts.length > 0, + ); + if (!firstSceneActExists) { + readinessBlockers.push({ + severity: 'blocker', + code: 'publish_missing_first_act', + message: '当前世界还没有主线第一幕,发布前至少要保留一个场景幕。', + }); + } + + const missingRoleAssets = [ + ...draftProfile.playableNpcs, + ...draftProfile.storyNpcs, + ].filter( + (role) => + !toText(role.generatedVisualAssetId) || + !toText(role.generatedAnimationSetId), + ); + if (missingRoleAssets.length > 0) { + readinessBlockers.push({ + severity: 'blocker', + code: 'publish_role_assets_incomplete', + targetId: missingRoleAssets[0]?.id ?? null, + message: '仍有角色缺少正式主图或动作资产,发布前需要先补齐。', + }); + } + + if (!draftProfile.camp || !toText(draftProfile.camp.imageSrc) || !hasGeneratedSceneAsset(draftProfile.camp)) { + readinessBlockers.push({ + severity: 'blocker', + code: 'publish_camp_scene_missing', + targetId: draftProfile.camp?.id ?? null, + message: '营地还缺少正式场景图资产,发布前需要先确认营地图。', + }); + } + + const missingLandmarkScenes = draftProfile.landmarks.filter( + (landmark) => + !toText(landmark.imageSrc) || !hasGeneratedSceneAsset(landmark), + ); + if (missingLandmarkScenes.length > 0) { + readinessBlockers.push({ + severity: 'blocker', + code: 'publish_landmark_scene_missing', + targetId: missingLandmarkScenes[0]?.id ?? null, + message: '仍有地点缺少正式场景图资产,发布前需要先补齐地点图。', + }); + } + + return { + profileId: + toText( + (params.draftProfile as Record | null) + ?.legacyResultProfile?.id, + ) || `agent-draft-${params.sessionId}`, + blockers: readinessBlockers, + }; + } + + /** + * Phase4 统一复用发布门禁摘要,避免 preview / works / enter-world 各自拼 blocker 口径。 + */ + summarizePublishGate(params: { + sessionId: string; + stage?: string | null; + draftProfile: unknown; + qualityFindings?: Array<{ + severity: 'info' | 'warning' | 'blocker'; + code?: string; + targetId?: string | null; + message: string; + }>; + }) { + const readiness = this.evaluatePublishReadiness(params); + const blockers = readiness.blockers.map((entry) => ({ + id: + typeof entry.code === 'string' && entry.code.trim() + ? entry.code + : `publish-blocker-${entry.message}`, + code: + typeof entry.code === 'string' && entry.code.trim() + ? entry.code + : 'publish_blocker', + message: entry.message, + })); + + return { + profileId: readiness.profileId, + blockers, + blockerCount: blockers.length, + publishReady: blockers.length === 0, + canEnterWorld: + String(params.stage ?? '').trim() === 'published' && blockers.length === 0, + }; + } + + buildPublishReadiness(params: { + sessionId: string; + draftProfile: unknown; + qualityFindings?: Array<{ + severity: 'info' | 'warning' | 'blocker'; + code?: string; + targetId?: string | null; + message: string; + }>; + }) { + const readiness = this.evaluatePublishReadiness(params); + if (readiness.blockers.length > 0) { + throw new Error( + `当前世界仍有 ${readiness.blockers.length} 个 blocker,暂时不能发布:${readiness.blockers + .map((entry) => entry.message) + .join(';')}`, + ); + } + + return { + profileId: readiness.profileId, + }; + } + + async publishSessionDraft(params: { + userId: string; + authorDisplayName: string; + sessionId: string; + draftProfile: Record; + qualityFindings?: Array<{ + severity: 'info' | 'warning' | 'blocker'; + message: string; + }>; + }) { + const readiness = this.buildPublishReadiness({ + sessionId: params.sessionId, + draftProfile: params.draftProfile, + qualityFindings: params.qualityFindings, + }); + const publishedProfile = buildRpgCreationPreviewProfileFromDraftProfile({ + sessionId: params.sessionId, + draftProfile: params.draftProfile, + profileId: readiness.profileId, + }); + + await this.rpgWorldProfileRepository.upsertOwnProfile( + params.userId, + readiness.profileId, + publishedProfile as unknown as Record, + params.authorDisplayName, + ); + + const mutation = await this.rpgWorldProfileRepository.publishOwnProfile( + params.userId, + readiness.profileId, + params.authorDisplayName, + ); + if (!mutation) { + throw new Error('世界发布失败,未找到目标作品。'); + } + + return { + profileId: readiness.profileId, + publishedProfile, + mutation, + }; + } +} diff --git a/server-node/src/services/customWorldAgentQualityGateService.ts b/server-node/src/services/customWorldAgentQualityGateService.ts new file mode 100644 index 00000000..856cd919 --- /dev/null +++ b/server-node/src/services/customWorldAgentQualityGateService.ts @@ -0,0 +1,88 @@ +import type { + CustomWorldAgentSessionSnapshot, + CustomWorldAgentStage, + CustomWorldAssetCoverageSummary, +} from '../../../packages/shared/src/contracts/customWorldAgent.js'; +import { normalizeFoundationDraftProfile } from './customWorldAgentDraftCompiler.js'; + +export type CustomWorldAgentQualityFinding = + CustomWorldAgentSessionSnapshot['qualityFindings'][number]; + +const QUALITY_GATE_STAGES = new Set([ + 'object_refining', + 'visual_refining', + 'long_tail_review', + 'ready_to_publish', +]); + +export class CustomWorldAgentQualityGateService { + // 当前先把最核心的阻断项和提醒项独立收口,后续 publish gate 可以直接复用同一套 finding。 + buildQualityFindings(params: { + draftProfile: unknown; + assetCoverage?: CustomWorldAssetCoverageSummary | null; + stage?: CustomWorldAgentStage; + }): CustomWorldAgentQualityFinding[] { + if (params.stage && !QUALITY_GATE_STAGES.has(params.stage)) { + return []; + } + + const profile = normalizeFoundationDraftProfile(params.draftProfile); + if (!profile) { + return []; + } + + const findings: CustomWorldAgentQualityFinding[] = []; + const totalRoleCount = [ + ...new Set( + [...profile.playableNpcs, ...profile.storyNpcs].map((entry) => entry.id), + ), + ].length; + + if (totalRoleCount === 0) { + findings.push({ + id: 'missing-core-roles', + severity: 'blocker', + code: 'missing_core_roles', + message: '当前世界底稿还没有任何角色,暂时无法进入发布前收口阶段。', + }); + } + + if (profile.landmarks.length === 0) { + findings.push({ + id: 'missing-core-landmarks', + severity: 'blocker', + code: 'missing_core_landmarks', + message: '当前世界底稿还没有任何地点,至少需要补出一处关键地点。', + }); + } + + if (!profile.playerGoal.trim()) { + findings.push({ + id: 'missing-player-goal', + severity: 'warning', + code: 'missing_player_goal', + message: '玩家目标还不够明确,后续进入结果页后建议优先补齐可执行目标。', + }); + } + + if (params.assetCoverage && !params.assetCoverage.allRoleAssetsReady) { + findings.push({ + id: 'role-assets-pending', + severity: 'warning', + code: 'role_assets_pending', + message: '仍有角色资产未完全补齐,结果页可继续补主图与动作资源。', + }); + } + + if (params.assetCoverage && !params.assetCoverage.allSceneAssetsReady) { + findings.push({ + id: 'scene-assets-pending', + severity: 'warning', + code: 'scene_assets_pending', + message: '仍有场景分幕图未补齐,后续结果页进入发布前需要继续完善。', + }); + } + + return findings; + } +} diff --git a/server-node/src/services/customWorldAgentRepositoryTestHelpers.ts b/server-node/src/services/customWorldAgentRepositoryTestHelpers.ts new file mode 100644 index 00000000..a6bcb094 --- /dev/null +++ b/server-node/src/services/customWorldAgentRepositoryTestHelpers.ts @@ -0,0 +1,305 @@ +import type { + CustomWorldGalleryCard, + CustomWorldLibraryEntry, + CustomWorldProfileRecord, + CustomWorldSessionRecord, +} from '../../../packages/shared/src/contracts/runtime.js'; +import { extractCustomWorldLibraryMetadata } from '../repositories/customWorldLibraryMetadata.js'; +import type { RpgAgentSessionRepositoryPort } from '../repositories/RpgAgentSessionRepository.js'; +import type { RpgWorldProfileRepositoryPort } from '../repositories/RpgWorldProfileRepository.js'; + +type StoredProfileEntry = CustomWorldLibraryEntry; +type SeedSessionRecord = CustomWorldSessionRecord & { userId: string }; + +function cloneRepositoryValue(value: T): T { + return JSON.parse(JSON.stringify(value)) as T; +} + +function ensureProfileRecord( + profileId: string, + profile: Record, +): CustomWorldProfileRecord { + return { + ...cloneRepositoryValue(profile), + id: profileId, + } as CustomWorldProfileRecord; +} + +function buildProfileEntry(params: { + userId: string; + profileId: string; + profile: Record; + authorDisplayName: string; + visibility: 'draft' | 'published'; + updatedAt: string; + publishedAt: string | null; +}) { + const profileRecord = ensureProfileRecord(params.profileId, params.profile); + const metadata = extractCustomWorldLibraryMetadata(profileRecord); + + return { + ownerUserId: params.userId, + profileId: params.profileId, + profile: profileRecord, + visibility: params.visibility, + publishedAt: params.visibility === 'published' ? params.publishedAt : null, + updatedAt: params.updatedAt, + authorDisplayName: params.authorDisplayName || '玩家', + worldName: metadata.worldName, + subtitle: metadata.subtitle, + summaryText: metadata.summaryText, + coverImageSrc: metadata.coverImageSrc, + themeMode: metadata.themeMode, + playableNpcCount: metadata.playableNpcCount, + landmarkCount: metadata.landmarkCount, + } satisfies StoredProfileEntry; +} + +function toGalleryCard(entry: StoredProfileEntry): CustomWorldGalleryCard { + const { profile: _profile, ...card } = entry; + return cloneRepositoryValue(card); +} + +function sortEntriesByUpdatedAt(entries: T[]) { + return [...entries].sort((left, right) => + right.updatedAt.localeCompare(left.updatedAt), + ); +} + +/** + * 这组内存仓储 helper 让 phase2~5 与 works 集成测试直接依赖工作包 F 拆出来的领域端口, + * 避免继续把 RuntimeRepositoryPort 当成 session/profile 的测试替身。 + */ +export function createInMemoryRpgWorldRepositoryPorts(options?: { + sessionRecords?: SeedSessionRecord[]; + profileEntries?: Array>; +}) { + const sessionsByUser = new Map>(); + const profilesByUser = new Map>(); + + const ensureSessionBucket = (userId: string) => { + const currentBucket = sessionsByUser.get(userId); + if (currentBucket) { + return currentBucket; + } + + const nextBucket = new Map(); + sessionsByUser.set(userId, nextBucket); + return nextBucket; + }; + + const ensureProfileBucket = (userId: string) => { + const currentBucket = profilesByUser.get(userId); + if (currentBucket) { + return currentBucket; + } + + const nextBucket = new Map(); + profilesByUser.set(userId, nextBucket); + return nextBucket; + }; + + const listOwnEntries = (userId: string) => + sortEntriesByUpdatedAt([...ensureProfileBucket(userId).values()]).map((entry) => + cloneRepositoryValue(entry), + ); + + options?.sessionRecords?.forEach((record) => { + ensureSessionBucket(record.userId).set( + record.sessionId, + cloneRepositoryValue(record), + ); + }); + + options?.profileEntries?.forEach((entry) => { + ensureProfileBucket(entry.ownerUserId).set( + entry.profileId, + cloneRepositoryValue(entry), + ); + }); + + const rpgAgentSessionRepository: RpgAgentSessionRepositoryPort = { + async listSessions(userId: string) { + return sortEntriesByUpdatedAt([...ensureSessionBucket(userId).values()]).map( + (record) => cloneRepositoryValue(record), + ); + }, + + async getSession(userId: string, sessionId: string) { + const record = ensureSessionBucket(userId).get(sessionId) ?? null; + return record ? cloneRepositoryValue(record) : null; + }, + + async upsertSession( + userId: string, + sessionId: string, + session: CustomWorldSessionRecord, + ) { + const nextSession = cloneRepositoryValue({ + ...session, + userId, + sessionId, + }); + ensureSessionBucket(userId).set(sessionId, nextSession); + return cloneRepositoryValue(nextSession); + }, + }; + + const rpgWorldProfileRepository: RpgWorldProfileRepositoryPort = { + async listOwnProfiles(userId: string) { + return listOwnEntries(userId); + }, + + async upsertOwnProfile( + userId: string, + profileId: string, + profile: Record, + authorDisplayName: string, + ) { + const bucket = ensureProfileBucket(userId); + const currentEntry = bucket.get(profileId); + const now = new Date().toISOString(); + const nextEntry = buildProfileEntry({ + userId, + profileId, + profile, + authorDisplayName: + authorDisplayName || currentEntry?.authorDisplayName || '玩家', + visibility: currentEntry?.visibility ?? 'draft', + updatedAt: now, + publishedAt: + currentEntry?.visibility === 'published' + ? currentEntry.publishedAt || now + : null, + }); + + bucket.set(profileId, nextEntry); + + return { + entry: cloneRepositoryValue(nextEntry), + entries: await listOwnEntries(userId), + }; + }, + + async syncProfileFromSnapshot( + userId: string, + profileId: string, + profile: Record, + syncedAt: string, + ) { + const bucket = ensureProfileBucket(userId); + const currentEntry = bucket.get(profileId); + bucket.set( + profileId, + buildProfileEntry({ + userId, + profileId, + profile, + authorDisplayName: currentEntry?.authorDisplayName || '玩家', + visibility: currentEntry?.visibility ?? 'draft', + updatedAt: syncedAt, + publishedAt: + currentEntry?.visibility === 'published' + ? currentEntry.publishedAt || syncedAt + : null, + }), + ); + }, + + async softDeleteOwnProfile(userId: string, profileId: string) { + ensureProfileBucket(userId).delete(profileId); + return listOwnEntries(userId); + }, + + async publishOwnProfile( + userId: string, + profileId: string, + authorDisplayName: string, + ) { + const bucket = ensureProfileBucket(userId); + const currentEntry = bucket.get(profileId); + if (!currentEntry) { + return null; + } + + const now = new Date().toISOString(); + const nextEntry = buildProfileEntry({ + userId, + profileId, + profile: currentEntry.profile, + authorDisplayName: + authorDisplayName || currentEntry.authorDisplayName || '玩家', + visibility: 'published', + updatedAt: now, + publishedAt: now, + }); + bucket.set(profileId, nextEntry); + + return { + entry: cloneRepositoryValue(nextEntry), + entries: await listOwnEntries(userId), + }; + }, + + async unpublishOwnProfile( + userId: string, + profileId: string, + authorDisplayName: string, + ) { + const bucket = ensureProfileBucket(userId); + const currentEntry = bucket.get(profileId); + if (!currentEntry) { + return null; + } + + const now = new Date().toISOString(); + const nextEntry = buildProfileEntry({ + userId, + profileId, + profile: currentEntry.profile, + authorDisplayName: + authorDisplayName || currentEntry.authorDisplayName || '玩家', + visibility: 'draft', + updatedAt: now, + publishedAt: null, + }); + bucket.set(profileId, nextEntry); + + return { + entry: cloneRepositoryValue(nextEntry), + entries: await listOwnEntries(userId), + }; + }, + + async listPublishedGallery() { + return [...profilesByUser.values()] + .flatMap((bucket) => [...bucket.values()]) + .filter((entry) => entry.visibility === 'published') + .sort((left, right) => { + const publishedAtDiff = (right.publishedAt || '').localeCompare( + left.publishedAt || '', + ); + if (publishedAtDiff !== 0) { + return publishedAtDiff; + } + + return right.updatedAt.localeCompare(left.updatedAt); + }) + .map((entry) => toGalleryCard(entry)); + }, + + async getPublishedGalleryDetail(ownerUserId: string, profileId: string) { + const entry = ensureProfileBucket(ownerUserId).get(profileId); + if (!entry || entry.visibility !== 'published') { + return null; + } + + return cloneRepositoryValue(entry); + }, + }; + + return { + rpgAgentSessionRepository, + rpgWorldProfileRepository, + }; +} diff --git a/server-node/src/services/customWorldAgentResultSyncService.test.ts b/server-node/src/services/customWorldAgentResultSyncService.test.ts new file mode 100644 index 00000000..af20b40a --- /dev/null +++ b/server-node/src/services/customWorldAgentResultSyncService.test.ts @@ -0,0 +1,118 @@ +import assert from 'node:assert/strict'; +import test from 'node:test'; + +import { + createRpgAgentFoundationDraftProfileFixture, + createRpgCreationPublishedProfileFixture, +} from '../../../packages/shared/src/contracts/rpgCreationFixtures.js'; +import { CustomWorldAgentResultSyncService } from './customWorldAgentResultSyncService.js'; + +test('result sync service only writes summary fields and matching asset confirmations back into draft profile', () => { + const service = new CustomWorldAgentResultSyncService(); + const currentDraftProfile = createRpgAgentFoundationDraftProfileFixture(); + const resultProfile = createRpgCreationPublishedProfileFixture(); + const nextDraftProfile = service.syncResultProfileIntoDraftProfile({ + currentDraftProfile: currentDraftProfile as unknown as Record, + resultProfile, + }); + + assert.equal(nextDraftProfile.name, resultProfile.name); + assert.equal(nextDraftProfile.summary, resultProfile.summary); + assert.equal( + ( + nextDraftProfile.playableNpcs as Array<{ + generatedAnimationSetId?: string | null; + }> + )[0]?.generatedAnimationSetId, + 'animation-set-playable-1', + ); + assert.equal( + ( + nextDraftProfile.landmarks as Array<{ + imageSrc?: string | null; + }> + )[0]?.imageSrc, + '/generated-custom-world-scenes/landmark-1/latest-scene.png', + ); + assert.equal( + ( + nextDraftProfile.sceneChapters as Array<{ + acts?: Array<{ backgroundAssetId?: string | null }>; + }> + )[0]?.acts?.[0]?.backgroundAssetId, + 'scene-asset-runtime', + ); +}); + +test('result sync service keeps existing foundation structure when result profile carries unmatched runtime-only entities', () => { + const service = new CustomWorldAgentResultSyncService(); + const currentDraftProfile = createRpgAgentFoundationDraftProfileFixture(); + const nextDraftProfile = service.syncResultProfileIntoDraftProfile({ + currentDraftProfile: currentDraftProfile as unknown as Record, + resultProfile: { + ...createRpgCreationPublishedProfileFixture(), + playableNpcs: [ + { + id: 'runtime-only-role', + name: '运行时临时角色', + title: '结果页临时角色', + role: '测试角色', + description: '不应覆盖 foundation draft。', + backstory: '测试', + personality: '冷静', + motivation: '测试', + combatStyle: '观察', + initialAffinity: 0, + relationshipHooks: [], + tags: [], + skills: [], + initialItems: [], + backstoryReveal: { + publicSummary: '测试', + privateChatUnlockAffinity: 0, + chapters: [], + }, + }, + ], + storyNpcs: [], + landmarks: [ + { + id: 'runtime-only-landmark', + name: '运行时临时地点', + description: '不应覆盖 foundation draft。', + dangerLevel: 'low', + sceneNpcIds: [], + connections: [], + }, + ], + sceneChapterBlueprints: [], + }, + }); + + assert.equal( + ( + nextDraftProfile.playableNpcs as Array<{ id?: string; name?: string }> + )[0]?.id, + 'playable-1', + ); + assert.equal( + ( + nextDraftProfile.playableNpcs as Array<{ id?: string; name?: string }> + )[0]?.name, + '沈砺', + ); + assert.equal( + ( + nextDraftProfile.landmarks as Array<{ id?: string; name?: string }> + )[0]?.id, + 'landmark-1', + ); + assert.equal( + ( + nextDraftProfile.legacyResultProfile as { + playableNpcs?: Array<{ id?: string }>; + } + ).playableNpcs?.[0]?.id, + 'runtime-only-role', + ); +}); diff --git a/server-node/src/services/customWorldAgentResultSyncService.ts b/server-node/src/services/customWorldAgentResultSyncService.ts new file mode 100644 index 00000000..8fb05b7e --- /dev/null +++ b/server-node/src/services/customWorldAgentResultSyncService.ts @@ -0,0 +1,150 @@ +import type { CustomWorldProfile } from '../modules/custom-world/runtimeTypes.js'; + +function toText(value: unknown) { + return typeof value === 'string' ? value.trim() : ''; +} + +function isRecord(value: unknown): value is Record { + return Boolean(value) && typeof value === 'object' && !Array.isArray(value); +} + +function toRecordArray(value: unknown) { + return Array.isArray(value) + ? value.filter((item): item is Record => isRecord(item)) + : []; +} + +function cloneJsonRecord(value: T): T { + return JSON.parse(JSON.stringify(value)) as T; +} + +function syncRoleAssetsFromResultProfile(params: { + currentRoles: unknown; + resultRoles: unknown; +}) { + const resultRoleById = new Map( + toRecordArray(params.resultRoles).map((role) => [toText(role.id), role]), + ); + + return toRecordArray(params.currentRoles).map((currentRole) => { + const resultRole = resultRoleById.get(toText(currentRole.id)); + if (!resultRole) { + return currentRole; + } + + return { + ...currentRole, + imageSrc: toText(resultRole.imageSrc) || null, + generatedVisualAssetId: toText(resultRole.generatedVisualAssetId) || null, + generatedAnimationSetId: + toText(resultRole.generatedAnimationSetId) || null, + animationMap: isRecord(resultRole.animationMap) + ? cloneJsonRecord(resultRole.animationMap) + : null, + } satisfies Record; + }); +} + +function syncLandmarkAssetsFromResultProfile(params: { + currentLandmarks: unknown; + resultLandmarks: unknown; +}) { + const resultLandmarkById = new Map( + toRecordArray(params.resultLandmarks).map((landmark) => [ + toText(landmark.id), + landmark, + ]), + ); + + return toRecordArray(params.currentLandmarks).map((currentLandmark) => { + const resultLandmark = resultLandmarkById.get(toText(currentLandmark.id)); + if (!resultLandmark) { + return currentLandmark; + } + + return { + ...currentLandmark, + imageSrc: toText(resultLandmark.imageSrc) || null, + } satisfies Record; + }); +} + +function syncSceneChapterAssetsFromResultProfile(params: { + currentSceneChapters: unknown; + resultSceneChapters: unknown; +}) { + const resultSceneChapterBySceneId = new Map( + toRecordArray(params.resultSceneChapters).map((chapter) => [ + toText(chapter.sceneId), + chapter, + ]), + ); + + return toRecordArray(params.currentSceneChapters).map((currentChapter) => { + const resultChapter = resultSceneChapterBySceneId.get( + toText(currentChapter.sceneId), + ); + if (!resultChapter) { + return currentChapter; + } + + const resultActById = new Map( + toRecordArray(resultChapter.acts).map((act) => [toText(act.id), act]), + ); + + return { + ...currentChapter, + acts: toRecordArray(currentChapter.acts).map((currentAct) => { + const resultAct = resultActById.get(toText(currentAct.id)); + if (!resultAct) { + return currentAct; + } + + return { + ...currentAct, + backgroundImageSrc: toText(resultAct.backgroundImageSrc) || null, + backgroundAssetId: toText(resultAct.backgroundAssetId) || null, + } satisfies Record; + }), + } satisfies Record; + }); +} + +export class CustomWorldAgentResultSyncService { + // 阶段一只允许结果页把摘要与资产确认结果回写进 foundation draft,避免 runtime 结构反向污染草稿真相源。 + syncResultProfileIntoDraftProfile(params: { + currentDraftProfile: Record | null | undefined; + resultProfile: CustomWorldProfile; + }) { + const currentDraftProfile = params.currentDraftProfile ?? {}; + const resultProfile = params.resultProfile; + + return { + ...currentDraftProfile, + name: resultProfile.name, + subtitle: resultProfile.subtitle, + summary: resultProfile.summary, + tone: resultProfile.tone, + playerGoal: resultProfile.playerGoal, + majorFactions: resultProfile.majorFactions, + coreConflicts: resultProfile.coreConflicts, + playableNpcs: syncRoleAssetsFromResultProfile({ + currentRoles: currentDraftProfile.playableNpcs, + resultRoles: resultProfile.playableNpcs, + }), + storyNpcs: syncRoleAssetsFromResultProfile({ + currentRoles: currentDraftProfile.storyNpcs, + resultRoles: resultProfile.storyNpcs, + }), + landmarks: syncLandmarkAssetsFromResultProfile({ + currentLandmarks: currentDraftProfile.landmarks, + resultLandmarks: resultProfile.landmarks, + }), + sceneChapters: syncSceneChapterAssetsFromResultProfile({ + currentSceneChapters: currentDraftProfile.sceneChapters, + resultSceneChapters: resultProfile.sceneChapterBlueprints, + }), + legacyResultProfile: resultProfile as unknown as Record, + } satisfies Record; + } +} diff --git a/server-node/src/services/customWorldAgentRoleAssetStateService.ts b/server-node/src/services/customWorldAgentRoleAssetStateService.ts index 04291936..b04e46b6 100644 --- a/server-node/src/services/customWorldAgentRoleAssetStateService.ts +++ b/server-node/src/services/customWorldAgentRoleAssetStateService.ts @@ -40,6 +40,14 @@ type DraftSceneChapterRecord = { acts: DraftSceneActRecord[]; }; +type DraftStandaloneSceneRecord = { + sceneId: string; + sceneName: string; + imageSrc: string | null; + assetId: string | null; + sceneKind: 'camp' | 'landmark'; +}; + type MergeRoleAssetIntoDraftProfilePayload = { roleId: string; portraitPath: string; @@ -244,6 +252,77 @@ function collectDraftSceneChapters(profileInput: unknown) { .filter((item): item is DraftSceneChapterRecord => Boolean(item)); } +function buildStandaloneSceneAssetSummary( + scene: DraftStandaloneSceneRecord, +): CustomWorldSceneAssetSummary { + const ready = Boolean(scene.imageSrc || scene.assetId); + + return { + sceneId: scene.sceneId, + sceneName: scene.sceneName, + actId: null, + actTitle: scene.sceneKind === 'camp' ? '营地正式背景图' : '场景正式背景图', + imageSrc: scene.imageSrc, + assetId: scene.assetId, + status: ready ? 'ready' : 'missing', + nextPointCost: ready ? 0 : 12, + }; +} + +function collectStandaloneSceneRecords( + profileInput: unknown, + coveredSceneIds: Set, +) { + const profile = toRecord(profileInput); + if (!profile) { + return [] as DraftStandaloneSceneRecord[]; + } + + const standaloneScenes: DraftStandaloneSceneRecord[] = []; + const camp = toRecord(profile.camp); + if (camp) { + const campId = toText(camp.id); + const campImageSrc = toText(camp.imageSrc) || null; + const campAssetId = toText(camp.generatedSceneAssetId) || null; + if ( + campId && + !coveredSceneIds.has(campId) && + Boolean(campImageSrc || campAssetId) + ) { + standaloneScenes.push({ + sceneId: campId, + sceneName: toText(camp.name) || '开局营地', + imageSrc: campImageSrc, + assetId: campAssetId, + sceneKind: 'camp', + }); + } + } + + toRecordArray(profile.landmarks).forEach((landmark, index) => { + const landmarkId = toText(landmark.id); + const landmarkImageSrc = toText(landmark.imageSrc) || null; + const landmarkAssetId = toText(landmark.generatedSceneAssetId) || null; + if ( + !landmarkId || + coveredSceneIds.has(landmarkId) || + !Boolean(landmarkImageSrc || landmarkAssetId) + ) { + return; + } + + standaloneScenes.push({ + sceneId: landmarkId, + sceneName: toText(landmark.name) || `关键地点 ${index + 1}`, + imageSrc: landmarkImageSrc, + assetId: landmarkAssetId, + sceneKind: 'landmark', + }); + }); + + return standaloneScenes; +} + export function resolveRoleAssetStatusLabel( status: CustomWorldRoleAssetStatus, ) { @@ -317,7 +396,7 @@ export function rebuildRoleAssetCoverage( const roleAssets = collectDraftRoles(draftProfile).map((entry) => buildRoleAssetSummary(entry), ); - const sceneAssets: CustomWorldSceneAssetSummary[] = collectDraftSceneChapters( + const chapterSceneAssets: CustomWorldSceneAssetSummary[] = collectDraftSceneChapters( draftProfile, ).flatMap((sceneChapter) => sceneChapter.acts.map((act) => { @@ -337,6 +416,14 @@ export function rebuildRoleAssetCoverage( } satisfies CustomWorldSceneAssetSummary; }), ); + const coveredSceneIds = new Set( + chapterSceneAssets.map((entry) => entry.sceneId).filter(Boolean), + ); + const standaloneSceneAssets = collectStandaloneSceneRecords( + draftProfile, + coveredSceneIds, + ).map((scene) => buildStandaloneSceneAssetSummary(scene)); + const sceneAssets = [...chapterSceneAssets, ...standaloneSceneAssets]; return { roleAssets, diff --git a/server-node/src/services/customWorldAgentSessionStore.ts b/server-node/src/services/customWorldAgentSessionStore.ts index 3ae5c140..73d5025e 100644 --- a/server-node/src/services/customWorldAgentSessionStore.ts +++ b/server-node/src/services/customWorldAgentSessionStore.ts @@ -1,546 +1,27 @@ import crypto from 'node:crypto'; import type { - CreatorIntentReadiness, CustomWorldAgentMessage, CustomWorldAgentOperationRecord, CustomWorldAgentSessionSnapshot, - CustomWorldAgentStage, - CustomWorldAssetCoverageSummary, - CustomWorldDraftCardSummary, - CustomWorldPendingClarification, - CustomWorldSuggestedAction, - EightAnchorContent, } from '../../../packages/shared/src/contracts/customWorldAgent.js'; import type { CustomWorldSessionRecord as LegacyCustomWorldSessionRecord } from '../../../packages/shared/src/contracts/runtime.js'; -import type { RuntimeRepositoryPort } from '../repositories/runtimeRepository.js'; import { - buildPendingClarifications, - evaluateCreatorIntentReadiness, - resolveCreatorIntentStage, -} from './customWorldAgentClarificationService.js'; + applyCustomWorldAgentSessionCompatibility, + isCustomWorldAgentSessionRecord, +} from './rpg-agent-session-store/rpgAgentSessionCompatibility.js'; +import { createCustomWorldAgentSessionRecord } from './rpg-agent-session-store/rpgAgentSessionFactory.js'; import { - normalizeCreatorIntentRecord, -} from './customWorldAgentIntentExtractionService.js'; -import { rebuildRoleAssetCoverage } from './customWorldAgentRoleAssetStateService.js'; + cloneRpgAgentSessionValue, + type CreateCustomWorldAgentSessionInput, + type CustomWorldAgentSessionRecord, + CUSTOM_WORLD_AGENT_SESSION_ID_PREFIX, +} from './rpg-agent-session-store/rpgAgentSessionRecord.js'; +import type { RpgAgentSessionRepositoryPort } from '../repositories/RpgAgentSessionRepository.js'; import { - buildAnchorPackFromEightAnchorContent, - buildCreatorIntentFromEightAnchorContent, - buildDraftSummaryFromEightAnchorContent, - buildDraftTitleFromEightAnchorContent, - buildEightAnchorContentFromCreatorIntent, - createEmptyEightAnchorContent, - estimateProgressPercentFromAnchorContent, - normalizeEightAnchorContent, -} from './eightAnchorCompatibilityService.js'; - -export const CUSTOM_WORLD_AGENT_SESSION_ID_PREFIX = - 'custom-world-agent-session-'; - -export type CustomWorldAgentSessionRecord = { - sessionId: string; - userId: string; - seedText: string; - currentTurn: number; - anchorContent: EightAnchorContent; - progressPercent: number; - lastAssistantReply: string | null; - stage: CustomWorldAgentStage; - focusCardId: string | null; - creatorIntent: Record | null; - creatorIntentReadiness: CreatorIntentReadiness; - anchorPack: Record | null; - lockState: Record | null; - draftProfile: Record | null; - messages: CustomWorldAgentMessage[]; - draftCards: CustomWorldDraftCardSummary[]; - pendingClarifications: CustomWorldPendingClarification[]; - suggestedActions: CustomWorldSuggestedAction[]; - recommendedReplies: string[]; - qualityFindings: Array<{ - id: string; - severity: 'info' | 'warning' | 'blocker'; - code: string; - targetId?: string | null; - message: string; - }>; - assetCoverage: CustomWorldAssetCoverageSummary; - operations: CustomWorldAgentOperationRecord[]; - checkpoints: Array<{ - checkpointId: string; - createdAt: string; - label: string; - }>; - createdAt: string; - updatedAt: string; -}; - -type CreateSessionInput = { - seedText?: string; - welcomeMessage: string; - currentTurn?: number; - anchorContent?: EightAnchorContent; - progressPercent?: number; - lastAssistantReply?: string | null; - pendingClarifications: CustomWorldAgentSessionRecord['pendingClarifications']; - creatorIntent?: CustomWorldAgentSessionRecord['creatorIntent']; - creatorIntentReadiness?: CreatorIntentReadiness; - anchorPack?: CustomWorldAgentSessionRecord['anchorPack']; - draftProfile?: CustomWorldAgentSessionRecord['draftProfile']; - stage?: CustomWorldAgentStage; - suggestedActions: CustomWorldSuggestedAction[]; - recommendedReplies?: string[]; -}; - -function cloneRecord(value: T): T { - return JSON.parse(JSON.stringify(value)) as T; -} - -function toRecord(value: unknown) { - return value && typeof value === 'object' && !Array.isArray(value) - ? (value as Record) - : null; -} - -function toText(value: unknown) { - return typeof value === 'string' ? value.trim() : ''; -} - -function isStage(value: unknown): value is CustomWorldAgentStage { - return ( - value === 'collecting_intent' || - value === 'clarifying' || - value === 'foundation_review' || - value === 'object_refining' || - value === 'visual_refining' || - value === 'long_tail_review' || - value === 'ready_to_publish' || - value === 'published' || - value === 'error' - ); -} - -function isAgentSessionRecord( - value: unknown, -): value is CustomWorldAgentSessionRecord { - const record = toRecord(value); - if (!record) { - return false; - } - - return ( - typeof record.sessionId === 'string' && - record.sessionId.startsWith(CUSTOM_WORLD_AGENT_SESSION_ID_PREFIX) && - typeof record.userId === 'string' && - isStage(record.stage) && - Array.isArray(record.messages) && - Array.isArray(record.operations) && - typeof record.createdAt === 'string' && - typeof record.updatedAt === 'string' - ); -} - -function isCreatorIntentReadiness( - value: unknown, -): value is CreatorIntentReadiness { - const record = toRecord(value); - if (!record) { - return false; - } - - return ( - typeof record.isReady === 'boolean' && - Array.isArray(record.completedKeys) && - Array.isArray(record.missingKeys) - ); -} - -function mapLegacyClarificationTargetKey(id: string) { - if (id === 'world_hook') return 'world_hook'; - if (id === 'player_premise') return 'player_premise'; - if (id === 'theme_and_tone' || id === 'tone_boundary') { - return 'theme_and_tone'; - } - if (id === 'core_conflict') return 'core_conflict'; - if (id === 'relationship_seed' || id === 'relationship_hook') { - return 'relationship_seed'; - } - if (id === 'iconic_element' || id === 'iconic_elements') { - return 'iconic_element'; - } - - return null; -} - -function hasUserInput(record: CustomWorldAgentSessionRecord) { - return ( - Boolean(record.seedText.trim()) || - record.messages.some( - (message) => message.role === 'user' && message.text.trim(), - ) - ); -} - -function buildCompatibleCreatorIntent(record: CustomWorldAgentSessionRecord) { - const compatibleAnchorIntent = buildCreatorIntentFromEightAnchorContent( - normalizeEightAnchorContent( - (record as Record).anchorContent ?? null, - ), - ); - - if ( - compatibleAnchorIntent && - (compatibleAnchorIntent.worldHook || - compatibleAnchorIntent.rawSettingText || - compatibleAnchorIntent.playerPremise || - compatibleAnchorIntent.openingSituation || - compatibleAnchorIntent.coreConflicts.length > 0 || - compatibleAnchorIntent.keyCharacters.length > 0 || - compatibleAnchorIntent.iconicElements.length > 0) - ) { - return compatibleAnchorIntent; - } - - return normalizeCreatorIntentRecord(record.creatorIntent); -} - -function buildCompatibleCurrentTurn(record: CustomWorldAgentSessionRecord) { - if (typeof (record as Record).currentTurn === 'number') { - return Math.max( - 0, - Math.round((record as Record).currentTurn as number), - ); - } - - return record.messages.filter((message) => message.role === 'user').length; -} - -function buildCompatibleAnchorContent(record: CustomWorldAgentSessionRecord) { - const normalized = normalizeEightAnchorContent( - (record as Record).anchorContent ?? null, - ); - - if ( - normalized.worldPromise || - normalized.playerFantasy || - normalized.themeBoundary || - normalized.playerEntryPoint || - normalized.coreConflict || - normalized.keyRelationships.length > 0 || - normalized.hiddenLines || - normalized.iconicElements - ) { - return normalized; - } - - return buildEightAnchorContentFromCreatorIntent( - buildCompatibleCreatorIntent(record), - ); -} - -function buildCompatibleProgressPercent(record: CustomWorldAgentSessionRecord) { - const rawProgress = (record as Record).progressPercent; - if (typeof rawProgress === 'number' && Number.isFinite(rawProgress)) { - return Math.max(0, Math.min(100, Math.round(rawProgress))); - } - - if ( - record.stage === 'foundation_review' || - record.stage === 'object_refining' || - record.stage === 'visual_refining' || - record.stage === 'long_tail_review' || - record.stage === 'ready_to_publish' || - record.stage === 'published' - ) { - return 100; - } - - return estimateProgressPercentFromAnchorContent( - buildCompatibleAnchorContent(record), - ); -} - -function buildCompatibleLastAssistantReply(record: CustomWorldAgentSessionRecord) { - const existingReply = (record as Record).lastAssistantReply; - if (typeof existingReply === 'string') { - return existingReply; - } - - const lastAssistantMessage = [...record.messages] - .reverse() - .find((message) => message.role === 'assistant' && message.text.trim()); - - return lastAssistantMessage?.text ?? null; -} - -function buildCompatibleReadiness(record: CustomWorldAgentSessionRecord) { - if ( - isCreatorIntentReadiness( - (record as Record).creatorIntentReadiness, - ) - ) { - return record.creatorIntentReadiness; - } - - return evaluateCreatorIntentReadiness( - normalizeCreatorIntentRecord(record.creatorIntent), - ); -} - -function buildCompatiblePendingClarifications( - record: CustomWorldAgentSessionRecord, -) { - const normalizedIntent = normalizeCreatorIntentRecord(record.creatorIntent); - const readiness = buildCompatibleReadiness(record); - const legacyClarifications = Array.isArray(record.pendingClarifications) - ? record.pendingClarifications - : []; - - const nextClarifications = legacyClarifications - .map((entry, index) => { - const targetKey = mapLegacyClarificationTargetKey(entry.id); - if (!targetKey) { - return null; - } - - return { - id: entry.id || targetKey, - label: entry.label || '待补充问题', - question: entry.question || '', - targetKey, - priority: - typeof entry.priority === 'number' ? entry.priority : index + 1, - answer: entry.answer, - } satisfies CustomWorldPendingClarification; - }) - .filter((entry): entry is CustomWorldPendingClarification => - Boolean(entry?.question), - ) - .slice(0, 3); - - if (nextClarifications.length > 0) { - return nextClarifications; - } - - return buildPendingClarifications(normalizedIntent, readiness); -} - -function buildCompatibleDraftProfile( - record: CustomWorldAgentSessionRecord, -) { - const anchorContent = buildCompatibleAnchorContent(record); - const existingDraftProfile = toRecord(record.draftProfile); - const hasFoundationContent = Boolean( - existingDraftProfile && - (typeof existingDraftProfile.name === 'string' || - Array.isArray(existingDraftProfile.playableNpcs) || - Array.isArray(existingDraftProfile.landmarks) || - Array.isArray(existingDraftProfile.factions) || - Array.isArray(existingDraftProfile.threads) || - Array.isArray(existingDraftProfile.chapters)), - ); - - if (hasFoundationContent) { - return { - ...existingDraftProfile, - name: - toText(existingDraftProfile?.name) || - toText(existingDraftProfile?.title) || - buildDraftTitleFromEightAnchorContent(anchorContent), - summary: - toText(existingDraftProfile?.summary) || - buildDraftSummaryFromEightAnchorContent(anchorContent), - }; - } - - return { - ...(existingDraftProfile ?? {}), - title: - toText(existingDraftProfile?.title) || - buildDraftTitleFromEightAnchorContent(anchorContent), - summary: - toText(existingDraftProfile?.summary) || - buildDraftSummaryFromEightAnchorContent(anchorContent), - }; -} - -function buildCompatibleSuggestedActions(params: { - record: CustomWorldAgentSessionRecord; - stage: CustomWorldAgentStage; - readiness: CreatorIntentReadiness; - draftProfile: Record; -}) { - if (params.record.suggestedActions.length > 0) { - return params.record.suggestedActions; - } - - const actions: CustomWorldSuggestedAction[] = [ - { - id: 'request_summary', - type: 'request_summary', - label: - params.stage === 'object_refining' || params.stage === 'visual_refining' - ? '总结当前世界底稿' - : '总结当前设定', - }, - ]; - const playableNpcs = Array.isArray(params.draftProfile.playableNpcs) - ? params.draftProfile.playableNpcs - : []; - const storyNpcs = Array.isArray(params.draftProfile.storyNpcs) - ? params.draftProfile.storyNpcs - : []; - const landmarks = Array.isArray(params.draftProfile.landmarks) - ? params.draftProfile.landmarks - : []; - - if (params.stage === 'foundation_review' && params.readiness.isReady) { - actions.push({ - id: 'draft_foundation', - type: 'draft_foundation', - label: '整理一版世界底稿', - }); - return actions; - } - - if (params.stage === 'object_refining' || params.stage === 'visual_refining') { - const firstCharacter = toRecord([...playableNpcs, ...storyNpcs][0]); - const firstLandmark = toRecord(landmarks[0]); - - actions.push({ - id: 'refine_world', - type: 'refine_focus_target', - label: '先看世界总卡', - targetId: 'world-foundation', - }); - - if (firstCharacter) { - actions.push({ - id: `refine-character-${toText(firstCharacter.id) || 'seed'}`, - type: 'refine_focus_target', - label: `精修角色:${toText(firstCharacter.name) || '关键角色'}`, - targetId: toText(firstCharacter.id) || null, - }); - } - - if (firstLandmark) { - actions.push({ - id: `refine-landmark-${toText(firstLandmark.id) || 'seed'}`, - type: 'refine_focus_target', - label: `继续补地点:${toText(firstLandmark.name) || '关键地点'}`, - targetId: toText(firstLandmark.id) || null, - }); - } - } - - return actions; -} - -function normalizeRecommendedReplies(value: unknown) { - if (!Array.isArray(value)) { - return []; - } - - return value - .map((item) => toText(item)) - .filter(Boolean) - .slice(0, 3); -} - -function buildCompatibleAssetCoverage( - record: CustomWorldAgentSessionRecord, - draftProfile: Record, -) { - const derivedCoverage = rebuildRoleAssetCoverage(draftProfile); - const existingCoverage = toRecord(record.assetCoverage); - const sceneAssets = - derivedCoverage.sceneAssets.length > 0 - ? derivedCoverage.sceneAssets - : Array.isArray(existingCoverage?.sceneAssets) - ? existingCoverage.sceneAssets - : []; - const allSceneAssetsReady = - derivedCoverage.sceneAssets.length > 0 - ? derivedCoverage.allSceneAssetsReady - : typeof existingCoverage?.allSceneAssetsReady === 'boolean' - ? existingCoverage.allSceneAssetsReady - : false; - - return { - ...derivedCoverage, - sceneAssets, - allSceneAssetsReady, - } satisfies CustomWorldAssetCoverageSummary; -} - -function applyCompatibility(record: CustomWorldAgentSessionRecord) { - const creatorIntent = buildCompatibleCreatorIntent(record); - const currentTurn = buildCompatibleCurrentTurn(record); - const anchorContent = buildCompatibleAnchorContent(record); - const progressPercent = buildCompatibleProgressPercent(record); - const lastAssistantReply = buildCompatibleLastAssistantReply(record); - const creatorIntentReadiness = - progressPercent >= 100 - ? { - isReady: true, - completedKeys: [ - 'world_hook', - 'player_premise', - 'theme_and_tone', - 'core_conflict', - 'relationship_seed', - 'iconic_element', - ], - missingKeys: [], - } - : evaluateCreatorIntentReadiness(creatorIntent); - const stage = - record.stage === 'object_refining' || - record.stage === 'visual_refining' || - record.stage === 'long_tail_review' || - record.stage === 'ready_to_publish' || - record.stage === 'published' - ? record.stage - : progressPercent >= 100 - ? ('foundation_review' as const) - : resolveCreatorIntentStage({ - hasUserInput: hasUserInput(record), - readiness: creatorIntentReadiness, - }); - const pendingClarifications = buildCompatiblePendingClarifications({ - ...record, - creatorIntent, - creatorIntentReadiness, - }); - const draftProfile = buildCompatibleDraftProfile(record); - - return { - ...record, - currentTurn, - anchorContent, - progressPercent, - lastAssistantReply, - stage, - creatorIntent, - creatorIntentReadiness, - anchorPack: - record.anchorPack && Object.keys(record.anchorPack).length > 0 - ? record.anchorPack - : buildAnchorPackFromEightAnchorContent(anchorContent, progressPercent), - draftProfile, - pendingClarifications, - suggestedActions: buildCompatibleSuggestedActions({ - record, - stage, - readiness: creatorIntentReadiness, - draftProfile, - }), - assetCoverage: buildCompatibleAssetCoverage(record, draftProfile), - recommendedReplies: normalizeRecommendedReplies( - (record as Record).recommendedReplies, - ), - } satisfies CustomWorldAgentSessionRecord; -} + RpgAgentSessionRepositoryAdapter, +} from './rpg-agent-session-store/rpgAgentSessionRepositoryAdapter.js'; +import { normalizeEightAnchorContent } from './eightAnchorCompatibilityService.js'; function toSnapshot( record: CustomWorldAgentSessionRecord, @@ -548,132 +29,118 @@ function toSnapshot( return { sessionId: record.sessionId, currentTurn: record.currentTurn, - anchorContent: cloneRecord(record.anchorContent), + anchorContent: cloneRpgAgentSessionValue(record.anchorContent), progressPercent: record.progressPercent, lastAssistantReply: record.lastAssistantReply, stage: record.stage, focusCardId: record.focusCardId, - creatorIntent: cloneRecord(record.creatorIntent), - creatorIntentReadiness: cloneRecord(record.creatorIntentReadiness), - anchorPack: cloneRecord(record.anchorPack), - lockState: cloneRecord(record.lockState), - draftProfile: cloneRecord(record.draftProfile), - messages: cloneRecord(record.messages), - draftCards: cloneRecord(record.draftCards), - pendingClarifications: cloneRecord(record.pendingClarifications), - suggestedActions: cloneRecord(record.suggestedActions), - recommendedReplies: cloneRecord(record.recommendedReplies), - qualityFindings: cloneRecord(record.qualityFindings), - assetCoverage: cloneRecord(record.assetCoverage), + creatorIntent: cloneRpgAgentSessionValue(record.creatorIntent), + creatorIntentReadiness: cloneRpgAgentSessionValue( + record.creatorIntentReadiness, + ), + anchorPack: cloneRpgAgentSessionValue(record.anchorPack), + lockState: cloneRpgAgentSessionValue(record.lockState), + draftProfile: cloneRpgAgentSessionValue(record.draftProfile), + messages: cloneRpgAgentSessionValue(record.messages), + draftCards: cloneRpgAgentSessionValue(record.draftCards), + pendingClarifications: cloneRpgAgentSessionValue( + record.pendingClarifications, + ), + suggestedActions: cloneRpgAgentSessionValue(record.suggestedActions), + recommendedReplies: cloneRpgAgentSessionValue(record.recommendedReplies), + qualityFindings: cloneRpgAgentSessionValue(record.qualityFindings), + assetCoverage: cloneRpgAgentSessionValue(record.assetCoverage), + checkpoints: record.checkpoints.map((checkpoint) => ({ + checkpointId: checkpoint.checkpointId, + createdAt: checkpoint.createdAt, + label: checkpoint.label, + })), updatedAt: record.updatedAt, }; } -export class CustomWorldAgentSessionStore { - constructor(private readonly runtimeRepository: RuntimeRepositoryPort) {} +function normalizeCompatibleSessionRecord( + record: CustomWorldAgentSessionRecord, +): CustomWorldAgentSessionRecord { + return cloneRpgAgentSessionValue( + applyCustomWorldAgentSessionCompatibility( + record, + ) as unknown as CustomWorldAgentSessionRecord, + ); +} - private async persist(record: CustomWorldAgentSessionRecord) { - await this.runtimeRepository.upsertCustomWorldSession( +export { CUSTOM_WORLD_AGENT_SESSION_ID_PREFIX }; +export type { CustomWorldAgentSessionRecord }; + +export class CustomWorldAgentSessionStore { + private readonly sessionRepository: RpgAgentSessionRepositoryAdapter; + + constructor(sessionRepository: RpgAgentSessionRepositoryPort) { + this.sessionRepository = new RpgAgentSessionRepositoryAdapter( + sessionRepository, + ); + } + + private async persist( + record: CustomWorldAgentSessionRecord, + ): Promise { + await this.sessionRepository.upsert( record.userId, record.sessionId, record as unknown as LegacyCustomWorldSessionRecord, ); - return cloneRecord(record); + + return cloneRpgAgentSessionValue(record); } private async mutate( userId: string, sessionId: string, mutateFn: (record: CustomWorldAgentSessionRecord) => void, - ) { + ): Promise { const current = await this.get(userId, sessionId); if (!current) { return null; } - const nextRecord = cloneRecord(current); + const nextRecord = cloneRpgAgentSessionValue(current); mutateFn(nextRecord); nextRecord.updatedAt = new Date().toISOString(); return this.persist(nextRecord); } - async create(userId: string, input: CreateSessionInput) { - const sessionId = `${CUSTOM_WORLD_AGENT_SESSION_ID_PREFIX}${crypto.randomBytes(16).toString('hex')}`; - const now = new Date().toISOString(); - const welcomeMessage: CustomWorldAgentMessage = { - id: `message-${crypto.randomBytes(8).toString('hex')}`, - role: 'assistant', - kind: 'chat', - text: input.welcomeMessage, - createdAt: now, - relatedOperationId: null, - }; - const record: CustomWorldAgentSessionRecord = { - sessionId, - userId, - seedText: input.seedText?.trim() ?? '', - currentTurn: Math.max(0, Math.round(input.currentTurn ?? 0)), - anchorContent: normalizeEightAnchorContent( - input.anchorContent ?? createEmptyEightAnchorContent(), - ), - progressPercent: Math.max( - 0, - Math.min(100, Math.round(input.progressPercent ?? 0)), - ), - lastAssistantReply: input.lastAssistantReply ?? input.welcomeMessage, - stage: input.stage ?? 'collecting_intent', - focusCardId: null, - creatorIntent: cloneRecord(input.creatorIntent ?? {}), - creatorIntentReadiness: input.creatorIntentReadiness ?? { - isReady: false, - completedKeys: [], - missingKeys: [], - }, - anchorPack: cloneRecord(input.anchorPack ?? {}), - lockState: {}, - draftProfile: cloneRecord(input.draftProfile ?? {}), - messages: [welcomeMessage], - draftCards: [], - pendingClarifications: cloneRecord(input.pendingClarifications), - suggestedActions: cloneRecord(input.suggestedActions), - recommendedReplies: cloneRecord(input.recommendedReplies ?? []), - qualityFindings: [], - assetCoverage: rebuildRoleAssetCoverage(input.draftProfile ?? {}), - operations: [], - checkpoints: [], - createdAt: now, - updatedAt: now, - }; - - const compatibleRecord = applyCompatibility(record); - await this.persist(compatibleRecord); - return cloneRecord(compatibleRecord); + async create( + userId: string, + input: CreateCustomWorldAgentSessionInput, + ): Promise { + const record = createCustomWorldAgentSessionRecord(userId, input); + await this.persist(record); + return cloneRpgAgentSessionValue(record); } - async list(userId: string) { - const records = - await this.runtimeRepository.listCustomWorldSessions(userId); + async list(userId: string): Promise { + const records = await this.sessionRepository.list(userId); return records - .filter((record) => isAgentSessionRecord(record)) - .map((record) => cloneRecord(applyCompatibility(record))) + .filter((record) => isCustomWorldAgentSessionRecord(record)) + .map((record) => normalizeCompatibleSessionRecord(record)) .sort((left, right) => right.updatedAt.localeCompare(left.updatedAt)); } - async get(userId: string, sessionId: string) { + async get( + userId: string, + sessionId: string, + ): Promise { if (!sessionId.trim()) { return null; } - const record = await this.runtimeRepository.getCustomWorldSession( - userId, - sessionId, - ); - if (!isAgentSessionRecord(record)) { + const record = await this.sessionRepository.get(userId, sessionId); + if (!isCustomWorldAgentSessionRecord(record)) { return null; } - return cloneRecord(applyCompatibility(record)); + return normalizeCompatibleSessionRecord(record); } async getSnapshot(userId: string, sessionId: string) { @@ -687,7 +154,7 @@ export class CustomWorldAgentSessionStore { message: CustomWorldAgentMessage, ) { return this.mutate(userId, sessionId, (record) => { - record.messages.push(cloneRecord(message)); + record.messages.push(cloneRpgAgentSessionValue(message)); }); } @@ -740,39 +207,47 @@ export class CustomWorldAgentSessionStore { record.focusCardId = patch.focusCardId; } if (patch.creatorIntent !== undefined) { - record.creatorIntent = cloneRecord(patch.creatorIntent); + record.creatorIntent = cloneRpgAgentSessionValue(patch.creatorIntent); } if (patch.creatorIntentReadiness !== undefined) { - record.creatorIntentReadiness = cloneRecord( + record.creatorIntentReadiness = cloneRpgAgentSessionValue( patch.creatorIntentReadiness, ); } if (patch.anchorPack !== undefined) { - record.anchorPack = cloneRecord(patch.anchorPack); + record.anchorPack = cloneRpgAgentSessionValue(patch.anchorPack); } if (patch.lockState !== undefined) { - record.lockState = cloneRecord(patch.lockState); + record.lockState = cloneRpgAgentSessionValue(patch.lockState); } if (patch.draftProfile !== undefined) { - record.draftProfile = cloneRecord(patch.draftProfile); + record.draftProfile = cloneRpgAgentSessionValue(patch.draftProfile); } if (patch.pendingClarifications !== undefined) { - record.pendingClarifications = cloneRecord(patch.pendingClarifications); + record.pendingClarifications = cloneRpgAgentSessionValue( + patch.pendingClarifications, + ); } if (patch.suggestedActions !== undefined) { - record.suggestedActions = cloneRecord(patch.suggestedActions); + record.suggestedActions = cloneRpgAgentSessionValue( + patch.suggestedActions, + ); } if (patch.recommendedReplies !== undefined) { - record.recommendedReplies = cloneRecord(patch.recommendedReplies); + record.recommendedReplies = cloneRpgAgentSessionValue( + patch.recommendedReplies, + ); } if (patch.draftCards !== undefined) { - record.draftCards = cloneRecord(patch.draftCards); + record.draftCards = cloneRpgAgentSessionValue(patch.draftCards); } if (patch.qualityFindings !== undefined) { - record.qualityFindings = cloneRecord(patch.qualityFindings); + record.qualityFindings = cloneRpgAgentSessionValue( + patch.qualityFindings, + ); } if (patch.assetCoverage !== undefined) { - record.assetCoverage = cloneRecord(patch.assetCoverage); + record.assetCoverage = cloneRpgAgentSessionValue(patch.assetCoverage); } }); } @@ -783,7 +258,7 @@ export class CustomWorldAgentSessionStore { operation: CustomWorldAgentOperationRecord, ) { return this.mutate(userId, sessionId, (record) => { - record.operations.push(cloneRecord(operation)); + record.operations.push(cloneRpgAgentSessionValue(operation)); }); } @@ -796,7 +271,7 @@ export class CustomWorldAgentSessionStore { const operation = record.operations.find( (item) => item.operationId === operationId, ); - return operation ? cloneRecord(operation) : null; + return operation ? cloneRpgAgentSessionValue(operation) : null; } async updateOperation( @@ -840,6 +315,28 @@ export class CustomWorldAgentSessionStore { input: { checkpointId?: string; label: string; + snapshot?: Partial< + Pick< + CustomWorldAgentSessionRecord, + | 'currentTurn' + | 'anchorContent' + | 'progressPercent' + | 'lastAssistantReply' + | 'stage' + | 'focusCardId' + | 'creatorIntent' + | 'creatorIntentReadiness' + | 'anchorPack' + | 'lockState' + | 'draftProfile' + | 'pendingClarifications' + | 'suggestedActions' + | 'recommendedReplies' + | 'draftCards' + | 'qualityFindings' + | 'assetCoverage' + > + > | null; }, ) { return this.mutate(userId, sessionId, (record) => { @@ -849,12 +346,106 @@ export class CustomWorldAgentSessionStore { `checkpoint-${crypto.randomBytes(8).toString('hex')}`, createdAt: new Date().toISOString(), label: input.label, + snapshot: input.snapshot + ? { + currentTurn: + typeof input.snapshot.currentTurn === 'number' + ? Math.max(0, Math.round(input.snapshot.currentTurn)) + : record.currentTurn, + anchorContent: cloneRpgAgentSessionValue( + input.snapshot.anchorContent ?? record.anchorContent, + ), + progressPercent: + typeof input.snapshot.progressPercent === 'number' + ? Math.max( + 0, + Math.min(100, Math.round(input.snapshot.progressPercent)), + ) + : record.progressPercent, + lastAssistantReply: + input.snapshot.lastAssistantReply ?? record.lastAssistantReply, + stage: input.snapshot.stage ?? record.stage, + focusCardId: + input.snapshot.focusCardId !== undefined + ? input.snapshot.focusCardId + : record.focusCardId, + creatorIntent: cloneRpgAgentSessionValue( + input.snapshot.creatorIntent ?? record.creatorIntent, + ), + creatorIntentReadiness: cloneRpgAgentSessionValue( + input.snapshot.creatorIntentReadiness ?? + record.creatorIntentReadiness, + ), + anchorPack: cloneRpgAgentSessionValue( + input.snapshot.anchorPack ?? record.anchorPack, + ), + lockState: cloneRpgAgentSessionValue( + input.snapshot.lockState ?? record.lockState, + ), + draftProfile: cloneRpgAgentSessionValue( + input.snapshot.draftProfile ?? record.draftProfile, + ), + pendingClarifications: cloneRpgAgentSessionValue( + input.snapshot.pendingClarifications ?? + record.pendingClarifications, + ), + suggestedActions: cloneRpgAgentSessionValue( + input.snapshot.suggestedActions ?? record.suggestedActions, + ), + recommendedReplies: cloneRpgAgentSessionValue( + input.snapshot.recommendedReplies ?? record.recommendedReplies, + ), + draftCards: cloneRpgAgentSessionValue( + input.snapshot.draftCards ?? record.draftCards, + ), + qualityFindings: cloneRpgAgentSessionValue( + input.snapshot.qualityFindings ?? record.qualityFindings, + ), + assetCoverage: cloneRpgAgentSessionValue( + input.snapshot.assetCoverage ?? record.assetCoverage, + ), + } + : null, }); }); } + async restoreCheckpoint( + userId: string, + sessionId: string, + checkpointId: string, + ) { + return this.mutate(userId, sessionId, (record) => { + const checkpoint = record.checkpoints.find( + (entry) => entry.checkpointId === checkpointId, + ); + if (!checkpoint?.snapshot) { + return; + } + + const snapshot = cloneRpgAgentSessionValue(checkpoint.snapshot); + record.currentTurn = snapshot.currentTurn; + record.anchorContent = snapshot.anchorContent; + record.progressPercent = snapshot.progressPercent; + record.lastAssistantReply = snapshot.lastAssistantReply; + record.stage = snapshot.stage; + record.focusCardId = snapshot.focusCardId; + record.creatorIntent = snapshot.creatorIntent; + record.creatorIntentReadiness = snapshot.creatorIntentReadiness; + record.anchorPack = snapshot.anchorPack; + record.lockState = snapshot.lockState; + record.draftProfile = snapshot.draftProfile; + record.pendingClarifications = snapshot.pendingClarifications; + record.suggestedActions = snapshot.suggestedActions; + record.recommendedReplies = snapshot.recommendedReplies; + record.draftCards = snapshot.draftCards; + record.qualityFindings = snapshot.qualityFindings; + record.assetCoverage = snapshot.assetCoverage; + }); + } + async listDraftCards(userId: string, sessionId: string) { const record = await this.get(userId, sessionId); - return record ? cloneRecord(record.draftCards) : null; + return record ? cloneRpgAgentSessionValue(record.draftCards) : null; } } diff --git a/server-node/src/services/customWorldAgentSnapshotBuilder.ts b/server-node/src/services/customWorldAgentSnapshotBuilder.ts new file mode 100644 index 00000000..76f69d3e --- /dev/null +++ b/server-node/src/services/customWorldAgentSnapshotBuilder.ts @@ -0,0 +1,199 @@ +import type { + CreatorIntentReadiness, + CustomWorldAgentStage, + CustomWorldAssetCoverageSummary, + CustomWorldDraftCardSummary, + CustomWorldPendingClarification, + EightAnchorContent, +} from '../../../packages/shared/src/contracts/customWorldAgent.js'; +import { + buildDraftSummaryFromIntent, + buildDraftTitleFromIntent, + type CustomWorldCreatorIntentRecord, +} from './customWorldAgentIntentExtractionService.js'; +import { CustomWorldAgentQualityGateService } from './customWorldAgentQualityGateService.js'; +import { rebuildRoleAssetCoverage } from './customWorldAgentRoleAssetStateService.js'; +import type { CustomWorldAgentSessionRecord } from './customWorldAgentSessionStore.js'; +import { CustomWorldAgentSuggestedActionService } from './customWorldAgentSuggestedActionService.js'; +import { buildAnchorPackFromEightAnchorContent } from './eightAnchorCompatibilityService.js'; +import { CustomWorldAgentDraftCompiler } from './customWorldAgentDraftCompiler.js'; + +export type CustomWorldAgentDerivedStatePatch = Partial< + Pick< + CustomWorldAgentSessionRecord, + | 'currentTurn' + | 'anchorContent' + | 'progressPercent' + | 'lastAssistantReply' + | 'stage' + | 'focusCardId' + | 'creatorIntent' + | 'creatorIntentReadiness' + | 'anchorPack' + | 'draftProfile' + | 'pendingClarifications' + | 'suggestedActions' + | 'recommendedReplies' + | 'draftCards' + | 'qualityFindings' + | 'assetCoverage' + > +>; + +export class CustomWorldAgentSnapshotBuilder { + constructor( + private readonly draftCompiler: CustomWorldAgentDraftCompiler, + private readonly suggestedActionService: CustomWorldAgentSuggestedActionService, + private readonly qualityGateService: CustomWorldAgentQualityGateService, + ) {} + + // 把“草稿改动后需要重算哪些派生字段”统一封装成一个入口,避免每个 action 都重复拼 patch。 + buildRefiningState(params: { + previousStage: CustomWorldAgentStage; + draftProfile: Record; + draftCards?: CustomWorldDraftCardSummary[]; + assetCoverage?: CustomWorldAssetCoverageSummary; + nextStage?: Extract< + CustomWorldAgentStage, + | 'object_refining' + | 'visual_refining' + | 'long_tail_review' + | 'ready_to_publish' + >; + focusCardId?: string | null; + }): CustomWorldAgentDerivedStatePatch { + const nextDraftCards = + params.draftCards ?? this.draftCompiler.compileDraftCards(params.draftProfile); + const assetCoverage = + params.assetCoverage ?? rebuildRoleAssetCoverage(params.draftProfile); + const nextStage = + params.nextStage ?? + (params.previousStage === 'visual_refining' + ? 'visual_refining' + : params.previousStage === 'long_tail_review' + ? 'long_tail_review' + : params.previousStage === 'ready_to_publish' + ? 'ready_to_publish' + : 'object_refining'); + + return { + stage: nextStage, + draftProfile: params.draftProfile, + draftCards: nextDraftCards, + assetCoverage, + qualityFindings: this.qualityGateService.buildQualityFindings({ + draftProfile: params.draftProfile, + assetCoverage, + stage: nextStage, + }), + focusCardId: params.focusCardId, + suggestedActions: this.suggestedActionService.buildSuggestedActions({ + stage: nextStage, + isReady: true, + draftProfile: params.draftProfile, + draftCards: nextDraftCards, + }), + recommendedReplies: [], + }; + } + + buildFoundationDraftState(params: { + creatorIntent: Record | null; + anchorPack: Record | null; + draftProfile: Record; + assetCoverage?: CustomWorldAssetCoverageSummary; + }): CustomWorldAgentDerivedStatePatch { + return { + ...this.buildRefiningState({ + previousStage: 'object_refining', + nextStage: 'object_refining', + draftProfile: params.draftProfile, + assetCoverage: params.assetCoverage, + }), + creatorIntent: params.creatorIntent, + anchorPack: params.anchorPack, + pendingClarifications: [], + }; + } + + buildMessageTurnState(params: { + latestSession: CustomWorldAgentSessionRecord; + nextAnchorContent: EightAnchorContent; + progressPercent: number; + replyText: string; + nextCreatorIntent: CustomWorldCreatorIntentRecord; + creatorIntentReadiness: CreatorIntentReadiness; + derivedDraftProfile: { + title: string; + summary: string; + }; + derivedPendingClarifications: CustomWorldPendingClarification[]; + derivedStage: CustomWorldAgentStage; + shouldStayInDraftStage: boolean; + }): CustomWorldAgentDerivedStatePatch { + const preservedStage = + params.latestSession.stage === 'visual_refining' + ? ('visual_refining' as const) + : ('object_refining' as const); + const nextDraftProfile = params.shouldStayInDraftStage + ? ((params.latestSession.draftProfile ?? {}) as Record) + : params.progressPercent >= 100 + ? { + title: buildDraftTitleFromIntent(params.nextCreatorIntent), + summary: buildDraftSummaryFromIntent(params.nextCreatorIntent), + } + : params.derivedDraftProfile; + const nextStage = params.shouldStayInDraftStage + ? preservedStage + : params.derivedStage; + const assetCoverage = params.shouldStayInDraftStage + ? params.latestSession.assetCoverage + : rebuildRoleAssetCoverage(nextDraftProfile); + + return { + currentTurn: params.latestSession.currentTurn + 1, + anchorContent: params.nextAnchorContent, + progressPercent: params.progressPercent, + lastAssistantReply: params.replyText, + stage: nextStage, + focusCardId: params.shouldStayInDraftStage + ? params.latestSession.focusCardId + : null, + creatorIntent: params.nextCreatorIntent, + creatorIntentReadiness: params.creatorIntentReadiness, + anchorPack: buildAnchorPackFromEightAnchorContent( + params.nextAnchorContent, + params.progressPercent, + ), + draftProfile: nextDraftProfile, + draftCards: params.shouldStayInDraftStage + ? params.latestSession.draftCards + : [], + assetCoverage, + qualityFindings: this.qualityGateService.buildQualityFindings({ + draftProfile: nextDraftProfile, + assetCoverage, + stage: nextStage, + }), + pendingClarifications: + params.progressPercent >= 100 ? [] : params.derivedPendingClarifications, + suggestedActions: params.shouldStayInDraftStage + ? this.suggestedActionService.buildSuggestedActions({ + stage: preservedStage, + isReady: true, + draftProfile: params.latestSession.draftProfile, + draftCards: params.latestSession.draftCards, + }) + : params.progressPercent >= 100 + ? [ + { + id: 'draft_foundation', + type: 'draft_foundation', + label: '生成游戏设定草稿', + }, + ] + : [], + recommendedReplies: [], + }; + } +} diff --git a/server-node/src/services/customWorldAgentSuggestedActionService.ts b/server-node/src/services/customWorldAgentSuggestedActionService.ts new file mode 100644 index 00000000..3516224a --- /dev/null +++ b/server-node/src/services/customWorldAgentSuggestedActionService.ts @@ -0,0 +1,82 @@ +import type { + CustomWorldAgentStage, + CustomWorldDraftCardSummary, + CustomWorldSuggestedAction, +} from '../../../packages/shared/src/contracts/customWorldAgent.js'; +import { + getWorldFoundationCardId, + normalizeFoundationDraftProfile, +} from './customWorldAgentDraftCompiler.js'; + +export class CustomWorldAgentSuggestedActionService { + // 统一维护 Agent 草稿阶段的建议动作,避免继续散落在 orchestrator 和 store 的兼容逻辑里。 + buildSuggestedActions( + params: { + stage?: CustomWorldAgentStage; + isReady?: boolean; + draftProfile?: unknown; + draftCards?: CustomWorldDraftCardSummary[]; + } = {}, + ): CustomWorldSuggestedAction[] { + const profile = normalizeFoundationDraftProfile(params.draftProfile); + const actions: CustomWorldSuggestedAction[] = [ + { + id: 'request_summary', + type: 'request_summary', + label: + params.stage === 'object_refining' || + params.stage === 'visual_refining' + ? '总结当前世界底稿' + : '总结当前设定', + }, + ]; + + if (params.stage === 'foundation_review' && params.isReady) { + actions.push({ + id: 'draft_foundation', + type: 'draft_foundation', + label: '整理一版世界底稿', + }); + return actions; + } + + if ( + (params.stage === 'object_refining' || + params.stage === 'visual_refining') && + profile + ) { + const worldCardId = + params.draftCards?.find((entry) => entry.kind === 'world')?.id ?? + getWorldFoundationCardId(); + const firstCharacter = [...profile.playableNpcs, ...profile.storyNpcs][0]; + const firstLandmark = profile.landmarks[0]; + + actions.push({ + id: 'refine_world', + type: 'refine_focus_target', + label: '先看世界总卡', + targetId: worldCardId, + }); + + if (firstCharacter) { + actions.push({ + id: `refine-character-${firstCharacter.id}`, + type: 'refine_focus_target', + label: `精修角色:${firstCharacter.name}`, + targetId: firstCharacter.id, + }); + } + + if (firstLandmark) { + actions.push({ + id: `refine-landmark-${firstLandmark.id}`, + type: 'refine_focus_target', + label: `继续补地点:${firstLandmark.name}`, + targetId: firstLandmark.id, + }); + } + } + + return actions; + } +} diff --git a/server-node/src/services/customWorldGenerationService.ts b/server-node/src/services/customWorldGenerationService.ts deleted file mode 100644 index 0a657318..00000000 --- a/server-node/src/services/customWorldGenerationService.ts +++ /dev/null @@ -1,33 +0,0 @@ -import type { AppContext } from '../context.js'; -import { - type CustomWorldGenerationProgress, - generateCustomWorldProfileFromOrchestrator, - type GenerateCustomWorldProfileInput, -} from '../modules/ai/customWorldOrchestrator.js'; -import type { CustomWorldSession } from './customWorldSessionStore.js'; - -export async function generateCustomWorldProfile( - context: AppContext, - session: CustomWorldSession, - options: { - onProgress?: (progress: CustomWorldGenerationProgress) => void; - signal?: AbortSignal; - } = {}, -) { - const input = { - settingText: session.settingText, - creatorIntent: session.creatorIntent, - generationMode: session.generationMode, - } satisfies GenerateCustomWorldProfileInput; - - const profile = await generateCustomWorldProfileFromOrchestrator( - context.llmClient, - input, - { - onProgress: options.onProgress, - signal: options.signal, - }, - ); - - return JSON.parse(JSON.stringify(profile)) as Record; -} diff --git a/server-node/src/services/customWorldSessionStore.ts b/server-node/src/services/customWorldSessionStore.ts deleted file mode 100644 index 6fb99c6a..00000000 --- a/server-node/src/services/customWorldSessionStore.ts +++ /dev/null @@ -1,229 +0,0 @@ -import crypto from 'node:crypto'; - -import type { JsonObject } from '../../../packages/shared/src/contracts/common.js'; -import type { - CustomWorldGenerationMode, - CustomWorldQuestion, - CustomWorldSessionRecord, - CustomWorldSessionStatus, -} from '../../../packages/shared/src/contracts/runtime.js'; -import type { RuntimeRepositoryPort } from '../repositories/runtimeRepository.js'; - -export type CustomWorldSession = { - sessionId: string; - status: CustomWorldSessionStatus; - settingText: string; - creatorIntent: JsonObject | null; - generationMode: CustomWorldGenerationMode; - questions: CustomWorldQuestion[]; - result?: JsonObject; - lastError?: string; - createdAt: string; - updatedAt: string; -}; - -function cloneSession(session: CustomWorldSession) { - return JSON.parse(JSON.stringify(session)) as CustomWorldSession; -} - -function toSessionRecord(session: CustomWorldSession): CustomWorldSessionRecord { - return { - sessionId: session.sessionId, - status: session.status, - settingText: session.settingText, - creatorIntent: session.creatorIntent, - generationMode: session.generationMode, - questions: session.questions, - result: session.result, - lastError: session.lastError, - createdAt: session.createdAt, - updatedAt: session.updatedAt, - }; -} - -function toSession(record: CustomWorldSessionRecord) { - return cloneSession({ - sessionId: record.sessionId, - status: record.status, - settingText: record.settingText, - creatorIntent: record.creatorIntent ?? null, - generationMode: record.generationMode, - questions: record.questions, - result: record.result, - lastError: record.lastError, - createdAt: record.createdAt, - updatedAt: record.updatedAt, - }); -} - -function hasPendingQuestion(questions: CustomWorldQuestion[]) { - return questions.some((question) => !question.answer?.trim()); -} - -function buildClarificationQuestions( - settingText: string, - creatorIntent: JsonObject | null, -) { - const questions: CustomWorldQuestion[] = []; - const worldHook = - typeof creatorIntent?.worldHook === 'string' ? creatorIntent.worldHook.trim() : ''; - const playerPremise = - typeof creatorIntent?.playerPremise === 'string' ? creatorIntent.playerPremise.trim() : ''; - const openingSituation = - typeof creatorIntent?.openingSituation === 'string' - ? creatorIntent.openingSituation.trim() - : ''; - const coreConflicts = Array.isArray(creatorIntent?.coreConflicts) - ? creatorIntent.coreConflicts - : []; - - if (!worldHook && settingText.trim().length < 24) { - questions.push({ - id: 'world_hook', - label: '世界核心', - question: '请用一句话补充这个世界最核心的命题或独特卖点。', - }); - } - if (!playerPremise) { - questions.push({ - id: 'player_premise', - label: '玩家身份', - question: '玩家在这个世界里是什么身份、立场或来历?', - }); - } - if (!openingSituation) { - questions.push({ - id: 'opening_situation', - label: '开局处境', - question: '故事开局时,玩家正处于什么局面?', - }); - } - if (coreConflicts.length === 0) { - questions.push({ - id: 'core_conflict', - label: '核心冲突', - question: '这个世界当前最核心的冲突、危机或悬念是什么?', - }); - } - - return questions; -} - -export class CustomWorldSessionStore { - constructor( - private readonly runtimeRepository: RuntimeRepositoryPort, - ) {} - - async create( - userId: string, - settingText: string, - creatorIntent: JsonObject | null, - generationMode: CustomWorldGenerationMode, - ) { - const sessionId = `custom-world-session-${crypto.randomBytes(16).toString('hex')}`; - const now = new Date().toISOString(); - const session: CustomWorldSession = { - sessionId, - status: 'ready_to_generate', - settingText, - creatorIntent, - generationMode, - questions: buildClarificationQuestions(settingText, creatorIntent), - createdAt: now, - updatedAt: now, - }; - - if (hasPendingQuestion(session.questions)) { - session.status = 'clarifying'; - } - - await this.runtimeRepository.upsertCustomWorldSession( - userId, - sessionId, - toSessionRecord(session), - ); - return cloneSession(session); - } - - async list(userId: string) { - const sessions = await this.runtimeRepository.listCustomWorldSessions(userId); - return sessions.map((session) => toSession(session)); - } - - async get(userId: string, sessionId: string) { - const session = await this.runtimeRepository.getCustomWorldSession( - userId, - sessionId, - ); - return session ? toSession(session) : null; - } - - async answer( - userId: string, - sessionId: string, - questionId: string, - answer: string, - ) { - const session = await this.get(userId, sessionId); - if (!session) { - return null; - } - - const question = session.questions.find((item) => item.id === questionId); - if (!question) { - return null; - } - - question.answer = answer; - session.status = hasPendingQuestion(session.questions) - ? 'clarifying' - : 'ready_to_generate'; - session.updatedAt = new Date().toISOString(); - await this.runtimeRepository.upsertCustomWorldSession( - userId, - sessionId, - toSessionRecord(session), - ); - return cloneSession(session); - } - - async updateStatus( - userId: string, - sessionId: string, - status: CustomWorldSessionStatus, - lastError = '', - ) { - const session = await this.get(userId, sessionId); - if (!session) { - return null; - } - - session.status = status; - session.lastError = lastError || undefined; - session.updatedAt = new Date().toISOString(); - await this.runtimeRepository.upsertCustomWorldSession( - userId, - sessionId, - toSessionRecord(session), - ); - return cloneSession(session); - } - - async setResult(userId: string, sessionId: string, result: JsonObject) { - const session = await this.get(userId, sessionId); - if (!session) { - return null; - } - - session.status = 'completed'; - session.lastError = undefined; - session.result = JSON.parse(JSON.stringify(result)) as JsonObject; - session.updatedAt = new Date().toISOString(); - await this.runtimeRepository.upsertCustomWorldSession( - userId, - sessionId, - toSessionRecord(session), - ); - return cloneSession(session); - } -} diff --git a/server-node/src/services/customWorldWorkSummaryService.integration.test.ts b/server-node/src/services/customWorldWorkSummaryService.integration.test.ts new file mode 100644 index 00000000..129ba6e9 --- /dev/null +++ b/server-node/src/services/customWorldWorkSummaryService.integration.test.ts @@ -0,0 +1,113 @@ +import assert from 'node:assert/strict'; +import test from 'node:test'; + +import { + createRpgAgentSessionFixture, + createRpgCreationWorksResponseFixture, + createRpgWorldLibraryEntryFixture, +} from '../../../packages/shared/src/contracts/rpgCreationFixtures.js'; +import type { CustomWorldSessionRecord } from '../../../packages/shared/src/contracts/runtime.js'; +import { createInMemoryRpgWorldRepositoryPorts } from './customWorldAgentRepositoryTestHelpers.js'; +import { + CUSTOM_WORLD_AGENT_SESSION_ID_PREFIX, + CustomWorldAgentSessionStore, +} from './customWorldAgentSessionStore.js'; +import { RpgWorldWorkSummaryService } from './RpgWorldWorkSummaryService.js'; + +test('work summary service can aggregate shared RPG fixtures into draft and published entries', async () => { + const sessionFixture = createRpgAgentSessionFixture(); + const sessionRecord: CustomWorldSessionRecord = { + ...JSON.parse(JSON.stringify(sessionFixture)), + sessionId: `${CUSTOM_WORLD_AGENT_SESSION_ID_PREFIX}fixture`, + userId: 'fixture-user', + seedText: '被海雾吞没的旧航路群岛', + operations: [], + checkpoints: [], + createdAt: sessionFixture.updatedAt, + updatedAt: sessionFixture.updatedAt, + } as CustomWorldSessionRecord; + const { rpgAgentSessionRepository, rpgWorldProfileRepository } = + createInMemoryRpgWorldRepositoryPorts({ + sessionRecords: [sessionRecord], + profileEntries: [createRpgWorldLibraryEntryFixture()], + }); + const sessionStore = new CustomWorldAgentSessionStore( + rpgAgentSessionRepository, + ); + const summaries = await new RpgWorldWorkSummaryService( + rpgWorldProfileRepository, + sessionStore, + ).list('fixture-user'); + const expected = createRpgCreationWorksResponseFixture(); + + assert.equal(summaries.length, expected.items.length); + + const draftItem = summaries.find((entry) => entry.sourceType === 'agent_session'); + const publishedItem = summaries.find( + (entry) => entry.sourceType === 'published_profile', + ); + const expectedDraft = expected.items.find( + (entry) => entry.sourceType === 'agent_session', + ); + const expectedPublished = expected.items.find( + (entry) => entry.sourceType === 'published_profile', + ); + + assert.ok(draftItem); + assert.ok(publishedItem); + assert.ok(expectedDraft); + assert.ok(expectedPublished); + + assert.equal(draftItem?.title, expectedDraft?.title); + assert.equal(draftItem?.subtitle, expectedDraft?.subtitle); + assert.equal(draftItem?.coverRenderMode, expectedDraft?.coverRenderMode); + assert.deepEqual( + draftItem?.coverCharacterImageSrcs, + expectedDraft?.coverCharacterImageSrcs, + ); + assert.equal(draftItem?.roleAssetSummaryLabel, expectedDraft?.roleAssetSummaryLabel); + assert.equal(draftItem?.publishReady, expectedDraft?.publishReady); + assert.equal(draftItem?.blockerCount, expectedDraft?.blockerCount); + + assert.equal(publishedItem?.title, expectedPublished?.title); + assert.equal(publishedItem?.profileId, expectedPublished?.profileId); + assert.equal(publishedItem?.canEnterWorld, true); + assert.equal(publishedItem?.coverRenderMode, expectedPublished?.coverRenderMode); +}); + +test('published agent sessions are filtered out after works unify to published profile truth', async () => { + const sessionFixture = createRpgAgentSessionFixture(); + const sessionRecord: CustomWorldSessionRecord = { + ...JSON.parse(JSON.stringify(sessionFixture)), + sessionId: `${CUSTOM_WORLD_AGENT_SESSION_ID_PREFIX}published-fixture`, + userId: 'fixture-user', + seedText: '被海雾吞没的旧航路群岛', + stage: 'published', + operations: [], + checkpoints: [], + createdAt: sessionFixture.updatedAt, + updatedAt: sessionFixture.updatedAt, + } as CustomWorldSessionRecord; + const { rpgAgentSessionRepository, rpgWorldProfileRepository } = + createInMemoryRpgWorldRepositoryPorts({ + sessionRecords: [sessionRecord], + profileEntries: [createRpgWorldLibraryEntryFixture()], + }); + const sessionStore = new CustomWorldAgentSessionStore( + rpgAgentSessionRepository, + ); + + const summaries = await new RpgWorldWorkSummaryService( + rpgWorldProfileRepository, + sessionStore, + ).list('fixture-user'); + + assert.equal( + summaries.some((entry) => entry.sourceType === 'agent_session'), + false, + ); + assert.equal( + summaries.filter((entry) => entry.sourceType === 'published_profile').length, + 1, + ); +}); diff --git a/server-node/src/services/customWorldWorkSummaryService.ts b/server-node/src/services/customWorldWorkSummaryService.ts deleted file mode 100644 index 79989c2a..00000000 --- a/server-node/src/services/customWorldWorkSummaryService.ts +++ /dev/null @@ -1,297 +0,0 @@ -import type { - CustomWorldAgentStage, - CustomWorldWorkSummary, -} from '../../../packages/shared/src/contracts/customWorldAgent.js'; -import type { - CustomWorldLibraryEntry, - CustomWorldProfileRecord, -} from '../../../packages/shared/src/contracts/runtime.js'; -import type { RuntimeRepositoryPort } from '../repositories/runtimeRepository.js'; -import { resolveCustomWorldCoverPresentation } from '../repositories/customWorldLibraryMetadata.js'; -import { normalizeFoundationDraftProfile } from './customWorldAgentDraftCompiler.js'; -import { - buildDraftSummaryFromIntent, - buildDraftTitleFromIntent, - normalizeCreatorIntentRecord, -} from './customWorldAgentIntentExtractionService.js'; -import { - rebuildRoleAssetCoverage, - resolveRoleAssetStatusLabel, -} from './customWorldAgentRoleAssetStateService.js'; -import type { - CustomWorldAgentSessionRecord, - CustomWorldAgentSessionStore, -} from './customWorldAgentSessionStore.js'; -import { - buildDraftSummaryFromEightAnchorContent, - buildDraftTitleFromEightAnchorContent, -} from './eightAnchorCompatibilityService.js'; - -function toText(value: unknown) { - return typeof value === 'string' ? value.trim() : ''; -} - -function toRecord(value: unknown) { - return value && typeof value === 'object' && !Array.isArray(value) - ? (value as Record) - : null; -} - -function toRecordArray(value: unknown) { - return Array.isArray(value) - ? value.filter((item) => item && typeof item === 'object') - : []; -} - -function truncateText(value: string, maxLength: number) { - if (value.length <= maxLength) { - return value; - } - - return `${value.slice(0, Math.max(0, maxLength - 1)).trim()}…`; -} - -function formatDraftStageLabel(stage: CustomWorldAgentStage) { - if (stage === 'collecting_intent') return '收集世界锚点'; - if (stage === 'clarifying') return '补齐关键锚点'; - if (stage === 'foundation_review') return '准备整理底稿'; - if (stage === 'object_refining') return '精修对象'; - if (stage === 'visual_refining') return '视觉工坊'; - if (stage === 'long_tail_review') return '扩展长尾'; - if (stage === 'ready_to_publish') return '准备发布'; - if (stage === 'published') return '已发布'; - return '发生错误'; -} - -function resolveDraftTitle(session: CustomWorldAgentSessionRecord) { - const intent = normalizeCreatorIntentRecord(session.creatorIntent); - const draftProfile = normalizeFoundationDraftProfile(session.draftProfile); - - return ( - draftProfile?.name || - buildDraftTitleFromEightAnchorContent(session.anchorContent) || - buildDraftTitleFromIntent(intent) || - toText(session.draftProfile?.title) || - truncateText(session.seedText, 18) || - '未命名草稿' - ); -} - -function resolveDraftSummary(session: CustomWorldAgentSessionRecord) { - const intent = normalizeCreatorIntentRecord(session.creatorIntent); - const compiledSummary = buildDraftSummaryFromIntent(intent); - const draftProfile = normalizeFoundationDraftProfile(session.draftProfile); - - return ( - draftProfile?.summary || - buildDraftSummaryFromEightAnchorContent(session.anchorContent) || - compiledSummary || - toText(session.draftProfile?.summary) || - truncateText(session.seedText, 72) || - '还在收集你的世界锚点。' - ); -} - -function resolveDraftCounts(session: CustomWorldAgentSessionRecord) { - const draftProfile = normalizeFoundationDraftProfile(session.draftProfile); - if (draftProfile) { - // 草稿列表里的“角色”展示的是当前草稿中全部可编辑角色,而不是仅限可扮演角色。 - const totalRoleCount = [ - ...new Set( - [...draftProfile.playableNpcs, ...draftProfile.storyNpcs].map( - (entry) => entry.id, - ), - ), - ].length; - - return { - playableNpcCount: totalRoleCount, - landmarkCount: draftProfile.landmarks.length, - }; - } - - const playableNpcCount = session.draftCards.filter( - (card) => card.kind === 'character', - ).length; - const landmarkCount = session.draftCards.filter( - (card) => card.kind === 'landmark' || card.kind === 'camp', - ).length; - - return { - playableNpcCount, - landmarkCount, - }; -} - -function resolveDraftRoleAssetProgress(session: CustomWorldAgentSessionRecord) { - const coverage = rebuildRoleAssetCoverage(session.draftProfile); - const roleVisualReadyCount = coverage.roleAssets.filter( - (entry) => entry.status !== 'missing', - ).length; - const roleAnimationReadyCount = coverage.roleAssets.filter( - (entry) => entry.status === 'complete', - ).length; - const leadRole = coverage.roleAssets[0]; - - return { - roleVisualReadyCount, - roleAnimationReadyCount, - roleAssetSummaryLabel: leadRole - ? `${leadRole.roleName} · ${resolveRoleAssetStatusLabel(leadRole.status)}` - : coverage.roleAssets.length > 0 - ? '角色资产进行中' - : null, - }; -} - -function resolveDraftCover(session: CustomWorldAgentSessionRecord) { - const draftProfile = toRecord(session.draftProfile); - if (!draftProfile) { - return { - imageSrc: null, - renderMode: 'image' as const, - characterImageSrcs: [], - }; - } - - return resolveCustomWorldCoverPresentation( - draftProfile as CustomWorldProfileRecord, - ); -} - -function isLibraryEntry( - value: unknown, -): value is CustomWorldLibraryEntry { - const record = toRecord(value); - return ( - Boolean(record) && - typeof record.ownerUserId === 'string' && - typeof record.profileId === 'string' && - Boolean(toRecord(record.profile)) - ); -} - -export async function listCustomWorldWorkSummaries( - userId: string, - dependencies: { - runtimeRepository: RuntimeRepositoryPort; - customWorldAgentSessions: CustomWorldAgentSessionStore; - }, -) { - const [profiles, sessions] = await Promise.all([ - dependencies.runtimeRepository.listCustomWorldProfiles(userId), - dependencies.customWorldAgentSessions.list(userId), - ]); - - const draftItems: CustomWorldWorkSummary[] = sessions.map((session) => { - const counts = resolveDraftCounts(session); - const roleAssetProgress = resolveDraftRoleAssetProgress(session); - const coverPresentation = resolveDraftCover(session); - - return { - workId: `draft:${session.sessionId}`, - sourceType: 'agent_session', - status: 'draft', - title: resolveDraftTitle(session), - subtitle: - normalizeFoundationDraftProfile(session.draftProfile)?.subtitle || - formatDraftStageLabel(session.stage), - summary: resolveDraftSummary(session), - coverImageSrc: coverPresentation.imageSrc, - coverRenderMode: coverPresentation.renderMode, - coverCharacterImageSrcs: coverPresentation.characterImageSrcs, - updatedAt: session.updatedAt, - publishedAt: null, - stage: session.stage, - stageLabel: formatDraftStageLabel(session.stage), - playableNpcCount: counts.playableNpcCount, - landmarkCount: counts.landmarkCount, - roleVisualReadyCount: roleAssetProgress.roleVisualReadyCount, - roleAnimationReadyCount: roleAssetProgress.roleAnimationReadyCount, - roleAssetSummaryLabel: roleAssetProgress.roleAssetSummaryLabel, - sessionId: session.sessionId, - profileId: null, - canResume: true, - canEnterWorld: false, - }; - }); - - const publishedItems: CustomWorldWorkSummary[] = profiles.map((profile) => { - const libraryEntry = isLibraryEntry(profile) ? profile : null; - const profileRecord = ( - libraryEntry?.profile ?? profile - ) as CustomWorldProfileRecord & Record; - const playableNpcs = toRecordArray(profileRecord.playableNpcs); - const landmarks = toRecordArray(profileRecord.landmarks); - const updatedAt = - (libraryEntry ? toText(libraryEntry.updatedAt) : '') || - toText(profileRecord.updatedAt) || - new Date().toISOString(); - const coverPresentation = resolveCustomWorldCoverPresentation(profileRecord); - const roleVisualReadyCount = playableNpcs.filter( - (entry) => - Boolean(toText(entry.imageSrc)) && - Boolean(toText(entry.generatedVisualAssetId)), - ).length; - const roleAnimationReadyCount = playableNpcs.filter( - (entry) => Boolean(toText(entry.generatedAnimationSetId)), - ).length; - - return { - workId: `published:${toText(profileRecord.id) || updatedAt}`, - sourceType: 'published_profile', - status: 'published', - title: - (libraryEntry ? toText(libraryEntry.worldName) : '') || - toText(profileRecord.name) || - '未命名世界', - subtitle: - (libraryEntry ? toText(libraryEntry.subtitle) : '') || - toText(profileRecord.subtitle) || - '已保存作品', - summary: - (libraryEntry ? toText(libraryEntry.summaryText) : '') || - toText(profileRecord.summary) || - '这个世界已经可以直接进入体验。', - coverImageSrc: - (libraryEntry ? libraryEntry.coverImageSrc : null) || - coverPresentation.imageSrc, - coverRenderMode: coverPresentation.renderMode, - coverCharacterImageSrcs: coverPresentation.characterImageSrcs, - updatedAt, - publishedAt: - (libraryEntry ? toText(libraryEntry.publishedAt) : '') || - toText(profileRecord.publishedAt) || - updatedAt, - stage: 'published', - stageLabel: '已发布', - playableNpcCount: - (libraryEntry?.playableNpcCount ?? 0) > 0 - ? libraryEntry!.playableNpcCount - : playableNpcs.length, - landmarkCount: - (libraryEntry?.landmarkCount ?? 0) > 0 - ? libraryEntry!.landmarkCount - : landmarks.length, - roleVisualReadyCount, - roleAnimationReadyCount, - roleAssetSummaryLabel: - roleAnimationReadyCount > 0 - ? `动作已就绪 ${roleAnimationReadyCount}` - : roleVisualReadyCount > 0 - ? `主图已就绪 ${roleVisualReadyCount}` - : null, - sessionId: null, - profileId: - (libraryEntry ? toText(libraryEntry.profileId) : '') || - toText(profileRecord.id) || - null, - canResume: false, - canEnterWorld: true, - }; - }); - - return [...draftItems, ...publishedItems].sort((left, right) => - right.updatedAt.localeCompare(left.updatedAt), - ); -} diff --git a/server-node/src/services/questService.ts b/server-node/src/services/questService.ts index e3774f73..7bc8db97 100644 --- a/server-node/src/services/questService.ts +++ b/server-node/src/services/questService.ts @@ -1,11 +1,11 @@ -import type { QuestGenerationRequest } from '../../../packages/shared/src/contracts/story.js'; +import type { QuestGenerationRequest } from '../../../packages/shared/src/contracts/rpgRuntimeQuestAssist.js'; import { QUEST_INTIMACY_LEVELS, QUEST_NARRATIVE_TYPES, QUEST_OBJECTIVE_KINDS, QUEST_REWARD_THEMES, QUEST_URGENCY_LEVELS, -} from '../../../packages/shared/src/contracts/story.js'; +} from '../../../packages/shared/src/contracts/rpgRuntimeQuestAssist.js'; import { parseJsonResponseText } from '../../../packages/shared/src/llm/parsers.js'; import { buildFallbackQuestIntent, diff --git a/server-node/src/services/rpg-agent-session-store/rpgAgentSessionCompatibility.test.ts b/server-node/src/services/rpg-agent-session-store/rpgAgentSessionCompatibility.test.ts new file mode 100644 index 00000000..a2fcac97 --- /dev/null +++ b/server-node/src/services/rpg-agent-session-store/rpgAgentSessionCompatibility.test.ts @@ -0,0 +1,158 @@ +import assert from 'node:assert/strict'; +import test from 'node:test'; + +import { createRpgAgentSessionFixture } from '../../../../packages/shared/src/contracts/rpgCreationFixtures.js'; +import { applyCustomWorldAgentSessionCompatibility } from './rpgAgentSessionCompatibility.js'; + +function createLegacySessionRecord() { + const session = createRpgAgentSessionFixture(); + + return { + ...JSON.parse(JSON.stringify(session)), + userId: 'fixture-user', + seedText: '被海雾吞没的旧航路群岛', + operations: [], + checkpoints: [], + createdAt: session.updatedAt, + updatedAt: session.updatedAt, + }; +} + +test('session compatibility can backfill foundation_review state directly without store participation', () => { + const legacyRecord = createLegacySessionRecord(); + legacyRecord.stage = 'collecting_intent'; + legacyRecord.progressPercent = 0; + legacyRecord.currentTurn = 0; + legacyRecord.lastAssistantReply = null; + legacyRecord.anchorPack = {}; + legacyRecord.pendingClarifications = []; + legacyRecord.suggestedActions = []; + legacyRecord.recommendedReplies = []; + legacyRecord.creatorIntentReadiness = { + isReady: false, + completedKeys: [], + missingKeys: [], + }; + legacyRecord.anchorContent = {}; + legacyRecord.creatorIntent = { + rawSettingText: '', + worldHook: '被海雾吞没的旧航路群岛', + playerPremise: '玩家回到群岛调查沉船真相。', + openingSituation: '首夜就有陌生船只闯入禁航区。', + themeKeywords: ['压抑', '潮湿', '悬疑'], + toneDirectives: ['旧灯塔', '潮雾'], + coreConflicts: ['守灯会与航运公会争夺旧航路控制权'], + keyCharacters: [ + { + id: 'playable-1', + name: '沈砺', + role: '旧航路引路人', + publicMask: '看上去像可靠旧友。', + hiddenHook: '暗中替沉船商盟引路。', + relationToPlayer: '旧友兼潜在背叛者', + notes: '关键同行者。', + }, + ], + iconicElements: ['回潮旧灯塔', '会移动的海雾'], + }; + legacyRecord.draftProfile = { + title: '潮雾列岛', + summary: '第一版世界底稿已经整理完成。', + playableNpcs: [ + { + id: 'playable-1', + name: '沈砺', + role: '旧航路引路人', + }, + ], + landmarks: [ + { + id: 'landmark-1', + name: '回潮旧灯塔', + description: '被海雾啃旧的石塔仍在夜里维持着微弱灯火。', + }, + ], + }; + + const normalized = applyCustomWorldAgentSessionCompatibility(legacyRecord); + + assert.equal(normalized.stage, 'foundation_review'); + assert.equal(normalized.progressPercent, 0); + assert.equal(normalized.creatorIntentReadiness.isReady, true); + assert.match( + normalized.lastAssistantReply ?? '', + /世界底稿已整理完成|结果页确认资产与发布门槛/u, + ); + assert.equal(normalized.pendingClarifications.length, 0); + assert.equal( + normalized.suggestedActions.some( + (entry) => entry.type === 'draft_foundation', + ), + true, + ); + assert.equal(normalized.anchorContent.worldPromise?.hook, '被海雾吞没的旧航路群岛'); + assert.equal(normalized.draftProfile.name, '潮雾列岛'); + assert.equal(normalized.assetCoverage.roleAssets.length, 1); +}); + +test('session compatibility can recover missing clarifications and anchor pack from sparse collecting records', () => { + const legacyRecord = createLegacySessionRecord(); + legacyRecord.seedText = ''; + legacyRecord.stage = 'collecting_intent'; + legacyRecord.progressPercent = Number.NaN; + legacyRecord.currentTurn = undefined; + legacyRecord.lastAssistantReply = undefined; + legacyRecord.anchorPack = null; + legacyRecord.pendingClarifications = []; + legacyRecord.suggestedActions = []; + legacyRecord.recommendedReplies = [1, '继续补世界一句话', null]; + legacyRecord.anchorContent = {}; + legacyRecord.creatorIntent = { + rawSettingText: '', + worldHook: '一个被潮雾反复切开的边境世界。', + playerPremise: '', + openingSituation: '', + themeKeywords: [], + toneDirectives: [], + coreConflicts: [], + keyCharacters: [], + iconicElements: [], + }; + legacyRecord.messages = [ + { + id: 'message-user', + role: 'user', + kind: 'chat', + text: '这个世界先定成一个被潮雾反复切开的边境世界。', + createdAt: legacyRecord.updatedAt, + relatedOperationId: null, + }, + { + id: 'message-assistant', + role: 'assistant', + kind: 'chat', + text: '你好!我是你的世界设定助手。', + createdAt: legacyRecord.updatedAt, + relatedOperationId: null, + }, + ]; + legacyRecord.draftProfile = { + title: '潮雾边境', + summary: '还在收集你的世界锚点。', + }; + + const normalized = applyCustomWorldAgentSessionCompatibility(legacyRecord); + + assert.equal(normalized.stage, 'clarifying'); + assert.ok(normalized.progressPercent > 0); + assert.equal(normalized.creatorIntentReadiness.isReady, false); + assert.ok(normalized.pendingClarifications.length > 0); + assert.ok(normalized.pendingClarifications[0]?.question); + assert.ok(normalized.anchorPack); + assert.deepEqual(normalized.recommendedReplies, ['继续补世界一句话']); + assert.ok( + normalized.suggestedActions.some( + (entry) => entry.type === 'request_summary', + ), + ); +}); diff --git a/server-node/src/services/rpg-agent-session-store/rpgAgentSessionCompatibility.ts b/server-node/src/services/rpg-agent-session-store/rpgAgentSessionCompatibility.ts new file mode 100644 index 00000000..be7ac766 --- /dev/null +++ b/server-node/src/services/rpg-agent-session-store/rpgAgentSessionCompatibility.ts @@ -0,0 +1,443 @@ +import type { + CreatorIntentReadiness, + CustomWorldAgentStage, + CustomWorldAssetCoverageSummary, + CustomWorldPendingClarification, +} from '../../../../packages/shared/src/contracts/customWorldAgent.js'; +import { + buildPendingClarifications, + evaluateCreatorIntentReadiness, + resolveCreatorIntentStage, +} from '../customWorldAgentClarificationService.js'; +import { + normalizeCreatorIntentRecord, +} from '../customWorldAgentIntentExtractionService.js'; +import { rebuildRoleAssetCoverage } from '../customWorldAgentRoleAssetStateService.js'; +import { + buildAnchorPackFromEightAnchorContent, + buildCreatorIntentFromEightAnchorContent, + buildDraftSummaryFromEightAnchorContent, + buildDraftTitleFromEightAnchorContent, + buildEightAnchorContentFromCreatorIntent, + estimateProgressPercentFromAnchorContent, + normalizeEightAnchorContent, +} from '../eightAnchorCompatibilityService.js'; +import { + CUSTOM_WORLD_AGENT_SESSION_ID_PREFIX, + type CustomWorldAgentSessionRecord, +} from './rpgAgentSessionRecord.js'; + +function toRecord(value: unknown) { + return value && typeof value === 'object' && !Array.isArray(value) + ? (value as Record) + : null; +} + +function toText(value: unknown) { + return typeof value === 'string' ? value.trim() : ''; +} + +function isStage(value: unknown): value is CustomWorldAgentStage { + return ( + value === 'collecting_intent' || + value === 'clarifying' || + value === 'foundation_review' || + value === 'object_refining' || + value === 'visual_refining' || + value === 'long_tail_review' || + value === 'ready_to_publish' || + value === 'published' || + value === 'error' + ); +} + +export function isCustomWorldAgentSessionRecord( + value: unknown, +): value is CustomWorldAgentSessionRecord { + const record = toRecord(value); + if (!record) { + return false; + } + + return ( + typeof record.sessionId === 'string' && + record.sessionId.startsWith(CUSTOM_WORLD_AGENT_SESSION_ID_PREFIX) && + typeof record.userId === 'string' && + isStage(record.stage) && + Array.isArray(record.messages) && + Array.isArray(record.operations) && + typeof record.createdAt === 'string' && + typeof record.updatedAt === 'string' + ); +} + +function isCreatorIntentReadiness( + value: unknown, +): value is CreatorIntentReadiness { + const record = toRecord(value); + if (!record) { + return false; + } + + return ( + typeof record.isReady === 'boolean' && + Array.isArray(record.completedKeys) && + Array.isArray(record.missingKeys) + ); +} + +function mapLegacyClarificationTargetKey(id: string) { + if (id === 'world_hook') return 'world_hook'; + if (id === 'player_premise') return 'player_premise'; + if (id === 'theme_and_tone' || id === 'tone_boundary') { + return 'theme_and_tone'; + } + if (id === 'core_conflict') return 'core_conflict'; + if (id === 'relationship_seed' || id === 'relationship_hook') { + return 'relationship_seed'; + } + if (id === 'iconic_element' || id === 'iconic_elements') { + return 'iconic_element'; + } + + return null; +} + +function hasUserInput(record: CustomWorldAgentSessionRecord) { + return ( + Boolean(record.seedText.trim()) || + record.messages.some( + (message) => message.role === 'user' && message.text.trim(), + ) + ); +} + +function buildCompatibleCreatorIntent(record: CustomWorldAgentSessionRecord) { + const compatibleAnchorIntent = buildCreatorIntentFromEightAnchorContent( + normalizeEightAnchorContent( + (record as Record).anchorContent ?? null, + ), + ); + + if ( + compatibleAnchorIntent && + (compatibleAnchorIntent.worldHook || + compatibleAnchorIntent.rawSettingText || + compatibleAnchorIntent.playerPremise || + compatibleAnchorIntent.openingSituation || + compatibleAnchorIntent.coreConflicts.length > 0 || + compatibleAnchorIntent.keyCharacters.length > 0 || + compatibleAnchorIntent.iconicElements.length > 0) + ) { + return compatibleAnchorIntent; + } + + return normalizeCreatorIntentRecord(record.creatorIntent); +} + +function buildCompatibleCurrentTurn(record: CustomWorldAgentSessionRecord) { + if (typeof (record as Record).currentTurn === 'number') { + return Math.max( + 0, + Math.round((record as Record).currentTurn as number), + ); + } + + return record.messages.filter((message) => message.role === 'user').length; +} + +function buildCompatibleAnchorContent(record: CustomWorldAgentSessionRecord) { + const normalized = normalizeEightAnchorContent( + (record as Record).anchorContent ?? null, + ); + + if ( + normalized.worldPromise || + normalized.playerFantasy || + normalized.themeBoundary || + normalized.playerEntryPoint || + normalized.coreConflict || + normalized.keyRelationships.length > 0 || + normalized.hiddenLines || + normalized.iconicElements + ) { + return normalized; + } + + return buildEightAnchorContentFromCreatorIntent( + buildCompatibleCreatorIntent(record), + ); +} + +function buildCompatibleProgressPercent(record: CustomWorldAgentSessionRecord) { + const rawProgress = (record as Record).progressPercent; + if (typeof rawProgress === 'number' && Number.isFinite(rawProgress)) { + return Math.max(0, Math.min(100, Math.round(rawProgress))); + } + + if ( + record.stage === 'foundation_review' || + record.stage === 'object_refining' || + record.stage === 'visual_refining' || + record.stage === 'long_tail_review' || + record.stage === 'ready_to_publish' || + record.stage === 'published' + ) { + return 100; + } + + return estimateProgressPercentFromAnchorContent( + buildCompatibleAnchorContent(record), + ); +} + +function buildCompatibleLastAssistantReply(record: CustomWorldAgentSessionRecord) { + const existingReply = (record as Record).lastAssistantReply; + if (typeof existingReply === 'string') { + return existingReply; + } + + const lastAssistantMessage = [...record.messages] + .reverse() + .find((message) => message.role === 'assistant' && message.text.trim()); + + return lastAssistantMessage?.text ?? null; +} + +function buildCompatiblePendingClarifications( + record: CustomWorldAgentSessionRecord, +) { + const normalizedIntent = normalizeCreatorIntentRecord(record.creatorIntent); + const readiness = buildCompatibleReadiness(record); + const legacyClarifications = Array.isArray(record.pendingClarifications) + ? record.pendingClarifications + : []; + + const nextClarifications = legacyClarifications + .map((entry, index) => { + const targetKey = mapLegacyClarificationTargetKey(entry.id); + if (!targetKey) { + return null; + } + + return { + id: entry.id || targetKey, + label: entry.label || '待补充问题', + question: entry.question || '', + targetKey, + priority: + typeof entry.priority === 'number' ? entry.priority : index + 1, + answer: entry.answer, + } satisfies CustomWorldPendingClarification; + }) + .filter((entry): entry is CustomWorldPendingClarification => + Boolean(entry?.question), + ) + .slice(0, 3); + + if (nextClarifications.length > 0) { + return nextClarifications; + } + + return buildPendingClarifications(normalizedIntent, readiness); +} + +function buildCompatibleReadiness(record: CustomWorldAgentSessionRecord) { + if ( + isCreatorIntentReadiness( + (record as Record).creatorIntentReadiness, + ) + ) { + return record.creatorIntentReadiness; + } + + return evaluateCreatorIntentReadiness( + normalizeCreatorIntentRecord(record.creatorIntent), + ); +} + +function buildCompatibleDraftProfile( + record: CustomWorldAgentSessionRecord, +) { + const anchorContent = buildCompatibleAnchorContent(record); + const existingDraftProfile = toRecord(record.draftProfile); + const hasFoundationContent = Boolean( + existingDraftProfile && + (typeof existingDraftProfile.name === 'string' || + Array.isArray(existingDraftProfile.playableNpcs) || + Array.isArray(existingDraftProfile.landmarks) || + Array.isArray(existingDraftProfile.factions) || + Array.isArray(existingDraftProfile.threads) || + Array.isArray(existingDraftProfile.chapters)), + ); + + if (hasFoundationContent) { + return { + ...existingDraftProfile, + name: + toText(existingDraftProfile?.name) || + toText(existingDraftProfile?.title) || + buildDraftTitleFromEightAnchorContent(anchorContent), + summary: + toText(existingDraftProfile?.summary) || + buildDraftSummaryFromEightAnchorContent(anchorContent), + }; + } + + return { + ...(existingDraftProfile ?? {}), + title: + toText(existingDraftProfile?.title) || + buildDraftTitleFromEightAnchorContent(anchorContent), + summary: + toText(existingDraftProfile?.summary) || + buildDraftSummaryFromEightAnchorContent(anchorContent), + }; +} + +function buildCompatibleSuggestedActions(params: { + record: CustomWorldAgentSessionRecord; + stage: CustomWorldAgentStage; + readiness: CreatorIntentReadiness; +}) { + if (params.record.suggestedActions.length > 0) { + // 旧快照里可能带有“精修对象”的 Agent 建议动作;当前 Agent 对话框只服务八锚点收集。 + const compatibleActions = params.record.suggestedActions.filter( + (action) => action.type !== 'refine_focus_target', + ); + if (compatibleActions.length > 0) { + return compatibleActions; + } + } + + const actions = [ + { + id: 'request_summary', + type: 'request_summary', + label: + params.stage === 'object_refining' || params.stage === 'visual_refining' + ? '总结当前世界底稿' + : '总结当前设定', + }, + ] as CustomWorldAgentSessionRecord['suggestedActions']; + + if (params.stage === 'foundation_review' && params.readiness.isReady) { + actions.push({ + id: 'draft_foundation', + type: 'draft_foundation', + label: '整理一版世界底稿', + }); + } + + return actions; +} + +function normalizeRecommendedReplies(value: unknown) { + if (!Array.isArray(value)) { + return []; + } + + return value + .map((item) => toText(item)) + .filter(Boolean) + .slice(0, 3); +} + +function buildCompatibleAssetCoverage( + record: CustomWorldAgentSessionRecord, + draftProfile: Record, +) { + const derivedCoverage = rebuildRoleAssetCoverage(draftProfile); + const existingCoverage = toRecord(record.assetCoverage); + const sceneAssets = + derivedCoverage.sceneAssets.length > 0 + ? derivedCoverage.sceneAssets + : Array.isArray(existingCoverage?.sceneAssets) + ? existingCoverage.sceneAssets + : []; + const allSceneAssetsReady = + derivedCoverage.sceneAssets.length > 0 + ? derivedCoverage.allSceneAssetsReady + : typeof existingCoverage?.allSceneAssetsReady === 'boolean' + ? existingCoverage.allSceneAssetsReady + : false; + + return { + ...derivedCoverage, + sceneAssets, + allSceneAssetsReady, + } satisfies CustomWorldAssetCoverageSummary; +} + +/** + * 兼容层集中收口旧 session 字段兜底,避免继续把兼容判断散落回 store 主逻辑。 + */ +export function applyCustomWorldAgentSessionCompatibility( + record: CustomWorldAgentSessionRecord, +) { + const creatorIntent = buildCompatibleCreatorIntent(record); + const currentTurn = buildCompatibleCurrentTurn(record); + const anchorContent = buildCompatibleAnchorContent(record); + const progressPercent = buildCompatibleProgressPercent(record); + const lastAssistantReply = buildCompatibleLastAssistantReply(record); + const creatorIntentReadiness = + progressPercent >= 100 + ? { + isReady: true, + completedKeys: [ + 'world_hook', + 'player_premise', + 'theme_and_tone', + 'core_conflict', + 'relationship_seed', + 'iconic_element', + ], + missingKeys: [], + } + : evaluateCreatorIntentReadiness(creatorIntent); + const stage = + record.stage === 'object_refining' || + record.stage === 'visual_refining' || + record.stage === 'long_tail_review' || + record.stage === 'ready_to_publish' || + record.stage === 'published' + ? record.stage + : progressPercent >= 100 + ? ('foundation_review' as const) + : resolveCreatorIntentStage({ + hasUserInput: hasUserInput(record), + readiness: creatorIntentReadiness, + }); + const pendingClarifications = buildCompatiblePendingClarifications({ + ...record, + creatorIntent, + creatorIntentReadiness, + }); + const draftProfile = buildCompatibleDraftProfile(record); + + return { + ...record, + currentTurn, + anchorContent, + progressPercent, + lastAssistantReply, + stage, + creatorIntent, + creatorIntentReadiness, + anchorPack: + record.anchorPack && Object.keys(record.anchorPack).length > 0 + ? record.anchorPack + : buildAnchorPackFromEightAnchorContent(anchorContent, progressPercent), + draftProfile, + pendingClarifications, + suggestedActions: buildCompatibleSuggestedActions({ + record, + stage, + readiness: creatorIntentReadiness, + }), + assetCoverage: buildCompatibleAssetCoverage(record, draftProfile), + recommendedReplies: normalizeRecommendedReplies( + (record as Record).recommendedReplies, + ), + } satisfies CustomWorldAgentSessionRecord; +} diff --git a/server-node/src/services/rpg-agent-session-store/rpgAgentSessionFactory.ts b/server-node/src/services/rpg-agent-session-store/rpgAgentSessionFactory.ts new file mode 100644 index 00000000..aeaaebd2 --- /dev/null +++ b/server-node/src/services/rpg-agent-session-store/rpgAgentSessionFactory.ts @@ -0,0 +1,74 @@ +import crypto from 'node:crypto'; + +import type { CustomWorldAgentMessage } from '../../../../packages/shared/src/contracts/customWorldAgent.js'; +import { rebuildRoleAssetCoverage } from '../customWorldAgentRoleAssetStateService.js'; +import { + createEmptyEightAnchorContent, + normalizeEightAnchorContent, +} from '../eightAnchorCompatibilityService.js'; +import { applyCustomWorldAgentSessionCompatibility } from './rpgAgentSessionCompatibility.js'; +import { + cloneRpgAgentSessionValue, + type CreateCustomWorldAgentSessionInput, + type CustomWorldAgentSessionRecord, + CUSTOM_WORLD_AGENT_SESSION_ID_PREFIX, +} from './rpgAgentSessionRecord.js'; + +/** + * 新建 session 的初始值统一在这里生成,后续 store 只负责持久化与状态变更。 + */ +export function createCustomWorldAgentSessionRecord( + userId: string, + input: CreateCustomWorldAgentSessionInput, +) { + const sessionId = `${CUSTOM_WORLD_AGENT_SESSION_ID_PREFIX}${crypto.randomBytes(16).toString('hex')}`; + const now = new Date().toISOString(); + const welcomeMessage: CustomWorldAgentMessage = { + id: `message-${crypto.randomBytes(8).toString('hex')}`, + role: 'assistant', + kind: 'chat', + text: input.welcomeMessage, + createdAt: now, + relatedOperationId: null, + }; + const record: CustomWorldAgentSessionRecord = { + sessionId, + userId, + seedText: input.seedText?.trim() ?? '', + currentTurn: Math.max(0, Math.round(input.currentTurn ?? 0)), + anchorContent: normalizeEightAnchorContent( + input.anchorContent ?? createEmptyEightAnchorContent(), + ), + progressPercent: Math.max( + 0, + Math.min(100, Math.round(input.progressPercent ?? 0)), + ), + lastAssistantReply: input.lastAssistantReply ?? input.welcomeMessage, + stage: input.stage ?? 'collecting_intent', + focusCardId: null, + creatorIntent: cloneRpgAgentSessionValue(input.creatorIntent ?? {}), + creatorIntentReadiness: input.creatorIntentReadiness ?? { + isReady: false, + completedKeys: [], + missingKeys: [], + }, + anchorPack: cloneRpgAgentSessionValue(input.anchorPack ?? {}), + lockState: {}, + draftProfile: cloneRpgAgentSessionValue(input.draftProfile ?? {}), + messages: [welcomeMessage], + draftCards: [], + pendingClarifications: cloneRpgAgentSessionValue( + input.pendingClarifications, + ), + suggestedActions: cloneRpgAgentSessionValue(input.suggestedActions), + recommendedReplies: cloneRpgAgentSessionValue(input.recommendedReplies ?? []), + qualityFindings: [], + assetCoverage: rebuildRoleAssetCoverage(input.draftProfile ?? {}), + operations: [], + checkpoints: [], + createdAt: now, + updatedAt: now, + }; + + return applyCustomWorldAgentSessionCompatibility(record); +} diff --git a/server-node/src/services/rpg-agent-session-store/rpgAgentSessionRecord.ts b/server-node/src/services/rpg-agent-session-store/rpgAgentSessionRecord.ts new file mode 100644 index 00000000..6681c9f5 --- /dev/null +++ b/server-node/src/services/rpg-agent-session-store/rpgAgentSessionRecord.ts @@ -0,0 +1,98 @@ +import type { + CreatorIntentReadiness, + CustomWorldAgentMessage, + CustomWorldAgentOperationRecord, + CustomWorldAgentStage, + CustomWorldAssetCoverageSummary, + CustomWorldDraftCardSummary, + CustomWorldPendingClarification, + CustomWorldSuggestedAction, + EightAnchorContent, +} from '../../../../packages/shared/src/contracts/customWorldAgent.js'; + +/** + * 当前阶段仍沿用旧 sessionId 前缀,避免影响已落库数据与前端恢复逻辑。 + */ +export const CUSTOM_WORLD_AGENT_SESSION_ID_PREFIX = + 'custom-world-agent-session-'; + +export type CustomWorldAgentSessionRecord = { + sessionId: string; + userId: string; + seedText: string; + currentTurn: number; + anchorContent: EightAnchorContent; + progressPercent: number; + lastAssistantReply: string | null; + stage: CustomWorldAgentStage; + focusCardId: string | null; + creatorIntent: Record | null; + creatorIntentReadiness: CreatorIntentReadiness; + anchorPack: Record | null; + lockState: Record | null; + draftProfile: Record | null; + messages: CustomWorldAgentMessage[]; + draftCards: CustomWorldDraftCardSummary[]; + pendingClarifications: CustomWorldPendingClarification[]; + suggestedActions: CustomWorldSuggestedAction[]; + recommendedReplies: string[]; + qualityFindings: Array<{ + id: string; + severity: 'info' | 'warning' | 'blocker'; + code: string; + targetId?: string | null; + message: string; + }>; + assetCoverage: CustomWorldAssetCoverageSummary; + operations: CustomWorldAgentOperationRecord[]; + checkpoints: Array<{ + checkpointId: string; + createdAt: string; + label: string; + snapshot?: { + currentTurn: number; + anchorContent: EightAnchorContent; + progressPercent: number; + lastAssistantReply: string | null; + stage: CustomWorldAgentStage; + focusCardId: string | null; + creatorIntent: Record | null; + creatorIntentReadiness: CreatorIntentReadiness; + anchorPack: Record | null; + lockState: Record | null; + draftProfile: Record | null; + pendingClarifications: CustomWorldAgentSessionRecord['pendingClarifications']; + suggestedActions: CustomWorldAgentSessionRecord['suggestedActions']; + recommendedReplies: string[]; + draftCards: CustomWorldDraftCardSummary[]; + qualityFindings: CustomWorldAgentSessionRecord['qualityFindings']; + assetCoverage: CustomWorldAssetCoverageSummary; + } | null; + }>; + createdAt: string; + updatedAt: string; +}; + +export type CreateCustomWorldAgentSessionInput = { + seedText?: string; + welcomeMessage: string; + currentTurn?: number; + anchorContent?: EightAnchorContent; + progressPercent?: number; + lastAssistantReply?: string | null; + pendingClarifications: CustomWorldAgentSessionRecord['pendingClarifications']; + creatorIntent?: CustomWorldAgentSessionRecord['creatorIntent']; + creatorIntentReadiness?: CreatorIntentReadiness; + anchorPack?: CustomWorldAgentSessionRecord['anchorPack']; + draftProfile?: CustomWorldAgentSessionRecord['draftProfile']; + stage?: CustomWorldAgentStage; + suggestedActions: CustomWorldSuggestedAction[]; + recommendedReplies?: string[]; +}; + +/** + * session 记录里大量字段都是 JSON 结构,统一走结构化克隆可避免调用方误共享引用。 + */ +export function cloneRpgAgentSessionValue(value: T): T { + return JSON.parse(JSON.stringify(value)) as T; +} diff --git a/server-node/src/services/rpg-agent-session-store/rpgAgentSessionRepositoryAdapter.ts b/server-node/src/services/rpg-agent-session-store/rpgAgentSessionRepositoryAdapter.ts new file mode 100644 index 00000000..76153a53 --- /dev/null +++ b/server-node/src/services/rpg-agent-session-store/rpgAgentSessionRepositoryAdapter.ts @@ -0,0 +1,24 @@ +import type { CustomWorldSessionRecord } from '../../../../packages/shared/src/contracts/runtime.js'; +import type { RpgAgentSessionRepositoryPort } from '../../repositories/RpgAgentSessionRepository.js'; + +export class RpgAgentSessionRepositoryAdapter { + constructor( + private readonly repository: RpgAgentSessionRepositoryPort, + ) {} + + async list(userId: string) { + return this.repository.listSessions(userId); + } + + async get(userId: string, sessionId: string) { + return this.repository.getSession(userId, sessionId); + } + + async upsert( + userId: string, + sessionId: string, + session: CustomWorldSessionRecord, + ) { + return this.repository.upsertSession(userId, sessionId, session); + } +} diff --git a/server-node/src/services/rpgCreationPreviewProfileBuilder.ts b/server-node/src/services/rpgCreationPreviewProfileBuilder.ts new file mode 100644 index 00000000..112133d7 --- /dev/null +++ b/server-node/src/services/rpgCreationPreviewProfileBuilder.ts @@ -0,0 +1,348 @@ +import type { CustomWorldProfileRecord } from '../../../packages/shared/src/contracts/runtime.js'; +import { buildRpgWorldPreviewProfile } from './RpgWorldPreviewCompiler.js'; +import { normalizeFoundationDraftProfile } from './customWorldAgentDraftCompiler.js'; + +function toText(value: unknown) { + return typeof value === 'string' ? value.trim() : ''; +} + +function isRecord(value: unknown): value is Record { + return Boolean(value) && typeof value === 'object' && !Array.isArray(value); +} + +function toRecordArray(value: unknown) { + return Array.isArray(value) + ? value.filter( + (entry): entry is Record => + Boolean(entry) && typeof entry === 'object' && !Array.isArray(entry), + ) + : []; +} + +function cloneRecord>(value: T): T { + return JSON.parse(JSON.stringify(value)) as T; +} + +function normalizeMatchText(value: unknown) { + return toText(value).toLocaleLowerCase(); +} + +function findUnusedMatchIndex( + records: Record[], + usedIndexes: Set, + matcher: (record: Record) => boolean, +) { + const matchedIndex = records.findIndex( + (record, index) => !usedIndexes.has(index) && matcher(record), + ); + if (matchedIndex >= 0) { + usedIndexes.add(matchedIndex); + } + return matchedIndex; +} + +function mergeDraftRolesIntoProfileRecord(params: { + baseRoles: unknown; + draftRoles: Array>; +}) { + const baseRoles = toRecordArray(params.baseRoles); + if (params.draftRoles.length <= 0) { + return baseRoles; + } + + const usedIndexes = new Set(); + return params.draftRoles.map((draftRole) => { + let matchedIndex = findUnusedMatchIndex( + baseRoles, + usedIndexes, + (record) => toText(record.id) === toText(draftRole.id), + ); + + if (matchedIndex < 0) { + matchedIndex = findUnusedMatchIndex( + baseRoles, + usedIndexes, + (record) => + normalizeMatchText(record.name) === normalizeMatchText(draftRole.name), + ); + } + + const baseRole = matchedIndex >= 0 ? baseRoles[matchedIndex] : null; + return { + ...(baseRole ?? {}), + ...draftRole, + imageSrc: toText(draftRole.imageSrc) || toText(baseRole?.imageSrc) || undefined, + generatedVisualAssetId: + toText(draftRole.generatedVisualAssetId) || + toText(baseRole?.generatedVisualAssetId) || + undefined, + generatedAnimationSetId: + toText(draftRole.generatedAnimationSetId) || + toText(baseRole?.generatedAnimationSetId) || + undefined, + animationMap: + isRecord(draftRole.animationMap) + ? draftRole.animationMap + : isRecord(baseRole?.animationMap) + ? baseRole.animationMap + : undefined, + } satisfies Record; + }); +} + +function mergeDraftLandmarksIntoProfileRecord(params: { + baseLandmarks: unknown; + draftLandmarks: Array>; +}) { + const baseLandmarks = toRecordArray(params.baseLandmarks); + if (params.draftLandmarks.length <= 0) { + return baseLandmarks; + } + + const usedIndexes = new Set(); + return params.draftLandmarks.map((draftLandmark) => { + let matchedIndex = findUnusedMatchIndex( + baseLandmarks, + usedIndexes, + (record) => toText(record.id) === toText(draftLandmark.id), + ); + + if (matchedIndex < 0) { + matchedIndex = findUnusedMatchIndex( + baseLandmarks, + usedIndexes, + (record) => + normalizeMatchText(record.name) === + normalizeMatchText(draftLandmark.name), + ); + } + + const baseLandmark = matchedIndex >= 0 ? baseLandmarks[matchedIndex] : null; + return { + ...(baseLandmark ?? {}), + ...draftLandmark, + imageSrc: + toText(draftLandmark.imageSrc) || toText(baseLandmark?.imageSrc) || undefined, + generatedSceneAssetId: + toText(draftLandmark.generatedSceneAssetId) || + toText(baseLandmark?.generatedSceneAssetId) || + undefined, + generatedScenePrompt: + toText(draftLandmark.generatedScenePrompt) || + toText(baseLandmark?.generatedScenePrompt) || + undefined, + generatedSceneModel: + toText(draftLandmark.generatedSceneModel) || + toText(baseLandmark?.generatedSceneModel) || + undefined, + } satisfies Record; + }); +} + +function mergeDraftSceneChaptersIntoProfileRecord(params: { + baseSceneChapters: unknown; + draftSceneChapters: unknown; +}) { + const baseSceneChapters = toRecordArray(params.baseSceneChapters); + const draftSceneChapters = toRecordArray(params.draftSceneChapters); + if (draftSceneChapters.length <= 0) { + return baseSceneChapters; + } + + const usedChapterIndexes = new Set(); + return draftSceneChapters.map((draftChapter) => { + let matchedIndex = findUnusedMatchIndex( + baseSceneChapters, + usedChapterIndexes, + (record) => toText(record.sceneId) === toText(draftChapter.sceneId), + ); + + if (matchedIndex < 0) { + matchedIndex = findUnusedMatchIndex( + baseSceneChapters, + usedChapterIndexes, + (record) => + normalizeMatchText(record.title) === normalizeMatchText(draftChapter.title), + ); + } + + const baseChapter = matchedIndex >= 0 ? baseSceneChapters[matchedIndex] : null; + const baseActs = toRecordArray(baseChapter?.acts); + const usedActIndexes = new Set(); + const mergedActs = toRecordArray(draftChapter.acts).map((draftAct) => { + let matchedActIndex = findUnusedMatchIndex( + baseActs, + usedActIndexes, + (record) => toText(record.id) === toText(draftAct.id), + ); + + if (matchedActIndex < 0) { + matchedActIndex = findUnusedMatchIndex( + baseActs, + usedActIndexes, + (record) => + normalizeMatchText(record.title) === + normalizeMatchText(draftAct.title), + ); + } + + const baseAct = matchedActIndex >= 0 ? baseActs[matchedActIndex] : null; + return { + ...(baseAct ?? {}), + ...draftAct, + backgroundImageSrc: + toText(draftAct.backgroundImageSrc) || + toText(baseAct?.backgroundImageSrc) || + undefined, + backgroundAssetId: + toText(draftAct.backgroundAssetId) || + toText(baseAct?.backgroundAssetId) || + undefined, + } satisfies Record; + }); + + return { + ...(baseChapter ?? {}), + ...draftChapter, + acts: mergedActs, + } satisfies Record; + }); +} + +function mergeDraftCampIntoProfileRecord(params: { + baseCamp: unknown; + draftCamp: unknown; +}) { + const draftCamp = isRecord(params.draftCamp) ? params.draftCamp : null; + if (!draftCamp) { + return isRecord(params.baseCamp) ? params.baseCamp : undefined; + } + + const baseCamp = isRecord(params.baseCamp) ? params.baseCamp : null; + return { + ...(baseCamp ?? {}), + ...draftCamp, + imageSrc: toText(draftCamp.imageSrc) || toText(baseCamp?.imageSrc) || undefined, + generatedSceneAssetId: + toText(draftCamp.generatedSceneAssetId) || + toText(baseCamp?.generatedSceneAssetId) || + undefined, + generatedScenePrompt: + toText(draftCamp.generatedScenePrompt) || + toText(baseCamp?.generatedScenePrompt) || + undefined, + generatedSceneModel: + toText(draftCamp.generatedSceneModel) || + toText(baseCamp?.generatedSceneModel) || + undefined, + } satisfies Record; +} + +function buildPreviewRawProfileSeed(params: { + sessionId: string; + profileId: string; + draftProfile: Record; +}) { + const foundationDraft = normalizeFoundationDraftProfile(params.draftProfile); + if (!foundationDraft) { + throw new Error('当前世界草稿为空,无法构建结果页预览。'); + } + + const legacyResultProfile = isRecord(params.draftProfile.legacyResultProfile) + ? cloneRecord(params.draftProfile.legacyResultProfile) + : null; + + const baseProfile = legacyResultProfile ?? { + id: params.profileId, + settingText: foundationDraft.worldHook, + name: foundationDraft.name, + subtitle: foundationDraft.subtitle, + summary: foundationDraft.summary, + tone: foundationDraft.tone, + playerGoal: foundationDraft.playerGoal, + templateWorldType: 'WUXIA', + majorFactions: foundationDraft.majorFactions, + coreConflicts: foundationDraft.coreConflicts, + playableNpcs: [], + storyNpcs: [], + items: [], + landmarks: [], + camp: null, + sceneChapterBlueprints: [], + generationMode: 'full', + generationStatus: 'complete', + }; + + return { + ...baseProfile, + id: params.profileId, + settingText: + toText(baseProfile.settingText) || foundationDraft.worldHook || foundationDraft.summary, + name: foundationDraft.name, + subtitle: foundationDraft.subtitle, + summary: foundationDraft.summary, + tone: foundationDraft.tone, + playerGoal: foundationDraft.playerGoal, + majorFactions: foundationDraft.majorFactions, + coreConflicts: foundationDraft.coreConflicts, + playableNpcs: mergeDraftRolesIntoProfileRecord({ + baseRoles: baseProfile.playableNpcs, + draftRoles: toRecordArray(params.draftProfile.playableNpcs), + }), + storyNpcs: mergeDraftRolesIntoProfileRecord({ + baseRoles: baseProfile.storyNpcs, + draftRoles: toRecordArray(params.draftProfile.storyNpcs), + }), + landmarks: mergeDraftLandmarksIntoProfileRecord({ + baseLandmarks: baseProfile.landmarks, + draftLandmarks: toRecordArray(params.draftProfile.landmarks), + }), + camp: mergeDraftCampIntoProfileRecord({ + baseCamp: baseProfile.camp, + draftCamp: params.draftProfile.camp, + }), + sceneChapterBlueprints: mergeDraftSceneChaptersIntoProfileRecord({ + baseSceneChapters: baseProfile.sceneChapterBlueprints, + draftSceneChapters: params.draftProfile.sceneChapters, + }), + creatorIntent: + (params.draftProfile.creatorIntent as Record | undefined) ?? + (baseProfile.creatorIntent as Record | undefined) ?? + null, + anchorPack: + (params.draftProfile.anchorPack as Record | undefined) ?? + (baseProfile.anchorPack as Record | undefined) ?? + null, + lockState: + (params.draftProfile.lockState as Record | undefined) ?? + (baseProfile.lockState as Record | undefined) ?? + null, + generationMode: 'full', + generationStatus: 'complete', + } satisfies Record; +} + +/** + * 结果页预览与正式发布统一走同一套“foundation draft + legacy 富字段合并”规则, + * 这样 Phase5 才能安全删除前端本地 fallback 编译桥。 + */ +export function buildRpgCreationPreviewProfileFromDraftProfile(params: { + sessionId: string; + draftProfile: Record; + profileId?: string; +}) { + const profileId = + toText(params.profileId) || + toText(params.draftProfile.legacyResultProfile?.id) || + `agent-draft-${params.sessionId}`; + const mergedProfile = buildPreviewRawProfileSeed({ + sessionId: params.sessionId, + profileId, + draftProfile: params.draftProfile, + }); + + return buildRpgWorldPreviewProfile( + mergedProfile, + toText(mergedProfile.settingText) || '', + ) as unknown as CustomWorldProfileRecord; +} diff --git a/server-node/src/services/runtimeItemService.ts b/server-node/src/services/runtimeItemService.ts index 20b9bdfd..c8a21f7c 100644 --- a/server-node/src/services/runtimeItemService.ts +++ b/server-node/src/services/runtimeItemService.ts @@ -1,10 +1,10 @@ import { RUNTIME_ITEM_FUNCTIONAL_BIAS_VALUES, RUNTIME_ITEM_TONE_VALUES, -} from '../../../packages/shared/src/contracts/story.js'; +} from '../../../packages/shared/src/contracts/rpgRuntimeQuestAssist.js'; import type { RuntimeItemIntentRequest, -} from '../../../packages/shared/src/contracts/story.js'; +} from '../../../packages/shared/src/contracts/rpgRuntimeQuestAssist.js'; import { parseJsonResponseText } from '../../../packages/shared/src/llm/parsers.js'; import { buildRuntimeItemAiIntent, diff --git a/server-node/src/services/storyService.ts b/server-node/src/services/storyService.ts index fed1348e..e2a66612 100644 --- a/server-node/src/services/storyService.ts +++ b/server-node/src/services/storyService.ts @@ -1,6 +1,6 @@ import { z } from 'zod'; -import type { StoryRequestPayload } from '../../../packages/shared/src/contracts/story.js'; +import type { StoryRequestPayload } from '../../../packages/shared/src/contracts/rpgRuntimeChat.js'; import { generateInitialStoryFromOrchestrator, generateNextStoryFromOrchestrator, diff --git a/src/App.tsx b/src/App.tsx index e780ec78..dc7ec6de 100644 --- a/src/App.tsx +++ b/src/App.tsx @@ -1,8 +1,8 @@ -import { GameShellRuntime } from './components/game-shell/GameShellRuntime.tsx'; -import { useGameShellRuntime } from './hooks/useGameShellRuntime'; +import { RpgRuntimeShell } from './components/rpg-runtime-shell'; +import { useRpgRuntimeSession } from './hooks/rpg-session'; export default function App() { - const gameShellProps = useGameShellRuntime(); + const gameShellProps = useRpgRuntimeSession(); - return ; + return ; } diff --git a/src/components/AdventureEntityModal.tsx b/src/components/AdventureEntityModal.tsx index a531d811..034c282c 100644 --- a/src/components/AdventureEntityModal.tsx +++ b/src/components/AdventureEntityModal.tsx @@ -46,7 +46,7 @@ import { } from '../data/npcInteractions'; import { normalizePlayerProgressionState } from '../data/playerProgression'; import { getSceneHostileNpcPresetIds } from '../data/scenePresets'; -import type { CharacterChatTarget } from '../hooks/useStoryGeneration'; +import type { CharacterChatTarget } from '../hooks/rpg-runtime-story'; import { getResourceLabelsForWorld } from '../services/customWorldPresentation'; import { AnimationState, diff --git a/src/components/CharacterChatModal.tsx b/src/components/CharacterChatModal.tsx index dadcdaf6..5892f8c5 100644 --- a/src/components/CharacterChatModal.tsx +++ b/src/components/CharacterChatModal.tsx @@ -1,7 +1,7 @@ import { AnimatePresence, motion } from 'motion/react'; import { useEffect, useRef } from 'react'; -import type { CharacterChatModalState } from '../hooks/useStoryGeneration'; +import type { CharacterChatModalState } from '../hooks/rpg-runtime-story'; import { CHROME_ICONS, getNineSliceStyle, UI_CHROME } from '../uiAssets'; import { PixelIcon } from './PixelIcon'; diff --git a/src/components/CharacterPanel.tsx b/src/components/CharacterPanel.tsx index 421281b8..aef98529 100644 --- a/src/components/CharacterPanel.tsx +++ b/src/components/CharacterPanel.tsx @@ -27,7 +27,7 @@ import { getEquipmentSlotLabel, } from '../data/equipmentEffects'; import { buildMedievalNpcVisualFromCustomWorldVisual } from '../data/medievalNpcVisuals'; -import type { CharacterChatTarget } from '../hooks/useStoryGeneration'; +import type { CharacterChatTarget } from '../hooks/rpg-runtime-story'; import { getResourceLabelsForWorld } from '../services/customWorldPresentation'; import { AnimationState, diff --git a/src/components/CustomWorldEntityCatalog.tsx b/src/components/CustomWorldEntityCatalog.tsx index 3043f9e5..0fa677dc 100644 --- a/src/components/CustomWorldEntityCatalog.tsx +++ b/src/components/CustomWorldEntityCatalog.tsx @@ -29,7 +29,7 @@ import { } from '../types'; import { CharacterAnimator } from './CharacterAnimator'; import { CustomWorldCoverArtwork } from './CustomWorldCoverArtwork'; -import type { CustomWorldEditorTarget } from './CustomWorldEntityEditorModal'; +import type { RpgCreationEditorTarget } from './rpg-creation-editor/RpgCreationEntityEditorModal'; import { CustomWorldNpcPortrait } from './CustomWorldNpcVisualEditor'; import { ResolvedAssetImage } from './ResolvedAssetImage'; @@ -50,7 +50,7 @@ interface CustomWorldEntityCatalogProps { previewCharacters: Character[]; activeTab: ResultTab; onActiveTabChange: (tab: ResultTab) => void; - onEditTarget: (target: CustomWorldEditorTarget) => void; + onEditTarget: (target: RpgCreationEditorTarget) => void; onProfileChange: (profile: CustomWorldProfile) => void; onDeleteStoryNpcs?: (ids: string[]) => void; onDeleteLandmarks?: (ids: string[]) => void; diff --git a/src/components/CustomWorldEntityEditorModal.test.tsx b/src/components/CustomWorldEntityEditorModal.test.tsx index 5d25c973..d73301b1 100644 --- a/src/components/CustomWorldEntityEditorModal.test.tsx +++ b/src/components/CustomWorldEntityEditorModal.test.tsx @@ -12,10 +12,11 @@ import type { } from '../types'; import { CustomWorldEntityCatalog } from './CustomWorldEntityCatalog'; import { - type CustomWorldEditorTarget, - CustomWorldEntityEditorModal, -} from './CustomWorldEntityEditorModal'; + type RpgCreationEditorTarget, + RpgCreationEntityEditorModal, +} from './rpg-creation-editor/RpgCreationEntityEditorModal'; import * as customWorldCoverAssetService from '../services/customWorldCoverAssetService'; +import * as rpgCreationAssetClient from '../services/rpg-creation/rpgCreationAssetClient'; vi.mock('../data/characterPresets', async () => { const actual = await vi.importActual( @@ -37,12 +38,23 @@ vi.mock('./CharacterAnimator', () => ({ CharacterAnimator: () =>
角色预览
, })); -vi.mock('../services/aiService', () => ({ - generateCustomWorldSceneImage: vi.fn(), - generateCustomWorldSceneNpc: vi.fn(), - generateInitialStory: vi.fn(), - generateNextStep: vi.fn(), -})); +vi.mock('../services/rpg-creation/rpgCreationAssetClient', () => { + const generateSceneImage = vi.fn(); + const generateSceneNpc = vi.fn(); + + return { + rpgCreationAssetClient: { + generateSceneImage, + generateSceneNpc, + }, + generateCustomWorldSceneImage: generateSceneImage, + generateCustomWorldSceneNpc: generateSceneNpc, + }; +}); + +const mockedRpgCreationAssetClient = vi.mocked( + rpgCreationAssetClient.rpgCreationAssetClient, +); vi.mock('./CustomWorldNpcVisualEditor', () => ({ CustomWorldNpcPortrait: ({ npc }: { npc: { name: string } }) => ( @@ -51,8 +63,8 @@ vi.mock('./CustomWorldNpcVisualEditor', () => ({ CustomWorldNpcVisualEditor: () =>
预设形象编辑器
, })); -vi.mock('./game-shell/GameShellRuntime', () => ({ - GameShellRuntime: ({ +vi.mock('./rpg-runtime-shell', () => ({ + RpgRuntimeShell: ({ session, }: { session: { gameState: { currentScenePreset?: { name?: string } | null } }; @@ -215,7 +227,7 @@ function createProfileWithLandmark(): CustomWorldProfile { function LandmarkEditorFlowHarness() { const [profile, setProfile] = useState(createProfileWithLandmark()); - const [target, setTarget] = useState({ + const [target, setTarget] = useState({ kind: 'landmark', mode: 'edit', id: 'landmark-1', @@ -236,7 +248,7 @@ function LandmarkEditorFlowHarness() { onDeleteStoryNpcs={() => {}} onDeleteLandmarks={() => {}} /> - setTarget(null)} @@ -278,7 +290,7 @@ function CampEditorFlowHarness() { ], }, }); - const [target, setTarget] = useState({ + const [target, setTarget] = useState({ kind: 'camp', }); @@ -297,7 +309,7 @@ function CampEditorFlowHarness() { onDeleteStoryNpcs={() => {}} onDeleteLandmarks={() => {}} /> - setTarget(null)} @@ -316,7 +328,7 @@ function CoverEditorFlowHarness() { characterRoleIds: ['playable-1'], }, }); - const [target, setTarget] = useState({ + const [target, setTarget] = useState({ kind: 'cover', }); @@ -325,7 +337,7 @@ function CoverEditorFlowHarness() {
         {JSON.stringify(profile)}
       
- setTarget(null)} @@ -350,7 +362,7 @@ test('playable角色打开AI工坊后不会自动关闭', async () => { const handleClose = vi.fn(); render( - { const handleClose = vi.fn(); render( - { const handleClose = vi.fn(); render( - { const handleClose = vi.fn(); render( - { const handleClose = vi.fn(); render( - { const handleClose = vi.fn(); render( - { }); test('场景图片保存后会同步更新编辑页和场景列表', async () => { - const aiService = await import('../services/aiService'); - vi.mocked(aiService.generateCustomWorldSceneImage).mockClear(); - vi.mocked(aiService.generateCustomWorldSceneImage).mockResolvedValue({ + mockedRpgCreationAssetClient.generateSceneImage.mockClear(); + mockedRpgCreationAssetClient.generateSceneImage.mockResolvedValue({ imageSrc: '/generated-custom-world-scenes/updated-scene.png', assetId: 'asset-1', model: 'wan2.2-t2i-flash', @@ -573,7 +584,7 @@ test('场景图片保存后会同步更新编辑页和场景列表', async () => await user.click(screen.getByRole('button', { name: '开始生成' })); await waitFor(() => { - expect(aiService.generateCustomWorldSceneImage).toHaveBeenCalledTimes(1); + expect(mockedRpgCreationAssetClient.generateSceneImage).toHaveBeenCalledTimes(1); }); await user.click(screen.getByRole('button', { name: '保存' })); @@ -609,9 +620,8 @@ test('场景图片保存后会同步更新编辑页和场景列表', async () => }); test('开局场景图片保存后会同步更新编辑页和场景列表', async () => { - const aiService = await import('../services/aiService'); - vi.mocked(aiService.generateCustomWorldSceneImage).mockClear(); - vi.mocked(aiService.generateCustomWorldSceneImage).mockResolvedValue({ + mockedRpgCreationAssetClient.generateSceneImage.mockClear(); + mockedRpgCreationAssetClient.generateSceneImage.mockResolvedValue({ imageSrc: '/generated-custom-world-scenes/updated-camp.png', assetId: 'asset-camp-1', model: 'wan2.2-t2i-flash', @@ -644,7 +654,7 @@ test('开局场景图片保存后会同步更新编辑页和场景列表', async await user.click(screen.getByRole('button', { name: '开始生成' })); await waitFor(() => { - expect(aiService.generateCustomWorldSceneImage).toHaveBeenCalledTimes(1); + expect(mockedRpgCreationAssetClient.generateSceneImage).toHaveBeenCalledTimes(1); }); await user.click(screen.getByRole('button', { name: '保存' })); diff --git a/src/components/CustomWorldResultView.test.tsx b/src/components/CustomWorldResultView.test.tsx index 9ee39d80..7db2c2c4 100644 --- a/src/components/CustomWorldResultView.test.tsx +++ b/src/components/CustomWorldResultView.test.tsx @@ -6,13 +6,35 @@ import { useState } from 'react'; import { expect, test, vi } from 'vitest'; import type { CustomWorldPlayableNpc, CustomWorldProfile } from '../types'; -import { CustomWorldResultView } from './CustomWorldResultView'; +import * as rpgCreationAssetClient from '../services/rpg-creation/rpgCreationAssetClient'; +import { RpgCreationResultView } from './rpg-creation-result/RpgCreationResultView'; -vi.mock('../services/aiService', () => ({ - generateCustomWorldPlayableNpc: vi.fn(), - generateCustomWorldStoryNpc: vi.fn(), - generateCustomWorldLandmark: vi.fn(), -})); +vi.mock('../services/rpg-creation/rpgCreationAssetClient', () => { + const generatePlayableNpc = vi.fn(); + const generateStoryNpc = vi.fn(); + const generateLandmark = vi.fn(); + const generateSceneImage = vi.fn(); + const generateSceneNpc = vi.fn(); + + return { + rpgCreationAssetClient: { + generatePlayableNpc, + generateStoryNpc, + generateLandmark, + generateSceneImage, + generateSceneNpc, + }, + generateCustomWorldPlayableNpc: generatePlayableNpc, + generateCustomWorldStoryNpc: generateStoryNpc, + generateCustomWorldLandmark: generateLandmark, + generateCustomWorldSceneImage: generateSceneImage, + generateCustomWorldSceneNpc: generateSceneNpc, + }; +}); + +const mockedRpgCreationAssetClient = vi.mocked( + rpgCreationAssetClient.rpgCreationAssetClient, +); vi.mock('./CharacterAnimator', () => ({ CharacterAnimator: () =>
角色预览
, @@ -24,26 +46,11 @@ vi.mock('./CustomWorldNpcVisualEditor', () => ({ ), })); -vi.mock('./CustomWorldEntityEditorModal', () => ({ - CustomWorldEntityEditorModal: () => null, +vi.mock('./rpg-creation-editor/RpgCreationEntityEditorModal', () => ({ + RpgCreationEntityEditorModal: () => null, default: () => null, })); -vi.mock('../services/assetReadUrlService', () => ({ - resolveAssetReadUrl: vi.fn(async (source: string | null | undefined) => { - const value = source?.trim() ?? ''; - return value ? `https://signed.example${value}` : ''; - }), - isGeneratedLegacyPath: vi.fn((source: string | null | undefined) => { - const value = source?.trim() ?? ''; - return /^\/generated-[^/?#]+\/.+/u.test(value); - }), -})); - -async function loadAiService() { - return import('../services/aiService'); -} - function createBackstoryReveal() { return { publicSummary: '公开背景', @@ -270,7 +277,7 @@ function ResultViewHarness() { const [profile, setProfile] = useState(baseProfile); return ( - { - const aiService = await loadAiService(); const user = userEvent.setup(); let resolveGeneration: ((value: CustomWorldPlayableNpc) => void) | null = null; - vi.mocked(aiService.generateCustomWorldPlayableNpc).mockImplementation( + mockedRpgCreationAssetClient.generatePlayableNpc.mockImplementation( () => new Promise((resolve) => { resolveGeneration = resolve; @@ -326,13 +332,9 @@ test('clicking新增可扮演角色 shows pending item, disables button, and mar expect(screen.getAllByText('新').length).toBeGreaterThan(0); }); -test('world basic setting renders eight anchor fields and hides legacy parsed/source copy', async () => { +test('world basic setting renders eight anchor fields and hides legacy parsed/source copy', () => { render(); - await waitFor(() => { - expect(screen.getByText('世界承诺')).toBeTruthy(); - }); - expect(screen.getByText('世界承诺')).toBeTruthy(); expect(screen.getByText('玩家幻想')).toBeTruthy(); expect(screen.getByText('主题边界')).toBeTruthy(); @@ -361,7 +363,7 @@ test('playable tab prefers generated portrait over runtime preview placeholder', } as CustomWorldProfile; render( - { +test('readOnly result view hides edit and create actions for agent preview mode', async () => { const user = userEvent.setup(); - const originalImportMetaEnv = import.meta.env; - const originalUrl = window.location.href; - const originalImage = globalThis.Image; - Object.defineProperty(import.meta, 'env', { - value: { - ...originalImportMetaEnv, - DEV: true, - }, - configurable: true, - }); - window.history.pushState({}, '', '/?debugCustomWorldAssets=1'); - class MockImage { - onload: (() => void) | null = null; - onerror: (() => void) | null = null; + render( + {}} + onProfileChange={() => {}} + readOnly + compactAgentResultMode + />, + ); - set src(_value: string) { - queueMicrotask(() => { - this.onload?.(); - }); - } - } - Object.defineProperty(globalThis, 'Image', { - value: MockImage, - configurable: true, - }); + expect(screen.queryByRole('button', { name: /^编辑$/u })).toBeNull(); - try { - render(); + await user.click(screen.getByRole('button', { name: /可扮演角色/u })); + expect(screen.queryByRole('button', { name: '新增可扮演角色' })).toBeNull(); - await user.click(screen.getByRole('button', { name: /场景\s*2/u })); - - const signedLink = await screen.findByRole('link', { - name: /打开 沉钟栈桥章节 \/ 潮声逼近幕图签名图/u, - }); - expect((signedLink as HTMLAnchorElement).href).toBe( - 'https://signed.example/generated-custom-world-scenes/scene-act-1.png', - ); - } finally { - Object.defineProperty(import.meta, 'env', { - value: originalImportMetaEnv, - configurable: true, - }); - window.history.pushState({}, '', originalUrl); - Object.defineProperty(globalThis, 'Image', { - value: originalImage, - configurable: true, - }); - } + await user.click(screen.getByRole('button', { name: /场景角色/u })); + expect(screen.queryByRole('button', { name: /批量删除/u })).toBeNull(); +}); + +test('agent result view shows publish blockers and disables publish-enter action', () => { + render( + {}} + onProfileChange={() => {}} + compactAgentResultMode + publishReady={false} + publishBlockers={[ + '仍有角色缺少正式主图或动作资产,发布前需要先补齐。', + '营地还缺少正式场景图资产,发布前需要先确认营地图。', + ]} + qualityFindings={[ + { + id: 'role-assets-pending', + severity: 'warning', + code: 'role_assets_pending', + message: '仍有角色资产未完全补齐。', + }, + ]} + previewSourceLabel="服务端预览" + enterWorldActionLabel="发布并进入世界" + onEnterWorld={() => {}} + />, + ); + + expect(screen.getByText(/当前结果页数据源:服务端预览/u)).toBeTruthy(); + expect(screen.getByText(/当前还有 2 个发布阻断项/u)).toBeTruthy(); + expect( + screen.getByText(/仍有角色缺少正式主图或动作资产/u), + ).toBeTruthy(); + const actionButton = screen.getByRole('button', { + name: '发布并进入世界', + }); + expect((actionButton as HTMLButtonElement).disabled).toBe(true); +}); + +test('agent result view keeps publish-enter action enabled when publish gate is clear', () => { + render( + {}} + onProfileChange={() => {}} + compactAgentResultMode + publishReady + publishBlockers={[]} + qualityFindings={[ + { + id: 'scene-assets-pending', + severity: 'warning', + code: 'scene_assets_pending', + message: '仍有场景分幕图未补齐。', + }, + ]} + previewSourceLabel="服务端预览" + enterWorldActionLabel="发布并进入世界" + onEnterWorld={() => {}} + />, + ); + + expect(screen.getByText(/发布后仍有 1 条 warning 可继续优化/u)).toBeTruthy(); + const actionButton = screen.getByRole('button', { + name: '发布并进入世界', + }); + expect((actionButton as HTMLButtonElement).disabled).toBe(false); }); diff --git a/src/components/CustomWorldResultView.tsx b/src/components/CustomWorldResultView.tsx deleted file mode 100644 index 5c780db5..00000000 --- a/src/components/CustomWorldResultView.tsx +++ /dev/null @@ -1,849 +0,0 @@ -import { type ReactNode, useEffect, useMemo, useRef, useState } from 'react'; - -import { normalizeCustomWorldLandmarks } from '../data/customWorldSceneGraph'; -import { - generateCustomWorldLandmark, - generateCustomWorldPlayableNpc, - generateCustomWorldStoryNpc, -} from '../services/aiService'; -import { resolveAssetReadUrl } from '../services/assetReadUrlService'; -import { - Character, - CustomWorldLandmark, - CustomWorldNpc, - CustomWorldPlayableNpc, - CustomWorldProfile, -} from '../types'; -import { - CustomWorldEntityCatalog, - type ResultTab, -} from './CustomWorldEntityCatalog'; -import CustomWorldEntityEditorModal, { - type CustomWorldEditorTarget, -} from './CustomWorldEntityEditorModal'; - -interface CustomWorldResultViewProps { - profile: CustomWorldProfile; - previewCharacters: Character[]; - isGenerating: boolean; - progress: number; - progressLabel: string; - error: string | null; - onBack: () => void; - onEditSetting?: () => void; - onRegenerate?: () => void; - onContinueExpand?: () => void; - onEnterWorld?: () => void; - onProfileChange: (profile: CustomWorldProfile) => void; - readOnly?: boolean; - backLabel?: string; - editActionLabel?: string; - regenerateActionLabel?: string; - enterWorldActionLabel?: string; - autoSaveState?: 'idle' | 'saving' | 'saved' | 'error'; -} - -type EntityGenerationKind = 'playable' | 'story' | 'landmark'; - -type PendingGeneratedEntity = { - id: string; - kind: EntityGenerationKind; - title: string; - progress: number; - phaseLabel: string; -}; - -type RecentGeneratedIds = Record; - -type CustomWorldAssetDebugEntry = { - id: string; - label: string; - imageSrc: string; - kind: 'playable' | 'story' | 'landmark' | 'scene-act'; -}; - -type AssetDebugLoadStatus = 'loading' | 'loaded' | 'error'; - -const CUSTOM_WORLD_ASSET_DEBUG_QUERY_KEY = 'debugCustomWorldAssets'; -const CUSTOM_WORLD_ASSET_DEBUG_STORAGE_KEY = - 'genarrative.debug.customWorldAssets'; - -function shouldEnableCustomWorldAssetDebugPanel() { - if (!import.meta.env.DEV || typeof window === 'undefined') { - return false; - } - - const searchParams = new URLSearchParams(window.location.search); - if (searchParams.get(CUSTOM_WORLD_ASSET_DEBUG_QUERY_KEY) === '1') { - return true; - } - - return ( - window.localStorage.getItem(CUSTOM_WORLD_ASSET_DEBUG_STORAGE_KEY) === '1' - ); -} - -function collectCustomWorldAssetDebugEntries( - profile: CustomWorldProfile, -): CustomWorldAssetDebugEntry[] { - const playableEntries = profile.playableNpcs - .map((role) => { - const imageSrc = role.imageSrc?.trim() || ''; - if (!imageSrc) { - return null; - } - - return { - id: `playable:${role.id}`, - label: `${role.name}主形象`, - imageSrc, - kind: 'playable' as const, - }; - }) - .filter( - (entry): entry is CustomWorldAssetDebugEntry => Boolean(entry), - ); - const storyEntries = profile.storyNpcs - .map((role) => { - const imageSrc = role.imageSrc?.trim() || ''; - if (!imageSrc) { - return null; - } - - return { - id: `story:${role.id}`, - label: `${role.name}场景角色主图`, - imageSrc, - kind: 'story' as const, - }; - }) - .filter( - (entry): entry is CustomWorldAssetDebugEntry => Boolean(entry), - ); - const landmarkEntries = profile.landmarks - .map((landmark) => { - const imageSrc = landmark.imageSrc?.trim() || ''; - if (!imageSrc) { - return null; - } - - return { - id: `landmark:${landmark.id}`, - label: `${landmark.name}场景主图`, - imageSrc, - kind: 'landmark' as const, - }; - }) - .filter( - (entry): entry is CustomWorldAssetDebugEntry => Boolean(entry), - ); - const sceneActEntries = - profile.sceneChapterBlueprints?.flatMap((chapter) => - chapter.acts - .map((act) => { - const imageSrc = act.backgroundImageSrc?.trim() || ''; - if (!imageSrc) { - return null; - } - - return { - id: `scene-act:${chapter.id}:${act.id}`, - label: `${chapter.title || chapter.sceneId} / ${act.title}幕图`, - imageSrc, - kind: 'scene-act' as const, - }; - }) - .filter( - (entry): entry is CustomWorldAssetDebugEntry => Boolean(entry), - ), - ) ?? []; - - return [ - ...playableEntries, - ...storyEntries, - ...landmarkEntries, - ...sceneActEntries, - ]; -} - -function resolveAssetDebugStatusLabel(status: AssetDebugLoadStatus | undefined) { - if (status === 'loaded') { - return '已加载'; - } - if (status === 'error') { - return '加载失败'; - } - return '检测中'; -} - -function resolveAssetDebugSummary(profile: CustomWorldProfile) { - return [ - { - label: '可扮演角色主图', - value: `${profile.playableNpcs.filter((role) => Boolean(role.imageSrc?.trim())).length}/${profile.playableNpcs.length}`, - }, - { - label: '场景角色主图', - value: `${profile.storyNpcs.filter((role) => Boolean(role.imageSrc?.trim())).length}/${profile.storyNpcs.length}`, - }, - { - label: '场景主图', - value: `${profile.landmarks.filter((landmark) => Boolean(landmark.imageSrc?.trim())).length}/${profile.landmarks.length}`, - }, - { - label: '分幕图', - value: `${profile.sceneChapterBlueprints?.reduce( - (sum, chapter) => - sum + - chapter.acts.filter((act) => Boolean(act.backgroundImageSrc?.trim())) - .length, - 0, - ) ?? 0}/${ - profile.sceneChapterBlueprints?.reduce( - (sum, chapter) => sum + chapter.acts.length, - 0, - ) ?? 0 - }`, - }, - ]; -} - -function SmallButton({ - onClick, - children, - tone = 'default', - disabled = false, -}: { - onClick: () => void; - children: ReactNode; - tone?: 'default' | 'sky'; - disabled?: boolean; -}) { - return ( - - ); -} - -function getCreateTargetByTab( - activeTab: ResultTab, -): CustomWorldEditorTarget | null { - if (activeTab === 'playable') return { kind: 'playable', mode: 'create' }; - if (activeTab === 'story') return { kind: 'story', mode: 'create' }; - if (activeTab === 'landmarks') return { kind: 'landmark', mode: 'create' }; - return null; -} - -function getCreateLabelByTab(activeTab: ResultTab) { - if (activeTab === 'playable') return '新增可扮演角色'; - if (activeTab === 'story') return '新增场景角色'; - if (activeTab === 'landmarks') return '新增场景'; - return ''; -} - -function createPendingGeneratedEntity( - kind: EntityGenerationKind, -): PendingGeneratedEntity { - return { - id: `pending-${kind}-${Date.now()}`, - kind, - title: - kind === 'playable' - ? '新可扮演角色' - : kind === 'story' - ? '新场景角色' - : '新场景', - progress: 8, - phaseLabel: '正在整理世界上下文', - }; -} - -function resolvePendingPhaseLabel( - kind: EntityGenerationKind, - progress: number, -) { - if (progress < 28) { - return '正在整理世界上下文'; - } - if (progress < 72) { - return kind === 'landmark' ? '正在推理场景结构' : '正在推理角色结构'; - } - return '正在回写结果'; -} - -function prependPlayableNpc( - profile: CustomWorldProfile, - npc: CustomWorldPlayableNpc, -) { - return { - ...profile, - playableNpcs: [npc, ...profile.playableNpcs], - } satisfies CustomWorldProfile; -} - -function prependStoryNpc(profile: CustomWorldProfile, npc: CustomWorldNpc) { - return { - ...profile, - storyNpcs: [npc, ...profile.storyNpcs], - } satisfies CustomWorldProfile; -} - -function prependLandmark( - profile: CustomWorldProfile, - landmark: CustomWorldLandmark, -) { - return { - ...profile, - landmarks: normalizeCustomWorldLandmarks({ - landmarks: [landmark, ...profile.landmarks], - storyNpcs: profile.storyNpcs, - }), - } satisfies CustomWorldProfile; -} - -function removeStoryNpcsFromProfile( - profile: CustomWorldProfile, - ids: string[], -) { - const idSet = new Set(ids); - const nextStoryNpcs = profile.storyNpcs.filter((npc) => !idSet.has(npc.id)); - - return { - ...profile, - storyNpcs: nextStoryNpcs, - landmarks: normalizeCustomWorldLandmarks({ - landmarks: profile.landmarks.map((landmark) => ({ - ...landmark, - sceneNpcIds: landmark.sceneNpcIds.filter((npcId) => !idSet.has(npcId)), - })), - storyNpcs: nextStoryNpcs, - }), - } satisfies CustomWorldProfile; -} - -function removeLandmarksFromProfile( - profile: CustomWorldProfile, - ids: string[], -) { - const idSet = new Set(ids); - const nextLandmarks = profile.landmarks.filter( - (landmark) => !idSet.has(landmark.id), - ); - - return { - ...profile, - landmarks: normalizeCustomWorldLandmarks({ - landmarks: nextLandmarks.map((landmark) => ({ - ...landmark, - connections: landmark.connections.filter( - (connection) => !idSet.has(connection.targetLandmarkId), - ), - })), - storyNpcs: profile.storyNpcs, - }), - } satisfies CustomWorldProfile; -} - -export function CustomWorldResultView({ - profile, - previewCharacters, - isGenerating, - progress, - progressLabel, - error, - onBack, - onEditSetting, - onRegenerate: triggerRegenerate, - onContinueExpand, - onEnterWorld, - onProfileChange, - readOnly = false, - backLabel = '返回', - editActionLabel = '修改设定', - regenerateActionLabel = '重新生成', - enterWorldActionLabel = '进入世界', - autoSaveState = 'idle', -}: CustomWorldResultViewProps) { - const [editorTarget, setEditorTarget] = - useState(null); - const [activeTab, setActiveTab] = useState('world'); - const [pendingGeneratedEntity, setPendingGeneratedEntity] = - useState(null); - const [recentGeneratedIds, setRecentGeneratedIds] = useState( - { - playable: [], - story: [], - landmark: [], - }, - ); - const [localGenerationError, setLocalGenerationError] = useState( - null, - ); - const pendingProgressTimerRef = useRef(null); - const assetDebugEnabled = useMemo( - () => shouldEnableCustomWorldAssetDebugPanel(), - [], - ); - const assetDebugEntries = useMemo( - () => - assetDebugEnabled ? collectCustomWorldAssetDebugEntries(profile) : [], - [assetDebugEnabled, profile], - ); - const assetDebugSummary = useMemo( - () => (assetDebugEnabled ? resolveAssetDebugSummary(profile) : []), - [assetDebugEnabled, profile], - ); - const [assetDebugStatusMap, setAssetDebugStatusMap] = useState< - Record - >({}); - const [assetDebugResolvedImageMap, setAssetDebugResolvedImageMap] = useState< - Record - >({}); - const assetDebugResolvedEntries = useMemo( - () => - assetDebugEntries.map((entry) => ({ - ...entry, - hasResolvedImageSrc: Object.prototype.hasOwnProperty.call( - assetDebugResolvedImageMap, - entry.id, - ), - resolvedImageSrc: assetDebugResolvedImageMap[entry.id] || entry.imageSrc, - })), - [assetDebugEntries, assetDebugResolvedImageMap], - ); - const assetDebugDetectableEntries = useMemo( - () => - assetDebugResolvedEntries.filter( - (entry) => - entry.hasResolvedImageSrc && Boolean(entry.resolvedImageSrc.trim()), - ), - [assetDebugResolvedEntries], - ); - - const createTarget = useMemo( - () => getCreateTargetByTab(activeTab), - [activeTab], - ); - const createLabel = useMemo( - () => getCreateLabelByTab(activeTab), - [activeTab], - ); - const stopPendingProgressTimer = () => { - if (pendingProgressTimerRef.current !== null) { - window.clearInterval(pendingProgressTimerRef.current); - pendingProgressTimerRef.current = null; - } - }; - - useEffect(() => () => stopPendingProgressTimer(), []); - - useEffect(() => { - if (!assetDebugEnabled) { - setAssetDebugResolvedImageMap({}); - return; - } - - if (assetDebugEntries.length === 0) { - setAssetDebugResolvedImageMap({}); - return; - } - - let cancelled = false; - - void Promise.all( - assetDebugEntries.map(async (entry) => [ - entry.id, - await resolveAssetReadUrl(entry.imageSrc), - ] as const), - ).then((resolvedEntries) => { - if (cancelled) { - return; - } - - setAssetDebugResolvedImageMap(Object.fromEntries(resolvedEntries)); - }); - - return () => { - cancelled = true; - }; - }, [assetDebugEnabled, assetDebugEntries]); - - useEffect(() => { - if (!assetDebugEnabled) { - setAssetDebugStatusMap({}); - return; - } - - if (assetDebugDetectableEntries.length === 0) { - setAssetDebugStatusMap({}); - return; - } - - let cancelled = false; - const cleanupList: Array<() => void> = []; - - // 诊断面板只根据已解析地址做探测,避免状态流里反复访问原始 generated-* 路径。 - setAssetDebugStatusMap( - Object.fromEntries( - assetDebugDetectableEntries.map((entry) => [entry.id, 'loading' as const]), - ), - ); - - assetDebugDetectableEntries.forEach((entry) => { - const image = new Image(); - const updateStatus = (status: AssetDebugLoadStatus) => { - if (cancelled) { - return; - } - - setAssetDebugStatusMap((current) => { - if (current[entry.id] === status) { - return current; - } - return { - ...current, - [entry.id]: status, - }; - }); - }; - - image.onload = () => updateStatus('loaded'); - image.onerror = () => updateStatus('error'); - image.src = entry.resolvedImageSrc; - cleanupList.push(() => { - image.onload = null; - image.onerror = null; - }); - }); - - return () => { - cancelled = true; - cleanupList.forEach((cleanup) => cleanup()); - }; - }, [assetDebugDetectableEntries, assetDebugEnabled]); - - const startPendingProgress = (kind: EntityGenerationKind) => { - stopPendingProgressTimer(); - setPendingGeneratedEntity(createPendingGeneratedEntity(kind)); - pendingProgressTimerRef.current = window.setInterval(() => { - setPendingGeneratedEntity((current) => { - if (!current || current.kind !== kind) { - return current; - } - - const nextProgress = Math.min( - current.progress + (current.progress < 56 ? 11 : 5), - 88, - ); - - return { - ...current, - progress: nextProgress, - phaseLabel: resolvePendingPhaseLabel(kind, nextProgress), - }; - }); - }, 520); - }; - - const finishPendingProgress = () => { - stopPendingProgressTimer(); - setPendingGeneratedEntity(null); - }; - - const markGeneratedAsRecent = ( - kind: EntityGenerationKind, - generatedId: string, - ) => { - setRecentGeneratedIds((current) => ({ - ...current, - [kind]: [generatedId, ...current[kind].filter((id) => id !== generatedId)].slice( - 0, - 6, - ), - })); - }; - - const handleGenerateEntity = async (kind: EntityGenerationKind) => { - if (readOnly || isGenerating || pendingGeneratedEntity) { - return; - } - - setLocalGenerationError(null); - startPendingProgress(kind); - - try { - if (kind === 'playable') { - const nextNpc = await generateCustomWorldPlayableNpc({ profile }); - onProfileChange(prependPlayableNpc(profile, nextNpc)); - markGeneratedAsRecent('playable', nextNpc.id); - } else if (kind === 'story') { - const nextNpc = await generateCustomWorldStoryNpc({ profile }); - onProfileChange(prependStoryNpc(profile, nextNpc)); - markGeneratedAsRecent('story', nextNpc.id); - } else { - const nextLandmark = await generateCustomWorldLandmark({ profile }); - onProfileChange(prependLandmark(profile, nextLandmark)); - markGeneratedAsRecent('landmark', nextLandmark.id); - } - } catch (generationError) { - setLocalGenerationError( - generationError instanceof Error - ? generationError.message - : '生成失败,请稍后重试。', - ); - } finally { - finishPendingProgress(); - } - }; - - const onRegenerate = () => { - if (isGenerating || !triggerRegenerate) return; - - const confirmed = window.confirm( - `确认重新生成“${profile.name}”吗?\n\n重新生成会重新生成当前世界中的所有信息,包括你修改和新增的所有内容。`, - ); - if (!confirmed) return; - - triggerRegenerate(); - }; - - const handleDeleteStoryNpcs = (ids: string[]) => { - if (ids.length === 0) return; - onProfileChange(removeStoryNpcsFromProfile(profile, ids)); - }; - - const handleDeleteLandmarks = (ids: string[]) => { - if (ids.length === 0) return; - onProfileChange(removeLandmarksFromProfile(profile, ids)); - }; - const autoSaveBadge = - autoSaveState === 'saved' ? ( -
- 已自动保存 -
- ) : autoSaveState === 'saving' ? ( -
- 保存中 -
- ) : autoSaveState === 'error' ? ( -
- 保存失败 -
- ) : null; - - return ( -
-
- - {autoSaveBadge} -
- -
- { - if (activeTab === 'playable') { - void handleGenerateEntity('playable'); - return; - } - if (activeTab === 'story') { - void handleGenerateEntity('story'); - return; - } - if (activeTab === 'landmarks') { - void handleGenerateEntity('landmark'); - return; - } - setEditorTarget(createTarget); - } - } - createActionDisabled={Boolean( - isGenerating || pendingGeneratedEntity, - )} - pendingGeneratedEntity={pendingGeneratedEntity} - recentGeneratedIds={recentGeneratedIds} - readOnly={readOnly} - /> -
- - {isGenerating && ( -
-
-
- {progressLabel} -
-
- {Math.round(progress)}% -
-
-
-
-
-
- )} - - {error ? ( -
- {error} -
- ) : null} - {!error && localGenerationError ? ( -
- {localGenerationError} -
- ) : null} - {assetDebugEnabled ? ( -
-
-
-
- 资产诊断 -
-
- 仅开发模式显示,用来核对结果页当前拿到的图片字段和实际加载状态。 -
-
-
- {assetDebugResolvedEntries.length}项 -
-
-
- {assetDebugSummary.map((entry) => ( -
-
{entry.label}
-
- {entry.value} -
-
- ))} -
-
- {assetDebugResolvedEntries.length > 0 ? ( - assetDebugResolvedEntries.map((entry) => ( -
-
-
-
- {entry.label} -
-
- {entry.imageSrc} -
-
-
- {resolveAssetDebugStatusLabel( - assetDebugStatusMap[entry.id], - )} -
-
-
- {entry.hasResolvedImageSrc ? ( - - 打开签名图 - - ) : ( -
- 正在解析签名地址... -
- )} -
-
- )) - ) : ( -
- 当前结果页 profile 里没有拿到任何可诊断的图片地址。 -
- )} -
-
- ) : null} - -
- {profile.generationStatus === 'key_only' ? ( -
- 当前世界处于快速预览模式,只生成了关键对象。继续补全后,系统会生成长尾场景角色与完整场景网络。 -
- ) : null} -
- {onEditSetting ? ( - {editActionLabel} - ) : null} - {triggerRegenerate ? ( - - {regenerateActionLabel} - - ) : null} - {profile.generationStatus === 'key_only' && onContinueExpand ? ( - - 继续补全世界 - - ) : null} - {onEnterWorld ? ( - - ) : null} -
-
- - setEditorTarget(null)} - onProfileChange={onProfileChange} - /> -
- ); -} diff --git a/src/components/DeveloperTeamModal.tsx b/src/components/DeveloperTeamModal.tsx deleted file mode 100644 index 388f68c6..00000000 --- a/src/components/DeveloperTeamModal.tsx +++ /dev/null @@ -1,65 +0,0 @@ -import { AnimatePresence, motion } from 'motion/react'; - -import { CHROME_ICONS, getNineSliceStyle, UI_CHROME } from '../uiAssets'; -import { PixelIcon } from './PixelIcon'; - -interface DeveloperTeamModalProps { - isOpen: boolean; - message: string; - onClose: () => void; -} - -export function DeveloperTeamModal({ - isOpen, - message, - onClose, -}: DeveloperTeamModalProps) { - return ( - - {isOpen && ( - - event.stopPropagation()} - > -
-
-
{'\u5f00\u53d1\u56e2\u961f'}
-
- -
- -
-
-
- {message} -
-
-
-
-
- )} -
- ); -} diff --git a/src/components/GameShell.tsx b/src/components/GameShell.tsx deleted file mode 100644 index 8837b85f..00000000 --- a/src/components/GameShell.tsx +++ /dev/null @@ -1,807 +0,0 @@ -import {AnimatePresence, motion} from 'motion/react'; -import {lazy, Suspense, useCallback, useEffect, useMemo, useState} from 'react'; - -import {getLiveGamePlayTimeMs} from '../data/runtimeStats'; -import type { HydratedSavedGameSnapshot } from '../persistence/runtimeSnapshotTypes'; -import {getWorldCampScenePreset} from '../data/scenePresets'; -import {BottomTab} from '../hooks/useGameFlow'; -import {resolveActiveSceneActBlueprint} from '../services/customWorldSceneActRuntime'; -import { - type BattleRewardUi, - type CharacterChatUi, - type GoalFlowUi, - type InventoryFlowUi, - type NpcChatQuestOfferUi, - type QuestFlowUi, - type StoryGenerationNpcUi, -} from '../hooks/useStoryGeneration'; -import { - type Character, - type CustomWorldProfile, - type CompanionRenderState, - type GameState, - type StoryMoment, - type StoryOption, -} from '../types'; -import {CHROME_ICONS, getNineSliceStyle, TAB_ICONS, UI_CHROME} from '../uiAssets'; -import {CharacterSelectionFlow} from './game-shell/CharacterSelectionFlow'; -import {PreGameSelectionFlow} from './game-shell/PreGameSelectionFlow'; -import {SCENE_TRANSITION_FUNCTION_MODES, useSceneTransitionModel} from './game-shell/useSceneTransitionModel'; -import {useGameShellViewModel} from './game-shell/useGameShellViewModel'; -import {GameCanvas} from './GameCanvas'; -import {PixelIcon} from './PixelIcon'; - -interface GameShellSessionProps { - gameState: GameState; - currentStory: StoryMoment | null; - isLoading: boolean; - aiError: string | null; - bottomTab: BottomTab; - setBottomTab: (tab: BottomTab) => void; - isMapOpen: boolean; - setIsMapOpen: (open: boolean) => void; -} - -interface GameShellStoryProps { - displayedOptions: StoryOption[]; - canRefreshOptions: boolean; - handleRefreshOptions: () => void; - handleChoice: (option: StoryOption) => void; - handleNpcChatInput: (input: string) => boolean; - exitNpcChat: () => boolean; - handleMapTravelToScene: (sceneId: string) => boolean; - npcUi: StoryGenerationNpcUi; - characterChatUi: CharacterChatUi; - inventoryUi: InventoryFlowUi; - battleRewardUi: BattleRewardUi; - questUi: QuestFlowUi; - npcChatQuestOfferUi: NpcChatQuestOfferUi; - goalUi: GoalFlowUi; -} - -interface GameShellEntryProps { - hasSavedGame: boolean; - savedSnapshot: HydratedSavedGameSnapshot | null; - handleContinueGame: (snapshot?: HydratedSavedGameSnapshot | null) => void; - handleStartNewGame: () => void; - handleSaveAndExit: () => void; - handleCustomWorldSelect: (customWorldProfile: CustomWorldProfile) => void; - handleBackToWorldSelect: () => void; - handleCharacterSelect: (character: Character) => void; -} - -interface GameShellCompanionProps { - companionRenderStates: CompanionRenderState[]; - buildCompanionRenderStates: (state: GameState) => CompanionRenderState[]; - onBenchCompanion: (npcId: string) => void; - onActivateRosterCompanion: (npcId: string, swapNpcId?: string | null) => void; -} - -interface GameShellAudioProps { - musicVolume: number; - onMusicVolumeChange: (value: number) => void; -} - -interface GameShellProps { - session: GameShellSessionProps; - story: GameShellStoryProps; - entry: GameShellEntryProps; - companions: GameShellCompanionProps; - audio: GameShellAudioProps; -} - -const AdventureEntityModal = lazy(async () => { - const module = await import('./AdventureEntityModal'); - - return { - default: module.AdventureEntityModal, - }; -}); - -const CharacterChatModal = lazy(async () => { - const module = await import('./CharacterChatModal'); - - return { - default: module.CharacterChatModal, - }; -}); - -const CompanionCampModal = lazy(async () => { - const module = await import('./CompanionCampModal'); - - return { - default: module.CompanionCampModal, - }; -}); - -const MapModal = lazy(async () => { - const module = await import('./MapModal'); - - return { - default: module.MapModal, - }; -}); - -const NpcModals = lazy(async () => { - const module = await import('./NpcModals'); - - return { - default: module.NpcModals, - }; -}); - -const AdventurePanel = lazy(async () => { - const module = await import('./AdventurePanel'); - - return { - default: module.AdventurePanel, - }; -}); - -const CharacterPanel = lazy(async () => { - const module = await import('./CharacterPanel'); - - return { - default: module.CharacterPanel, - }; -}); - -const InventoryPanel = lazy(async () => { - const module = await import('./InventoryPanel'); - - return { - default: module.InventoryPanel, - }; -}); - -function ModalLoadingFallback({ - label, - onClose, -}: { - label: string; - onClose?: (() => void) | null; -}) { - return ( -
-
event.stopPropagation()} - > - {label} -
-
- ); -} - -function PanelLoadingFallback({ - label, -}: { - label: string; -}) { - return ( -
- {label} -
- ); -} - -export function GameShell({session, story, entry, companions, audio}: GameShellProps) { - const { - gameState, - currentStory, - isLoading, - aiError, - bottomTab, - setBottomTab, - isMapOpen, - setIsMapOpen, - } = session; - const { - displayedOptions, - canRefreshOptions, - handleRefreshOptions, - handleChoice, - handleNpcChatInput, - exitNpcChat, - handleMapTravelToScene, - npcUi, - characterChatUi, - inventoryUi, - battleRewardUi, - questUi, - npcChatQuestOfferUi, - goalUi, - } = story; - const { - hasSavedGame, - savedSnapshot, - handleContinueGame, - handleStartNewGame, - handleSaveAndExit, - handleCustomWorldSelect, - handleBackToWorldSelect, - handleCharacterSelect, - } = entry; - const { - companionRenderStates, - buildCompanionRenderStates, - onBenchCompanion, - onActivateRosterCompanion, - } = companions; - const {musicVolume, onMusicVolumeChange} = audio; - - const [clockNow, setClockNow] = useState(() => Date.now()); - const openingCampSceneId = useMemo( - () => (gameState.worldType ? getWorldCampScenePreset(gameState.worldType)?.id ?? null : null), - [gameState.worldType], - ); - const hasNpcModalOpen = Boolean(npcUi.tradeModal || npcUi.giftModal || npcUi.recruitModal); - const { - selectionStage, - setSelectionStage, - overlayPanel, - openOverlayPanel, - closeOverlayPanel, - selectedSceneEntity, - setSelectedSceneEntity, - openPartyMemberDetails, - closeAdventureEntityModal, - showTeamModal, - openCampModal, - closeCampModal, - resetForSaveAndExit, - shouldMountAdventureEntityModal, - shouldMountCampModal, - shouldMountMapModal, - shouldMountCharacterChatModal, - shouldMountNpcModals, - } = useGameShellViewModel({ - gameState, - isMapOpen, - characterChatModalOpen: Boolean(characterChatUi.modal), - hasNpcModalOpen, - }); - const { - visibleGameState, - visibleCurrentStory, - sceneTransitionPhase, - sceneTransitionToken, - setSceneTransitionDurations, - beginSceneTransition, - } = useSceneTransitionModel({ - gameState, - currentStory, - openingCampSceneId, - }); - const isCharacterSelectionStage = - gameState.currentScene === 'Selection' && - Boolean(gameState.worldType) && - !gameState.playerCharacter; - const collapseTopStage = gameState.currentScene === 'Selection'; - const shouldHideStoryOptions = sceneTransitionPhase !== 'idle'; - const visibleStoryForRender = visibleCurrentStory; - - const dialogueIndicator = useMemo(() => { - if (!isLoading || visibleCurrentStory?.displayMode !== 'dialogue' || visibleGameState.currentEncounter?.kind !== 'npc') { - return null; - } - - const lastSpeaker = visibleCurrentStory.dialogue?.[visibleCurrentStory.dialogue.length - 1]?.speaker ?? null; - return { - showPlayer: true, - showEncounter: true, - activeSpeaker: lastSpeaker === 'player' ? 'player' : lastSpeaker ? 'npc' : null, - } as const; - }, [visibleCurrentStory?.dialogue, visibleCurrentStory?.displayMode, visibleGameState.currentEncounter?.kind, isLoading]); - - const characterChatSummaries = useMemo( - () => - Object.fromEntries( - Object.entries(gameState.characterChats ?? {}).map(([characterId, record]) => [characterId, record.summary]), - ), - [gameState.characterChats], - ); - - const visibleCompanionRenderStates = useMemo( - () => buildCompanionRenderStates(visibleGameState), - [buildCompanionRenderStates, visibleGameState], - ); - - const canvasCompanionRenderStates = useMemo(() => { - const activeEncounterNpcId = visibleGameState.currentEncounter?.kind === 'npc' - ? visibleGameState.currentEncounter.id ?? null - : null; - if (!activeEncounterNpcId) return visibleCompanionRenderStates; - return visibleCompanionRenderStates.filter(companion => companion.npcId !== activeEncounterNpcId); - }, [visibleCompanionRenderStates, visibleGameState.currentEncounter]); - - const livePlayTimeMs = useMemo( - () => getLiveGamePlayTimeMs(gameState.runtimeStats, clockNow), - [clockNow, gameState.runtimeStats], - ); - const activeSceneAct = useMemo( - () => resolveActiveSceneActBlueprint({ - profile: visibleGameState.customWorldProfile, - sceneId: visibleGameState.currentScenePreset?.id ?? null, - storyEngineMemory: visibleGameState.storyEngineMemory, - }), - [ - visibleGameState.currentScenePreset?.id, - visibleGameState.customWorldProfile, - visibleGameState.storyEngineMemory, - ], - ); - const activeSceneChapter = useMemo(() => { - if (!visibleGameState.customWorldProfile || !visibleGameState.currentScenePreset?.id) { - return null; - } - - return ( - visibleGameState.customWorldProfile.sceneChapterBlueprints?.find( - entry => entry.sceneId === visibleGameState.currentScenePreset?.id - || entry.linkedLandmarkIds.includes(visibleGameState.currentScenePreset?.id ?? ''), - ) ?? null - ); - }, [ - visibleGameState.currentScenePreset?.id, - visibleGameState.customWorldProfile, - ]); - - const adventureStatistics = useMemo( - () => ({ - playTimeMs: livePlayTimeMs, - hostileNpcsDefeated: gameState.runtimeStats.hostileNpcsDefeated, - questsAccepted: gameState.runtimeStats.questsAccepted, - questsCompleted: visibleGameState.quests.filter(quest => quest.status === 'completed' || quest.status === 'turned_in').length, - questsTurnedIn: visibleGameState.quests.filter(quest => quest.status === 'turned_in').length, - itemsUsed: gameState.runtimeStats.itemsUsed, - scenesTraveled: gameState.runtimeStats.scenesTraveled, - currentSceneName: visibleGameState.currentScenePreset?.name ?? 'Current Area', - playerCurrency: visibleGameState.playerCurrency, - inventoryItemCount: visibleGameState.playerInventory.reduce((sum, item) => sum + item.quantity, 0), - inventoryStackCount: visibleGameState.playerInventory.length, - activeCompanionCount: visibleGameState.companions.length, - rosterCompanionCount: visibleGameState.roster.length, - }), - [ - gameState.runtimeStats.itemsUsed, - gameState.runtimeStats.hostileNpcsDefeated, - gameState.runtimeStats.questsAccepted, - gameState.runtimeStats.scenesTraveled, - livePlayTimeMs, - visibleGameState.companions.length, - visibleGameState.currentScenePreset?.name, - visibleGameState.playerCurrency, - visibleGameState.playerInventory, - visibleGameState.quests, - visibleGameState.roster.length, - ], - ); - - useEffect(() => { - if (!gameState.playerCharacter || gameState.currentScene !== 'Story') { - return; - } - - setClockNow(Date.now()); - const intervalId = window.setInterval(() => setClockNow(Date.now()), 1000); - return () => window.clearInterval(intervalId); - }, [gameState.currentScene, gameState.playerCharacter]); - - const handleSceneTransitionChoice = useCallback((option: StoryOption) => { - const transitionMode = SCENE_TRANSITION_FUNCTION_MODES[option.functionId]; - if (transitionMode) { - beginSceneTransition(transitionMode); - } - handleChoice(option); - }, [beginSceneTransition, handleChoice]); - - return ( -
-
- {collapseTopStage ? null : ( - setIsMapOpen(true)} - sceneTransitionPhase={sceneTransitionPhase} - sceneTransitionToken={sceneTransitionToken} - onSceneTransitionDurationsChange={setSceneTransitionDurations} - /> - )} -
- -
- - {!gameState.worldType && ( - - )} - - {gameState.worldType && !gameState.playerCharacter && ( - - { - handleBackToWorldSelect(); - setSelectionStage('platform'); - }} - onConfirm={handleCharacterSelect} - /> - - )} - - {visibleGameState.playerCharacter && visibleStoryForRender && ( - -
- - - -
- - {bottomTab === 'character' && ( - }> - - - )} - - {bottomTab === 'adventure' && ( - }> - openOverlayPanel('character')} - onOpenInventory={() => openOverlayPanel('inventory')} - playerCharacter={visibleGameState.playerCharacter} - worldType={visibleGameState.worldType} - quests={visibleGameState.quests} - questUi={questUi} - npcChatQuestOfferUi={npcChatQuestOfferUi} - goalStack={goalUi.goalStack} - goalPulse={goalUi.pulse} - onDismissGoalPulse={goalUi.dismissPulse} - battleRewardUi={battleRewardUi} - playerHp={visibleGameState.playerHp} - playerMaxHp={visibleGameState.playerMaxHp} - playerMana={visibleGameState.playerMana} - playerMaxMana={visibleGameState.playerMaxMana} - playerSkillCooldowns={visibleGameState.playerSkillCooldowns} - inBattle={visibleGameState.inBattle} - currentNpcBattleMode={visibleGameState.currentNpcBattleMode} - chapterState={visibleGameState.chapterState ?? null} - journeyBeat={ - visibleGameState.storyEngineMemory?.currentJourneyBeat ?? null - } - currentSceneActTitle={activeSceneAct?.title ?? null} - currentSceneActIndex={ - activeSceneChapter && activeSceneAct - ? (() => { - const actIndex = activeSceneChapter.acts.findIndex( - act => act.id === activeSceneAct.id, - ); - return actIndex >= 0 ? actIndex + 1 : null; - })() - : null - } - currentSceneActCount={activeSceneChapter?.acts.length ?? null} - statistics={adventureStatistics} - musicVolume={musicVolume} - onMusicVolumeChange={onMusicVolumeChange} - onSaveAndExit={() => { - resetForSaveAndExit(); - handleSaveAndExit(); - }} - /> - - )} - - {bottomTab === 'inventory' && ( - }> - - - )} -
- )} -
-
- - {shouldMountAdventureEntityModal && ( - }> - { - closeAdventureEntityModal(); - characterChatUi.openChat(target); - }} - /> - - )} - - - {overlayPanel && gameState.playerCharacter && ( - - event.stopPropagation()} - > -
-
{overlayPanel === 'character' ? '队伍' : '背包'}
- -
-
- {overlayPanel === 'character' ? ( - }> - { - closeOverlayPanel(); - openCampModal(); - }} - onOpenCharacterChat={target => { - closeOverlayPanel(); - characterChatUi.openChat(target); - }} - chatSummaries={characterChatSummaries} - onInspectMember={openPartyMemberDetails} - /> - - ) : ( - }> - - - )} -
-
-
- )} -
- - {shouldMountCampModal && ( - }> - - - )} - - {shouldMountMapModal && ( - setIsMapOpen(false)} />}> - { - const triggered = handleMapTravelToScene(scene.id); - if (triggered) { - setIsMapOpen(false); - } - }} - isTraveling={isLoading} - onClose={() => setIsMapOpen(false)} - /> - - )} - - {shouldMountCharacterChatModal && ( - }> - - - )} - - {shouldMountNpcModals && ( - }> - - - )} -
- ); -} diff --git a/src/components/LazySkillEffectPreview.tsx b/src/components/LazySkillEffectPreview.tsx deleted file mode 100644 index c03ab1f3..00000000 --- a/src/components/LazySkillEffectPreview.tsx +++ /dev/null @@ -1,33 +0,0 @@ -import {lazy, Suspense} from 'react'; - -import type {SkillEffectPreviewProps} from './SkillEffectPreview'; - -const SkillEffectPreview = lazy(async () => { - const module = await import('./SkillEffectPreview'); - - return { - default: module.SkillEffectPreview, - }; -}); - -function SkillEffectPreviewFallback() { - return ( -
-
-
-
-
-
-
-
-
- ); -} - -export function LazySkillEffectPreview(props: SkillEffectPreviewProps) { - return ( - }> - - - ); -} diff --git a/src/components/NpcModals.tsx b/src/components/NpcModals.tsx index e5feed34..f2c90a13 100644 --- a/src/components/NpcModals.tsx +++ b/src/components/NpcModals.tsx @@ -23,7 +23,7 @@ import { getGiftCandidates, getRarityLabel, } from '../data/npcInteractions'; -import { StoryGenerationNpcUi } from '../hooks/useStoryGeneration'; +import { StoryGenerationNpcUi } from '../hooks/rpg-runtime-story'; import { GameState, InventoryItem } from '../types'; import { CHROME_ICONS, getInventoryItemVisualSrc, getNineSliceStyle, UI_CHROME } from '../uiAssets'; import { PixelIcon } from './PixelIcon'; diff --git a/src/components/auth/AuthGate.test.tsx b/src/components/auth/AuthGate.test.tsx index 80ec7a69..3ffc82e4 100644 --- a/src/components/auth/AuthGate.test.tsx +++ b/src/components/auth/AuthGate.test.tsx @@ -9,9 +9,9 @@ import { AuthGate } from './AuthGate'; import { useAuthUi } from './AuthUiContext'; const authMocks = vi.hoisted(() => ({ - getStoredAccessToken: vi.fn(), ensureAutoAuthUser: vi.fn(), getAuthLoginOptions: vi.fn(), + getCurrentAuthUser: vi.fn(), loginWithPhoneCode: vi.fn(), sendPhoneLoginCode: vi.fn(), startWechatLogin: vi.fn(), @@ -20,7 +20,6 @@ const authMocks = vi.hoisted(() => ({ vi.mock('../../services/apiClient', () => ({ AUTH_STATE_EVENT: 'genarrative-auth-state-changed', - getStoredAccessToken: authMocks.getStoredAccessToken, })); vi.mock('../../services/authService', () => ({ @@ -31,9 +30,9 @@ vi.mock('../../services/authService', () => ({ getAuthAuditLogs: vi.fn(), getAuthLoginOptions: authMocks.getAuthLoginOptions, getAuthRiskBlocks: vi.fn(), + getCurrentAuthUser: authMocks.getCurrentAuthUser, getAuthSessions: vi.fn(), getCaptchaChallengeFromError: vi.fn(() => null), - getCurrentAuthUser: vi.fn(), liftAuthRiskBlock: vi.fn(), loginWithPhoneCode: authMocks.loginWithPhoneCode, logoutAllAuthSessions: vi.fn(), @@ -76,8 +75,11 @@ const mockUser: AuthUser = { beforeEach(() => { vi.clearAllMocks(); - authMocks.getStoredAccessToken.mockReturnValue(null); authMocks.consumeAuthCallbackResult.mockReturnValue(null); + authMocks.getCurrentAuthUser.mockResolvedValue({ + user: null, + availableLoginMethods: ['phone'], + }); authMocks.loginWithPhoneCode.mockResolvedValue(mockUser); authMocks.sendPhoneLoginCode.mockResolvedValue({ cooldownSeconds: 60, diff --git a/src/components/auth/AuthGate.tsx b/src/components/auth/AuthGate.tsx index 0e09e0ef..0ff917c1 100644 --- a/src/components/auth/AuthGate.tsx +++ b/src/components/auth/AuthGate.tsx @@ -10,7 +10,6 @@ import { import { useGameSettings } from '../../hooks/useGameSettings'; import { AUTH_STATE_EVENT, - getStoredAccessToken, } from '../../services/apiClient'; import { type AuthAuditLogEntry, @@ -229,12 +228,6 @@ export function AuthGate({ children }: AuthGateProps) { setShowLoginModal(true); } - const token = getStoredAccessToken(); - if (!token) { - await resolveGuestFallback(); - return; - } - try { const nextSession = await getCurrentAuthUser(); if (!isActive) { @@ -242,9 +235,8 @@ export function AuthGate({ children }: AuthGateProps) { } if (!nextSession.user) { - setUser(null); setAvailableLoginMethods(nextSession.availableLoginMethods); - setStatus('unauthenticated'); + await resolveGuestFallback(); return; } diff --git a/src/components/custom-world-agent/CustomWorldAgentClarificationPanel.test.tsx b/src/components/custom-world-agent/CustomWorldAgentClarificationPanel.test.tsx deleted file mode 100644 index 53a4f105..00000000 --- a/src/components/custom-world-agent/CustomWorldAgentClarificationPanel.test.tsx +++ /dev/null @@ -1,98 +0,0 @@ -/* @vitest-environment jsdom */ - -import { render, screen } from '@testing-library/react'; -import { renderToStaticMarkup } from 'react-dom/server'; -import { afterEach, expect, test, vi } from 'vitest'; - -import { CustomWorldAgentClarificationPanel } from './CustomWorldAgentClarificationPanel'; - -afterEach(() => { - vi.restoreAllMocks(); -}); - -test('clarification panel shows pending questions and ready state', () => { - const pendingHtml = renderToStaticMarkup( - , - ); - const readyHtml = renderToStaticMarkup( - , - ); - - expect(pendingHtml).toContain('待补充问题'); - expect(pendingHtml).toContain('玩家是谁,故事开场时卡在什么处境里'); - expect(readyHtml).toContain('当前设定已齐备,可以进入下一阶段'); -}); - -test('falls back to stable keys when clarification ids are empty', () => { - const consoleErrorSpy = vi - .spyOn(console, 'error') - .mockImplementation(() => undefined); - - render( - , - ); - - expect(screen.getByText(/玩家身份与开局/u)).toBeTruthy(); - expect(screen.getByText(/核心冲突/u)).toBeTruthy(); - - const duplicateKeyCalls = consoleErrorSpy.mock.calls.filter((call) => - call.some( - (arg) => - typeof arg === 'string' && - arg.includes('Encountered two children with the same key'), - ), - ); - - expect(duplicateKeyCalls).toHaveLength(0); -}); diff --git a/src/components/custom-world-agent/CustomWorldAgentClarificationPanel.tsx b/src/components/custom-world-agent/CustomWorldAgentClarificationPanel.tsx deleted file mode 100644 index a0e9ca8c..00000000 --- a/src/components/custom-world-agent/CustomWorldAgentClarificationPanel.tsx +++ /dev/null @@ -1,64 +0,0 @@ -import type { - CreatorIntentReadiness, - CustomWorldPendingClarification, -} from '../../../packages/shared/src/contracts/customWorldAgent'; - -type CustomWorldAgentClarificationPanelProps = { - pendingClarifications: CustomWorldPendingClarification[]; - readiness: CreatorIntentReadiness; -}; - -export function CustomWorldAgentClarificationPanel({ - pendingClarifications, - readiness, -}: CustomWorldAgentClarificationPanelProps) { - if (readiness.isReady) { - return ( -
-
- 下一阶段 -
-
- 当前设定已齐备,可以进入下一阶段 -
-
- ); - } - - return ( -
-
-
-
- 待补充问题 -
-
- 先补最关键的 1 到 3 项 -
-
- - {pendingClarifications.length} - -
- -
- {pendingClarifications.slice(0, 3).map((item, index) => ( -
-
-
- {index + 1}. {item.label} -
-
P{item.priority}
-
-
- {item.question} -
-
- ))} -
-
- ); -} diff --git a/src/components/custom-world-agent/CustomWorldAgentDraftDetailPanel.interaction.test.tsx b/src/components/custom-world-agent/CustomWorldAgentDraftDetailPanel.interaction.test.tsx deleted file mode 100644 index 14a2f771..00000000 --- a/src/components/custom-world-agent/CustomWorldAgentDraftDetailPanel.interaction.test.tsx +++ /dev/null @@ -1,115 +0,0 @@ -/* @vitest-environment jsdom */ - -import { render, screen } from '@testing-library/react'; -import userEvent from '@testing-library/user-event'; -import { useState } from 'react'; -import { expect, test } from 'vitest'; - -import type { CustomWorldDraftCardDetail } from '../../../packages/shared/src/contracts/customWorldAgent'; -import { CustomWorldAgentDraftDetailPanel } from './CustomWorldAgentDraftDetailPanel'; -import { CustomWorldGenerateEntityModal } from './CustomWorldGenerateEntityModal'; - -const CHARACTER_DETAIL: CustomWorldDraftCardDetail = { - id: 'character-1', - kind: 'character', - title: '沈砺', - sections: [ - { - id: 'name', - label: '角色名', - value: '沈砺', - }, - { - id: 'publicMask', - label: '外显身份', - value: '守灯会里最熟悉旧航道的人。', - }, - { - id: 'summary', - label: '角色摘要', - value: '他像旧友,但也像一把始终没收回鞘的刀。', - }, - ], - linkedIds: ['thread-1'], - locked: false, - editable: true, - editableSectionIds: ['name', 'publicMask', 'summary'], - warningMessages: [], - assetStatus: 'missing', - assetStatusLabel: '待生成主图', -}; - -function DetailInteractionHarness() { - const [editMode, setEditMode] = useState(false); - const [generateMode, setGenerateMode] = useState<'character' | 'landmark' | null>( - null, - ); - const [savedPayload, setSavedPayload] = useState(''); - - return ( - <> - {}} - onStartEdit={() => { - setEditMode(true); - }} - onCancelEdit={() => { - setEditMode(false); - }} - onSave={(sections) => { - setSavedPayload(JSON.stringify(sections)); - setEditMode(false); - }} - onGenerateCharacter={() => { - setGenerateMode('character'); - }} - onGenerateLandmark={() => { - setGenerateMode('landmark'); - }} - onOpenRoleAssetStudio={() => {}} - /> - { - setGenerateMode(null); - }} - onSubmit={() => { - setGenerateMode(null); - }} - /> -
{savedPayload}
- - ); -} - -test('draft detail panel supports edit save and opening generate modals', async () => { - const user = userEvent.setup(); - - render(); - - await user.click(screen.getByRole('button', { name: '编辑设定' })); - const summaryInput = screen.getByLabelText('角色摘要'); - await user.clear(summaryInput); - await user.type(summaryInput, '他像旧友,也像最早知道航道秘密的人。'); - await user.click(screen.getByRole('button', { name: '保存' })); - - expect(screen.getByTestId('saved-payload').textContent).toContain( - '他像旧友,也像最早知道航道秘密的人。', - ); - - await user.click(screen.getByRole('button', { name: '新增角色' })); - expect(screen.getByRole('button', { name: '生成角色' })).toBeTruthy(); - expect(screen.getByText('当前参考卡')).toBeTruthy(); - const closeButtons = screen.getAllByRole('button', { name: '关闭' }); - await user.click(closeButtons[closeButtons.length - 1]!); - - expect(screen.getByRole('button', { name: '角色资产' })).toBeTruthy(); - - await user.click(screen.getByRole('button', { name: '新增场景' })); - expect(screen.getByRole('button', { name: '生成场景' })).toBeTruthy(); -}); diff --git a/src/components/custom-world-agent/CustomWorldAgentDraftDetailPanel.test.tsx b/src/components/custom-world-agent/CustomWorldAgentDraftDetailPanel.test.tsx deleted file mode 100644 index 315e1c62..00000000 --- a/src/components/custom-world-agent/CustomWorldAgentDraftDetailPanel.test.tsx +++ /dev/null @@ -1,81 +0,0 @@ -import { renderToStaticMarkup } from 'react-dom/server'; -import { expect, test } from 'vitest'; - -import { CustomWorldAgentDraftDetailPanel } from './CustomWorldAgentDraftDetailPanel'; - -test('draft detail panel renders sections and warnings', () => { - const html = renderToStaticMarkup( - {}} - onStartEdit={() => {}} - onGenerateCharacter={() => {}} - onGenerateLandmark={() => {}} - />, - ); - - expect(html).toContain('谁掌握航道解释权'); - expect(html).toContain('线程类型'); - expect(html).toContain('守灯会与沉船商盟'); - expect(html).toContain('继续精修'); - expect(html).toContain('编辑设定'); - expect(html).toContain('新增角色'); -}); - -test('draft detail panel renders scene chapter label and background preview', () => { - const html = renderToStaticMarkup( - {}} - onStartEdit={() => {}} - />, - ); - - expect(html).toContain('场景章节'); - expect(html).toContain('第 1 幕背景图'); - expect(html).toContain('img'); -}); diff --git a/src/components/custom-world-agent/CustomWorldAgentDraftDetailPanel.tsx b/src/components/custom-world-agent/CustomWorldAgentDraftDetailPanel.tsx deleted file mode 100644 index 5409ef88..00000000 --- a/src/components/custom-world-agent/CustomWorldAgentDraftDetailPanel.tsx +++ /dev/null @@ -1,222 +0,0 @@ -import type { CustomWorldDraftCardDetail } from '../../../packages/shared/src/contracts/customWorldAgent'; -import { ResolvedAssetImage } from '../ResolvedAssetImage'; -import { CustomWorldDraftEditPanel } from './CustomWorldDraftEditPanel'; - -type CustomWorldAgentDraftDetailPanelProps = { - detail: CustomWorldDraftCardDetail | null; - loading: boolean; - busy?: boolean; - editMode?: boolean; - onClose: () => void; - onStartEdit?: () => void; - onCancelEdit?: () => void; - onSave?: ( - sections: Array<{ - sectionId: string; - value: string; - }>, - ) => void; - onGenerateCharacter?: () => void; - onGenerateLandmark?: () => void; - onOpenRoleAssetStudio?: () => void; -}; - -function resolveKindLabel(kind: CustomWorldDraftCardDetail['kind']) { - if (kind === 'world') return '世界总卡'; - if (kind === 'camp') return '营地'; - if (kind === 'faction') return '势力'; - if (kind === 'character') return '角色'; - if (kind === 'landmark') return '地点'; - if (kind === 'thread') return '线程'; - if (kind === 'chapter') return '第一幕'; - if (kind === 'scene_chapter') return '场景章节'; - return '草稿卡'; -} - -function ActionButton(props: { - label: string; - onClick?: () => void; - disabled?: boolean; - tone?: 'default' | 'sky'; -}) { - const { label, onClick, disabled = false, tone = 'default' } = props; - - if (!onClick) { - return null; - } - - return ( - - ); -} - -export function CustomWorldAgentDraftDetailPanel({ - detail, - loading, - busy = false, - editMode = false, - onClose, - onStartEdit, - onCancelEdit, - onSave, - onGenerateCharacter, - onGenerateLandmark, - onOpenRoleAssetStudio, -}: CustomWorldAgentDraftDetailPanelProps) { - const shouldRenderImagePreview = ( - detailKind: CustomWorldDraftCardDetail['kind'], - sectionId: string, - value: string, - ) => - detailKind === 'scene_chapter' && - sectionId.endsWith(':backgroundImageSrc') && - value !== '待继续精修'; - - return ( -
-
-
-
- 卡片详情 -
-
- {loading ? '正在读取' : detail?.title || '选择一张草稿卡'} -
-
- -
- - {loading ? ( -
- 正在整理这张卡的内容。 -
- ) : detail ? ( -
-
- - {resolveKindLabel(detail.kind)} - - - 关联 {detail.linkedIds.length} - - {detail.editable ? ( - - 可编辑 - - ) : null} - {detail.kind === 'character' && detail.assetStatusLabel ? ( - - {detail.assetStatusLabel} - - ) : null} -
- -
- {!editMode && detail.editable ? ( - - ) : null} - {!editMode && detail.kind === 'character' ? ( - - ) : null} - {!editMode ? ( - <> - - - - ) : null} -
- - {editMode && onSave && onCancelEdit ? ( - - ) : ( -
- {detail.sections.map((section) => ( -
-
- {section.label} -
- {shouldRenderImagePreview(detail.kind, section.id, section.value) ? ( - - ) : null} -
- {section.value} -
-
- ))} -
- )} - - {detail.warningMessages.length > 0 ? ( -
-
- 继续精修 -
-
- {detail.warningMessages.map((message, index) => ( -
- {message} -
- ))} -
-
- ) : null} -
- ) : ( -
- 从草稿抽屉里点开一张卡,就能在这里看世界底稿的具体内容。 -
- )} -
- ); -} diff --git a/src/components/custom-world-agent/CustomWorldAgentDraftDrawer.tsx b/src/components/custom-world-agent/CustomWorldAgentDraftDrawer.tsx deleted file mode 100644 index cd2a3812..00000000 --- a/src/components/custom-world-agent/CustomWorldAgentDraftDrawer.tsx +++ /dev/null @@ -1,117 +0,0 @@ -import type { CustomWorldDraftCardSummary } from '../../../packages/shared/src/contracts/customWorldAgent'; - -type CustomWorldAgentDraftDrawerProps = { - draftCards: CustomWorldDraftCardSummary[]; - activeCardId?: string | null; - onSelectCard: (cardId: string) => void; -}; - -const DRAWER_KIND_ORDER: CustomWorldDraftCardSummary['kind'][] = [ - 'world', - 'chapter', - 'scene_chapter', - 'thread', - 'faction', - 'character', - 'landmark', - 'camp', -]; - -function resolveGroupLabel(kind: CustomWorldDraftCardSummary['kind']) { - if (kind === 'world') return '世界总卡'; - if (kind === 'chapter') return '第一幕'; - if (kind === 'scene_chapter') return '场景章节'; - if (kind === 'thread') return '世界线程'; - if (kind === 'faction') return '势力'; - if (kind === 'character') return '关键角色'; - if (kind === 'landmark') return '关键地点'; - if (kind === 'camp') return '营地'; - return '草稿卡'; -} - -export function CustomWorldAgentDraftDrawer({ - draftCards, - activeCardId, - onSelectCard, -}: CustomWorldAgentDraftDrawerProps) { - const groupedCards = DRAWER_KIND_ORDER.map((kind) => ({ - kind, - items: draftCards.filter((card) => card.kind === kind), - })).filter((group) => group.items.length > 0); - - return ( -
-
- 草稿抽屉 -
- {groupedCards.length > 0 ? ( -
- {groupedCards.map((group) => ( -
-
-
- {resolveGroupLabel(group.kind)} -
-
- {group.items.length} -
-
-
- {group.items.map((card, index) => { - const isActive = activeCardId === card.id; - - return ( - - ); - })} -
-
- ))} -
- ) : ( -
- 当前设定收束后,世界底稿会先从这里长出来。 -
- )} -
- ); -} diff --git a/src/components/custom-world-agent/CustomWorldAgentIntentSummaryPanel.test.tsx b/src/components/custom-world-agent/CustomWorldAgentIntentSummaryPanel.test.tsx deleted file mode 100644 index 3b8d6cf5..00000000 --- a/src/components/custom-world-agent/CustomWorldAgentIntentSummaryPanel.test.tsx +++ /dev/null @@ -1,40 +0,0 @@ -import { renderToStaticMarkup } from 'react-dom/server'; -import { expect, test } from 'vitest'; - -import { CustomWorldAgentIntentSummaryPanel } from './CustomWorldAgentIntentSummaryPanel'; - -test('intent summary panel shows collected custom world anchors', () => { - const html = renderToStaticMarkup( - , - ); - - expect(html).toContain('已收集设定'); - expect(html).toContain('世界一句话'); - expect(html).toContain('一个被潮雾切开的列岛世界'); - expect(html).toContain('守灯会与沉船商盟争夺航道解释权'); - expect(html).toContain('5/6'); -}); diff --git a/src/components/custom-world-agent/CustomWorldAgentIntentSummaryPanel.tsx b/src/components/custom-world-agent/CustomWorldAgentIntentSummaryPanel.tsx deleted file mode 100644 index 3d00a72c..00000000 --- a/src/components/custom-world-agent/CustomWorldAgentIntentSummaryPanel.tsx +++ /dev/null @@ -1,99 +0,0 @@ -import type { CreatorIntentReadiness } from '../../../packages/shared/src/contracts/customWorldAgent'; -import { - evaluateCustomWorldCreatorIntentReadiness, - hasMeaningfulCustomWorldCreatorIntent, - normalizeCustomWorldCreatorIntent, -} from '../../services/customWorldCreatorIntent'; - -type CustomWorldAgentIntentSummaryPanelProps = { - creatorIntent: Record | null; - readiness: CreatorIntentReadiness; -}; - -export function CustomWorldAgentIntentSummaryPanel({ - creatorIntent, - readiness, -}: CustomWorldAgentIntentSummaryPanelProps) { - const intent = normalizeCustomWorldCreatorIntent(creatorIntent); - const resolvedReadiness = - readiness ?? evaluateCustomWorldCreatorIntentReadiness(intent); - const items = [ - { - label: '世界一句话', - value: intent?.worldHook || '', - ready: resolvedReadiness.completedKeys.includes('world_hook'), - }, - { - label: '玩家身份', - value: intent?.playerPremise || '', - ready: Boolean(intent?.playerPremise), - }, - { - label: '开局处境', - value: intent?.openingSituation || '', - ready: Boolean(intent?.openingSituation), - }, - { - label: '核心冲突', - value: intent?.coreConflicts.join('、') || '', - ready: resolvedReadiness.completedKeys.includes('core_conflict'), - }, - { - label: '主题气质', - value: - [...(intent?.themeKeywords ?? []), ...(intent?.toneDirectives ?? [])] - .filter(Boolean) - .join('、') || '', - ready: resolvedReadiness.completedKeys.includes('theme_and_tone'), - }, - { - label: '标志性要素', - value: intent?.iconicElements.join('、') || '', - ready: resolvedReadiness.completedKeys.includes('iconic_element'), - }, - ]; - - return ( -
-
-
-
- 已收集设定 -
-
- {resolvedReadiness.isReady ? '创作输入已齐备' : '继续收世界骨架'} -
-
- - {resolvedReadiness.completedKeys.length}/6 - -
- - {hasMeaningfulCustomWorldCreatorIntent(intent) ? ( -
- {items.map((item) => ( -
-
- {item.label} -
-
- {item.value || '待补充'} -
-
- ))} -
- ) : ( -
- 还在收集你的世界设定 -
- )} -
- ); -} diff --git a/src/components/custom-world-agent/CustomWorldAgentLauncherModal.tsx b/src/components/custom-world-agent/CustomWorldAgentLauncherModal.tsx deleted file mode 100644 index 29f901b8..00000000 --- a/src/components/custom-world-agent/CustomWorldAgentLauncherModal.tsx +++ /dev/null @@ -1,90 +0,0 @@ -import { X } from 'lucide-react'; - -type CustomWorldAgentLauncherModalProps = { - isOpen: boolean; - seedText: string; - isBusy: boolean; - error: string | null; - onClose: () => void; - onSeedTextChange: (value: string) => void; - onConfirm: () => void; -}; - -export function CustomWorldAgentLauncherModal({ - isOpen, - seedText, - isBusy, - error, - onClose, - onSeedTextChange, - onConfirm, -}: CustomWorldAgentLauncherModalProps) { - if (!isOpen) { - return null; - } - - return ( -
-
-
-
-
- 开始和 Agent 共创 -
-
- 输入一段种子灵感,先进入新的工作区。 -
-
- -
- -
-