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/README.md b/docs/audits/engineering/README.md index 94efb4c8..0fd8f240 100644 --- a/docs/audits/engineering/README.md +++ b/docs/audits/engineering/README.md @@ -4,27 +4,31 @@ ## 当前推荐入口 -1. [FRONTEND_LOGIC_BACKEND_MIGRATION_AUDIT_2026-04-21.md](./FRONTEND_LOGIC_BACKEND_MIGRATION_AUDIT_2026-04-21.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 后端的运行时、鉴权、生成编排与本地真相残留。 -2. [ENGINEERING_DEAD_CODE_CLEANUP_BATCH_D_2026-04-21.md](./ENGINEERING_DEAD_CODE_CLEANUP_BATCH_D_2026-04-21.md) +4. [ENGINEERING_DEAD_CODE_CLEANUP_BATCH_D_2026-04-21.md](./ENGINEERING_DEAD_CODE_CLEANUP_BATCH_D_2026-04-21.md) 这一版是第四批落地记录,聚焦未接入业务的数据生成产物、测试专用 stub 与对应配置残留出清。 -3. [ENGINEERING_DEAD_CODE_CLEANUP_BATCH_C_2026-04-21.md](./ENGINEERING_DEAD_CODE_CLEANUP_BATCH_C_2026-04-21.md) +5. [ENGINEERING_DEAD_CODE_CLEANUP_BATCH_C_2026-04-21.md](./ENGINEERING_DEAD_CODE_CLEANUP_BATCH_C_2026-04-21.md) 这一版是第三批落地记录,聚焦鉴权真相收口,先移除前端保存自动登录用户名/密码的本地真相,并明确运行时快照前置写入为什么当前还不能硬砍。 -4. [ENGINEERING_DEAD_CODE_CLEANUP_BATCH_B_2026-04-21.md](./ENGINEERING_DEAD_CODE_CLEANUP_BATCH_B_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 的正式出清。 -5. [ENGINEERING_DEAD_CODE_CLEANUP_BATCH_A_2026-04-21.md](./ENGINEERING_DEAD_CODE_CLEANUP_BATCH_A_2026-04-21.md) +7. [ENGINEERING_DEAD_CODE_CLEANUP_BATCH_A_2026-04-21.md](./ENGINEERING_DEAD_CODE_CLEANUP_BATCH_A_2026-04-21.md) 这一版是第一批落地记录,聚焦高置信度小型孤岛、prompt 壳子、stub 和无入口 modal 的首轮清理。 -6. [CURRENT_ENGINEERING_OPTIMIZATION_OPPORTUNITIES_2026-04-20.md](./CURRENT_ENGINEERING_OPTIMIZATION_OPPORTUNITIES_2026-04-20.md) +8. [CURRENT_ENGINEERING_OPTIMIZATION_OPPORTUNITIES_2026-04-20.md](./CURRENT_ENGINEERING_OPTIMIZATION_OPPORTUNITIES_2026-04-20.md) 这一版是面向当前仓库状态的优化点盘点,适合直接拿来排优先级和拆执行批次。 -7. [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` 基线的当前仓库复核,明确哪些问题已经处理、哪些表述需要纠正、热点又迁移到了哪里。 -8. [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) 这一版保留原始问题快照和执行回填,适合回看“为什么会有这轮清理与边界收口”。 -9. [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) 这一版最适合作为当前工程基线,重点从“是否真正绿色”“门禁有没有覆盖真实风险”来判断仓库状态。 -10. [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 边界、分层过渡期问题。 -11. [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) 适合看第一轮系统性工程扫描,了解最早的问题基线。 ## 融合结论 @@ -34,6 +38,8 @@ - 第二批已经开始清理旧主流程壳层与旧 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/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_DRAFT_RESULT_ASSET_MERGE_FIX_2026-04-21.md b/docs/technical/AGENT_DRAFT_RESULT_ASSET_MERGE_FIX_2026-04-21.md index 9079d0da..ac183414 100644 --- 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 @@ -94,3 +94,26 @@ 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/packages/shared/src/contracts/rpgRuntimeContracts.test.ts b/packages/shared/src/contracts/rpgRuntimeContracts.test.ts index e1e8fff0..d402dcf8 100644 --- a/packages/shared/src/contracts/rpgRuntimeContracts.test.ts +++ b/packages/shared/src/contracts/rpgRuntimeContracts.test.ts @@ -1,17 +1,17 @@ import { describe, expect, test } from 'vitest'; +import type { CharacterChatReplyRequest } from './rpgRuntimeChat'; +import { QUEST_NARRATIVE_TYPES } from './rpgRuntimeQuestAssist'; import { - CharacterChatReplyRequest, - QUEST_NARRATIVE_TYPES, - RuntimeStoryActionRequest, SERVER_RUNTIME_FUNCTION_IDS, + TASK5_RUNTIME_OPTION_SCOPES, TASK6_RUNTIME_FUNCTION_IDS, -} from './story'; -import { TASK5_RUNTIME_OPTION_SCOPES } from './rpgRuntimeStoryAction'; + type RuntimeStoryActionRequest, +} from './rpgRuntimeStoryAction'; import type { RuntimeStoryStateRequest } from './rpgRuntimeStoryState'; -describe('RPG runtime shared contract façades', () => { - test('旧 story façade 继续导出 runtime story action 常量与类型', () => { +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']); @@ -27,7 +27,7 @@ describe('RPG runtime shared contract façades', () => { expect(request.action.functionId).toBe('npc_chat'); }); - test('旧 story façade 继续导出 chat 与 quest assist 契约', () => { + test('拆分后的 chat 与 quest assist 契约继续导出运行时类型', () => { const payload: CharacterChatReplyRequest = { worldType: 'WUXIA', playerCharacter: {}, 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/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/modules/custom-world/runtime-profile/runtimeProfileCompiler.ts b/server-node/src/modules/custom-world/runtime-profile/runtimeProfileCompiler.ts deleted file mode 100644 index 7a78cd49..00000000 --- a/server-node/src/modules/custom-world/runtime-profile/runtimeProfileCompiler.ts +++ /dev/null @@ -1,13 +0,0 @@ -/** - * 兼容期 façade: - * 旧的 runtimeProfileCompiler 文件名暂时保留,避免工作包 G 完整拆分后影响仍未迁移的局部导入。 - * 新实现已经拆到目录模块中,后续新增逻辑禁止继续回写到这个文件。 - */ -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/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/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/rpg-runtime-story/index.ts b/server-node/src/modules/rpg-runtime-story/index.ts deleted file mode 100644 index 9497cded..00000000 --- a/server-node/src/modules/rpg-runtime-story/index.ts +++ /dev/null @@ -1,35 +0,0 @@ -export { - buildRpgRuntimeAvailableOptions, - buildRpgRuntimeLegacyCurrentStory, - buildRpgRuntimeViewModel, -} from './RpgRuntimeOptionCompiler.js'; -export { - appendStoryHistory, - getEncounterKey, - getEncounterNpcState, - getPlayerCharacter, - getPlayerSkillCooldowns, - isCombatFunctionId, - isNpcFunctionId, - isStoryFunctionId, - isTask5FunctionId, - isTask6RuntimeFunctionId, - MAX_TASK5_COMPANIONS, - setEncounterNpcState, - TASK6_DEFERRED_FUNCTION_IDS, - type RuntimeEncounter, - type RuntimeNpcState, - type RuntimeSession as RuntimeSessionPrimitives, -} from './RpgRuntimeSessionPrimitives.js'; -export { - loadRpgRuntimeSession, - type RuntimeSession, -} from './RpgRuntimeSessionLoader.js'; -export { - replaceRpgRuntimeSessionRawGameState, - syncRpgRuntimeSnapshot, -} from './RpgRuntimeSnapshotSync.js'; -export { - resolveRpgRuntimeStoryAction, -} from './RpgRuntimeStoryActionService.js'; -export { getRpgRuntimeStoryState } from './RpgRuntimeStoryStateService.js'; 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/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/repositories/rpg-entry/index.ts b/server-node/src/repositories/rpg-entry/index.ts deleted file mode 100644 index 674edd5b..00000000 --- a/server-node/src/repositories/rpg-entry/index.ts +++ /dev/null @@ -1,8 +0,0 @@ -export { - RpgSaveArchiveRepository, - type RpgSaveArchiveRepositoryPort, -} from './RpgSaveArchiveRepository.js'; -export { - RpgWorldLibraryRepository, - type RpgWorldLibraryRepositoryPort, -} from './RpgWorldLibraryRepository.js'; diff --git a/server-node/src/repositories/rpg-profile/index.ts b/server-node/src/repositories/rpg-profile/index.ts deleted file mode 100644 index 9abdcd25..00000000 --- a/server-node/src/repositories/rpg-profile/index.ts +++ /dev/null @@ -1,8 +0,0 @@ -export { - RpgBrowseHistoryRepository, - type RpgBrowseHistoryRepositoryPort, -} from './RpgBrowseHistoryRepository.js'; -export { - RpgProfileDashboardRepository, - type RpgProfileDashboardRepositoryPort, -} from './RpgProfileDashboardRepository.js'; diff --git a/server-node/src/repositories/rpg-runtime/index.ts b/server-node/src/repositories/rpg-runtime/index.ts deleted file mode 100644 index 759f002b..00000000 --- a/server-node/src/repositories/rpg-runtime/index.ts +++ /dev/null @@ -1,4 +0,0 @@ -export { - RpgRuntimeSnapshotRepository, - type RpgRuntimeSnapshotRepositoryPort, -} from './RpgRuntimeSnapshotRepository.js'; diff --git a/server-node/src/routes/customWorldAgent.ts b/server-node/src/routes/customWorldAgent.ts index 7ae5c472..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({ @@ -98,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/index.ts b/server-node/src/routes/rpg-entry/index.ts deleted file mode 100644 index f5e9f05d..00000000 --- a/server-node/src/routes/rpg-entry/index.ts +++ /dev/null @@ -1,11 +0,0 @@ -export { - createRpgEntrySaveRoutes, - RPG_ENTRY_SAVE_ARCHIVE_ROUTE_BASE_PATH, - RPG_ENTRY_SAVE_ROUTE_BASE_PATH, -} from './rpgEntrySaveRoutes.js'; -export { - createRpgWorldLibraryRoutes, - RPG_WORLD_GALLERY_ROUTE_BASE_PATH, - RPG_WORLD_LIBRARY_ROUTE_BASE_PATH, - RPG_WORLD_WORKS_ROUTE_BASE_PATH, -} from './rpgWorldLibraryRoutes.js'; diff --git a/server-node/src/routes/rpg-profile/index.ts b/server-node/src/routes/rpg-profile/index.ts deleted file mode 100644 index b1d2cc8f..00000000 --- a/server-node/src/routes/rpg-profile/index.ts +++ /dev/null @@ -1,4 +0,0 @@ -export { - createRpgProfileRoutes, - RPG_PROFILE_ROUTE_BASE_PATH, -} from './rpgProfileRoutes.js'; diff --git a/server-node/src/routes/rpg-runtime/index.ts b/server-node/src/routes/rpg-runtime/index.ts deleted file mode 100644 index fe2a5b4f..00000000 --- a/server-node/src/routes/rpg-runtime/index.ts +++ /dev/null @@ -1,8 +0,0 @@ -export { - createRpgRuntimeAiAssistRoutes, - RPG_RUNTIME_AI_ASSIST_ROUTE_BASE_PATH, -} from './rpgRuntimeAiAssistRoutes.js'; -export { - createRpgRuntimeStoryRoutes, - RPG_RUNTIME_STORY_ROUTE_BASE_PATH, -} from './rpgRuntimeStoryRoutes.js'; diff --git a/server-node/src/routes/rpgCreationAgentRoutes.ts b/server-node/src/routes/rpgCreationAgentRoutes.ts deleted file mode 100644 index e8e923d3..00000000 --- a/server-node/src/routes/rpgCreationAgentRoutes.ts +++ /dev/null @@ -1 +0,0 @@ -export { createCustomWorldAgentRoutes as createRpgCreationAgentRoutes } from './customWorldAgent.js'; diff --git a/server-node/src/routes/rpgWorldGalleryRoutes.ts b/server-node/src/routes/rpgWorldGalleryRoutes.ts deleted file mode 100644 index 81d135cd..00000000 --- a/server-node/src/routes/rpgWorldGalleryRoutes.ts +++ /dev/null @@ -1,14 +0,0 @@ -import { Router } from 'express'; - -import type { AppContext } from '../context.js'; - -/** - * 工作包 A 先建立 RPG 世界广场路由的命名骨架。 - * 当前广场查询仍由旧 runtime 路由承载,后续工作包会再迁移实现。 - */ -export const RPG_WORLD_GALLERY_ROUTE_BASE_PATH = - '/runtime/custom-world-gallery'; - -export function createRpgWorldGalleryRoutes(_context: AppContext) { - return Router(); -} diff --git a/server-node/src/scripts/syncCustomWorldSavedProfileAssets.ts b/server-node/src/scripts/syncCustomWorldSavedProfileAssets.ts deleted file mode 100644 index eabe111e..00000000 --- a/server-node/src/scripts/syncCustomWorldSavedProfileAssets.ts +++ /dev/null @@ -1,233 +0,0 @@ -import fs from 'node:fs'; -import path from 'node:path'; - -import sharp from 'sharp'; - -import { createDatabase } from '../db.js'; -import { loadConfig } from '../config.js'; -import { RpgAgentSessionRepository } from '../repositories/RpgAgentSessionRepository.js'; -import { RpgWorldProfileRepository } from '../repositories/RpgWorldProfileRepository.js'; -import { CustomWorldAgentSessionStore } from '../services/customWorldAgentSessionStore.js'; - -type RecordValue = Record; - -function toText(value: unknown) { - return typeof value === 'string' ? value.trim() : ''; -} - -function isRecord(value: unknown): value is RecordValue { - return Boolean(value) && typeof value === 'object' && !Array.isArray(value); -} - -function toRecordArray(value: unknown) { - return Array.isArray(value) - ? value.filter((entry): entry is RecordValue => isRecord(entry)) - : []; -} - -function resolvePublicAssetPath(publicDir: string, imageSrc: unknown) { - const normalizedImageSrc = toText(imageSrc); - if (!normalizedImageSrc) { - return null; - } - - return path.join(publicDir, normalizedImageSrc.replace(/^\/+/u, '')); -} - -async function ensureSquareRoleImage(publicDir: string, imageSrc: unknown) { - const assetPath = resolvePublicAssetPath(publicDir, imageSrc); - if (!assetPath || !fs.existsSync(assetPath)) { - return null; - } - - const metadata = await sharp(assetPath).metadata(); - if ( - typeof metadata.width === 'number' && - typeof metadata.height === 'number' && - metadata.width === metadata.height - ) { - return { - imageSrc: toText(imageSrc), - updated: false, - width: metadata.width, - height: metadata.height, - }; - } - - const squaredBuffer = await sharp(assetPath) - .resize(1024, 1024, { - fit: 'cover', - position: 'attention', - }) - .png() - .toBuffer(); - fs.writeFileSync(assetPath, squaredBuffer); - - return { - imageSrc: toText(imageSrc), - updated: true, - width: 1024, - height: 1024, - }; -} - -async function main() { - const userId = 'user_02b1dea4e951b13fe53db236560bdf28'; - const sessionId = 'custom-world-agent-session-019e192a4060d18b92df127f1dafe8ae'; - const profileId = 'custom-world-mo744tca-深海奇境'; - const config = loadConfig({ - projectRoot: path.resolve(process.cwd(), '..'), - }); - const db = await createDatabase(config); - - try { - const rpgAgentSessionRepository = new RpgAgentSessionRepository(db); - const rpgWorldProfileRepository = new RpgWorldProfileRepository(db); - const sessionStore = new CustomWorldAgentSessionStore( - rpgAgentSessionRepository, - ); - const session = await sessionStore.getSnapshot(userId, sessionId); - if (!session || !isRecord(session.draftProfile)) { - throw new Error('未找到目标世界草稿 session,无法同步历史保存档案。'); - } - - const savedProfileEntry = ( - await rpgWorldProfileRepository.listOwnProfiles(userId) - ).find((entry) => entry.profileId === profileId); - if (!savedProfileEntry) { - throw new Error('未找到目标 saved profile,无法同步历史保存档案。'); - } - - const draftProfile = session.draftProfile; - const nextProfile = JSON.parse( - JSON.stringify(savedProfileEntry.profile), - ) as RecordValue; - - const draftPlayableById = new Map( - toRecordArray(draftProfile.playableNpcs).map((entry) => [toText(entry.id), entry] as const), - ); - const draftStoryById = new Map( - toRecordArray(draftProfile.storyNpcs).map((entry) => [toText(entry.id), entry] as const), - ); - const draftLandmarkById = new Map( - toRecordArray(draftProfile.landmarks).map((entry) => [toText(entry.id), entry] as const), - ); - const draftSceneChapterBySceneId = new Map( - toRecordArray(draftProfile.sceneChapters).map((entry) => [toText(entry.sceneId), entry] as const), - ); - - const playableNpcs = toRecordArray(nextProfile.playableNpcs).map((role) => { - const draftRole = draftPlayableById.get(toText(role.id)); - if (!draftRole) { - return role; - } - - return { - ...role, - imageSrc: toText(draftRole.imageSrc) || role.imageSrc, - generatedVisualAssetId: - toText(draftRole.generatedVisualAssetId) || role.generatedVisualAssetId, - }; - }); - - const storyNpcs = toRecordArray(nextProfile.storyNpcs).map((role) => { - const draftRole = draftStoryById.get(toText(role.id)); - if (!draftRole) { - return role; - } - - return { - ...role, - imageSrc: toText(draftRole.imageSrc) || role.imageSrc, - generatedVisualAssetId: - toText(draftRole.generatedVisualAssetId) || role.generatedVisualAssetId, - }; - }); - - const landmarks = toRecordArray(nextProfile.landmarks).map((landmark) => { - const draftLandmark = draftLandmarkById.get(toText(landmark.id)); - const draftSceneChapter = draftSceneChapterBySceneId.get(toText(landmark.id)); - const firstActImageSrc = - toRecordArray(draftSceneChapter?.acts) - .map((act) => toText(act.backgroundImageSrc)) - .find(Boolean) || ''; - - return { - ...landmark, - imageSrc: - toText(draftLandmark?.imageSrc) || - firstActImageSrc || - toText(landmark.imageSrc) || - undefined, - }; - }); - - const sceneChapterBlueprints = toRecordArray( - nextProfile.sceneChapterBlueprints, - ).map((chapter) => { - const draftChapter = draftSceneChapterBySceneId.get(toText(chapter.sceneId)); - if (!draftChapter) { - return chapter; - } - - const draftActById = new Map( - toRecordArray(draftChapter.acts).map((act) => [toText(act.id), act] as const), - ); - - return { - ...chapter, - acts: toRecordArray(chapter.acts).map((act) => { - const draftAct = draftActById.get(toText(act.id)); - if (!draftAct) { - return act; - } - - return { - ...act, - backgroundImageSrc: - toText(draftAct.backgroundImageSrc) || act.backgroundImageSrc, - backgroundAssetId: - toText(draftAct.backgroundAssetId) || act.backgroundAssetId, - }; - }), - }; - }); - - const roleImageUpdates = await Promise.all( - [...playableNpcs, ...storyNpcs].map((role) => - ensureSquareRoleImage(config.publicDir, role.imageSrc), - ), - ); - - nextProfile.playableNpcs = playableNpcs; - nextProfile.storyNpcs = storyNpcs; - nextProfile.landmarks = landmarks; - nextProfile.sceneChapterBlueprints = sceneChapterBlueprints; - - const updatedEntry = await rpgWorldProfileRepository.upsertOwnProfile( - userId, - profileId, - nextProfile, - savedProfileEntry.authorDisplayName || '玩家', - ); - - const summary = { - profileId, - syncedPlayableCount: playableNpcs.length, - syncedStoryCount: storyNpcs.length, - syncedLandmarkCount: landmarks.length, - syncedSceneChapterCount: sceneChapterBlueprints.length, - squareRoleImagesUpdated: roleImageUpdates.filter((entry) => entry?.updated).length, - coverImageSrc: updatedEntry.entry.coverImageSrc, - }; - - console.log(JSON.stringify(summary, null, 2)); - } finally { - await db.close(); - } -} - -void main().catch((error) => { - console.error(error); - process.exit(1); -}); diff --git a/server-node/src/services/RpgAgentOrchestrator.ts b/server-node/src/services/RpgAgentOrchestrator.ts deleted file mode 100644 index 60a68c71..00000000 --- a/server-node/src/services/RpgAgentOrchestrator.ts +++ /dev/null @@ -1 +0,0 @@ -export { CustomWorldAgentOrchestrator as RpgAgentOrchestrator } from './customWorldAgentOrchestrator.js'; diff --git a/server-node/src/services/RpgAgentSessionStore.ts b/server-node/src/services/RpgAgentSessionStore.ts deleted file mode 100644 index 2075826c..00000000 --- a/server-node/src/services/RpgAgentSessionStore.ts +++ /dev/null @@ -1,5 +0,0 @@ -export type { CustomWorldAgentSessionRecord as RpgAgentSessionRecord } from './customWorldAgentSessionStore.js'; -export { - CUSTOM_WORLD_AGENT_SESSION_ID_PREFIX as RPG_AGENT_SESSION_ID_PREFIX, - CustomWorldAgentSessionStore as RpgAgentSessionStore, -} from './customWorldAgentSessionStore.js'; diff --git a/server-node/src/services/customWorldAgentPhase2.test.ts b/server-node/src/services/customWorldAgentPhase2.test.ts index 46532ed1..f3087757 100644 --- a/server-node/src/services/customWorldAgentPhase2.test.ts +++ b/server-node/src/services/customWorldAgentPhase2.test.ts @@ -13,7 +13,7 @@ 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'; +import { RpgWorldWorkSummaryService } from './RpgWorldWorkSummaryService.js'; async function waitForOperation( orchestrator: CustomWorldAgentOrchestrator, @@ -217,10 +217,10 @@ test('phase2 work summaries compile draft title and summary from creator intent' update.operation.operationId, ); - const items = await listCustomWorldWorkSummaries(userId, { - rpgWorldProfiles: rpgWorldProfileRepository, - 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 72384507..e572f998 100644 --- a/server-node/src/services/customWorldAgentPhase3.test.ts +++ b/server-node/src/services/customWorldAgentPhase3.test.ts @@ -11,7 +11,7 @@ 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'; +import { RpgWorldWorkSummaryService } from './RpgWorldWorkSummaryService.js'; function createAutoAssetTestConfig(testName: string): AppConfig { const projectRoot = fs.mkdtempSync( @@ -368,10 +368,10 @@ test('phase3 work summaries prefer compiled foundation draft fields', async () = response.operation.operationId, ); - const items = await listCustomWorldWorkSummaries(userId, { - rpgWorldProfiles: rpgWorldProfileRepository, - customWorldAgentSessions: sessionStore, - }); + const items = await new RpgWorldWorkSummaryService( + rpgWorldProfileRepository, + sessionStore, + ).list(userId); const draft = items.find((item) => item.sessionId === readySession.sessionId); const compiledProfile = normalizeFoundationDraftProfile( ( diff --git a/server-node/src/services/customWorldAgentPhase4.test.ts b/server-node/src/services/customWorldAgentPhase4.test.ts index a03c5c48..b2b1453c 100644 --- a/server-node/src/services/customWorldAgentPhase4.test.ts +++ b/server-node/src/services/customWorldAgentPhase4.test.ts @@ -6,7 +6,7 @@ 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'; +import { RpgWorldWorkSummaryService } from './RpgWorldWorkSummaryService.js'; async function waitForOperation( orchestrator: CustomWorldAgentOrchestrator, @@ -552,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, { - rpgWorldProfiles: rpgWorldProfileRepository, - 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'); @@ -641,10 +641,10 @@ test('phase4 work summaries exclude library draft entries after phase3 downgrade '玩家', ); - const workItems = await listCustomWorldWorkSummaries(userId, { - rpgWorldProfiles: rpgWorldProfileRepository, - customWorldAgentSessions: sessionStore, - }); + const workItems = await new RpgWorldWorkSummaryService( + rpgWorldProfileRepository, + sessionStore, + ).list(userId); assert.ok(workItems.some((item) => item.sessionId === session.sessionId)); assert.equal( @@ -688,10 +688,10 @@ test('phase4 work summaries hide published agent sessions from draft lane and ke '玩家', ); - const workItems = await listCustomWorldWorkSummaries(userId, { - rpgWorldProfiles: rpgWorldProfileRepository, - customWorldAgentSessions: sessionStore, - }); + 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}`, diff --git a/server-node/src/services/customWorldAgentPublishGateService.ts b/server-node/src/services/customWorldAgentPublishGateService.ts deleted file mode 100644 index 64c9d1e0..00000000 --- a/server-node/src/services/customWorldAgentPublishGateService.ts +++ /dev/null @@ -1,141 +0,0 @@ -import type { - CustomWorldAgentSessionSnapshot, -} from '../../../packages/shared/src/contracts/customWorldAgent.js'; -import { normalizeFoundationDraftProfile } from './customWorldAgentDraftCompiler.js'; - -export type CustomWorldAgentPublishGateResult = { - blockers: CustomWorldAgentSessionSnapshot['qualityFindings']; - warnings: CustomWorldAgentSessionSnapshot['qualityFindings']; -}; - -function buildFinding(params: { - id: string; - code: string; - severity: 'warning' | 'blocker'; - targetId?: string | null; - message: string; -}) { - return { - id: params.id, - code: params.code, - severity: params.severity, - targetId: params.targetId ?? null, - message: params.message, - } satisfies CustomWorldAgentSessionSnapshot['qualityFindings'][number]; -} - -export class CustomWorldAgentPublishGateService { - evaluate(params: { - draftProfile: unknown; - qualityFindings: CustomWorldAgentSessionSnapshot['qualityFindings']; - }): CustomWorldAgentPublishGateResult { - const draftProfile = normalizeFoundationDraftProfile(params.draftProfile); - const blockers = params.qualityFindings.filter( - (entry) => entry.severity === 'blocker', - ); - const warnings = params.qualityFindings.filter( - (entry) => entry.severity === 'warning', - ); - - if (!draftProfile) { - return { - blockers: [ - ...blockers, - buildFinding({ - id: 'publish-empty-draft', - code: 'publish_empty_draft', - severity: 'blocker', - message: '当前还没有可发布的世界底稿,请先整理世界骨架。', - }), - ], - warnings, - }; - } - - if ((draftProfile.chapters?.length ?? 0) <= 0) { - blockers.push( - buildFinding({ - id: 'publish-missing-main-chapter', - code: 'publish_missing_main_chapter', - severity: 'blocker', - message: '发布前至少需要保留主线第一幕,当前世界还缺少章节草稿。', - }), - ); - } - - const missingRoleVisuals = [ - ...draftProfile.playableNpcs, - ...draftProfile.storyNpcs, - ].filter( - (entry) => - !entry.generatedVisualAssetId?.trim() || - !entry.generatedAnimationSetId?.trim(), - ); - if (missingRoleVisuals.length > 0) { - blockers.push( - buildFinding({ - id: 'publish-role-assets-incomplete', - code: 'publish_role_assets_incomplete', - severity: 'blocker', - targetId: missingRoleVisuals[0]?.id ?? null, - message: '仍有角色缺少正式主图或动作资产,发布前需要先补齐。', - }), - ); - } - - if ( - !draftProfile.camp?.imageSrc?.trim() || - !(draftProfile.camp as Record | null)?.generatedSceneAssetId - ) { - blockers.push( - buildFinding({ - id: 'publish-camp-scene-missing', - code: 'publish_camp_scene_missing', - severity: 'blocker', - targetId: draftProfile.camp?.id ?? null, - message: '营地还缺少正式场景图资产,发布前需要先确认营地图。', - }), - ); - } - - const missingLandmarkScenes = draftProfile.landmarks.filter((entry) => { - const record = entry as Record; - return ( - !entry.imageSrc?.trim() || !String(record.generatedSceneAssetId ?? '').trim() - ); - }); - if (missingLandmarkScenes.length > 0) { - blockers.push( - buildFinding({ - id: 'publish-landmark-scenes-missing', - code: 'publish_landmark_scenes_missing', - severity: 'blocker', - targetId: missingLandmarkScenes[0]?.id ?? null, - message: '仍有地点缺少正式场景图资产,发布前需要先补齐地点图。', - }), - ); - } - - const invalidSceneChapters = draftProfile.sceneChapters.filter( - (entry) => - entry.linkedThreadIds.length <= 0 || - entry.acts.every((act) => act.encounterNpcIds.length <= 0), - ); - if (invalidSceneChapters.length > 0) { - blockers.push( - buildFinding({ - id: 'publish-scene-chapter-unbound', - code: 'publish_scene_chapter_unbound', - severity: 'blocker', - targetId: invalidSceneChapters[0]?.id ?? null, - message: '场景章节还没有绑定足够的线程或角色,发布前请先补齐主线挂钩。', - }), - ); - } - - return { - blockers, - warnings, - }; - } -} diff --git a/server-node/src/services/customWorldAgentPublishService.ts b/server-node/src/services/customWorldAgentPublishService.ts deleted file mode 100644 index f3fa02ae..00000000 --- a/server-node/src/services/customWorldAgentPublishService.ts +++ /dev/null @@ -1,410 +0,0 @@ -import type { - CustomWorldAgentSessionRecord, -} from './customWorldAgentSessionStore.js'; -import { - buildCompiledCustomWorldProfile, -} from '../modules/custom-world/runtimeProfile.js'; -import type { CustomWorldProfile } from '../modules/custom-world/runtimeTypes.js'; -import type { RpgWorldProfileRepositoryPort } from '../repositories/RpgWorldProfileRepository.js'; -import type { CustomWorldProfileRecord } from '../../../packages/shared/src/contracts/runtime.js'; - -function toText(value: unknown, fallback = '') { - return typeof value === 'string' ? value.trim() : fallback; -} - -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 => - Boolean(item) && typeof item === 'object' && !Array.isArray(item), - ) - : []; -} - -function toStringArray(value: unknown, max = 8) { - if (!Array.isArray(value)) { - return []; - } - - return [...new Set(value.map((item) => toText(item)).filter(Boolean))].slice( - 0, - max, - ); -} - -function buildSettingTextFromSession(session: CustomWorldAgentSessionRecord) { - const anchorContent = session.anchorContent; - - const anchorLines = [ - anchorContent.worldPromise - ? `世界承诺:${[ - anchorContent.worldPromise.hook, - anchorContent.worldPromise.differentiator, - anchorContent.worldPromise.desiredExperience, - ] - .filter(Boolean) - .join(';')}` - : '', - anchorContent.playerFantasy - ? `玩家幻想:${[ - anchorContent.playerFantasy.playerRole, - anchorContent.playerFantasy.corePursuit, - anchorContent.playerFantasy.fearOfLoss, - ] - .filter(Boolean) - .join(';')}` - : '', - anchorContent.coreConflict - ? `核心冲突:${[ - anchorContent.coreConflict.surfaceConflicts.join('、'), - anchorContent.coreConflict.hiddenCrisis, - anchorContent.coreConflict.firstTouchedConflict, - ] - .filter(Boolean) - .join(';')}` - : '', - anchorContent.iconicElements - ? `标志元素:${[ - anchorContent.iconicElements.iconicMotifs.join('、'), - anchorContent.iconicElements.institutionsOrArtifacts.join('、'), - anchorContent.iconicElements.hardRules.join('、'), - ] - .filter(Boolean) - .join(';')}` - : '', - ].filter(Boolean); - - if (anchorLines.length > 0) { - return anchorLines.join('\n'); - } - - return session.seedText.trim() || '当前世界草稿已经进入发布阶段。'; -} - -function buildRuntimeRoleFromDraft( - draftRole: Record, - roleKind: 'playable' | 'story', - index: number, -) { - const name = toText(draftRole.name) || `角色 ${index + 1}`; - const title = - toText(draftRole.title) || - toText(draftRole.role) || - (roleKind === 'playable' ? '关键角色' : '场景角色'); - const role = toText(draftRole.role) || title; - - return { - id: toText(draftRole.id) || `${roleKind}-draft-${index + 1}`, - name, - title, - role, - description: - toText(draftRole.summary) || - toText(draftRole.publicIdentity) || - toText(draftRole.publicMask) || - toText(draftRole.currentPressure), - backstory: [ - toText(draftRole.publicIdentity), - toText(draftRole.currentPressure), - toText(draftRole.hiddenHook) - ? `暗线:${toText(draftRole.hiddenHook)}` - : '', - ] - .filter(Boolean) - .join(';'), - personality: - toText(draftRole.publicMask) || - toText(draftRole.publicIdentity) || - toText(draftRole.summary), - motivation: - toText(draftRole.relationToPlayer) || - toText(draftRole.currentPressure) || - toText(draftRole.hiddenHook), - combatStyle: role, - initialAffinity: roleKind === 'playable' ? 18 : 6, - relationshipHooks: [ - toText(draftRole.relationToPlayer), - toText(draftRole.currentPressure), - toText(draftRole.hiddenHook), - ].filter(Boolean), - tags: [ - ...toStringArray(draftRole.threadIds, 4), - roleKind === 'playable' ? '草稿主角' : '草稿角色', - ], - imageSrc: toText(draftRole.imageSrc) || undefined, - generatedVisualAssetId: - toText(draftRole.generatedVisualAssetId) || undefined, - generatedAnimationSetId: - toText(draftRole.generatedAnimationSetId) || undefined, - animationMap: isRecord(draftRole.animationMap) - ? draftRole.animationMap - : undefined, - } satisfies Record; -} - -function buildRuntimeLandmarkFromDraft( - draftLandmark: Record, - storyNpcIdSet: Set, - index: number, -) { - return { - id: toText(draftLandmark.id) || `landmark-draft-${index + 1}`, - name: toText(draftLandmark.name) || `关键地点 ${index + 1}`, - description: - toText(draftLandmark.description) || - toText(draftLandmark.summary) || - [toText(draftLandmark.purpose), toText(draftLandmark.mood)] - .filter(Boolean) - .join(';'), - dangerLevel: - toText(draftLandmark.dangerLevel) || - toText(draftLandmark.importance) || - toText(draftLandmark.mood) || - 'medium', - imageSrc: toText(draftLandmark.imageSrc) || undefined, - generatedSceneAssetId: - toText(draftLandmark.generatedSceneAssetId) || undefined, - generatedScenePrompt: - toText(draftLandmark.generatedScenePrompt) || undefined, - generatedSceneModel: - toText(draftLandmark.generatedSceneModel) || undefined, - sceneNpcIds: toStringArray(draftLandmark.characterIds).filter((entry) => - storyNpcIdSet.has(entry), - ), - connections: [], - } satisfies Record; -} - -function buildRuntimeCampFromDraft(draftCamp: Record | null) { - if (!draftCamp) { - return null; - } - - const name = toText(draftCamp.name); - const description = toText(draftCamp.description); - if (!name && !description) { - return null; - } - - return { - id: toText(draftCamp.id) || 'camp-home', - name: name || '开局营地', - description: description || '当前世界的开局落脚点。', - dangerLevel: - toText(draftCamp.dangerLevel) || toText(draftCamp.mood) || 'low', - imageSrc: toText(draftCamp.imageSrc) || undefined, - generatedSceneAssetId: - toText(draftCamp.generatedSceneAssetId) || undefined, - generatedScenePrompt: - toText(draftCamp.generatedScenePrompt) || undefined, - generatedSceneModel: - toText(draftCamp.generatedSceneModel) || undefined, - sceneNpcIds: [], - connections: [], - } satisfies Record; -} - -function buildRuntimeSceneChaptersFromDraft( - draftProfile: Record, - storyNpcIdSet: Set, - landmarkIdSet: Set, -) { - return toRecordArray(draftProfile.sceneChapters) - .map((sceneChapter, chapterIndex) => { - const sceneId = toText(sceneChapter.sceneId); - if (!sceneId) { - return null; - } - - const acts = toRecordArray(sceneChapter.acts) - .map((act, actIndex) => { - const encounterNpcIds = toStringArray(act.encounterNpcIds).filter( - (entry) => storyNpcIdSet.has(entry), - ); - const primaryNpcId = - toText(act.primaryNpcId) || encounterNpcIds[0] || ''; - - return { - id: toText(act.id) || `scene-act-${sceneId}-${actIndex + 1}`, - sceneId, - title: toText(act.title) || `第 ${actIndex + 1} 幕`, - summary: - toText(act.summary) || - toText(act.actGoal) || - `围绕${toText(sceneChapter.sceneName, sceneId)}继续推进`, - stageCoverage: - toStringArray(act.stageCoverage).length > 0 - ? toStringArray(act.stageCoverage) - : actIndex === 0 - ? ['opening'] - : ['climax', 'aftermath'], - backgroundImageSrc: - toText(act.backgroundImageSrc) || undefined, - backgroundAssetId: toText(act.backgroundAssetId) || undefined, - encounterNpcIds, - primaryNpcId, - linkedThreadIds: toStringArray(act.linkedThreadIds, 8), - advanceRule: - toText(act.advanceRule) || 'after_active_step_complete', - actGoal: toText(act.actGoal), - transitionHook: toText(act.transitionHook), - } satisfies Record; - }) - .filter( - (entry) => - entry.encounterNpcIds.length > 0 || entry.backgroundImageSrc, - ); - - return { - id: - toText(sceneChapter.id) || - `scene-chapter-${sceneId}-${chapterIndex + 1}`, - sceneId, - title: - toText(sceneChapter.title) || - toText(sceneChapter.sceneName) || - sceneId, - summary: - toText(sceneChapter.summary) || - toText(sceneChapter.title) || - toText(sceneChapter.sceneName) || - sceneId, - linkedThreadIds: toStringArray(sceneChapter.linkedThreadIds, 8), - linkedLandmarkIds: toStringArray( - sceneChapter.linkedLandmarkIds, - 8, - ).filter((entry) => landmarkIdSet.has(entry)), - acts, - } satisfies Record; - }) - .filter(Boolean); -} - -function buildPublishRawProfile( - session: CustomWorldAgentSessionRecord, - profileId: string, -) { - const draftProfile = isRecord(session.draftProfile) ? session.draftProfile : {}; - const legacyResultProfile = isRecord(draftProfile.legacyResultProfile) - ? draftProfile.legacyResultProfile - : null; - const playableNpcs = toRecordArray(draftProfile.playableNpcs).map( - (entry, index) => buildRuntimeRoleFromDraft(entry, 'playable', index), - ); - const storyNpcs = toRecordArray(draftProfile.storyNpcs).map((entry, index) => - buildRuntimeRoleFromDraft(entry, 'story', index), - ); - const storyNpcIdSet = new Set( - storyNpcs.map((entry) => toText(entry.id)).filter(Boolean), - ); - const landmarks = toRecordArray(draftProfile.landmarks).map((entry, index) => - buildRuntimeLandmarkFromDraft(entry, storyNpcIdSet, index), - ); - const landmarkIdSet = new Set( - landmarks.map((entry) => toText(entry.id)).filter(Boolean), - ); - - return { - ...(legacyResultProfile ?? {}), - id: profileId, - settingText: buildSettingTextFromSession(session), - name: - toText(draftProfile.name) || - toText(legacyResultProfile?.name) || - '未命名世界底稿', - subtitle: - toText(draftProfile.subtitle) || - toText(legacyResultProfile?.subtitle) || - '已发布世界', - summary: - toText(draftProfile.summary) || - toText(legacyResultProfile?.summary) || - '当前世界已经进入发布态。', - tone: - toText(draftProfile.tone) || - toText(legacyResultProfile?.tone) || - '整体气质仍带着明显张力', - playerGoal: - toText(draftProfile.playerGoal) || - toText(legacyResultProfile?.playerGoal) || - '先站稳局势,再判断下一步', - majorFactions: - toStringArray(draftProfile.majorFactions, 6).length > 0 - ? toStringArray(draftProfile.majorFactions, 6) - : Array.isArray(legacyResultProfile?.majorFactions) - ? legacyResultProfile.majorFactions - : [], - coreConflicts: - toStringArray(draftProfile.coreConflicts, 6).length > 0 - ? toStringArray(draftProfile.coreConflicts, 6) - : Array.isArray(legacyResultProfile?.coreConflicts) - ? legacyResultProfile.coreConflicts - : [toText(draftProfile.summary) || '核心冲突仍待继续补强'], - playableNpcs, - storyNpcs, - landmarks, - camp: buildRuntimeCampFromDraft( - isRecord(draftProfile.camp) ? draftProfile.camp : null, - ), - sceneChapterBlueprints: buildRuntimeSceneChaptersFromDraft( - draftProfile, - storyNpcIdSet, - landmarkIdSet, - ), - anchorContent: session.anchorContent, - creatorIntent: session.creatorIntent, - anchorPack: session.anchorPack, - lockState: session.lockState, - generationMode: 'full', - generationStatus: 'complete', - } satisfies Record; -} - -export class CustomWorldAgentPublishService { - buildProfileId(sessionId: string) { - return `agent-draft-${sessionId}`; - } - - compilePublishedProfile( - session: CustomWorldAgentSessionRecord, - ): CustomWorldProfile { - const profileId = this.buildProfileId(session.sessionId); - const rawProfile = buildPublishRawProfile(session, profileId); - - return buildCompiledCustomWorldProfile( - rawProfile, - buildSettingTextFromSession(session), - ); - } - - async publish(params: { - session: CustomWorldAgentSessionRecord; - userId: string; - authorDisplayName: string; - profileRepository: RpgWorldProfileRepositoryPort; - }) { - const publishedProfile = this.compilePublishedProfile(params.session); - const profileId = this.buildProfileId(params.session.sessionId); - const mutation = await params.profileRepository.upsertOwnProfile( - params.userId, - profileId, - publishedProfile as unknown as CustomWorldProfileRecord, - params.authorDisplayName || '玩家', - ); - const publishedMutation = await params.profileRepository.publishOwnProfile( - params.userId, - profileId, - params.authorDisplayName || '玩家', - ); - - return { - profileId, - publishedProfile, - mutation: publishedMutation ?? mutation, - }; - } -} diff --git a/server-node/src/services/customWorldWorkSummaryService.integration.test.ts b/server-node/src/services/customWorldWorkSummaryService.integration.test.ts index 9dde95cb..129ba6e9 100644 --- a/server-node/src/services/customWorldWorkSummaryService.integration.test.ts +++ b/server-node/src/services/customWorldWorkSummaryService.integration.test.ts @@ -8,11 +8,11 @@ import { } from '../../../packages/shared/src/contracts/rpgCreationFixtures.js'; import type { CustomWorldSessionRecord } from '../../../packages/shared/src/contracts/runtime.js'; import { createInMemoryRpgWorldRepositoryPorts } from './customWorldAgentRepositoryTestHelpers.js'; -import { listCustomWorldWorkSummaries } from './customWorldWorkSummaryService.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(); @@ -34,10 +34,10 @@ test('work summary service can aggregate shared RPG fixtures into draft and publ const sessionStore = new CustomWorldAgentSessionStore( rpgAgentSessionRepository, ); - const summaries = await listCustomWorldWorkSummaries('fixture-user', { - rpgWorldProfiles: rpgWorldProfileRepository, - customWorldAgentSessions: sessionStore, - }); + const summaries = await new RpgWorldWorkSummaryService( + rpgWorldProfileRepository, + sessionStore, + ).list('fixture-user'); const expected = createRpgCreationWorksResponseFixture(); assert.equal(summaries.length, expected.items.length); @@ -97,10 +97,10 @@ test('published agent sessions are filtered out after works unify to published p rpgAgentSessionRepository, ); - const summaries = await listCustomWorldWorkSummaries('fixture-user', { - rpgWorldProfiles: rpgWorldProfileRepository, - customWorldAgentSessions: sessionStore, - }); + const summaries = await new RpgWorldWorkSummaryService( + rpgWorldProfileRepository, + sessionStore, + ).list('fixture-user'); assert.equal( summaries.some((entry) => entry.sourceType === 'agent_session'), diff --git a/server-node/src/services/customWorldWorkSummaryService.ts b/server-node/src/services/customWorldWorkSummaryService.ts deleted file mode 100644 index f66fef77..00000000 --- a/server-node/src/services/customWorldWorkSummaryService.ts +++ /dev/null @@ -1,20 +0,0 @@ -import type { RpgWorldProfileRepositoryPort } from '../repositories/RpgWorldProfileRepository.js'; -import type { CustomWorldAgentSessionStore } from './customWorldAgentSessionStore.js'; -import { RpgWorldWorkSummaryService } from './RpgWorldWorkSummaryService.js'; - -/** - * 兼容服务入口保留旧文件名,内部则改走 RPG works 组装器,便于后续继续迁到新命名。 - */ -export async function listCustomWorldWorkSummaries( - userId: string, - dependencies: { - rpgWorldProfiles: RpgWorldProfileRepositoryPort; - customWorldAgentSessions: CustomWorldAgentSessionStore; - }, -) { - const service = new RpgWorldWorkSummaryService( - dependencies.rpgWorldProfiles, - dependencies.customWorldAgentSessions, - ); - return service.list(userId); -} 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/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/npcVisualEditorModel.ts b/src/components/npcVisualEditorModel.ts deleted file mode 100644 index b3fdf901..00000000 --- a/src/components/npcVisualEditorModel.ts +++ /dev/null @@ -1,176 +0,0 @@ -import { - buildBodyPath, - buildMedievalAtlasSpec, - buildRaceAssetPath, - clampMedievalAtlasFrame, - getMedievalAtlasOptions, - getMedievalPoseOptions, - MEDIEVAL_BODY_COLORS, - type MedievalAtlasSourceType, - type MedievalNpcVisualOverride, - type MedievalRace, -} from '../data/medievalNpcVisuals'; -import type { Encounter } from '../types'; -import { type NpcLayoutConfig, type NpcLayoutPart } from './npcVisualShared'; - -export type GearSourceType = 'none' | MedievalAtlasSourceType; - -export type EditableNpcVisualState = { - race: MedievalRace; - bodyColor: string; - headIndex: number; - hairColorIndex: number; - hairStyleFrame: number; - facialHairEnabled: boolean; - facialHairColorIndex: number; - facialHairStyleFrame: number; - headgearType: GearSourceType; - headgearFile: string; - headgearFrame: number; - mainHandType: GearSourceType; - mainHandFile: string; - mainHandFrame: number; - offHandType: GearSourceType; - offHandFile: string; - offHandFrame: number; -}; - -export type EditorNpcOption = { - encounter: Encounter; - sceneNames: string[]; -}; - -const NPC_LAYOUT_PARTS: NpcLayoutPart[] = [ - 'body', - 'head', - 'facialHair', - 'hair', - 'headgear', - 'hand', - 'mainHand', - 'offHand', -]; - -export function sanitizeFrameSelection( - type: GearSourceType, - file: string, - frame: number, - usage: 'headgear' | 'mainHand' | 'offHand', -) { - if (type === 'none' || !file) return 0; - const poseOptions = getMedievalPoseOptions(type, file, usage); - if (poseOptions.length === 0) return 0; - if (poseOptions.some(option => option.value === frame)) { - return clampMedievalAtlasFrame(type, file, frame); - } - const firstOption = poseOptions[0]; - return firstOption ? firstOption.value : 0; -} - -export function getDefaultFileForType(type: GearSourceType) { - if (type === 'none') return ''; - return getMedievalAtlasOptions(type)[0]?.file ?? ''; -} - -export function getDefaultFrameForSelection( - type: GearSourceType, - file: string, - usage: 'headgear' | 'mainHand' | 'offHand', -) { - if (type === 'none' || !file) return 0; - return getMedievalPoseOptions(type, file, usage)[0]?.value ?? 0; -} - -export function isRecord(value: unknown): value is Record { - return Boolean(value) && typeof value === 'object' && !Array.isArray(value); -} - -export function isNpcLayoutConfig(value: unknown): value is NpcLayoutConfig { - return ( - isRecord(value) - && NPC_LAYOUT_PARTS.every(part => { - const coordinate = value[part]; - return ( - isRecord(coordinate) - && typeof coordinate.x === 'number' - && Number.isFinite(coordinate.x) - && typeof coordinate.y === 'number' - && Number.isFinite(coordinate.y) - ); - }) - ); -} - -export function buildOverrideFromEditorState( - state: EditableNpcVisualState, -): MedievalNpcVisualOverride { - return { - race: state.race, - bodySrc: buildBodyPath( - state.bodyColor as (typeof MEDIEVAL_BODY_COLORS)[number], - ), - headSrc: buildRaceAssetPath(state.race, 'head', state.headIndex), - hairSrc: buildRaceAssetPath(state.race, 'hair', state.hairColorIndex), - handSrc: buildRaceAssetPath(state.race, 'hand', 1), - facialHairSrc: state.facialHairEnabled - ? buildRaceAssetPath(state.race, 'facialHair', state.facialHairColorIndex) - : undefined, - headgear: - state.headgearType === 'none' - ? undefined - : buildMedievalAtlasSpec( - state.headgearType, - state.headgearFile, - sanitizeFrameSelection( - state.headgearType, - state.headgearFile, - state.headgearFrame, - 'headgear', - ), - ), - mainHand: - state.mainHandType === 'none' - ? undefined - : buildMedievalAtlasSpec( - state.mainHandType, - state.mainHandFile, - sanitizeFrameSelection( - state.mainHandType, - state.mainHandFile, - state.mainHandFrame, - 'mainHand', - ), - ), - offHand: - state.offHandType === 'none' - ? undefined - : buildMedievalAtlasSpec( - state.offHandType, - state.offHandFile, - sanitizeFrameSelection( - state.offHandType, - state.offHandFile, - state.offHandFrame, - 'offHand', - ), - ), - bodyFrames: [0, 1, 2, 3], - headFrame: 0, - hairFrame: state.hairStyleFrame, - handFrame: 0, - facialHairFrame: state.facialHairEnabled - ? state.facialHairStyleFrame - : undefined, - }; -} - -export function buildNpcVisualSavePayload( - overrideMap: Record, - npcId: string, - editorState: EditableNpcVisualState, -) { - return { - ...overrideMap, - [npcId]: buildOverrideFromEditorState(editorState), - }; -} diff --git a/src/components/npcVisualEditorPersistence.test.ts b/src/components/npcVisualEditorPersistence.test.ts deleted file mode 100644 index 47310f0c..00000000 --- a/src/components/npcVisualEditorPersistence.test.ts +++ /dev/null @@ -1,107 +0,0 @@ -import { describe, expect, it, vi } from 'vitest'; - -import type { MedievalNpcVisualOverride } from '../data/medievalNpcVisuals'; -import type { EditableNpcVisualState } from './npcVisualEditorModel'; -import { - NPC_LAYOUT_CONFIG_API_PATH, - NPC_VISUAL_OVERRIDES_API_PATH, - persistNpcLayoutConfig, - persistNpcVisualOverrides, -} from './npcVisualEditorPersistence'; -import type { NpcLayoutConfig } from './npcVisualShared'; - -function createEditorState(): EditableNpcVisualState { - return { - race: 'human', - bodyColor: 'black', - headIndex: 1, - hairColorIndex: 1, - hairStyleFrame: 0, - facialHairEnabled: false, - facialHairColorIndex: 1, - facialHairStyleFrame: 0, - headgearType: 'none', - headgearFile: '', - headgearFrame: 0, - mainHandType: 'none', - mainHandFile: '', - mainHandFrame: 0, - offHandType: 'none', - offHandFile: '', - offHandFrame: 0, - }; -} - -function createExistingOverride(): MedievalNpcVisualOverride { - return { - race: 'elf', - bodySrc: '/body.png', - headSrc: '/head.png', - hairSrc: '/hair.png', - handSrc: '/hand.png', - bodyFrames: [0, 1, 2, 3], - headFrame: 0, - hairFrame: 1, - handFrame: 0, - }; -} - -function createLayoutDraft(): NpcLayoutConfig { - return { - body: { x: 0, y: 0 }, - head: { x: 1, y: 2 }, - facialHair: { x: 3, y: 4 }, - hair: { x: 5, y: 6 }, - headgear: { x: 7, y: 8 }, - hand: { x: 9, y: 10 }, - mainHand: { x: 11, y: 12 }, - offHand: { x: 13, y: 14 }, - }; -} - -describe('npcVisualEditorPersistence', () => { - it('persists merged npc visual overrides and returns the writeback payload', async () => { - const saveJson = vi.fn(async () => undefined); - const result = await persistNpcVisualOverrides({ - overrideMap: { - existing: createExistingOverride(), - }, - npcId: 'npc-1', - editorState: createEditorState(), - saveJson, - }); - - expect(saveJson).toHaveBeenCalledWith( - NPC_VISUAL_OVERRIDES_API_PATH, - expect.objectContaining({ - existing: createExistingOverride(), - 'npc-1': expect.objectContaining({ - race: 'human', - bodyFrames: [0, 1, 2, 3], - }), - }), - '保存角色形象覆盖配置失败', - ); - expect(result.nextOverrideMap.existing).toEqual(createExistingOverride()); - expect(result.nextOverrideMap['npc-1']).toEqual(expect.objectContaining({ race: 'human' })); - expect(result.saveMessage).toContain('npcVisualOverrides.json'); - }); - - it('persists layout config with a cloned payload for local writeback', async () => { - const saveJson = vi.fn(async () => undefined); - const layoutDraft = createLayoutDraft(); - const result = await persistNpcLayoutConfig({ - layoutDraft, - saveJson, - }); - - expect(saveJson).toHaveBeenCalledWith( - NPC_LAYOUT_CONFIG_API_PATH, - expect.objectContaining(layoutDraft), - '保存角色布局配置失败', - ); - expect(result.nextLayout).toEqual(layoutDraft); - expect(result.nextLayout).not.toBe(layoutDraft); - expect(result.saveMessage).toContain('角色布局'); - }); -}); diff --git a/src/components/npcVisualEditorPersistence.ts b/src/components/npcVisualEditorPersistence.ts deleted file mode 100644 index 7b9aecab..00000000 --- a/src/components/npcVisualEditorPersistence.ts +++ /dev/null @@ -1,65 +0,0 @@ -import type { MedievalNpcVisualOverride } from '../data/medievalNpcVisuals'; -import { - buildEditorJsonApiPath, - EDITOR_JSON_RESOURCE_IDS, -} from '../editor/shared/editorApiClient'; -import { saveJsonObject } from '../editor/shared/jsonClient'; -import { - buildNpcVisualSavePayload, - type EditableNpcVisualState, -} from './npcVisualEditorModel'; -import { cloneNpcLayoutConfig, type NpcLayoutConfig } from './npcVisualShared'; - -export const NPC_VISUAL_OVERRIDES_API_PATH = buildEditorJsonApiPath( - EDITOR_JSON_RESOURCE_IDS.npcVisualOverrides, -); -export const NPC_LAYOUT_CONFIG_API_PATH = buildEditorJsonApiPath( - EDITOR_JSON_RESOURCE_IDS.npcLayoutConfig, -); - -type SaveEditorJsonFn = typeof saveJsonObject; - -export async function persistNpcVisualOverrides(params: { - overrideMap: Record; - npcId: string; - editorState: EditableNpcVisualState; - saveJson?: SaveEditorJsonFn; -}) { - const { - overrideMap, - npcId, - editorState, - saveJson = saveJsonObject, - } = params; - const nextOverrideMap = buildNpcVisualSavePayload(overrideMap, npcId, editorState); - - await saveJson( - NPC_VISUAL_OVERRIDES_API_PATH, - nextOverrideMap, - '保存角色形象覆盖配置失败', - ); - - return { - nextOverrideMap, - saveMessage: '已将角色形象覆盖配置保存到 src/data/npcVisualOverrides.json。', - }; -} - -export async function persistNpcLayoutConfig(params: { - layoutDraft: NpcLayoutConfig; - saveJson?: SaveEditorJsonFn; -}) { - const { layoutDraft, saveJson = saveJsonObject } = params; - const nextLayout = cloneNpcLayoutConfig(layoutDraft); - - await saveJson( - NPC_LAYOUT_CONFIG_API_PATH, - nextLayout, - '保存角色布局配置失败', - ); - - return { - nextLayout, - saveMessage: '已保存共享角色布局配置。', - }; -} diff --git a/src/components/rpg-creation-asset-studio/index.ts b/src/components/rpg-creation-asset-studio/index.ts deleted file mode 100644 index d382b4eb..00000000 --- a/src/components/rpg-creation-asset-studio/index.ts +++ /dev/null @@ -1,4 +0,0 @@ -export { - RpgCreationRoleAssetStudioModal, - type RpgCreationRoleAssetStudioModalProps, -} from './RpgCreationRoleAssetStudioModal'; diff --git a/src/components/rpg-creation-editor/CustomWorldSceneChapterEditorSection.tsx b/src/components/rpg-creation-editor/CustomWorldSceneChapterEditorSection.tsx deleted file mode 100644 index eddeb37f..00000000 --- a/src/components/rpg-creation-editor/CustomWorldSceneChapterEditorSection.tsx +++ /dev/null @@ -1 +0,0 @@ -export { SectionPanel as default } from './RpgCreationEntityEditorShared'; diff --git a/src/components/rpg-creation-editor/index.ts b/src/components/rpg-creation-editor/index.ts deleted file mode 100644 index 57c52ca0..00000000 --- a/src/components/rpg-creation-editor/index.ts +++ /dev/null @@ -1,4 +0,0 @@ -export { - RpgCreationEntityEditorModal, - type RpgCreationEntityEditorModalProps, -} from './RpgCreationEntityEditorModal'; diff --git a/src/components/rpg-creation-result/index.ts b/src/components/rpg-creation-result/index.ts deleted file mode 100644 index 37fddfcf..00000000 --- a/src/components/rpg-creation-result/index.ts +++ /dev/null @@ -1,4 +0,0 @@ -export { - RpgCreationResultView, - type RpgCreationResultViewProps, -} from './RpgCreationResultView'; diff --git a/src/components/rpg-entry/RpgEntryFlowShell.agent.interaction.test.tsx b/src/components/rpg-entry/RpgEntryFlowShell.agent.interaction.test.tsx index 3dffbd6e..ae7e3bc1 100644 --- a/src/components/rpg-entry/RpgEntryFlowShell.agent.interaction.test.tsx +++ b/src/components/rpg-entry/RpgEntryFlowShell.agent.interaction.test.tsx @@ -17,6 +17,7 @@ import { upsertRpgWorldProfile, } from '../../services/rpg-creation'; import type { AuthUser } from '../../services/authService'; +import { ApiClientError } from '../../services/apiClient'; import { clearRpgProfileBrowseHistory as clearProfileBrowseHistory, deleteRpgEntryWorldProfile, @@ -517,6 +518,7 @@ beforeEach(() => { vi.mocked(createRpgCreationSession).mockResolvedValue({ session: mockSession, }); + vi.mocked(getRpgCreationSession).mockResolvedValue(mockSession); vi.mocked(listRpgCreationWorks).mockResolvedValue([]); vi.mocked(executeRpgCreationAction).mockResolvedValue({ operation: { @@ -669,6 +671,67 @@ test('create tab resumes agent workspace when draft has no compiled result yet', expect(screen.queryByText('世界档案')).toBeNull(); }); +test('opening a compiled draft with a missing agent session falls back to create hub', async () => { + const user = userEvent.setup(); + + vi.mocked(listRpgCreationWorks) + .mockResolvedValueOnce([ + { + workId: 'draft:custom-world-agent-session-missing', + sourceType: 'agent_session', + status: 'draft', + title: '潮雾列岛', + subtitle: '世界底稿已生成', + summary: '这是一份已经整理过首版结果页的草稿。', + coverImageSrc: null, + coverRenderMode: 'image', + coverCharacterImageSrcs: [], + updatedAt: '2026-04-20T11:00:00.000Z', + publishedAt: null, + stage: 'object_refining', + stageLabel: '整理关键对象', + playableNpcCount: 1, + landmarkCount: 1, + roleVisualReadyCount: 0, + roleAnimationReadyCount: 0, + roleAssetSummaryLabel: null, + sessionId: 'custom-world-agent-session-missing', + profileId: null, + canResume: true, + canEnterWorld: false, + }, + ]) + .mockResolvedValueOnce([]); + + vi.mocked(getRpgCreationSession).mockRejectedValueOnce( + new ApiClientError({ + message: 'custom world agent session not found', + status: 404, + code: 'NOT_FOUND', + }), + ); + + render(); + + await openCreationHub(user); + await user.click(screen.getByRole('button', { name: /继续完善/u })); + + await waitFor(() => { + expect( + screen.getByText( + '这份共创草稿已失效,已为你返回创作中心,请重新开始创作。', + ), + ).toBeTruthy(); + }); + + expect(window.location.search).toBe(''); + expect(listRpgCreationWorks).toHaveBeenCalledTimes(2); + expect(screen.getByText('还没有作品')).toBeTruthy(); + expect( + screen.queryByText('Agent工作区:custom-world-agent-session-missing'), + ).toBeNull(); +}); + test('clicking a public work while logged out routes through requireAuth', async () => { const user = userEvent.setup(); const requireAuth = vi.fn(); diff --git a/src/components/rpg-entry/useRpgEntryLibraryDetail.ts b/src/components/rpg-entry/useRpgEntryLibraryDetail.ts index e47c0cfb..d267c02e 100644 --- a/src/components/rpg-entry/useRpgEntryLibraryDetail.ts +++ b/src/components/rpg-entry/useRpgEntryLibraryDetail.ts @@ -16,6 +16,7 @@ import { publishRpgEntryWorldProfile, unpublishRpgEntryWorldProfile, } from '../../services/rpg-entry'; +import { ApiClientError } from '../../services/apiClient'; import type { CustomWorldProfile } from '../../types'; import { normalizeRpgEntryAgentBackedProfile, @@ -79,6 +80,14 @@ type UseRpgEntryLibraryDetailParams = { markAutoSavedProfile: (profile: CustomWorldProfile) => void; }; +function isMissingRpgEntryAgentSessionError(error: unknown) { + return ( + error instanceof ApiClientError && + error.status === 404 && + error.code === 'NOT_FOUND' + ); +} + /** * 负责平台详情、创作作品入口和结果页打开路径。 * 平台壳层只消费“打开哪个面板”的结果,不再自己拼接恢复流程细节。 @@ -218,7 +227,6 @@ export function useRpgEntryLibraryDetail( const handleOpenCreationWork = useCallback( async (work: CustomWorldWorkSummary) => { if (work.status === 'draft' && work.sessionId) { - persistAgentUiState(work.sessionId, null); setCustomWorldError(null); setCustomWorldAutoSaveError(null); setCustomWorldAutoSaveState('idle'); @@ -228,33 +236,57 @@ export function useRpgEntryLibraryDetail( const shouldOpenAgentWorkspace = work.playableNpcCount <= 0 && work.landmarkCount <= 0; - if (shouldOpenAgentWorkspace) { - // 仅八锚点未整理成底稿时才恢复 Agent 对话工作区。 - suppressAgentDraftResultAutoOpen(); - setGeneratedCustomWorldProfile(null); - setCustomWorldResultViewSource(null); + try { + if (shouldOpenAgentWorkspace) { + // 仅八锚点未整理成底稿时才恢复 Agent 对话工作区。 + suppressAgentDraftResultAutoOpen(); + persistAgentUiState(work.sessionId, null); + setGeneratedCustomWorldProfile(null); + setCustomWorldResultViewSource(null); + setPlatformTabToCreate(); + setSelectionStage('agent-workspace'); + return; + } + + releaseAgentDraftResultAutoOpenSuppression(); + const latestSession = await syncAgentSessionSnapshot(work.sessionId); + const nextProfile = buildDraftResultProfile(latestSession); + if (!nextProfile) { + persistAgentUiState(work.sessionId, null); + setPlatformError('当前草稿还没有可编辑的结果页数据,请先继续补齐锚点。'); + setPlatformTabToCreate(); + setSelectionStage('agent-workspace'); + return; + } + + persistAgentUiState(work.sessionId, null); + setGeneratedCustomWorldProfile( + normalizeRpgEntryAgentBackedProfile(nextProfile), + ); + setCustomWorldResultViewSource('agent-draft'); setPlatformTabToCreate(); - setSelectionStage('agent-workspace'); + setSelectionStage('custom-world-result'); + return; + } catch (error) { + if (isMissingRpgEntryAgentSessionError(error)) { + // 失效会话不能继续保留在恢复状态里,否则刷新后会重复命中同一个坏 session。 + persistAgentUiState(null, null); + setGeneratedCustomWorldProfile(null); + setCustomWorldResultViewSource(null); + await refreshCustomWorldWorks().catch(() => []); + setPlatformError( + '这份共创草稿已失效,已为你返回创作中心,请重新开始创作。', + ); + } else { + setPlatformError( + resolveRpgEntryErrorMessage(error, '读取创作草稿失败。'), + ); + } + + setPlatformTabToCreate(); + setSelectionStage('platform'); return; } - - releaseAgentDraftResultAutoOpenSuppression(); - const latestSession = await syncAgentSessionSnapshot(work.sessionId); - const nextProfile = buildDraftResultProfile(latestSession); - if (!nextProfile) { - setPlatformError('当前草稿还没有可编辑的结果页数据,请先继续补齐锚点。'); - setPlatformTabToCreate(); - setSelectionStage('agent-workspace'); - return; - } - - setGeneratedCustomWorldProfile( - normalizeRpgEntryAgentBackedProfile(nextProfile), - ); - setCustomWorldResultViewSource('agent-draft'); - setPlatformTabToCreate(); - setSelectionStage('custom-world-result'); - return; } if (!work.profileId) { diff --git a/src/data/editorValidation.ts b/src/data/editorValidation.ts deleted file mode 100644 index afcd8c24..00000000 --- a/src/data/editorValidation.ts +++ /dev/null @@ -1,254 +0,0 @@ -import { Character, ItemCatalogOverride, WorldType } from '../types'; -import { CharacterPresetOverride } from './characterPresets'; -import { MonsterPreset, MonsterPresetOverride } from './hostileNpcPresets'; -import { SceneNpcPresetOverride, ScenePreset, ScenePresetOverride } from './scenePresets'; - -function pushError(errors: string[], message: string) { - errors.push(message); -} - -function isPositiveNumber(value: number | undefined) { - return typeof value === 'number' && Number.isFinite(value) && value > 0; -} - -function isKnownGender(value: unknown): value is 'male' | 'female' { - return value === 'male' || value === 'female'; -} - -function isNonEmptyStringArray(value: unknown): value is string[] { - return Array.isArray(value) && value.every(item => typeof item === 'string' && item.trim().length > 0); -} - -function validateBuildBuffs(errors: string[], ownerId: string, label: string, buffs: unknown) { - if (!Array.isArray(buffs)) { - pushError(errors, `${ownerId} ${label} must be an array.`); - return; - } - - buffs.forEach((buff, index) => { - if (!buff || typeof buff !== 'object') { - pushError(errors, `${ownerId} ${label}[${index}] must be an object.`); - return; - } - - const typedBuff = buff as { - name?: unknown; - tags?: unknown; - durationTurns?: unknown; - }; - - if (typeof typedBuff.name !== 'string' || !typedBuff.name.trim()) { - pushError(errors, `${ownerId} ${label}[${index}] is missing a valid name.`); - } - - if (!isNonEmptyStringArray(typedBuff.tags)) { - pushError(errors, `${ownerId} ${label}[${index}].tags must be a non-empty string array.`); - } - - if (typeof typedBuff.durationTurns !== 'number' || !Number.isFinite(typedBuff.durationTurns) || typedBuff.durationTurns <= 0) { - pushError(errors, `${ownerId} ${label}[${index}].durationTurns must be > 0.`); - } - }); -} - -export function validateCharacterOverrides( - overrideMap: Record, - characters: Character[], - scenesByWorld: Partial>>>, -) { - const errors: string[] = []; - const validCharacterIds = new Set(characters.map(character => character.id)); - const validSceneIdsByWorld = { - [WorldType.WUXIA]: new Set((scenesByWorld[WorldType.WUXIA] ?? []).map(scene => scene.id)), - [WorldType.XIANXIA]: new Set((scenesByWorld[WorldType.XIANXIA] ?? []).map(scene => scene.id)), - [WorldType.CUSTOM]: new Set((scenesByWorld[WorldType.CUSTOM] ?? []).map(scene => scene.id)), - }; - - Object.entries(overrideMap).forEach(([characterId, override]) => { - if (!validCharacterIds.has(characterId)) { - pushError(errors, `未知角色覆盖:${characterId}`); - return; - } - - if (override.gender !== undefined && !isKnownGender(override.gender)) { - pushError(errors, `${characterId} gender must be "male" or "female".`); - } - - if (override.combatTags !== undefined && !isNonEmptyStringArray(override.combatTags)) { - pushError(errors, `${characterId} combatTags must be a non-empty string array.`); - } - - if (override.skills) { - override.skills.forEach((skill, index) => { - const skillLabel = `${characterId} skill ${skill.id || index + 1}`; - if (!skill.id?.trim()) pushError(errors, `${skillLabel} is missing id.`); - if (!skill.name?.trim()) pushError(errors, `${skillLabel} is missing name.`); - if (!isPositiveNumber(skill.range)) pushError(errors, `${skillLabel} range must be > 0.`); - if (typeof skill.damage !== 'number' || skill.damage < 0) pushError(errors, `${skillLabel} damage must be >= 0.`); - if (typeof skill.manaCost !== 'number' || skill.manaCost < 0) pushError(errors, `${skillLabel} manaCost must be >= 0.`); - if (typeof skill.cooldownTurns !== 'number' || skill.cooldownTurns < 0) pushError(errors, `${skillLabel} cooldownTurns must be >= 0.`); - if (skill.buildBuffs !== undefined) { - validateBuildBuffs(errors, characterId, `skill ${skill.id || index + 1} buildBuffs`, skill.buildBuffs); - } - }); - } - - if (override.sceneBindings) { - Object.entries(override.sceneBindings).forEach(([world, binding]) => { - if (!binding) return; - const worldType = world as WorldType; - const validSceneIds = validSceneIdsByWorld[worldType]; - - if (binding.homeSceneId && !validSceneIds.has(binding.homeSceneId)) { - pushError(errors, `${characterId} has invalid homeSceneId for ${worldType}: ${binding.homeSceneId}`); - } - - (binding.npcSceneIds ?? []).forEach(sceneId => { - if (!validSceneIds.has(sceneId)) { - pushError(errors, `${characterId} has invalid npcSceneId for ${worldType}: ${sceneId}`); - } - }); - }); - } - }); - - return errors; -} - -export function validateMonsterOverrides( - overrideMap: Record, - monsters: MonsterPreset[], -) { - const errors: string[] = []; - const validMonsterIds = new Set(monsters.map(monster => monster.id)); - - Object.entries(overrideMap).forEach(([monsterId, override]) => { - if (!validMonsterIds.has(monsterId)) { - pushError(errors, `未知怪物覆盖:${monsterId}`); - return; - } - - Object.entries(override.baseStats ?? {}).forEach(([key, value]) => { - const numericValue = typeof value === 'number' ? value : undefined; - if (!isPositiveNumber(numericValue)) { - pushError(errors, `${monsterId} baseStats.${key} must be > 0.`); - } - }); - - if (override.combatTags !== undefined && !isNonEmptyStringArray(override.combatTags)) { - pushError(errors, `${monsterId} combatTags must be a non-empty string array.`); - } - - Object.entries(override.animations ?? {}).forEach(([animation, rawConfig]) => { - const config = rawConfig as { frames?: number; fps?: number } | undefined; - if (!config) return; - if (!isPositiveNumber(config.frames)) pushError(errors, `${monsterId} ${animation}.frames must be > 0.`); - if (!isPositiveNumber(config.fps)) pushError(errors, `${monsterId} ${animation}.fps must be > 0.`); - }); - }); - - return errors; -} - -export function validateSceneOverrides( - overrideMap: Record, - scenes: ScenePreset[], - _monstersByWorld: Partial>, -) { - const errors: string[] = []; - const sceneById = new Map(scenes.map(scene => [scene.id, scene])); - const validSceneIds = new Set(scenes.map(scene => scene.id)); - - Object.entries(overrideMap).forEach(([sceneId, override]) => { - const scene = sceneById.get(sceneId); - if (!scene) { - pushError(errors, `未知场景覆盖:${sceneId}`); - return; - } - - if (override.forwardSceneId && !validSceneIds.has(override.forwardSceneId)) { - pushError(errors, `${sceneId} has invalid forwardSceneId: ${override.forwardSceneId}`); - } - - (override.connectedSceneIds ?? []).forEach(targetSceneId => { - if (!validSceneIds.has(targetSceneId)) { - pushError(errors, `${sceneId} has invalid connectedSceneId: ${targetSceneId}`); - } - }); - - }); - - return errors; -} - -export function validateSceneNpcOverrides( - overrideMap: Record, - validNpcIds: string[], - characters: Character[], -) { - const errors: string[] = []; - const npcIdSet = new Set(validNpcIds); - const characterIdSet = new Set(characters.map(character => character.id)); - - Object.entries(overrideMap).forEach(([npcId, override]) => { - if (!npcIdSet.has(npcId)) { - pushError(errors, `未知场景角色覆盖:${npcId}`); - return; - } - - if (override.gender !== undefined && !isKnownGender(override.gender)) { - pushError(errors, `${npcId} gender must be "male" or "female".`); - } - - if (override.characterId && !characterIdSet.has(override.characterId)) { - pushError(errors, `${npcId} has invalid characterId: ${override.characterId}`); - } - }); - - return errors; -} - -export function validateItemOverrides( - overrideMap: Record, - validItemIds: string[], -) { - const errors: string[] = []; - const itemIdSet = new Set(validItemIds); - - Object.entries(overrideMap).forEach(([itemId, override]) => { - if (!itemIdSet.has(itemId)) { - pushError(errors, `未知物品覆盖:${itemId}`); - return; - } - - if (override.name !== undefined && !override.name.trim()) { - pushError(errors, `${itemId} name cannot be empty.`); - } - - if (override.category !== undefined && !override.category.trim()) { - pushError(errors, `${itemId} category cannot be empty.`); - } - - if (override.description !== undefined && !override.description.trim()) { - pushError(errors, `${itemId} description cannot be empty.`); - } - - if (override.tags !== undefined && !isNonEmptyStringArray(override.tags)) { - pushError(errors, `${itemId} tags must be a non-empty string array.`); - } - - if (override.buildProfile?.tags !== undefined && !isNonEmptyStringArray(override.buildProfile.tags)) { - pushError(errors, `${itemId} buildProfile.tags must be a non-empty string array.`); - } - - if (override.buildProfile?.craftTags !== undefined && !isNonEmptyStringArray(override.buildProfile.craftTags)) { - pushError(errors, `${itemId} buildProfile.craftTags must be a non-empty string array.`); - } - - if (override.useProfile?.buildBuffs !== undefined) { - validateBuildBuffs(errors, itemId, 'useProfile.buildBuffs', override.useProfile.buildBuffs); - } - }); - - return errors; -} diff --git a/src/data/functionCatalog/flow/storyOpeningCampDialogue.ts b/src/data/functionCatalog/flow/storyOpeningCampDialogue.ts index 2cc00390..f05d015e 100644 --- a/src/data/functionCatalog/flow/storyOpeningCampDialogue.ts +++ b/src/data/functionCatalog/flow/storyOpeningCampDialogue.ts @@ -30,7 +30,7 @@ export const STORY_OPENING_CAMP_DIALOGUE_FUNCTION: FunctionDocumentationEntry = storyMode: 'special_sequence', uiMode: 'none', executor: - 'src/hooks/rpg-runtime-story/openingAdventure.ts + src/services/prompt.ts', + 'server-node/src/modules/rpg-runtime-story/RpgRuntimeStoryActionDomain.ts + src/hooks/rpg-runtime-story/storyContextBuilder.ts', animationNote: '重点在对白本身,不额外驱动独立战斗/位移动画。', storyNote: '会把 prompt 切到营地开场对白模式,并要求输出结构化对话行。', uiNote: '不弹 modal,直接进入对白流。', diff --git a/src/data/functionCatalog/index.ts b/src/data/functionCatalog/index.ts index a554c40f..c5d78b89 100644 --- a/src/data/functionCatalog/index.ts +++ b/src/data/functionCatalog/index.ts @@ -14,6 +14,7 @@ export * from './flow/campTravelHomeScene'; export * from './flow/storyContinueAdventure'; export * from './flow/storyOpeningCampDialogue'; export * from './npc/npcChat'; +export * from './npc/npcChatQuestOffer'; export * from './npc/npcFight'; export * from './npc/npcGift'; export * from './npc/npcHelp'; diff --git a/src/data/functionCatalog/npc/index.ts b/src/data/functionCatalog/npc/index.ts index ce075106..d372db24 100644 --- a/src/data/functionCatalog/npc/index.ts +++ b/src/data/functionCatalog/npc/index.ts @@ -1,5 +1,10 @@ import type { FunctionDocumentationEntry } from '../types'; import { NPC_CHAT_FUNCTION } from './npcChat'; +import { + NPC_CHAT_QUEST_OFFER_ABANDON_FUNCTION, + NPC_CHAT_QUEST_OFFER_REPLACE_FUNCTION, + NPC_CHAT_QUEST_OFFER_VIEW_FUNCTION, +} from './npcChatQuestOffer'; import { NPC_FIGHT_FUNCTION } from './npcFight'; import { NPC_GIFT_FUNCTION } from './npcGift'; import { NPC_HELP_FUNCTION } from './npcHelp'; @@ -18,6 +23,9 @@ export const NPC_FUNCTION_DOCUMENTATION: FunctionDocumentationEntry[] = [ NPC_SPAR_FUNCTION, NPC_HELP_FUNCTION, NPC_CHAT_FUNCTION, + NPC_CHAT_QUEST_OFFER_VIEW_FUNCTION, + NPC_CHAT_QUEST_OFFER_REPLACE_FUNCTION, + NPC_CHAT_QUEST_OFFER_ABANDON_FUNCTION, NPC_GIFT_FUNCTION, NPC_RECRUIT_FUNCTION, NPC_QUEST_ACCEPT_FUNCTION, diff --git a/src/data/functionCatalog/npc/npcChat.ts b/src/data/functionCatalog/npc/npcChat.ts index c1b4b041..a0680b94 100644 --- a/src/data/functionCatalog/npc/npcChat.ts +++ b/src/data/functionCatalog/npc/npcChat.ts @@ -24,7 +24,7 @@ export const NPC_CHAT_FUNCTION: FunctionDocumentationEntry = { storyMode: 'stream_then_defer', uiMode: 'none', executor: - 'src/hooks/rpg-runtime-story/npcEncounterActions.ts -> commitNpcChatState', + 'src/hooks/rpg-runtime-story/useRpgRuntimeNpcInteraction.ts -> commitNpcChatState', animationNote: '重点在流式对白和轻量站场,不额外打开窗口。', storyNote: '先生成聊天正文,再把真正的新选项放入 deferredOptions,等待 continue adventure。', diff --git a/src/data/functionCatalog/npc/npcChatQuestOffer.ts b/src/data/functionCatalog/npc/npcChatQuestOffer.ts new file mode 100644 index 00000000..6c142c0a --- /dev/null +++ b/src/data/functionCatalog/npc/npcChatQuestOffer.ts @@ -0,0 +1,86 @@ +import type { FunctionDocumentationEntry } from '../types'; + +/** + * npc_chat_quest_offer_* + * + * NPC 聊天态里的临时委托处理 function。它们不是新的任务系统, + * 而是高好感聊天中 pending quest offer 的查看、更换和放弃入口。 + */ +const QUEST_OFFER_SOURCE = 'src/data/functionCatalog/npc/npcChatQuestOffer.ts'; +const QUEST_OFFER_EXECUTOR = + 'server-node/src/modules/quest/questStoryActionService.ts -> resolveQuestStoryAction'; + +export const NPC_CHAT_QUEST_OFFER_VIEW_FUNCTION: FunctionDocumentationEntry = { + id: 'npc_chat_quest_offer_view', + domain: 'npc', + title: '查看委托', + source: QUEST_OFFER_SOURCE, + summary: '查看当前聊天中 NPC 刚提出但尚未领取的委托。', + detailedDescription: + '它用于 pending quest offer 阶段,只打开或返回当前待领取任务详情,不把任务写入正式 quest log。', + trigger: 'NPC 聊天触发待领取委托后,任务处理态选项中出现。', + execution: + '后端读取当前 pending quest offer,并返回可展示的任务详情与领取入口。', + result: '玩家可以查看任务目标和奖励,确认领取前不会改变正式任务日志。', + active: true, + runtime: { + storyMode: 'local_only', + uiMode: 'none', + executor: QUEST_OFFER_EXECUTOR, + animationNote: '不触发角色位移动画,重点是切换任务详情展示。', + storyNote: '只保留当前委托上下文,不生成新的聊天剧情。', + uiNote: '展示待领取任务详情,等待玩家领取、替换或返回聊天。', + compactDetailText: '查看这份委托', + }, +}; + +export const NPC_CHAT_QUEST_OFFER_REPLACE_FUNCTION: FunctionDocumentationEntry = + { + id: 'npc_chat_quest_offer_replace', + domain: 'npc', + title: '更换委托', + source: QUEST_OFFER_SOURCE, + summary: '让 NPC 重新生成一份聊天内待领取委托。', + detailedDescription: + '它不会本地改写现有任务文案,而是重新走任务生成链,替换当前 pending quest offer。', + trigger: 'NPC 聊天任务处理态中,玩家不满意当前委托时出现。', + execution: + '后端调用任务生成链生成新 quest offer,并覆盖当前聊天态 pending offer。', + result: + '当前待领取委托被替换,聊天仍停留在任务处理态,正式 quest log 不变。', + active: true, + runtime: { + storyMode: 'local_effect_then_generate', + uiMode: 'none', + executor: QUEST_OFFER_EXECUTOR, + animationNote: '不触发战斗或移动演出,只追加轻量聊天反馈。', + storyNote: '重新生成 pending quest offer,并说明 NPC 换了一个委托。', + uiNote: '继续显示查看、更换、放弃这组任务处理选项。', + compactDetailText: '换一个委托', + }, + }; + +export const NPC_CHAT_QUEST_OFFER_ABANDON_FUNCTION: FunctionDocumentationEntry = + { + id: 'npc_chat_quest_offer_abandon', + domain: 'npc', + title: '放弃委托', + source: QUEST_OFFER_SOURCE, + summary: '丢弃当前聊天中尚未领取的委托。', + detailedDescription: + '它只清理 pending quest offer,不影响已经写入 quest log 的正式任务,也不会扣除奖励或结算任务失败。', + trigger: 'NPC 聊天任务处理态中,玩家暂时不想接这份委托时出现。', + execution: + '后端清空当前聊天态 pending quest offer,并恢复普通 NPC 聊天选项。', + result: '待领取委托消失,玩家回到自由聊天或离开 NPC 的正常流程。', + active: true, + runtime: { + storyMode: 'local_only', + uiMode: 'none', + executor: QUEST_OFFER_EXECUTOR, + animationNote: '不触发额外演出,只回到普通聊天态。', + storyNote: '追加玩家暂时不接委托的轻量反馈。', + uiNote: '恢复普通 npc_chat 建议和自定义输入。', + compactDetailText: '暂时不接', + }, + }; diff --git a/src/data/functionCatalog/npc/npcFight.ts b/src/data/functionCatalog/npc/npcFight.ts index 7cb78e3d..6986c8df 100644 --- a/src/data/functionCatalog/npc/npcFight.ts +++ b/src/data/functionCatalog/npc/npcFight.ts @@ -23,7 +23,7 @@ export const NPC_FIGHT_FUNCTION: FunctionDocumentationEntry = { storyMode: 'special_sequence', uiMode: 'none', executor: - 'src/hooks/rpg-runtime-story/npcEncounterActions.ts -> handleNpcInteraction', + 'src/hooks/rpg-runtime-story/useRpgRuntimeNpcInteraction.ts -> handleNpcInteraction', animationNote: '切到 NPC 战斗模式后,由战斗播放链路驱动后续动画。', storyNote: '不会先弹窗,直接把当前 encounter 切成战斗态并进入后续结算。', uiNote: '不弹 modal,直接进入战斗。', diff --git a/src/data/functionCatalog/npc/npcHelp.ts b/src/data/functionCatalog/npc/npcHelp.ts index f04be5f1..18f7b0ec 100644 --- a/src/data/functionCatalog/npc/npcHelp.ts +++ b/src/data/functionCatalog/npc/npcHelp.ts @@ -22,7 +22,7 @@ export const NPC_HELP_FUNCTION: FunctionDocumentationEntry = { storyMode: 'local_effect_then_generate', uiMode: 'none', executor: - 'src/hooks/rpg-runtime-story/npcEncounterActions.ts -> handleNpcInteraction', + 'src/hooks/rpg-runtime-story/useRpgRuntimeNpcInteraction.ts -> handleNpcInteraction', animationNote: '不单独开窗口,直接在当前交互里结算帮助结果。', storyNote: '点击后立即按本地奖励规则结算,并继续生成新的故事状态。', uiNote: '不弹 modal,直接获得帮助反馈。', diff --git a/src/data/functionCatalog/npc/npcLeave.ts b/src/data/functionCatalog/npc/npcLeave.ts index 16190117..e9157f68 100644 --- a/src/data/functionCatalog/npc/npcLeave.ts +++ b/src/data/functionCatalog/npc/npcLeave.ts @@ -21,7 +21,7 @@ export const NPC_LEAVE_FUNCTION: FunctionDocumentationEntry = { storyMode: 'local_effect_then_generate', uiMode: 'none', executor: - 'src/hooks/rpg-runtime-story/npcEncounterActions.ts -> handleNpcInteraction', + 'src/hooks/rpg-runtime-story/useRpgRuntimeNpcInteraction.ts -> handleNpcInteraction', animationNote: '通常只做轻量离场,不单独打开窗口。', storyNote: '点击后结束当前 NPC 交互,并回到新的探索剧情。', uiNote: '不弹 modal,直接退出互动。', diff --git a/src/data/functionCatalog/npc/npcPreviewTalk.ts b/src/data/functionCatalog/npc/npcPreviewTalk.ts index 343ccbd2..e6931b1c 100644 --- a/src/data/functionCatalog/npc/npcPreviewTalk.ts +++ b/src/data/functionCatalog/npc/npcPreviewTalk.ts @@ -60,7 +60,7 @@ export const NPC_PREVIEW_TALK_FUNCTION: FunctionDocumentationEntry = { uiMode: 'npc_interaction_entry', visuals: NPC_PREVIEW_TALK_OPTION_VISUALS, executor: - 'src/hooks/rpg-runtime-story/choiceActions.ts + src/hooks/rpg-runtime-story/npcEncounterActions.ts', + 'src/hooks/rpg-runtime-story/choiceActions.ts + src/hooks/rpg-runtime-story/useRpgRuntimeNpcInteraction.ts', animationNote: '保持轻量 idle 站场,不做额外位移,重点是把交互焦点切到 NPC 身上。', storyNote: diff --git a/src/data/functionCatalog/npc/npcSpar.ts b/src/data/functionCatalog/npc/npcSpar.ts index a0b72bfa..3875432d 100644 --- a/src/data/functionCatalog/npc/npcSpar.ts +++ b/src/data/functionCatalog/npc/npcSpar.ts @@ -21,7 +21,7 @@ export const NPC_SPAR_FUNCTION: FunctionDocumentationEntry = { storyMode: 'special_sequence', uiMode: 'none', executor: - 'src/hooks/rpg-runtime-story/npcEncounterActions.ts -> handleNpcInteraction', + 'src/hooks/rpg-runtime-story/useRpgRuntimeNpcInteraction.ts -> handleNpcInteraction', animationNote: '切到 spar 战斗模式后,由战斗播放链路驱动切磋演出。', storyNote: '不会先弹窗,直接进入点到为止的切磋流程。', uiNote: '不弹 modal,直接切磋。', diff --git a/src/data/functionCatalog/state/battleAttackBasic.ts b/src/data/functionCatalog/state/battleAttackBasic.ts new file mode 100644 index 00000000..acbdec3d --- /dev/null +++ b/src/data/functionCatalog/state/battleAttackBasic.ts @@ -0,0 +1,35 @@ +import type { FunctionDocumentationEntry } from '../types'; + +/** + * battle_attack_basic + * + * 后端单行为战斗模型的普通攻击入口。该 function 只登记文档和契约, + * 不进入前端本地 state function 候选池。 + */ +export const BATTLE_ATTACK_BASIC_FUNCTION: FunctionDocumentationEntry = { + id: 'battle_attack_basic', + domain: 'state', + title: '普通攻击', + source: 'src/data/functionCatalog/state/battleAttackBasic.ts', + summary: '后端单行为战斗模型中的基础攻击 function。', + detailedDescription: + '这个 function 代表一次明确的普通攻击点击,后端直接结算伤害、敌方反击和下一轮战斗选项,不再请求 AI 续写整段战斗剧情。', + trigger: '仅在 battle 状态且场上仍有存活敌人时,由后端战斗 option 池下发。', + execution: + '前端透传 functionId,后端 combatResolutionService 直接按普通攻击规则结算本回合。', + result: '刷新 HP、战斗日志和下一轮战斗 options;若敌人被击败,再进入脱战剧情推理。', + state: 'battle', + category: 'battle', + active: true, + runtime: { + storyMode: 'local_only', + uiMode: 'none', + executor: + 'server-node/src/modules/combat/combatResolutionService.ts -> resolveCombatAction', + animationNote: '播放一次基础攻击和受击反馈,不扩展成连续多段连击。', + storyNote: + '战斗未结束时只展示本次结算文本;战斗结束后才请求脱战剧情。', + uiNote: '由后端战斗 option 池生成,不进入前端本地 state function 候选。', + compactDetailText: '直接攻击眼前敌人', + }, +}; diff --git a/src/data/functionCatalog/state/battleUseSkill.ts b/src/data/functionCatalog/state/battleUseSkill.ts new file mode 100644 index 00000000..47c1f87f --- /dev/null +++ b/src/data/functionCatalog/state/battleUseSkill.ts @@ -0,0 +1,36 @@ +import type { FunctionDocumentationEntry } from '../types'; + +/** + * battle_use_skill + * + * 后端单行为战斗模型的技能释放入口。每个技能 option 复用同一个 + * functionId,具体技能必须由 runtimePayload.skillId 指定。 + */ +export const BATTLE_USE_SKILL_FUNCTION: FunctionDocumentationEntry = { + id: 'battle_use_skill', + domain: 'state', + title: '释放技能', + source: 'src/data/functionCatalog/state/battleUseSkill.ts', + summary: '后端单行为战斗模型中的指定技能释放 function。', + detailedDescription: + '这个 functionId 可以对应多个技能 option 实例。前端只展示技能名和不可用原因,后端根据 runtimePayload.skillId 校验蓝量、冷却并结算本次技能效果。', + trigger: '仅在 battle 状态下由后端按角色技能列表生成,可能携带 disabled 状态。', + execution: + '前端透传 runtimePayload.skillId,后端 combatResolutionService 校验技能并完成一次技能动作结算。', + result: + '更新 MP、技能冷却、敌我 HP 和下一轮战斗 options;若战斗结束,再触发脱战剧情推理。', + state: 'battle', + category: 'battle', + active: true, + runtime: { + storyMode: 'local_only', + uiMode: 'none', + executor: + 'server-node/src/modules/combat/combatResolutionService.ts -> resolveCombatAction', + animationNote: '根据技能 option 播放一次技能演出,不在本 function 内追加多回合动作。', + storyNote: + '战斗未结束时使用本次技能结算文本;只有战斗结束才请求新剧情。', + uiNote: '每个技能是一个后端下发的独立 option,必须携带 skillId。', + compactDetailText: '释放一个指定技能', + }, +}; diff --git a/src/data/functionCatalog/state/index.ts b/src/data/functionCatalog/state/index.ts index 73b0a582..c4337f07 100644 --- a/src/data/functionCatalog/state/index.ts +++ b/src/data/functionCatalog/state/index.ts @@ -1,11 +1,13 @@ import type { StateFunctionSource } from '../types'; import { BATTLE_ALL_IN_CRUSH_FUNCTION_SOURCE } from './battleAllInCrush'; +import { BATTLE_ATTACK_BASIC_FUNCTION } from './battleAttackBasic'; import { BATTLE_ESCAPE_BREAKOUT_FUNCTION_SOURCE } from './battleEscapeBreakout'; import { BATTLE_FEINT_STEP_FUNCTION_SOURCE } from './battleFeintStep'; import { BATTLE_FINISHER_WINDOW_FUNCTION_SOURCE } from './battleFinisherWindow'; import { BATTLE_GUARD_BREAK_FUNCTION_SOURCE } from './battleGuardBreak'; import { BATTLE_PROBE_PRESSURE_FUNCTION_SOURCE } from './battleProbePressure'; import { BATTLE_RECOVER_BREATH_FUNCTION_SOURCE } from './battleRecoverBreath'; +import { BATTLE_USE_SKILL_FUNCTION } from './battleUseSkill'; import { IDLE_CALL_OUT_FUNCTION_SOURCE } from './idleCallOut'; import { IDLE_EXPLORE_FORWARD_FUNCTION_SOURCE } from './idleExploreForward'; import { IDLE_FOLLOW_CLUE_FUNCTION_SOURCE } from './idleFollowClue'; @@ -40,7 +42,9 @@ export const STATE_FUNCTION_PROMPT_DESCRIPTIONS = Object.fromEntries( ]), ) as Record; -export const STATE_FUNCTION_DOCUMENTATION = STATE_FUNCTION_SOURCES.map( - (source) => source.documentation, -); +export const STATE_FUNCTION_DOCUMENTATION = [ + BATTLE_ATTACK_BASIC_FUNCTION, + BATTLE_USE_SKILL_FUNCTION, + ...STATE_FUNCTION_SOURCES.map((source) => source.documentation), +]; diff --git a/src/data/sceneObservation.ts b/src/data/sceneObservation.ts deleted file mode 100644 index 1fbdecc6..00000000 --- a/src/data/sceneObservation.ts +++ /dev/null @@ -1,37 +0,0 @@ -import { WorldType } from '../types'; -import { getSceneFriendlyNpcs, getSceneHostileNpcs,getScenePresetById } from './scenePresets'; - -export function buildSceneObserveSignsStoryText( - worldType: WorldType | null, - sceneId: string | null | undefined, -) { - if (!worldType) { - return '你停下来倾听,但目前场景上下文不足,无法判断附近有什么。'; - } - - const scene = getScenePresetById(worldType, sceneId); - if (!scene) { - return '你停下来倾听,但这个区域还没有露出任何可靠的痕迹。'; - } - - const friendlyNpcs = getSceneFriendlyNpcs(scene); - const hostileNpcs = getSceneHostileNpcs(scene); - const npcSummary = friendlyNpcs.length > 0 - ? `可能的角色:${friendlyNpcs.map(npc => `${npc.name}(${npc.role})`).join(',')}` - : '可能的角色:暂无明确识别'; - const hostileSummary = hostileNpcs.length > 0 - ? `可能的敌对角色:${hostileNpcs.map(npc => npc.name).join(',')}` - : '可能的敌对角色:无明确威胁特征'; - const treasureSummary = scene.treasureHints.length > 0 - ? `可能的宝藏线索:${scene.treasureHints.slice(0, 2).join(',')}` - : '可能的宝藏线索:暂无发现'; - - const bossCandidate = hostileNpcs[0] ?? null; - const bossSummary = bossCandidate - ? hostileNpcs.length >= 3 - ? `Boss线索:${bossCandidate.name} 感觉是这里最强的敌对存在。${bossCandidate.description}` - : `Boss线索:暂无明显首领,但${bossCandidate.name} 仍然是最需要警惕的危险威胁。` - : 'Boss线索:暂无迹象指向该区域有明确首领。'; - - return `你稳住队伍,梳理${scene.name}周围隐藏的迹象。${npcSummary}。${hostileSummary}。${treasureSummary}。${bossSummary}`; -} diff --git a/src/editor/shared/EditorNotice.tsx b/src/editor/shared/EditorNotice.tsx deleted file mode 100644 index e733d8da..00000000 --- a/src/editor/shared/EditorNotice.tsx +++ /dev/null @@ -1,20 +0,0 @@ -type EditorNoticeTone = 'muted' | 'warning'; - -const TONE_CLASS_NAMES: Record = { - muted: 'text-xs text-zinc-400', - warning: 'text-xs text-amber-200/90', -}; - -export function EditorNotice({ - message, - tone = 'muted', -}: { - message: string | null; - tone?: EditorNoticeTone; -}) { - if (!message) { - return null; - } - - return
{message}
; -} diff --git a/src/editor/shared/FormFields.tsx b/src/editor/shared/FormFields.tsx deleted file mode 100644 index be10a0ad..00000000 --- a/src/editor/shared/FormFields.tsx +++ /dev/null @@ -1,161 +0,0 @@ -import { Save } from 'lucide-react'; - -import { EditorNotice } from './EditorNotice'; - -function safeNumber(value: number) { - return Number.isFinite(value) ? value : 0; -} - -function toNumber(value: string, fallback = 0) { - const next = Number(value); - return Number.isFinite(next) ? next : fallback; -} - -export type SelectFieldOption = { - label: string; - value: string | number; -}; - -export function TextField({ - label, - value, - onChange, - placeholder, - disabled = false, -}: { - label: string; - value: string; - onChange: (value: string) => void; - placeholder?: string; - disabled?: boolean; -}) { - return ( - - ); -} - -export function NumberField({ - label, - value, - onChange, - min, - step = 1, -}: { - label: string; - value: number; - onChange: (value: number) => void; - min?: number; - step?: number; -}) { - return ( - - ); -} - -export function TextAreaField({ - label, - value, - onChange, - rows = 4, - placeholder, - disabled = false, -}: { - label: string; - value: string; - onChange: (value: string) => void; - rows?: number; - placeholder?: string; - disabled?: boolean; -}) { - return ( -