This commit is contained in:
2026-04-21 19:18:26 +08:00
parent 4372ab5be1
commit 48957311bc
78 changed files with 643 additions and 3801 deletions

View File

@@ -0,0 +1,117 @@
# 工程死分支清理执行记录 E2026-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 创作编辑器与运行时热点文件的职责拆分。

View File

@@ -0,0 +1,91 @@
# 工程死分支清理执行记录 F2026-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而不应再按文件名或历史命名直接硬删。

View File

@@ -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 后端的运行时、鉴权、生成编排与本地真相残留。 这一版是本轮前端越界逻辑专项审计,专门汇总当前仍应继续迁到 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 与对应配置残留出清。 这一版是第四批落地记录,聚焦未接入业务的数据生成产物、测试专用 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 的正式出清。 这一版是第二批落地记录,聚焦旧主流程壳层、旧 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 的首轮清理。 这一版是第一批落地记录聚焦高置信度小型孤岛、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` 基线的当前仓库复核,明确哪些问题已经处理、哪些表述需要纠正、热点又迁移到了哪里。 这一版是对 `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 边界、分层过渡期问题。 适合回看运行时主链路、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当前主工程的“现行入口”和“历史入口”边界正在变得更清楚。 - 第二批已经开始清理旧主流程壳层与旧 flow Hook当前主工程的“现行入口”和“历史入口”边界正在变得更清楚。
- 第三批已经先完成鉴权真相收口的一段,前端不再保存自动登录用户名/密码;运行时快照链仍需先补后端 contract再继续往前删。 - 第三批已经先完成鉴权真相收口的一段,前端不再保存自动登录用户名/密码;运行时快照链仍需先补后端 contract再继续往前删。
- 第四批已经继续收掉未接入业务的数据生成产物、测试专用 stub 与对应脚本/配置残留,主工程里的“假数据主源”进一步减少。 - 第四批已经继续收掉未接入业务的数据生成产物、测试专用 stub 与对应脚本/配置残留,主工程里的“假数据主源”进一步减少。
- 第五批已经继续收掉旧命名 re-export、空路由骨架、旧发布 service、前端 prompt 镜像与无入口编辑器壳层,主工程里的“假入口”和“假 prompt 主源”进一步减少。
- 第六批已经继续收掉无入口 `questDirector`、旧观察文案 helper、一次性硬编码同步脚本并修复 function catalog 对后端运行时契约的覆盖缺口。
- 当前仓库已经完成“旧 dev 插件链路删除、根目录噪音清理、`server-node -> src/**` 反向依赖切断”这批第一阶段任务。 - 当前仓库已经完成“旧 dev 插件链路删除、根目录噪音清理、`server-node -> src/**` 反向依赖切断”这批第一阶段任务。
- 当前如果想直接判断“今天先优化什么”,优先看 `CURRENT_ENGINEERING_OPTIMIZATION_OPPORTUNITIES_2026-04-20.md` - 当前如果想直接判断“今天先优化什么”,优先看 `CURRENT_ENGINEERING_OPTIMIZATION_OPPORTUNITIES_2026-04-20.md`
- 当前的新重点已经进一步收敛到三类:未接线孤岛模块、前端残留的运行时/鉴权真相、热点向 prompt/runtime profile/平台入口壳层迁移。 - 当前的新重点已经进一步收敛到三类:未接线孤岛模块、前端残留的运行时/鉴权真相、热点向 prompt/runtime profile/平台入口壳层迁移。

View File

@@ -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`。_ _文档目的交接给下一个 Agent 时,优先读本文件 + `UI_CODING_STANDARD.md`,再改 `uiAssets.ts` / `App.tsx` / `index.css`。_

View File

@@ -32,6 +32,14 @@
## 基础状态 Function ## 基础状态 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` - `battle_all_in_crush`
脚本:`src/data/functionCatalog/state/battleAllInCrush.ts` 脚本:`src/data/functionCatalog/state/battleAllInCrush.ts`
说明:战斗中的正面强压动作,只在 `battle` 状态且有存活敌人时进入候选池。它会提高伤害与终结/爆发技能权重,同时抬高承伤,适合收头、压血和赌一波换血抢节奏。 说明:战斗中的正面强压动作,只在 `battle` 状态且有存活敌人时进入候选池。它会提高伤害与终结/爆发技能权重,同时抬高承伤,适合收头、压血和赌一波换血抢节奏。
@@ -110,6 +118,18 @@
脚本:`src/data/functionCatalog/npc/npcChat.ts` 脚本:`src/data/functionCatalog/npc/npcChat.ts`
说明:围绕当前话题与 NPC 继续交谈的 function。它会先生成对话正文再把真正的新选项延迟到 `story_continue_adventure` 之后展示。 说明:围绕当前话题与 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` - `npc_gift`
脚本:`src/data/functionCatalog/npc/npcGift.ts` 脚本:`src/data/functionCatalog/npc/npcGift.ts`
说明:向 NPC 送礼的入口 function。第一次点击通常只打开礼物面板确认礼物后才结算好感变化并继续剧情。 说明:向 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/` - 非 state function 目前仍由各自原有流程模块执行,但它们的 `id`、标题和详细说明已经统一收口到 `functionCatalog/`
- 后续新增 function 时,建议先补独立脚本,再把运行时调用接进来,最后同步这份目录文档。 - 后续新增 function 时,建议先补独立脚本,再把运行时调用接进来,最后同步这份目录文档。

View File

@@ -94,3 +94,26 @@
1. `legacyResultProfile` 继续长期作为结果页主列表来源风险很高 1. `legacyResultProfile` 继续长期作为结果页主列表来源风险很高
2. 只做“按 id 回贴图片字段”在对象集合会漂移的链路里不够稳 2. 只做“按 id 回贴图片字段”在对象集合会漂移的链路里不够稳
3. 后续如果继续推进后端单一真相源,应优先把结果页对象集合完全改为服务端最新 compile/preview 结果,而不是继续让前端桥接层承担最终裁决 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`,但入口没挂鉴权”的低层断线问题。

View File

@@ -1,17 +1,17 @@
import { describe, expect, test } from 'vitest'; import { describe, expect, test } from 'vitest';
import type { CharacterChatReplyRequest } from './rpgRuntimeChat';
import { QUEST_NARRATIVE_TYPES } from './rpgRuntimeQuestAssist';
import { import {
CharacterChatReplyRequest,
QUEST_NARRATIVE_TYPES,
RuntimeStoryActionRequest,
SERVER_RUNTIME_FUNCTION_IDS, SERVER_RUNTIME_FUNCTION_IDS,
TASK5_RUNTIME_OPTION_SCOPES,
TASK6_RUNTIME_FUNCTION_IDS, TASK6_RUNTIME_FUNCTION_IDS,
} from './story'; type RuntimeStoryActionRequest,
import { TASK5_RUNTIME_OPTION_SCOPES } from './rpgRuntimeStoryAction'; } from './rpgRuntimeStoryAction';
import type { RuntimeStoryStateRequest } from './rpgRuntimeStoryState'; import type { RuntimeStoryStateRequest } from './rpgRuntimeStoryState';
describe('RPG runtime shared contract façades', () => { describe('RPG runtime shared contracts', () => {
test('旧 story façade 继续导出 runtime story action 常量与类型', () => { test('拆分后的 runtime story action 契约继续导出常量与类型', () => {
expect(SERVER_RUNTIME_FUNCTION_IDS).toContain('npc_chat'); expect(SERVER_RUNTIME_FUNCTION_IDS).toContain('npc_chat');
expect(TASK6_RUNTIME_FUNCTION_IDS).toContain('npc_trade'); expect(TASK6_RUNTIME_FUNCTION_IDS).toContain('npc_trade');
expect(TASK5_RUNTIME_OPTION_SCOPES).toEqual(['story', 'combat', 'npc']); 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'); expect(request.action.functionId).toBe('npc_chat');
}); });
test('旧 story façade 继续导出 chat 与 quest assist 契约', () => { test('拆分后的 chat 与 quest assist 契约继续导出运行时类型', () => {
const payload: CharacterChatReplyRequest = { const payload: CharacterChatReplyRequest = {
worldType: 'WUXIA', worldType: 'WUXIA',
playerCharacter: {}, playerCharacter: {},

View File

@@ -25,8 +25,8 @@ import {
} from '../src/data/questFlow.ts'; } from '../src/data/questFlow.ts';
import { createInitialGameRuntimeStats } from '../src/data/runtimeStats.ts'; import { createInitialGameRuntimeStats } from '../src/data/runtimeStats.ts';
import { createSceneCallOutEncounter, createSceneEncounterPreview, ensureSceneEncounterPreview } from '../src/data/sceneEncounterPreviews.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 { getSceneHostileNpcPresetIds, getScenePresetsByWorld } from '../src/data/scenePresets.ts';
import { resolveFunctionOption } from '../src/data/stateFunctions.ts';
import { buildTreasureEncounterStoryMoment, buildTreasureResultText, resolveTreasureReward } from '../src/data/treasureInteractions.ts'; import { buildTreasureEncounterStoryMoment, buildTreasureResultText, resolveTreasureReward } from '../src/data/treasureInteractions.ts';
import { AnimationState, GameState, WorldType } from '../src/types.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?.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}`); assert(callOutResult.currentEncounter || callOutResult.sceneHostileNpcs.length > 0 || getSceneHostileNpcPresetIds(scene).length === 0, `[idle] call_out failed for ${scene.id}`);
const observeText = buildSceneObserveSignsStoryText(worldType, scene.id); const observeOption = resolveFunctionOption(
assert(observeText.length > 12, `[idle] observe_signs text too short for ${scene.id}`); '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}`);
} }
} }

View File

@@ -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';

View File

@@ -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';

View File

@@ -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';

View File

@@ -1 +0,0 @@
export * from './inventoryMutationService.js';

View File

@@ -1,2 +0,0 @@
export * from './questProgressionService.js';
export { generateQuestForNpcEncounter } from '../../services/questService.js';

View File

@@ -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';

View File

@@ -1,2 +0,0 @@
export * from './runtimeItemResolutionService.js';
export { generateRuntimeItemIntents } from '../../services/runtimeItemService.js';

View File

@@ -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<typeof buildQuestRuntimeItemGenerationContext>[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);
});

View File

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

View File

@@ -1,8 +0,0 @@
export {
RpgSaveArchiveRepository,
type RpgSaveArchiveRepositoryPort,
} from './RpgSaveArchiveRepository.js';
export {
RpgWorldLibraryRepository,
type RpgWorldLibraryRepositoryPort,
} from './RpgWorldLibraryRepository.js';

View File

@@ -1,8 +0,0 @@
export {
RpgBrowseHistoryRepository,
type RpgBrowseHistoryRepositoryPort,
} from './RpgBrowseHistoryRepository.js';
export {
RpgProfileDashboardRepository,
type RpgProfileDashboardRepositoryPort,
} from './RpgProfileDashboardRepository.js';

View File

@@ -1,4 +0,0 @@
export {
RpgRuntimeSnapshotRepository,
type RpgRuntimeSnapshotRepositoryPort,
} from './RpgRuntimeSnapshotRepository.js';

View File

@@ -9,6 +9,7 @@ import type {
import type { AppContext } from '../context.js'; import type { AppContext } from '../context.js';
import { badRequest, notFound } from '../errors.js'; import { badRequest, notFound } from '../errors.js';
import { asyncHandler, prepareApiResponse, sendApiResponse } from '../http.js'; import { asyncHandler, prepareApiResponse, sendApiResponse } from '../http.js';
import { requireJwtAuth } from '../middleware/auth.js';
import { routeMeta } from '../middleware/routeMeta.js'; import { routeMeta } from '../middleware/routeMeta.js';
const createSessionSchema = z.object({ const createSessionSchema = z.object({
@@ -98,6 +99,9 @@ function readParam(param: string | string[] | undefined) {
export function createCustomWorldAgentRoutes(context: AppContext) { export function createCustomWorldAgentRoutes(context: AppContext) {
const router = Router(); const router = Router();
const requireAuth = requireJwtAuth(context.config, context.userRepository);
router.use(requireAuth);
router.post( router.post(
'/sessions', '/sessions',

View File

@@ -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';

View File

@@ -1,4 +0,0 @@
export {
createRpgProfileRoutes,
RPG_PROFILE_ROUTE_BASE_PATH,
} from './rpgProfileRoutes.js';

View File

@@ -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';

View File

@@ -1 +0,0 @@
export { createCustomWorldAgentRoutes as createRpgCreationAgentRoutes } from './customWorldAgent.js';

View File

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

View File

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

View File

@@ -1 +0,0 @@
export { CustomWorldAgentOrchestrator as RpgAgentOrchestrator } from './customWorldAgentOrchestrator.js';

View File

@@ -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';

View File

@@ -13,7 +13,7 @@ import { CustomWorldAgentOrchestrator } from './customWorldAgentOrchestrator.js'
import { createInMemoryRpgWorldRepositoryPorts } from './customWorldAgentRepositoryTestHelpers.js'; import { createInMemoryRpgWorldRepositoryPorts } from './customWorldAgentRepositoryTestHelpers.js';
import { CustomWorldAgentSessionStore } from './customWorldAgentSessionStore.js'; import { CustomWorldAgentSessionStore } from './customWorldAgentSessionStore.js';
import { createTestCustomWorldAgentSingleTurnLlmClient } from './customWorldAgentTestHelpers.js'; import { createTestCustomWorldAgentSingleTurnLlmClient } from './customWorldAgentTestHelpers.js';
import { listCustomWorldWorkSummaries } from './customWorldWorkSummaryService.js'; import { RpgWorldWorkSummaryService } from './RpgWorldWorkSummaryService.js';
async function waitForOperation( async function waitForOperation(
orchestrator: CustomWorldAgentOrchestrator, orchestrator: CustomWorldAgentOrchestrator,
@@ -217,10 +217,10 @@ test('phase2 work summaries compile draft title and summary from creator intent'
update.operation.operationId, update.operation.operationId,
); );
const items = await listCustomWorldWorkSummaries(userId, { const items = await new RpgWorldWorkSummaryService(
rpgWorldProfiles: rpgWorldProfileRepository, rpgWorldProfileRepository,
customWorldAgentSessions: sessionStore, sessionStore,
}); ).list(userId);
const draft = items.find( const draft = items.find(
(item) => item.sessionId === createdSession.sessionId, (item) => item.sessionId === createdSession.sessionId,
); );

View File

@@ -11,7 +11,7 @@ import { CustomWorldAgentOrchestrator } from './customWorldAgentOrchestrator.js'
import { createInMemoryRpgWorldRepositoryPorts } from './customWorldAgentRepositoryTestHelpers.js'; import { createInMemoryRpgWorldRepositoryPorts } from './customWorldAgentRepositoryTestHelpers.js';
import { CustomWorldAgentSessionStore } from './customWorldAgentSessionStore.js'; import { CustomWorldAgentSessionStore } from './customWorldAgentSessionStore.js';
import { createTestCustomWorldAgentSingleTurnLlmClient } from './customWorldAgentTestHelpers.js'; import { createTestCustomWorldAgentSingleTurnLlmClient } from './customWorldAgentTestHelpers.js';
import { listCustomWorldWorkSummaries } from './customWorldWorkSummaryService.js'; import { RpgWorldWorkSummaryService } from './RpgWorldWorkSummaryService.js';
function createAutoAssetTestConfig(testName: string): AppConfig { function createAutoAssetTestConfig(testName: string): AppConfig {
const projectRoot = fs.mkdtempSync( const projectRoot = fs.mkdtempSync(
@@ -368,10 +368,10 @@ test('phase3 work summaries prefer compiled foundation draft fields', async () =
response.operation.operationId, response.operation.operationId,
); );
const items = await listCustomWorldWorkSummaries(userId, { const items = await new RpgWorldWorkSummaryService(
rpgWorldProfiles: rpgWorldProfileRepository, rpgWorldProfileRepository,
customWorldAgentSessions: sessionStore, sessionStore,
}); ).list(userId);
const draft = items.find((item) => item.sessionId === readySession.sessionId); const draft = items.find((item) => item.sessionId === readySession.sessionId);
const compiledProfile = normalizeFoundationDraftProfile( const compiledProfile = normalizeFoundationDraftProfile(
( (

View File

@@ -6,7 +6,7 @@ import { CustomWorldAgentOrchestrator } from './customWorldAgentOrchestrator.js'
import { createInMemoryRpgWorldRepositoryPorts } from './customWorldAgentRepositoryTestHelpers.js'; import { createInMemoryRpgWorldRepositoryPorts } from './customWorldAgentRepositoryTestHelpers.js';
import { CustomWorldAgentSessionStore } from './customWorldAgentSessionStore.js'; import { CustomWorldAgentSessionStore } from './customWorldAgentSessionStore.js';
import { createTestCustomWorldAgentSingleTurnLlmClient } from './customWorldAgentTestHelpers.js'; import { createTestCustomWorldAgentSingleTurnLlmClient } from './customWorldAgentTestHelpers.js';
import { listCustomWorldWorkSummaries } from './customWorldWorkSummaryService.js'; import { RpgWorldWorkSummaryService } from './RpgWorldWorkSummaryService.js';
async function waitForOperation( async function waitForOperation(
orchestrator: CustomWorldAgentOrchestrator, 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), [...profile.playableNpcs, ...profile.storyNpcs].map((entry) => entry.id),
), ),
].length; ].length;
const workItems = await listCustomWorldWorkSummaries(userId, { const workItems = await new RpgWorldWorkSummaryService(
rpgWorldProfiles: rpgWorldProfileRepository, rpgWorldProfileRepository,
customWorldAgentSessions: sessionStore, sessionStore,
}); ).list(userId);
const draftItem = workItems.find((item) => item.sessionId === session.sessionId); const draftItem = workItems.find((item) => item.sessionId === session.sessionId);
assert.equal(operation?.status, 'completed'); 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, { const workItems = await new RpgWorldWorkSummaryService(
rpgWorldProfiles: rpgWorldProfileRepository, rpgWorldProfileRepository,
customWorldAgentSessions: sessionStore, sessionStore,
}); ).list(userId);
assert.ok(workItems.some((item) => item.sessionId === session.sessionId)); assert.ok(workItems.some((item) => item.sessionId === session.sessionId));
assert.equal( assert.equal(
@@ -688,10 +688,10 @@ test('phase4 work summaries hide published agent sessions from draft lane and ke
'玩家', '玩家',
); );
const workItems = await listCustomWorldWorkSummaries(userId, { const workItems = await new RpgWorldWorkSummaryService(
rpgWorldProfiles: rpgWorldProfileRepository, rpgWorldProfileRepository,
customWorldAgentSessions: sessionStore, sessionStore,
}); ).list(userId);
const draftItem = workItems.find((item) => item.sessionId === session.sessionId); const draftItem = workItems.find((item) => item.sessionId === session.sessionId);
const publishedItem = workItems.find( const publishedItem = workItems.find(
(item) => item.profileId === `agent-draft-${session.sessionId}`, (item) => item.profileId === `agent-draft-${session.sessionId}`,

View File

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

View File

@@ -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<string, unknown> {
return Boolean(value) && typeof value === 'object' && !Array.isArray(value);
}
function toRecordArray(value: unknown) {
return Array.isArray(value)
? value.filter(
(item): item is Record<string, unknown> =>
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<string, unknown>,
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<string, unknown>;
}
function buildRuntimeLandmarkFromDraft(
draftLandmark: Record<string, unknown>,
storyNpcIdSet: Set<string>,
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<string, unknown>;
}
function buildRuntimeCampFromDraft(draftCamp: Record<string, unknown> | 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<string, unknown>;
}
function buildRuntimeSceneChaptersFromDraft(
draftProfile: Record<string, unknown>,
storyNpcIdSet: Set<string>,
landmarkIdSet: Set<string>,
) {
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<string, unknown>;
})
.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<string, unknown>;
})
.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<string, unknown>;
}
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,
};
}
}

View File

@@ -8,11 +8,11 @@ import {
} from '../../../packages/shared/src/contracts/rpgCreationFixtures.js'; } from '../../../packages/shared/src/contracts/rpgCreationFixtures.js';
import type { CustomWorldSessionRecord } from '../../../packages/shared/src/contracts/runtime.js'; import type { CustomWorldSessionRecord } from '../../../packages/shared/src/contracts/runtime.js';
import { createInMemoryRpgWorldRepositoryPorts } from './customWorldAgentRepositoryTestHelpers.js'; import { createInMemoryRpgWorldRepositoryPorts } from './customWorldAgentRepositoryTestHelpers.js';
import { listCustomWorldWorkSummaries } from './customWorldWorkSummaryService.js';
import { import {
CUSTOM_WORLD_AGENT_SESSION_ID_PREFIX, CUSTOM_WORLD_AGENT_SESSION_ID_PREFIX,
CustomWorldAgentSessionStore, CustomWorldAgentSessionStore,
} from './customWorldAgentSessionStore.js'; } from './customWorldAgentSessionStore.js';
import { RpgWorldWorkSummaryService } from './RpgWorldWorkSummaryService.js';
test('work summary service can aggregate shared RPG fixtures into draft and published entries', async () => { test('work summary service can aggregate shared RPG fixtures into draft and published entries', async () => {
const sessionFixture = createRpgAgentSessionFixture(); const sessionFixture = createRpgAgentSessionFixture();
@@ -34,10 +34,10 @@ test('work summary service can aggregate shared RPG fixtures into draft and publ
const sessionStore = new CustomWorldAgentSessionStore( const sessionStore = new CustomWorldAgentSessionStore(
rpgAgentSessionRepository, rpgAgentSessionRepository,
); );
const summaries = await listCustomWorldWorkSummaries('fixture-user', { const summaries = await new RpgWorldWorkSummaryService(
rpgWorldProfiles: rpgWorldProfileRepository, rpgWorldProfileRepository,
customWorldAgentSessions: sessionStore, sessionStore,
}); ).list('fixture-user');
const expected = createRpgCreationWorksResponseFixture(); const expected = createRpgCreationWorksResponseFixture();
assert.equal(summaries.length, expected.items.length); 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, rpgAgentSessionRepository,
); );
const summaries = await listCustomWorldWorkSummaries('fixture-user', { const summaries = await new RpgWorldWorkSummaryService(
rpgWorldProfiles: rpgWorldProfileRepository, rpgWorldProfileRepository,
customWorldAgentSessions: sessionStore, sessionStore,
}); ).list('fixture-user');
assert.equal( assert.equal(
summaries.some((entry) => entry.sourceType === 'agent_session'), summaries.some((entry) => entry.sourceType === 'agent_session'),

View File

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

View File

@@ -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 (
<AnimatePresence>
{isOpen && (
<motion.div
initial={{ opacity: 0 }}
animate={{ opacity: 1 }}
exit={{ opacity: 0 }}
className="fixed inset-0 z-[74] flex items-center justify-center bg-black/78 p-3 sm:p-4 backdrop-blur-sm"
onClick={onClose}
>
<motion.div
initial={{ opacity: 0, scale: 0.96, y: 10 }}
animate={{ opacity: 1, scale: 1, y: 0 }}
exit={{ opacity: 0, scale: 0.96, y: 10 }}
transition={{ duration: 0.18, ease: 'easeOut' }}
className="pixel-nine-slice pixel-modal-shell flex max-h-[min(92vh,48rem)] w-full max-w-[min(96vw,42rem)] flex-col overflow-hidden shadow-[0_28px_90px_rgba(0,0,0,0.58)]"
style={getNineSliceStyle(UI_CHROME.modalPanel)}
onClick={event => event.stopPropagation()}
>
<div className="flex items-center justify-between gap-3 border-b border-white/10 px-4 py-3 sm:px-5 sm:py-4">
<div className="min-w-0">
<div className="mt-1 text-sm font-semibold text-white">{'\u5f00\u53d1\u56e2\u961f'}</div>
</div>
<button
type="button"
onClick={onClose}
className="rounded-full border border-white/10 bg-black/20 p-2 text-zinc-400 transition-colors hover:text-white"
aria-label="关闭开发团队弹窗"
>
<PixelIcon src={CHROME_ICONS.close} className="h-4 w-4" />
</button>
</div>
<div className="min-h-0 flex-1 overflow-y-auto p-4 sm:p-5">
<div
className="pixel-nine-slice pixel-panel flex flex-col items-center gap-5"
style={getNineSliceStyle(UI_CHROME.panel, { paddingX: 16, paddingY: 16 })}
>
<div className="whitespace-pre-line text-center text-sm leading-7 text-zinc-100">
{message}
</div>
</div>
</div>
</motion.div>
</motion.div>
)}
</AnimatePresence>
);
}

View File

@@ -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 (
<div className="rounded-2xl border border-white/10 bg-black/20 p-4">
<div className="mb-3 space-y-2">
<div className="h-4 w-28 rounded bg-white/10" />
<div className="h-3 w-40 rounded bg-white/5" />
</div>
<div className="overflow-hidden rounded-2xl border border-white/10 bg-black">
<div className="h-[300px] animate-pulse bg-[radial-gradient(circle_at_top,rgba(255,255,255,0.08),transparent_42%),linear-gradient(180deg,rgba(255,255,255,0.05),rgba(255,255,255,0.02))]" />
</div>
</div>
);
}
export function LazySkillEffectPreview(props: SkillEffectPreviewProps) {
return (
<Suspense fallback={<SkillEffectPreviewFallback />}>
<SkillEffectPreview {...props} />
</Suspense>
);
}

View File

@@ -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<string, unknown> {
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<string, MedievalNpcVisualOverride>,
npcId: string,
editorState: EditableNpcVisualState,
) {
return {
...overrideMap,
[npcId]: buildOverrideFromEditorState(editorState),
};
}

View File

@@ -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('角色布局');
});
});

View File

@@ -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<string, MedievalNpcVisualOverride>;
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: '已保存共享角色布局配置。',
};
}

View File

@@ -1,4 +0,0 @@
export {
RpgCreationRoleAssetStudioModal,
type RpgCreationRoleAssetStudioModalProps,
} from './RpgCreationRoleAssetStudioModal';

View File

@@ -1 +0,0 @@
export { SectionPanel as default } from './RpgCreationEntityEditorShared';

View File

@@ -1,4 +0,0 @@
export {
RpgCreationEntityEditorModal,
type RpgCreationEntityEditorModalProps,
} from './RpgCreationEntityEditorModal';

View File

@@ -1,4 +0,0 @@
export {
RpgCreationResultView,
type RpgCreationResultViewProps,
} from './RpgCreationResultView';

View File

@@ -17,6 +17,7 @@ import {
upsertRpgWorldProfile, upsertRpgWorldProfile,
} from '../../services/rpg-creation'; } from '../../services/rpg-creation';
import type { AuthUser } from '../../services/authService'; import type { AuthUser } from '../../services/authService';
import { ApiClientError } from '../../services/apiClient';
import { import {
clearRpgProfileBrowseHistory as clearProfileBrowseHistory, clearRpgProfileBrowseHistory as clearProfileBrowseHistory,
deleteRpgEntryWorldProfile, deleteRpgEntryWorldProfile,
@@ -517,6 +518,7 @@ beforeEach(() => {
vi.mocked(createRpgCreationSession).mockResolvedValue({ vi.mocked(createRpgCreationSession).mockResolvedValue({
session: mockSession, session: mockSession,
}); });
vi.mocked(getRpgCreationSession).mockResolvedValue(mockSession);
vi.mocked(listRpgCreationWorks).mockResolvedValue([]); vi.mocked(listRpgCreationWorks).mockResolvedValue([]);
vi.mocked(executeRpgCreationAction).mockResolvedValue({ vi.mocked(executeRpgCreationAction).mockResolvedValue({
operation: { operation: {
@@ -669,6 +671,67 @@ test('create tab resumes agent workspace when draft has no compiled result yet',
expect(screen.queryByText('世界档案')).toBeNull(); 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(<TestWrapper withAuth />);
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 () => { test('clicking a public work while logged out routes through requireAuth', async () => {
const user = userEvent.setup(); const user = userEvent.setup();
const requireAuth = vi.fn(); const requireAuth = vi.fn();

View File

@@ -16,6 +16,7 @@ import {
publishRpgEntryWorldProfile, publishRpgEntryWorldProfile,
unpublishRpgEntryWorldProfile, unpublishRpgEntryWorldProfile,
} from '../../services/rpg-entry'; } from '../../services/rpg-entry';
import { ApiClientError } from '../../services/apiClient';
import type { CustomWorldProfile } from '../../types'; import type { CustomWorldProfile } from '../../types';
import { import {
normalizeRpgEntryAgentBackedProfile, normalizeRpgEntryAgentBackedProfile,
@@ -79,6 +80,14 @@ type UseRpgEntryLibraryDetailParams = {
markAutoSavedProfile: (profile: CustomWorldProfile) => void; 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( const handleOpenCreationWork = useCallback(
async (work: CustomWorldWorkSummary) => { async (work: CustomWorldWorkSummary) => {
if (work.status === 'draft' && work.sessionId) { if (work.status === 'draft' && work.sessionId) {
persistAgentUiState(work.sessionId, null);
setCustomWorldError(null); setCustomWorldError(null);
setCustomWorldAutoSaveError(null); setCustomWorldAutoSaveError(null);
setCustomWorldAutoSaveState('idle'); setCustomWorldAutoSaveState('idle');
@@ -228,33 +236,57 @@ export function useRpgEntryLibraryDetail(
const shouldOpenAgentWorkspace = const shouldOpenAgentWorkspace =
work.playableNpcCount <= 0 && work.landmarkCount <= 0; work.playableNpcCount <= 0 && work.landmarkCount <= 0;
if (shouldOpenAgentWorkspace) { try {
// 仅八锚点未整理成底稿时才恢复 Agent 对话工作区。 if (shouldOpenAgentWorkspace) {
suppressAgentDraftResultAutoOpen(); // 仅八锚点未整理成底稿时才恢复 Agent 对话工作区。
setGeneratedCustomWorldProfile(null); suppressAgentDraftResultAutoOpen();
setCustomWorldResultViewSource(null); 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(); 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; 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) { if (!work.profileId) {

View File

@@ -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<string, CharacterPresetOverride>,
characters: Character[],
scenesByWorld: Partial<Record<WorldType, Array<Pick<ScenePreset, 'id'>>>>,
) {
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<string, MonsterPresetOverride>,
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<string, ScenePresetOverride>,
scenes: ScenePreset[],
_monstersByWorld: Partial<Record<WorldType, MonsterPreset[]>>,
) {
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<string, SceneNpcPresetOverride>,
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<string, ItemCatalogOverride>,
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;
}

View File

@@ -30,7 +30,7 @@ export const STORY_OPENING_CAMP_DIALOGUE_FUNCTION: FunctionDocumentationEntry =
storyMode: 'special_sequence', storyMode: 'special_sequence',
uiMode: 'none', uiMode: 'none',
executor: 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: '重点在对白本身,不额外驱动独立战斗/位移动画。', animationNote: '重点在对白本身,不额外驱动独立战斗/位移动画。',
storyNote: '会把 prompt 切到营地开场对白模式,并要求输出结构化对话行。', storyNote: '会把 prompt 切到营地开场对白模式,并要求输出结构化对话行。',
uiNote: '不弹 modal直接进入对白流。', uiNote: '不弹 modal直接进入对白流。',

View File

@@ -14,6 +14,7 @@ export * from './flow/campTravelHomeScene';
export * from './flow/storyContinueAdventure'; export * from './flow/storyContinueAdventure';
export * from './flow/storyOpeningCampDialogue'; export * from './flow/storyOpeningCampDialogue';
export * from './npc/npcChat'; export * from './npc/npcChat';
export * from './npc/npcChatQuestOffer';
export * from './npc/npcFight'; export * from './npc/npcFight';
export * from './npc/npcGift'; export * from './npc/npcGift';
export * from './npc/npcHelp'; export * from './npc/npcHelp';

View File

@@ -1,5 +1,10 @@
import type { FunctionDocumentationEntry } from '../types'; import type { FunctionDocumentationEntry } from '../types';
import { NPC_CHAT_FUNCTION } from './npcChat'; 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_FIGHT_FUNCTION } from './npcFight';
import { NPC_GIFT_FUNCTION } from './npcGift'; import { NPC_GIFT_FUNCTION } from './npcGift';
import { NPC_HELP_FUNCTION } from './npcHelp'; import { NPC_HELP_FUNCTION } from './npcHelp';
@@ -18,6 +23,9 @@ export const NPC_FUNCTION_DOCUMENTATION: FunctionDocumentationEntry[] = [
NPC_SPAR_FUNCTION, NPC_SPAR_FUNCTION,
NPC_HELP_FUNCTION, NPC_HELP_FUNCTION,
NPC_CHAT_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_GIFT_FUNCTION,
NPC_RECRUIT_FUNCTION, NPC_RECRUIT_FUNCTION,
NPC_QUEST_ACCEPT_FUNCTION, NPC_QUEST_ACCEPT_FUNCTION,

View File

@@ -24,7 +24,7 @@ export const NPC_CHAT_FUNCTION: FunctionDocumentationEntry = {
storyMode: 'stream_then_defer', storyMode: 'stream_then_defer',
uiMode: 'none', uiMode: 'none',
executor: executor:
'src/hooks/rpg-runtime-story/npcEncounterActions.ts -> commitNpcChatState', 'src/hooks/rpg-runtime-story/useRpgRuntimeNpcInteraction.ts -> commitNpcChatState',
animationNote: '重点在流式对白和轻量站场,不额外打开窗口。', animationNote: '重点在流式对白和轻量站场,不额外打开窗口。',
storyNote: storyNote:
'先生成聊天正文,再把真正的新选项放入 deferredOptions等待 continue adventure。', '先生成聊天正文,再把真正的新选项放入 deferredOptions等待 continue adventure。',

View File

@@ -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: '暂时不接',
},
};

View File

@@ -23,7 +23,7 @@ export const NPC_FIGHT_FUNCTION: FunctionDocumentationEntry = {
storyMode: 'special_sequence', storyMode: 'special_sequence',
uiMode: 'none', uiMode: 'none',
executor: executor:
'src/hooks/rpg-runtime-story/npcEncounterActions.ts -> handleNpcInteraction', 'src/hooks/rpg-runtime-story/useRpgRuntimeNpcInteraction.ts -> handleNpcInteraction',
animationNote: '切到 NPC 战斗模式后,由战斗播放链路驱动后续动画。', animationNote: '切到 NPC 战斗模式后,由战斗播放链路驱动后续动画。',
storyNote: '不会先弹窗,直接把当前 encounter 切成战斗态并进入后续结算。', storyNote: '不会先弹窗,直接把当前 encounter 切成战斗态并进入后续结算。',
uiNote: '不弹 modal直接进入战斗。', uiNote: '不弹 modal直接进入战斗。',

View File

@@ -22,7 +22,7 @@ export const NPC_HELP_FUNCTION: FunctionDocumentationEntry = {
storyMode: 'local_effect_then_generate', storyMode: 'local_effect_then_generate',
uiMode: 'none', uiMode: 'none',
executor: executor:
'src/hooks/rpg-runtime-story/npcEncounterActions.ts -> handleNpcInteraction', 'src/hooks/rpg-runtime-story/useRpgRuntimeNpcInteraction.ts -> handleNpcInteraction',
animationNote: '不单独开窗口,直接在当前交互里结算帮助结果。', animationNote: '不单独开窗口,直接在当前交互里结算帮助结果。',
storyNote: '点击后立即按本地奖励规则结算,并继续生成新的故事状态。', storyNote: '点击后立即按本地奖励规则结算,并继续生成新的故事状态。',
uiNote: '不弹 modal直接获得帮助反馈。', uiNote: '不弹 modal直接获得帮助反馈。',

View File

@@ -21,7 +21,7 @@ export const NPC_LEAVE_FUNCTION: FunctionDocumentationEntry = {
storyMode: 'local_effect_then_generate', storyMode: 'local_effect_then_generate',
uiMode: 'none', uiMode: 'none',
executor: executor:
'src/hooks/rpg-runtime-story/npcEncounterActions.ts -> handleNpcInteraction', 'src/hooks/rpg-runtime-story/useRpgRuntimeNpcInteraction.ts -> handleNpcInteraction',
animationNote: '通常只做轻量离场,不单独打开窗口。', animationNote: '通常只做轻量离场,不单独打开窗口。',
storyNote: '点击后结束当前 NPC 交互,并回到新的探索剧情。', storyNote: '点击后结束当前 NPC 交互,并回到新的探索剧情。',
uiNote: '不弹 modal直接退出互动。', uiNote: '不弹 modal直接退出互动。',

View File

@@ -60,7 +60,7 @@ export const NPC_PREVIEW_TALK_FUNCTION: FunctionDocumentationEntry = {
uiMode: 'npc_interaction_entry', uiMode: 'npc_interaction_entry',
visuals: NPC_PREVIEW_TALK_OPTION_VISUALS, visuals: NPC_PREVIEW_TALK_OPTION_VISUALS,
executor: 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: animationNote:
'保持轻量 idle 站场,不做额外位移,重点是把交互焦点切到 NPC 身上。', '保持轻量 idle 站场,不做额外位移,重点是把交互焦点切到 NPC 身上。',
storyNote: storyNote:

View File

@@ -21,7 +21,7 @@ export const NPC_SPAR_FUNCTION: FunctionDocumentationEntry = {
storyMode: 'special_sequence', storyMode: 'special_sequence',
uiMode: 'none', uiMode: 'none',
executor: executor:
'src/hooks/rpg-runtime-story/npcEncounterActions.ts -> handleNpcInteraction', 'src/hooks/rpg-runtime-story/useRpgRuntimeNpcInteraction.ts -> handleNpcInteraction',
animationNote: '切到 spar 战斗模式后,由战斗播放链路驱动切磋演出。', animationNote: '切到 spar 战斗模式后,由战斗播放链路驱动切磋演出。',
storyNote: '不会先弹窗,直接进入点到为止的切磋流程。', storyNote: '不会先弹窗,直接进入点到为止的切磋流程。',
uiNote: '不弹 modal直接切磋。', uiNote: '不弹 modal直接切磋。',

View File

@@ -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: '直接攻击眼前敌人',
},
};

View File

@@ -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: '释放一个指定技能',
},
};

View File

@@ -1,11 +1,13 @@
import type { StateFunctionSource } from '../types'; import type { StateFunctionSource } from '../types';
import { BATTLE_ALL_IN_CRUSH_FUNCTION_SOURCE } from './battleAllInCrush'; 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_ESCAPE_BREAKOUT_FUNCTION_SOURCE } from './battleEscapeBreakout';
import { BATTLE_FEINT_STEP_FUNCTION_SOURCE } from './battleFeintStep'; import { BATTLE_FEINT_STEP_FUNCTION_SOURCE } from './battleFeintStep';
import { BATTLE_FINISHER_WINDOW_FUNCTION_SOURCE } from './battleFinisherWindow'; import { BATTLE_FINISHER_WINDOW_FUNCTION_SOURCE } from './battleFinisherWindow';
import { BATTLE_GUARD_BREAK_FUNCTION_SOURCE } from './battleGuardBreak'; import { BATTLE_GUARD_BREAK_FUNCTION_SOURCE } from './battleGuardBreak';
import { BATTLE_PROBE_PRESSURE_FUNCTION_SOURCE } from './battleProbePressure'; import { BATTLE_PROBE_PRESSURE_FUNCTION_SOURCE } from './battleProbePressure';
import { BATTLE_RECOVER_BREATH_FUNCTION_SOURCE } from './battleRecoverBreath'; 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_CALL_OUT_FUNCTION_SOURCE } from './idleCallOut';
import { IDLE_EXPLORE_FORWARD_FUNCTION_SOURCE } from './idleExploreForward'; import { IDLE_EXPLORE_FORWARD_FUNCTION_SOURCE } from './idleExploreForward';
import { IDLE_FOLLOW_CLUE_FUNCTION_SOURCE } from './idleFollowClue'; import { IDLE_FOLLOW_CLUE_FUNCTION_SOURCE } from './idleFollowClue';
@@ -40,7 +42,9 @@ export const STATE_FUNCTION_PROMPT_DESCRIPTIONS = Object.fromEntries(
]), ]),
) as Record<string, string>; ) as Record<string, string>;
export const STATE_FUNCTION_DOCUMENTATION = STATE_FUNCTION_SOURCES.map( export const STATE_FUNCTION_DOCUMENTATION = [
(source) => source.documentation, BATTLE_ATTACK_BASIC_FUNCTION,
); BATTLE_USE_SKILL_FUNCTION,
...STATE_FUNCTION_SOURCES.map((source) => source.documentation),
];

View File

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

View File

@@ -1,20 +0,0 @@
type EditorNoticeTone = 'muted' | 'warning';
const TONE_CLASS_NAMES: Record<EditorNoticeTone, string> = {
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 <div className={TONE_CLASS_NAMES[tone]}>{message}</div>;
}

View File

@@ -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 (
<label className="block">
<div className="mb-1 text-xs font-medium text-zinc-300">{label}</div>
<input
value={value}
onChange={(event) => onChange(event.target.value)}
placeholder={placeholder}
disabled={disabled}
className="w-full rounded-lg border border-white/10 bg-black/30 px-3 py-2 text-sm text-white outline-none transition focus:border-emerald-400/40 disabled:cursor-not-allowed disabled:opacity-60"
/>
</label>
);
}
export function NumberField({
label,
value,
onChange,
min,
step = 1,
}: {
label: string;
value: number;
onChange: (value: number) => void;
min?: number;
step?: number;
}) {
return (
<label className="block">
<div className="mb-1 text-xs font-medium text-zinc-300">{label}</div>
<input
type="number"
value={safeNumber(value)}
min={min}
step={step}
onChange={(event) => onChange(toNumber(event.target.value, value))}
className="w-full rounded-lg border border-white/10 bg-black/30 px-3 py-2 text-sm text-white outline-none transition focus:border-emerald-400/40"
/>
</label>
);
}
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 (
<label className="block">
<div className="mb-1 text-xs font-medium text-zinc-300">{label}</div>
<textarea
rows={rows}
value={value}
onChange={(event) => onChange(event.target.value)}
placeholder={placeholder}
disabled={disabled}
className="w-full rounded-lg border border-white/10 bg-black/30 px-3 py-2 text-sm leading-relaxed text-white outline-none transition focus:border-emerald-400/40 disabled:cursor-not-allowed disabled:opacity-60"
/>
</label>
);
}
export function SelectField({
label,
value,
onChange,
options,
disabled = false,
}: {
label: string;
value: string | number;
onChange: (value: string) => void;
options: SelectFieldOption[];
disabled?: boolean;
}) {
return (
<label className="block">
<div className="mb-1 text-xs font-medium text-zinc-300">{label}</div>
<select
value={String(value)}
onChange={(event) => onChange(event.target.value)}
disabled={disabled}
className="w-full rounded-lg border border-white/10 bg-black/30 px-3 py-2 text-sm text-white outline-none transition focus:border-emerald-400/40 disabled:cursor-not-allowed disabled:opacity-60"
>
{options.map((option) => (
<option key={`${label}-${option.value}`} value={String(option.value)}>
{option.label}
</option>
))}
</select>
</label>
);
}
export function SaveBar({
saveLabel,
onSave,
isSaving,
saveMessage,
}: {
saveLabel: string;
onSave: () => void;
isSaving: boolean;
saveMessage: string | null;
}) {
return (
<div className="mt-5 flex flex-wrap items-center gap-3">
<button
type="button"
onClick={onSave}
disabled={isSaving}
className="inline-flex items-center gap-2 rounded-lg bg-emerald-500 px-4 py-2 text-sm font-medium text-black transition hover:bg-emerald-400 disabled:cursor-not-allowed disabled:opacity-60"
>
<Save className="h-4 w-4" />
<span>{isSaving ? '保存中...' : saveLabel}</span>
</button>
<EditorNotice message={saveMessage} />
</div>
);
}

View File

@@ -1,29 +0,0 @@
import type { ReactNode } from 'react';
export function SectionCard({
title,
description,
children,
className = '',
}: {
title: string;
description?: string;
children: ReactNode;
className?: string;
}) {
return (
<section
className={`rounded-2xl border border-white/10 bg-black/20 p-5 ${className}`}
>
<div className="mb-4">
<div className="text-sm font-semibold text-white">{title}</div>
{description && (
<div className="mt-1 text-xs leading-relaxed text-zinc-400">
{description}
</div>
)}
</div>
{children}
</section>
);
}

View File

@@ -26,7 +26,7 @@ import {
type StoryOption, type StoryOption,
WorldType, WorldType,
} from '../../types'; } from '../../types';
import { createStoryNpcEncounterActions } from './npcEncounterActions'; import { createStoryNpcEncounterActions } from './useRpgRuntimeNpcInteraction';
function createCharacter(): Character { function createCharacter(): Character {
return { return {

View File

@@ -1,6 +0,0 @@
export {
createRpgRuntimeNpcEncounterActions as createStoryNpcEncounterActions,
useRpgRuntimeNpcInteraction,
type RpgRuntimeNpcInteractionResult,
type UseRpgRuntimeNpcInteractionParams,
} from './useRpgRuntimeNpcInteraction';

View File

@@ -1,230 +0,0 @@
import type { Dispatch, SetStateAction } from 'react';
import {
hasEncounterEntity,
interpolateEncounterTransitionState,
} from '../../data/encounterTransition';
import { STORY_OPENING_CAMP_DIALOGUE_FUNCTION } from '../../data/functionCatalog';
import {
CALL_OUT_ENTRY_X_METERS,
RESOLVED_ENTITY_X_METERS,
} from '../../data/sceneEncounterPreviews';
import { getWorldCampScenePreset } from '../../data/scenePresets';
import { sortStoryOptionsByPriority } from '../../data/stateFunctions';
import { generateNextStep } from '../../services/aiService';
import type { StoryGenerationContext } from '../../services/aiTypes';
import { createHistoryMoment } from '../../services/storyHistory';
import type {
Character,
Encounter,
GameState,
StoryMoment,
StoryOption,
} from '../../types';
const ENCOUNTER_ENTRY_DURATION_MS = 1800;
const ENCOUNTER_ENTRY_TICK_MS = 180;
const OPENING_CAMP_DIALOGUE_FUNCTION_ID =
STORY_OPENING_CAMP_DIALOGUE_FUNCTION.id;
export type PreparedOpeningAdventure = {
encounterKey: string;
actionText: string;
resultText: string;
fallbackText: string;
openingOptions: StoryOption[];
};
export function buildPreparedOpeningAdventure({
state,
character,
getNpcEncounterKey,
appendHistory,
buildCampCompanionOpeningOptions,
buildCampCompanionOpeningResultText,
buildInitialCompanionDialogueText,
}: {
state: GameState;
character: Character;
getNpcEncounterKey: (encounter: Encounter) => string;
appendHistory: (
state: GameState,
actionText: string,
resultText: string,
) => GameState['storyHistory'];
buildCampCompanionOpeningOptions: (
state: GameState,
character: Character,
encounter: Encounter,
) => StoryOption[];
buildCampCompanionOpeningResultText: (
character: Character,
encounter: Encounter,
worldType: GameState['worldType'],
) => string;
buildInitialCompanionDialogueText: (
character: Character,
encounter: Encounter,
worldType: GameState['worldType'],
) => string;
}): PreparedOpeningAdventure | null {
const encounter = state.currentEncounter;
if (
!encounter ||
encounter.kind !== 'npc' ||
encounter.specialBehavior !== 'initial_companion'
) {
return null;
}
const campScene = state.worldType
? getWorldCampScenePreset(state.worldType)
: null;
const actionText = '开始冒险';
const resultText = buildCampCompanionOpeningResultText(
character,
encounter,
state.worldType,
);
const dialogueText = buildInitialCompanionDialogueText(
character,
encounter,
state.worldType,
);
const resolvedEncounter: Encounter = {
...encounter,
specialBehavior: 'camp_companion',
xMeters: RESOLVED_ENTITY_X_METERS,
};
const resolvedState: GameState = {
...state,
currentScenePreset: campScene ?? state.currentScenePreset,
currentEncounter: resolvedEncounter,
npcInteractionActive: false,
};
const nextHistory = appendHistory(state, actionText, resultText);
const stateWithHistory: GameState = {
...resolvedState,
storyHistory: nextHistory,
};
return {
encounterKey: getNpcEncounterKey(encounter),
actionText,
resultText,
fallbackText: dialogueText,
openingOptions: buildCampCompanionOpeningOptions(
stateWithHistory,
character,
resolvedEncounter,
),
};
}
export async function playOpeningAdventureSequence({
gameState,
encounter,
preparedStory,
setGameState,
setCurrentStory,
setAiError,
setIsLoading,
}: {
gameState: GameState;
character: Character;
encounter: Encounter;
preparedStory: PreparedOpeningAdventure;
setGameState: Dispatch<SetStateAction<GameState>>;
setCurrentStory: Dispatch<SetStateAction<StoryMoment | null>>;
setAiError: Dispatch<SetStateAction<string | null>>;
setIsLoading: Dispatch<SetStateAction<boolean>>;
buildDialogueStoryMoment: (
npcName: string,
text: string,
options: StoryOption[],
streaming?: boolean,
) => StoryMoment;
buildStoryContextFromState: (
state: GameState,
extras?: { lastFunctionId?: string | null },
) => StoryGenerationContext;
getStoryGenerationHostileNpcs: (
state: GameState,
) => GameState['sceneHostileNpcs'];
hasRenderableDialogueTurns: (text: string, npcName: string) => boolean;
inferOpeningCampFollowupOptions: (
state: GameState,
character: Character,
baseOptions: StoryOption[],
openingBackground: string,
openingDialogue: string,
) => Promise<StoryOption[]>;
getTypewriterDelay: (char: string) => number;
}) {
const { fallbackText, openingOptions } = preparedStory;
const campScene = gameState.worldType
? getWorldCampScenePreset(gameState.worldType)
: null;
const storyEncounter: Encounter = {
...encounter,
xMeters: RESOLVED_ENTITY_X_METERS,
specialBehavior: 'camp_companion',
};
const resolvedState: GameState = {
...gameState,
currentScenePreset: campScene ?? gameState.currentScenePreset,
currentEncounter: storyEncounter,
npcInteractionActive: true,
};
setAiError(null);
setIsLoading(false);
try {
setGameState(resolvedState);
setCurrentStory({
text: fallbackText,
options: sortStoryOptionsByPriority(openingOptions),
displayMode: 'dialogue',
dialogue: [
{
speaker: 'npc',
speakerName: encounter.npcName,
text: fallbackText,
},
],
streaming: false,
npcChatState: {
npcId: storyEncounter.id ?? storyEncounter.npcName,
npcName: storyEncounter.npcName,
turnCount: 0,
customInputPlaceholder: '输入你想对 TA 说的话',
},
});
} catch (error) {
console.error('Failed to play opening adventure sequence:', error);
setAiError(error instanceof Error ? error.message : '未知智能生成错误');
setGameState(resolvedState);
setCurrentStory({
text: fallbackText,
options: sortStoryOptionsByPriority(openingOptions),
displayMode: 'dialogue',
dialogue: [
{
speaker: 'npc',
speakerName: encounter.npcName,
text: fallbackText,
},
],
streaming: false,
npcChatState: {
npcId: storyEncounter.id ?? storyEncounter.npcName,
npcName: storyEncounter.npcName,
turnCount: 0,
customInputPlaceholder: '输入你想对 TA 说的话',
},
});
} finally {
setIsLoading(false);
}
}

View File

@@ -1,356 +0,0 @@
import { describe, expect, it, vi } from 'vitest';
import { getWorldCampScenePreset } from '../../data/scenePresets';
import {
AnimationState,
type Character,
type Encounter,
type GameState,
type StoryMoment,
type StoryOption,
WorldType,
} from '../../types';
import {
buildCampCompanionOpeningResultText,
buildInitialCompanionDialogueText,
createCampCompanionStoryHelpers,
} from './storyCampCompanion';
function createCharacter(): Character {
return {
id: 'sword-princess',
name: '测试同伴',
title: '试剑公主',
description: '在营地观察局势的试炼者。',
backstory: '她在旅途中始终保留自己的真正目标。',
avatar: '/hero.png',
portrait: '/hero-portrait.png',
assetFolder: 'hero',
assetVariant: 'default',
attributes: {
strength: 12,
agility: 10,
intelligence: 8,
spirit: 9,
},
personality: '谨慎冷静',
skills: [],
adventureOpenings: {
[WorldType.WUXIA]: {
reason: '调查旧路异动',
goal: '查清前方局势',
monologue: '风声里还藏着未说破的话。',
surfaceHook: '我来这里,是为了确认旧路尽头到底出了什么事。',
immediateConcern: '眼下的风向不对,我们不能直接把底牌亮出来。',
guardedMotive: '我真正要找的东西,还不能让更多人知道。',
},
},
};
}
function createOption(
functionId: string,
actionText = functionId,
interaction?: StoryOption['interaction'],
): StoryOption {
return {
functionId,
actionText,
text: actionText,
interaction,
visuals: {
playerAnimation: AnimationState.IDLE,
playerMoveMeters: 0,
playerOffsetY: 0,
playerFacing: 'right',
scrollWorld: false,
monsterChanges: [],
},
};
}
function createEncounter(overrides: Partial<Encounter> = {}): Encounter {
return {
id: 'camp-companion',
kind: 'npc',
characterId: 'sword-princess',
npcName: '沈砺',
npcDescription: '正靠在营地灯火旁观察风向。',
npcAvatar: '/npc.png',
context: '营地夜谈',
specialBehavior: 'camp_companion',
...overrides,
};
}
function createStory(text: string, options: StoryOption[] = []): StoryMoment {
return {
text,
options,
};
}
function createGameState(overrides: Partial<GameState> = {}): GameState {
return {
worldType: WorldType.WUXIA,
customWorldProfile: null,
playerCharacter: createCharacter(),
runtimeStats: {
playTimeMs: 0,
lastPlayTickAt: null,
hostileNpcsDefeated: 0,
questsAccepted: 0,
itemsUsed: 0,
scenesTraveled: 0,
},
currentScene: 'Story',
storyHistory: [],
characterChats: {},
animationState: AnimationState.IDLE,
currentEncounter: null,
npcInteractionActive: false,
currentScenePreset: getWorldCampScenePreset(WorldType.WUXIA),
sceneHostileNpcs: [],
playerX: 0,
playerOffsetY: 0,
playerFacing: 'right',
playerActionMode: 'idle',
scrollWorld: false,
inBattle: false,
playerHp: 100,
playerMaxHp: 100,
playerMana: 30,
playerMaxMana: 30,
playerSkillCooldowns: {},
activeCombatEffects: [],
playerCurrency: 0,
playerInventory: [],
playerEquipment: {
weapon: null,
armor: null,
relic: null,
},
npcStates: {},
quests: [],
roster: [],
companions: [],
currentBattleNpcId: null,
currentNpcBattleMode: null,
currentNpcBattleOutcome: null,
sparReturnEncounter: null,
sparPlayerHpBefore: null,
sparPlayerMaxHpBefore: null,
sparStoryHistoryBefore: null,
...overrides,
} as GameState;
}
describe('storyCampCompanion', () => {
it('builds opening dialogue from the character adventure opening', () => {
const text = buildInitialCompanionDialogueText(
createCharacter(),
createEncounter(),
WorldType.WUXIA,
);
expect(text).toContain('先和你打个招呼。');
expect(text).toContain('我来这里,是为了确认旧路尽头到底出了什么事。');
expect(text).toContain('眼下的风向不对,我们不能直接把底牌亮出来。');
expect(text).toContain('我真正要找的东西,还不能让更多人知道。');
expect(text).not.toContain('像是在等你把话接下去');
});
it('summarizes the camp opening result with the current concern', () => {
const text = buildCampCompanionOpeningResultText(
createCharacter(),
createEncounter(),
WorldType.WUXIA,
);
expect(text).toContain('沈砺 在');
expect(text).toContain('眼下的风向不对');
});
it('keeps the opening camp options focused on继续交谈', () => {
const buildNpcStory = vi.fn(() =>
createStory('营地开场', [
createOption('npc_chat', '继续交谈'),
createOption('npc_recruit', '邀请同行'),
createOption('npc_trade', '查看货物'),
]),
);
const helpers = createCampCompanionStoryHelpers({
buildNpcStory,
buildStoryContextFromState: vi.fn(),
getStoryGenerationHostileNpcs: vi.fn(() => []),
getNpcEncounterKey: vi.fn(() => 'camp-companion'),
generateNextStep: vi.fn(),
});
const options = helpers.buildCampCompanionOpeningOptions(
createGameState(),
createCharacter(),
createEncounter(),
);
expect(options.map((option) => option.functionId)).toEqual(['npc_chat']);
});
it('uses AI follow-up options when the camp follow-up request succeeds and falls back on errors', async () => {
const baseOptions = [
createOption('npc_chat', '继续交谈', {
kind: 'npc',
npcId: 'camp-companion',
action: 'chat',
}),
createOption('camp_travel_home_scene', '前往旧地点'),
];
const generateNextStep = vi
.fn()
.mockResolvedValueOnce({
storyText: '继续营地交谈',
options: [
createOption('npc_chat', '顺着刚才的话继续问下去'),
createOption('camp_travel_home_scene', '先回云河渡'),
],
})
.mockRejectedValueOnce(new Error('llm failed'));
const buildStoryContextFromState = vi.fn(() => ({
playerHp: 100,
playerMaxHp: 100,
playerMana: 30,
playerMaxMana: 30,
inBattle: false,
playerX: 0,
playerFacing: 'right' as const,
playerAnimation: AnimationState.IDLE,
skillCooldowns: {},
sceneId: 'camp',
sceneName: '营地',
sceneDescription: '营火微亮。',
pendingSceneEncounter: false,
}));
const helpers = createCampCompanionStoryHelpers({
buildNpcStory: vi.fn(),
buildStoryContextFromState,
getStoryGenerationHostileNpcs: vi.fn(() => []),
getNpcEncounterKey: vi.fn(() => 'camp-companion'),
generateNextStep,
});
const state = createGameState();
const character = createCharacter();
const consoleErrorSpy = vi
.spyOn(console, 'error')
.mockImplementation(() => undefined);
try {
const resolvedOptions = await helpers.inferOpeningCampFollowupOptions(
state,
character,
baseOptions,
'营地里风声微沉。',
'你们刚交换完第一轮判断。',
);
const fallbackOptions = await helpers.inferOpeningCampFollowupOptions(
state,
character,
baseOptions,
'营地里风声微沉。',
'你们刚交换完第一轮判断。',
);
expect(buildStoryContextFromState).toHaveBeenCalledWith(
state,
expect.objectContaining({
openingCampBackground: '营地里风声微沉。',
openingCampDialogue: '你们刚交换完第一轮判断。',
}),
);
expect(resolvedOptions).toEqual([
expect.objectContaining({
functionId: 'npc_chat',
actionText: '顺着刚才的话继续问下去',
interaction: {
kind: 'npc',
npcId: 'camp-companion',
action: 'chat',
},
}),
expect.objectContaining({
functionId: 'camp_travel_home_scene',
actionText: '先回云河渡',
}),
]);
expect(fallbackOptions).toBe(baseOptions);
} finally {
consoleErrorSpy.mockRestore();
}
});
it('reconstructs the opening camp chat context from story history and filters idle camp options', () => {
const encounter = createEncounter();
const buildNpcStory = vi.fn(() =>
createStory('营地常态', [
createOption('npc_chat', '继续交谈'),
createOption('npc_leave', '结束对话'),
createOption('npc_fight', '直接切磋'),
createOption('npc_trade', '查看货物'),
]),
);
const helpers = createCampCompanionStoryHelpers({
buildNpcStory,
buildStoryContextFromState: vi.fn(),
getStoryGenerationHostileNpcs: vi.fn(() => []),
getNpcEncounterKey: vi.fn(() => 'camp-companion'),
generateNextStep: vi.fn(),
});
const state = createGameState({
currentEncounter: encounter,
npcStates: {
'camp-companion': {
affinity: 16,
helpUsed: false,
chattedCount: 1,
giftsGiven: 0,
inventory: [],
recruited: false,
},
},
storyHistory: [
{
text: `在营地与 ${encounter.npcName} 交换开场判断`,
options: [],
historyRole: 'action',
},
{
text: '你们先对了一遍眼前局势。',
options: [],
historyRole: 'result',
},
],
});
const chatContext = helpers.buildOpeningCampChatContext(
state,
createCharacter(),
encounter,
);
const idleStory = helpers.buildCampCompanionIdleStory(
state,
createCharacter(),
encounter,
);
expect(chatContext).toEqual(
expect.objectContaining({
openingCampBackground: expect.stringContaining('沈砺 在'),
openingCampDialogue: '你们先对了一遍眼前局势。',
}),
);
expect(idleStory.options.map((option) => option.functionId)).toEqual([
'npc_chat',
'npc_trade',
'camp_travel_home_scene',
]);
});
});

View File

@@ -1,293 +0,0 @@
import {
getCharacterAdventureOpening,
getCharacterHomeSceneId,
} from '../../data/characterPresets';
import {
buildCampTravelHomeOption,
NPC_CHAT_FUNCTION,
NPC_FIGHT_FUNCTION,
NPC_LEAVE_FUNCTION,
} from '../../data/functionCatalog';
import {
buildInitialNpcState,
buildNpcChatOpeningText,
} from '../../data/npcInteractions';
import {
getForwardScenePreset,
getScenePresetById,
getTravelScenePreset,
getWorldCampScenePreset,
} from '../../data/scenePresets';
import { sortStoryOptionsByPriority } from '../../data/stateFunctions';
import type { StoryGenerationContext } from '../../services/aiService';
import type {
Character,
Encounter,
GameState,
StoryMoment,
StoryOption,
WorldType,
} from '../../types';
import { resolveStoryResponseOptions } from './storyResponseOptions';
type BuildNpcStory = (
state: GameState,
character: Character,
encounter: Encounter,
overrideText?: string,
) => StoryMoment;
type BuildStoryContextFromState = (
state: GameState,
extras?: {
openingCampBackground?: string | null;
openingCampDialogue?: string | null;
},
) => StoryGenerationContext;
type GetStoryGenerationHostileNpcs = (
state: GameState,
) => GameState['sceneHostileNpcs'];
type GetNpcEncounterKey = (encounter: Encounter) => string;
type GenerateNextStep =
(typeof import('../../services/aiService'))['generateNextStep'];
export function buildInitialCompanionDialogueText(
character: Character,
encounter: Encounter,
worldType: WorldType | null,
) {
const resolvedEncounter =
encounter.characterId === character.id
? encounter
: {
...encounter,
characterId: encounter.characterId ?? character.id,
};
const initialNpcState = buildInitialNpcState(resolvedEncounter, worldType);
return buildNpcChatOpeningText(
resolvedEncounter,
initialNpcState,
worldType,
character,
);
}
export function buildCampCompanionOpeningResultText(
character: Character,
encounter: Encounter,
worldType: WorldType | null,
) {
const opening = getCharacterAdventureOpening(character, worldType);
const campSceneName = worldType
? (getWorldCampScenePreset(worldType)?.name ?? '归处')
: '归处';
if (!opening) {
return `${encounter.npcName} 已经来到你身边。在${campSceneName},你稍作停顿,决定下一步去向何方。`;
}
return `${encounter.npcName}${campSceneName}来到你身边。你们首先就“${opening.immediateConcern ?? '前方不稳定的道路'}”交换了意见,而双方都暂时保留了部分真相。`;
}
function getCampCompanionHomeScene(state: GameState, character: Character) {
if (!state.worldType) return null;
const sceneId = getCharacterHomeSceneId(state.worldType, character.id);
return getScenePresetById(state.worldType, sceneId);
}
export function createCampCompanionStoryHelpers(params: {
buildNpcStory: BuildNpcStory;
buildStoryContextFromState: BuildStoryContextFromState;
getStoryGenerationHostileNpcs: GetStoryGenerationHostileNpcs;
getNpcEncounterKey: GetNpcEncounterKey;
generateNextStep: GenerateNextStep;
}) {
const getCampCompanionTravelScene = (
state: GameState,
character: Character,
) => {
if (!state.worldType) return null;
const campScene = getWorldCampScenePreset(state.worldType);
const homeScene = getCampCompanionHomeScene(state, character);
if (
homeScene &&
homeScene.id !== campScene?.id &&
homeScene.id !== state.currentScenePreset?.id
) {
return homeScene;
}
const fallbackSceneId =
campScene?.id ?? state.currentScenePreset?.id ?? null;
return (
getForwardScenePreset(state.worldType, fallbackSceneId) ??
getTravelScenePreset(state.worldType, fallbackSceneId) ??
homeScene
);
};
const buildCampCompanionOpeningOptions = (
state: GameState,
character: Character,
encounter: Encounter,
) => {
const baseOptions = params.buildNpcStory(
state,
character,
encounter,
).options;
return baseOptions
.filter((option) => option.functionId === NPC_CHAT_FUNCTION.id)
.slice(0, 3);
};
const inferOpeningCampFollowupOptions = async (
state: GameState,
character: Character,
baseOptions: StoryOption[],
openingBackground: string,
openingDialogue: string,
) => {
if (!state.worldType || baseOptions.length === 0) {
return baseOptions;
}
try {
const response = await params.generateNextStep(
state.worldType,
character,
params.getStoryGenerationHostileNpcs(state),
state.storyHistory,
'继续承接营地中的这段交谈,并整理出眼下最自然的后续行动。',
params.buildStoryContextFromState(state, {
openingCampBackground: openingBackground,
openingCampDialogue: openingDialogue,
}),
{
availableOptions: baseOptions,
},
);
return resolveStoryResponseOptions({
responseOptions: response.options,
availableOptions: baseOptions,
getSanitizedOptions: () => sortStoryOptionsByPriority(baseOptions),
});
} catch (error) {
console.error('Failed to infer opening camp follow-up options:', error);
return baseOptions;
}
};
const buildOpeningCampChatContext = (
state: GameState,
character: Character,
encounter: Encounter,
) => {
if (encounter.specialBehavior !== 'camp_companion') {
return {};
}
const npcState =
state.npcStates[params.getNpcEncounterKey(encounter)] ??
buildInitialNpcState(encounter, state.worldType, state);
if (npcState.chattedCount > 2) {
return {};
}
const openingActionText = `在营地与 ${encounter.npcName} 交换开场判断`;
let openingDialogue: string | null = null;
for (let index = 0; index < state.storyHistory.length - 1; index += 1) {
const entry = state.storyHistory[index];
if (!entry) {
continue;
}
if (entry.historyRole !== 'action' || entry.text !== openingActionText) {
continue;
}
for (
let nextIndex = index + 1;
nextIndex < state.storyHistory.length;
nextIndex += 1
) {
const nextEntry = state.storyHistory[nextIndex];
if (!nextEntry) {
continue;
}
if (nextEntry.historyRole === 'action') {
break;
}
if (nextEntry.text.trim()) {
openingDialogue = nextEntry.text;
break;
}
}
if (openingDialogue) {
break;
}
}
if (!openingDialogue) {
return {};
}
return {
openingCampBackground: buildCampCompanionOpeningResultText(
character,
encounter,
state.worldType,
),
openingCampDialogue: openingDialogue,
};
};
const buildCampCompanionIdleStory = (
state: GameState,
character: Character,
encounter: Encounter,
overrideText?: string,
): StoryMoment => {
const targetScene = getCampCompanionTravelScene(state, character);
const baseStory = params.buildNpcStory(
state,
character,
encounter,
overrideText,
);
const filteredOptions = baseStory.options.filter(
(option) =>
option.functionId !== NPC_LEAVE_FUNCTION.id &&
option.functionId !== NPC_FIGHT_FUNCTION.id,
);
if (!targetScene) {
return {
...baseStory,
options: filteredOptions,
};
}
return {
...baseStory,
options: [
...filteredOptions.slice(0, 2),
buildCampTravelHomeOption(targetScene.name),
...filteredOptions.slice(2),
],
};
};
return {
getCampCompanionTravelScene,
buildCampCompanionOpeningOptions,
inferOpeningCampFollowupOptions,
buildOpeningCampChatContext,
buildCampCompanionIdleStory,
};
}

View File

@@ -1,241 +0,0 @@
import type {
Character,
GameState,
StoryDialogueTurn,
StoryMoment,
StoryOption,
} from '../../types';
import {
buildFallbackStoryMoment,
normalizeSkillProbabilities,
} from '../combatStoryUtils';
const MIN_OPTION_POOL_SIZE = 6;
export function dedupeStoryOptions(options: StoryOption[]) {
const seen = new Set<string>();
return options.filter((option) => {
const identity = `${option.functionId}::${option.actionText}::${option.text}`;
if (seen.has(identity)) return false;
seen.add(identity);
return true;
});
}
export function buildLocalCharacterChatSummary(
character: Character,
history: Array<{ speaker: 'player' | 'character'; text: string }>,
previousSummary: string,
) {
const latestTurns = history
.slice(-4)
.map(
(turn) =>
`${turn.speaker === 'player' ? '玩家' : character.name}${turn.text}`,
)
.join(' ');
const currentSummary = latestTurns
? `${character.name}在私下交谈里变得更愿意坦率回应。最近交流:${latestTurns}`
: `${character.name}愿意继续私下交谈,对玩家的信任也在慢慢加深。`;
if (!previousSummary) {
return currentSummary.slice(0, 118);
}
return `${previousSummary} ${currentSummary}`.slice(0, 118);
}
export function buildLocalCharacterChatSuggestions(character: Character) {
return [
'我想听你把这件事再说得更明白一点。',
`${character.name},你现在真正担心的是什么?`,
'先把外面的局势放一放,我想更了解你一些。',
];
}
export function sanitizeOptions(
options: StoryOption[],
character: Character,
state: GameState,
) {
const normalizedOptions = dedupeStoryOptions(
options.map((option) => normalizeSkillProbabilities(option, character)),
);
if (normalizedOptions.length === 0) {
return buildFallbackStoryMoment(state, character).options;
}
if (normalizedOptions.length >= MIN_OPTION_POOL_SIZE) {
return normalizedOptions;
}
return dedupeStoryOptions([
...normalizedOptions,
...buildFallbackStoryMoment(state, character).options,
]).slice(0, MIN_OPTION_POOL_SIZE);
}
function escapeRegExp(value: string) {
const specialChars = [
'\\',
'^',
'$',
'*',
'+',
'?',
'.',
'(',
')',
'|',
'[',
']',
'{',
'}',
];
return specialChars.reduce(
(escaped, char) => escaped.split(char).join('\\' + char),
value,
);
}
function normalizeDialogueSpeakerName(rawSpeakerName: string) {
return rawSpeakerName
.trim()
.replace(
/^[[\]{}()<>\u300a\u300b\u300c\u300d\u300e\u300f\u3010\u3011\uFF08\uFF09]+/u,
'',
)
.replace(
/[[\]{}()<>\u300a\u300b\u300c\u300d\u300e\u300f\u3010\u3011\uFF08\uFF09]+$/u,
'',
)
.replace(/^(?:\u540c\u4f34|\u961f\u53cb)\s*/u, '')
.trim();
}
function parseDialogueTurns(
text: string,
npcName: string,
): StoryDialogueTurn[] {
const turns: StoryDialogueTurn[] = [];
const dialogueColonPattern = '(?:\\uFF1A|:)';
const playerPrefixPattern = new RegExp(
'^(?:\\\\u4f60|\\\\u73a9\\\\u5bb6|\\\\u4e3b\\\\u89d2)\\\\s*' +
dialogueColonPattern +
'\\\\s*(.+)$',
'u',
);
const npcPrefixPattern = new RegExp(
'^' +
escapeRegExp(npcName) +
'\\\\s*' +
dialogueColonPattern +
'\\\\s*(.+)$',
'u',
);
const namedSpeakerPattern = new RegExp(
'^(.{1,24}?)\\\\s*' + dialogueColonPattern + '\\\\s*(.+)$',
'u',
);
const lines = text
.replace(/\r/g, '')
.split('\n')
.map((line) => line.trim())
.filter(Boolean);
for (const line of lines) {
const playerMatch = line.match(playerPrefixPattern);
const playerText = playerMatch?.[1]?.trim();
if (playerText) {
turns.push({ speaker: 'player', text: playerText });
continue;
}
const npcMatch = line.match(npcPrefixPattern);
const npcText = npcMatch?.[1]?.trim();
if (npcText) {
turns.push({ speaker: 'npc', speakerName: npcName, text: npcText });
continue;
}
const namedSpeakerMatch = line.match(namedSpeakerPattern);
if (namedSpeakerMatch) {
const rawSpeakerName = namedSpeakerMatch[1];
const rawSpeakerText = namedSpeakerMatch[2];
if (!rawSpeakerName || !rawSpeakerText) {
continue;
}
const speakerName = normalizeDialogueSpeakerName(rawSpeakerName);
const speakerText = rawSpeakerText.trim();
if (speakerName && speakerText) {
turns.push({
speaker: speakerName === npcName ? 'npc' : 'companion',
speakerName,
text: speakerText,
});
continue;
}
}
if (line.startsWith('你:') || line.startsWith('你:')) {
turns.push({ speaker: 'player', text: line.slice(2).trim() });
continue;
}
if (line.startsWith(npcName + ':') || line.startsWith(npcName + '')) {
turns.push({
speaker: 'npc',
text: line.slice(npcName.length + 1).trim(),
});
continue;
}
if (line.startsWith('主角:') || line.startsWith('主角:')) {
turns.push({ speaker: 'player', text: line.slice(3).trim() });
continue;
}
if (turns.length > 0) {
const lastTurnIndex = turns.length - 1;
const lastTurn = turns[lastTurnIndex];
if (lastTurn) {
turns[lastTurnIndex] = {
...lastTurn,
text: lastTurn.text + line,
};
}
}
}
return turns.filter((turn) => turn.text.length > 0);
}
export function buildDialogueStoryMoment(
npcName: string,
text: string,
options: StoryOption[],
streaming = false,
): StoryMoment {
return {
text,
options,
displayMode: 'dialogue',
dialogue: parseDialogueTurns(text, npcName),
streaming,
};
}
export function hasRenderableDialogueTurns(text: string, npcName: string) {
return parseDialogueTurns(text, npcName).length >= 2;
}
export function getTypewriterDelay(char: string) {
if (/[!?]/u.test(char)) return 240;
if (/[;:]/u.test(char)) return 150;
if (/\s/u.test(char)) return 45;
return 90;
}

View File

@@ -1,175 +0,0 @@
import type {QuestGenerationContext} from '../services/aiTypes';
import type {QuestOpportunity, QuestSceneSnapshot} from '../services/questTypes';
import { buildQuestVisibilitySlice } from '../services/storyEngine/visibilityEngine';
function describeWorld(worldType: QuestGenerationContext['worldType']) {
switch (worldType) {
case 'WUXIA':
return '边城模板';
case 'XIANXIA':
return '灵潮模板';
case 'CUSTOM':
return '自定义世界';
default:
return '未知世界';
}
}
function summarizeRecentStoryMoments(context: QuestGenerationContext) {
const moments = context.recentStoryMoments
.slice(-4)
.map(moment => `- ${moment.text}`)
.join('\n');
return moments || '- 暂无近期剧情记录';
}
function summarizeCurrentQuests(context: QuestGenerationContext) {
const summary = context.currentQuestSummary?.map(quest =>
`- ${quest.title}${quest.status}),发布者 ${quest.issuerNpcId}`,
).join('\n');
return summary || '- 当前没有进行中的任务';
}
function summarizeCompanions(context: QuestGenerationContext) {
const active = context.activeCompanions?.map(companion => companion.characterId).join('、') || '无';
const roster = context.rosterCompanions?.map(companion => companion.characterId).join('、') || '无';
return `当前同行角色:${active}\n队伍名册${roster}`;
}
function summarizePlayerState(context: QuestGenerationContext) {
const playerName = context.playerCharacter?.name ?? '未知角色';
const playerTitle = context.playerCharacter?.title ?? '未知称号';
const hp = `${context.playerHp ?? 0}/${context.playerMaxHp ?? 0}`;
const mana = `${context.playerMana ?? 0}/${context.playerMaxMana ?? 0}`;
const inventory = context.playerInventory?.slice(0, 8).map(item => item.name).join('、') || '无';
return [
`玩家:${playerName}${playerTitle}`,
`生命:${hp}`,
`灵力:${mana}`,
`背包快照:${inventory}`,
].join('\n');
}
function summarizeScene(scene: QuestSceneSnapshot | null, context: QuestGenerationContext) {
const hostileNpcIds = context.currentSceneHostileNpcIds?.join('、') || '无';
const treasureHintCount = context.currentSceneTreasureHintCount ?? 0;
return [
`场景:${scene?.name ?? context.currentSceneName ?? '未知区域'}`,
`场景描述:${scene?.description ?? context.currentSceneDescription ?? '暂无'}`,
`敌对角色 ID${hostileNpcIds}`,
`宝藏线索数量:${treasureHintCount}`,
].join('\n');
}
function summarizeActiveThreads(context: QuestGenerationContext) {
if (!context.activeThreadIds?.length) {
return '暂无明确激活线程';
}
const storyGraph = context.customWorldProfile?.storyGraph;
const labels = context.activeThreadIds.map((threadId) =>
[...(storyGraph?.visibleThreads ?? []), ...(storyGraph?.hiddenThreads ?? [])]
.find((thread) => thread.id === threadId)?.title ?? threadId,
);
return labels.join('、');
}
function summarizeIssuerNarrativeProfile(context: QuestGenerationContext) {
const profile = context.issuerNarrativeProfile;
if (!profile) {
return '暂无额外叙事档案';
}
return [
`公开面:${profile.publicMask}`,
`表层线:${profile.visibleLine}`,
`当前压力:${profile.immediatePressure}`,
profile.reactionHooks.length > 0
? `反应钩子:${profile.reactionHooks.join('、')}`
: null,
]
.filter(Boolean)
.join('\n');
}
function summarizeQuestVisibility(context: QuestGenerationContext) {
const slice = buildQuestVisibilitySlice({
issuerNarrativeProfile: context.issuerNarrativeProfile,
activeThreadIds: context.activeThreadIds,
});
return [
`可直接披露:${slice.sayableFactIds.join('、') || '无'}`,
`只宜写成推测:${slice.inferredFactIds.join('、') || '无'}`,
`禁止直接说破:${slice.forbiddenFactIds.join('、') || '无'}`,
].join('\n');
}
export const QUEST_INTENT_SYSTEM_PROMPT = `你是 AI 原生叙事 RPG 的任务导演。
只返回 JSON不要输出 Markdown。
输出结构:
{
"intent": {
"title": "中文任务标题",
"description": "中文任务描述",
"summary": "中文短摘要",
"narrativeType": "bounty|escort|investigation|retrieval|relationship|trial",
"dramaticNeed": "string",
"issuerGoal": "string",
"playerHook": "string",
"worldReason": "string",
"recommendedObjectiveKinds": ["defeat_hostile_npc" | "inspect_treasure" | "spar_with_npc" | "talk_to_npc" | "reach_scene" | "deliver_item"],
"urgency": "low|medium|high",
"intimacy": "transactional|cooperative|trust_based",
"rewardTheme": "currency|resource|relationship|intel|rare_item",
"followupHooks": ["string"]
}
}
规则:
- 所有自然语言字段都必须使用中文。
- 任务必须扎根于当前场景、发布者和近期剧情。
- 不要编造奖励、ID、数量、状态或不受支持的规则变化。
- recommendedObjectiveKinds 只是语义建议,不是硬编码结果。
- 优先给出简洁、可玩的任务框架,不要堆砌华丽辞藻。
- title 必须是 4 到 10 个中文字符左右的短任务名,不要写成长句。
- description 解释任务为什么在当前剧情里成立,避免纯规则说明。
- summary 必须是清晰达成条件,优先使用“击败 / 调查 / 返回 / 前往 / 交付 / 切磋”等明确动词,不要写“处理一下”“看看情况”这类模糊表达。`;
export function buildQuestIntentPrompt(params: {
context: QuestGenerationContext;
scene: QuestSceneSnapshot | null;
opportunity: QuestOpportunity;
}) {
const {context, scene, opportunity} = params;
const customWorldSummary = context.customWorldProfile
? `${context.customWorldProfile.name}: ${context.customWorldProfile.summary}`
: '无';
return [
`世界:${describeWorld(context.worldType)}`,
`自定义世界摘要:${customWorldSummary}`,
`发布角色:${context.issuerNpcName ?? '未知'}${context.issuerNpcId ?? '未知'}`,
`发布者身份:${context.issuerNpcContext ?? '暂无'}`,
`发布者好感:${context.issuerAffinity ?? 0}`,
`发布者信息揭示阶段:${context.issuerDisclosureStage ?? '未知'}`,
`发布者亲疏阶段:${context.issuerWarmthStage ?? '未知'}`,
`当前激活线程:${summarizeActiveThreads(context)}`,
`发布者叙事档案:\n${summarizeIssuerNarrativeProfile(context)}`,
`任务可见性切片:\n${summarizeQuestVisibility(context)}`,
`当前遭遇类型:${context.encounterKind ?? '无'}`,
summarizeScene(scene, context),
summarizePlayerState(context),
summarizeCompanions(context),
`当前任务机会:${opportunity.reason}`,
`当前任务列表:\n${summarizeCurrentQuests(context)}`,
`近期剧情片段:\n${summarizeRecentStoryMoments(context)}`,
'现在请基于这次具体局势,生成一个自然生长出来的任务意图。',
].join('\n\n');
}

View File

@@ -1,119 +0,0 @@
import {
buildRuntimeItemAiIntent,
buildRuntimeItemAiPromptInput,
} from '../data/runtimeItemNarrative';
import type {
RuntimeItemGenerationContext,
RuntimeItemPlan,
RuntimeRelationAnchor,
} from '../types';
import { buildRuntimeItemStoryFingerprint } from '../services/storyEngine/carrierNarrativeCompiler';
import { buildCarrierVisibilitySlice } from '../services/storyEngine/visibilityEngine';
function describeRelationAnchor(anchor: RuntimeRelationAnchor) {
switch (anchor.type) {
case 'npc':
return `NPC:${anchor.npcName}`;
case 'scene':
return `场景:${anchor.sceneName}`;
case 'monster':
return `怪物:${anchor.monsterName}`;
case 'quest':
return `任务:${anchor.questName}`;
case 'faction':
return `势力:${anchor.factionName}`;
default:
return `地标:${anchor.landmarkName}`;
}
}
function describeCarrierFactId(factId: string) {
if (factId === 'visibleClue') return '可见线索';
if (factId === 'currentAppearanceReason') return '当前出现理由';
if (factId === 'witnessMark') return '见证痕';
if (factId === 'unresolvedQuestion') return '未完成问题';
if (factId.startsWith('thread:')) return `线程索引(${factId.slice('thread:'.length)})`;
return factId;
}
function describePlan(
context: RuntimeItemGenerationContext,
plan: RuntimeItemPlan,
index: number,
) {
const promptInput = buildRuntimeItemAiPromptInput(context, plan);
const fallbackIntent = buildRuntimeItemAiIntent(context, plan);
const fallbackFingerprint = buildRuntimeItemStoryFingerprint({
context,
plan,
intent: fallbackIntent,
});
const visibilitySlice = buildCarrierVisibilitySlice({
activeThreadIds: context.activeThreadIds,
storyFingerprint: fallbackFingerprint,
});
return [
`物品 ${index + 1}`,
`- slot: ${plan.slot}`,
`- 物品类型: ${promptInput.desiredItemKind}`,
`- 持续性: ${promptInput.permanence}`,
`- 关系锚点: ${describeRelationAnchor(plan.relationAnchor)}`,
`- 世界摘要: ${promptInput.worldSummary}`,
`- 场景摘要: ${promptInput.sceneSummary || '无'}`,
`- 遭遇摘要: ${promptInput.encounterSummary || '无'}`,
`- 相关人物: ${promptInput.relatedNpcSummary}`,
`- 当前激活线程: ${promptInput.activeThreadSummary || '暂无'}`,
`- 近期剧情: ${promptInput.recentStorySummary}`,
`- 玩家当前 build: ${promptInput.playerBuildDirection.join('、') || '均衡'}`,
`- 玩家待补缺口: ${promptInput.playerBuildGaps.join('、') || '无明显缺口'}`,
`- 本次目标方向: ${plan.targetBuildDirection.join('、') || '均衡'}`,
`- 物件可直写线索: ${visibilitySlice.sayableFactIds.map(describeCarrierFactId).join('、') || '无'}`,
`- 物件只宜暗写: ${visibilitySlice.inferredFactIds.map(describeCarrierFactId).join('、') || '无'}`,
].join('\n');
}
export const RUNTIME_ITEM_INTENT_SYSTEM_PROMPT = `你是 AI 原生叙事 RPG 的运行时物品导演。
你只返回 JSON不要输出 Markdown、解释或代码块。
输出结构:
{
"intents": [
{
"shortNameSeed": "中文短种子",
"sourcePhrase": "中文来源短语",
"reasonToAppear": "中文出现理由",
"relationHooks": ["中文关系钩子"],
"desiredBuildTags": ["中文 build 标签"],
"desiredFunctionalBias": ["heal|mana|cooldown|guard|damage"],
"tone": "grim|mysterious|martial|ritual|survival",
"visibleClue": "玩家第一眼能抓到的痕迹",
"witnessMark": "它见证过什么的使用痕",
"unfinishedBusiness": "背后仍未结清的问题",
"hiddenHook": "更深一层但别直接讲穿的钩子",
"reactionHooks": ["以后谁会对它起反应"],
"namingPattern": "命名范式建议"
}
]
}
规则:
- intents 数量必须与输入物品数量完全一致,顺序也必须一致。
- 所有自然语言字段都必须使用中文。
- 物品意图必须贴合当前场景、关系锚点、近期剧情和玩家当前 build。
- visibleClue / witnessMark / unfinishedBusiness / hiddenHook 要优先围绕当前激活线程、相关角色压力和旧痕来写。
- desiredBuildTags 要优先围绕玩家当前 build 与待补缺口,不要写空数组。
- desiredFunctionalBias 只能从给定枚举里选 1 到 2 个。
- reasonToAppear 必须解释为什么这件东西会在当前局势里出现,而不是泛泛描述。`;
export function buildRuntimeItemIntentPrompt(params: {
context: RuntimeItemGenerationContext;
plans: RuntimeItemPlan[];
}) {
return [
`生成渠道:${params.context.generationChannel}`,
`以下每个物品都需要给出一条可编译的运行时物品意图。`,
...params.plans.map((plan, index) => describePlan(params.context, plan, index)),
'请严格返回 JSON。',
].join('\n\n');
}

View File

@@ -1,139 +0,0 @@
import {
getNpcDisclosureStage,
getNpcWarmthStage,
} from '../data/npcInteractions';
import { evaluateQuestOpportunity } from '../data/questFlow';
import type { Encounter, GameState, QuestLogEntry } from '../types';
import type { QuestGenerationContext } from './aiTypes';
import { requestJson } from './apiClient';
import type { QuestPreviewRequest } from './questTypes';
import {
buildFallbackActorNarrativeProfile,
normalizeActorNarrativeProfile,
} from './storyEngine/actorNarrativeProfile';
import { buildThemePackFromWorldProfile } from './storyEngine/themePack';
import { buildFallbackWorldStoryGraph } from './storyEngine/worldStoryGraph';
function resolveIssuerNarrativeProfile(state: GameState, encounter: Encounter) {
if (encounter.narrativeProfile) {
return encounter.narrativeProfile;
}
if (!state.customWorldProfile) {
return null;
}
const role =
state.customWorldProfile.storyNpcs.find(
(npc) => npc.id === encounter.id || npc.name === encounter.npcName,
) ??
state.customWorldProfile.playableNpcs.find(
(npc) => npc.id === encounter.id || npc.name === encounter.npcName,
);
if (!role) {
return null;
}
const themePack =
state.customWorldProfile.themePack ??
buildThemePackFromWorldProfile(state.customWorldProfile);
const storyGraph =
state.customWorldProfile.storyGraph ??
buildFallbackWorldStoryGraph(state.customWorldProfile, themePack);
return normalizeActorNarrativeProfile(
role.narrativeProfile,
buildFallbackActorNarrativeProfile(role, storyGraph, themePack),
);
}
export function buildQuestGenerationContextFromState(params: {
state: GameState;
encounter: Encounter;
}): QuestGenerationContext {
const { state, encounter } = params;
const issuerNpcId = encounter.id ?? encounter.npcName;
const issuerState = state.npcStates[issuerNpcId];
const issuerNarrativeProfile = resolveIssuerNarrativeProfile(
state,
encounter,
);
return {
worldType: state.worldType,
customWorldProfile: state.customWorldProfile ?? null,
actState: state.storyEngineMemory?.actState ?? null,
currentSceneId: state.currentScenePreset?.id ?? null,
currentSceneName: state.currentScenePreset?.name ?? null,
currentSceneDescription: state.currentScenePreset?.description ?? null,
issuerNpcId,
issuerNpcName: encounter.npcName,
issuerNpcContext: encounter.context,
issuerAffinity: issuerState?.affinity ?? 0,
issuerNarrativeProfile,
issuerDisclosureStage: getNpcDisclosureStage(issuerState?.affinity ?? 0),
issuerWarmthStage: getNpcWarmthStage(issuerState?.affinity ?? 0),
activeThreadIds:
state.storyEngineMemory?.activeThreadIds?.slice(0, 4) ??
issuerNarrativeProfile?.relatedThreadIds?.slice(0, 4) ??
[],
encounterKind: encounter.kind ?? 'npc',
currentSceneTreasureHintCount:
state.currentScenePreset?.treasureHints?.length ?? 0,
currentSceneHostileNpcIds: (state.currentScenePreset?.npcs ?? [])
.filter((npc) => Boolean(npc.hostile || npc.monsterPresetId))
.map((npc) => npc.id),
recentStoryMoments: state.storyHistory.slice(-6),
playerCharacter: state.playerCharacter,
playerProgression: state.playerProgression ?? null,
playerHp: state.playerHp,
playerMaxHp: state.playerMaxHp,
playerMana: state.playerMana,
playerMaxMana: state.playerMaxMana,
playerInventory: state.playerInventory,
playerEquipment: state.playerEquipment,
activeCompanions: state.companions,
rosterCompanions: state.roster,
currentQuestSummary: state.quests.map((quest) => ({
id: quest.id,
title: quest.title,
status: quest.status,
issuerNpcId: quest.issuerNpcId,
})),
};
}
export async function generateQuestForNpcEncounter(params: {
state: GameState;
encounter: Encounter;
}): Promise<QuestLogEntry | null> {
const { state, encounter } = params;
const issuerNpcId = encounter.id ?? encounter.npcName;
const request: QuestPreviewRequest = {
issuerNpcId,
issuerNpcName: encounter.npcName,
roleText: encounter.context,
scene: state.currentScenePreset,
worldType: state.worldType,
currentQuests: state.quests.map((quest) => ({
id: quest.id,
issuerNpcId: quest.issuerNpcId,
status: quest.status,
})),
context: buildQuestGenerationContextFromState({ state, encounter }),
origin: 'ai_compiled',
};
const opportunity = evaluateQuestOpportunity(request);
if (!opportunity.shouldOffer) {
return null;
}
return requestJson<QuestLogEntry | null>(
'/api/runtime/quests/generate',
{
method: 'POST',
headers: { 'Content-Type': 'application/json' },
body: JSON.stringify(params),
},
'任务生成失败',
);
}

View File

@@ -1 +0,0 @@
export * from '../prompts/questPrompts';

View File

@@ -1 +0,0 @@
export * from '../prompts/runtimeItemPrompts';

View File

@@ -1,43 +0,0 @@
import { describe, expect, it } from 'vitest';
import { buildContentDependencyGraph } from './contentDependencyGraph';
describe('contentDependencyGraph', () => {
it('connects scenario, campaign, world, companions, and threads', () => {
const graph = buildContentDependencyGraph({
scenarioPack: {
id: 'scenario-1',
title: 'Scenario',
version: '0.1.0',
worldPackIds: ['world-1'],
campaignIds: ['campaign-1'],
sharedConstraintPackIds: [],
},
campaignPack: {
id: 'campaign-1',
scenarioPackId: 'scenario-1',
title: 'Campaign',
authoringStyle: 'classic',
campaignStateSeed: {
id: 'campaign-state',
title: 'Campaign',
currentActId: 'act-1',
currentActIndex: 0,
},
actTemplates: [],
requiredCompanionIds: [],
},
profile: {
id: 'world-1',
name: 'World',
playableNpcs: [{ id: 'npc-1', name: 'A' }],
storyGraph: {
visibleThreads: [{ id: 'thread-1', title: 'T1' }],
},
} as never,
});
expect(graph.nodes.length).toBeGreaterThan(2);
expect(graph.edges.some((edge) => edge.from === 'campaign-1' && edge.to === 'world-1')).toBe(true);
});
});

View File

@@ -1,76 +0,0 @@
import type {
CampaignPack,
CustomWorldProfile,
ScenarioPack,
} from '../../types';
export interface ContentDependencyNode {
id: string;
type: 'scenario' | 'campaign' | 'world' | 'thread' | 'companion' | 'constraint';
label: string;
}
export interface ContentDependencyEdge {
from: string;
to: string;
reason: string;
}
export function buildContentDependencyGraph(params: {
scenarioPack: ScenarioPack;
campaignPack: CampaignPack;
profile: CustomWorldProfile;
}) {
const nodes: ContentDependencyNode[] = [
{
id: params.scenarioPack.id,
type: 'scenario',
label: params.scenarioPack.title,
},
{
id: params.campaignPack.id,
type: 'campaign',
label: params.campaignPack.title,
},
{
id: params.profile.id,
type: 'world',
label: params.profile.name,
},
...params.profile.playableNpcs.map((npc) => ({
id: npc.id,
type: 'companion',
label: npc.name,
} as ContentDependencyNode)),
...(params.profile.storyGraph?.visibleThreads ?? []).map((thread) => ({
id: thread.id,
type: 'thread',
label: thread.title,
} as ContentDependencyNode)),
];
const edges: ContentDependencyEdge[] = [
{
from: params.scenarioPack.id,
to: params.campaignPack.id,
reason: 'scenario contains campaign',
},
{
from: params.campaignPack.id,
to: params.profile.id,
reason: 'campaign depends on world profile',
},
...params.profile.playableNpcs.map((npc) => ({
from: params.campaignPack.id,
to: npc.id,
reason: 'campaign references companion',
})),
...(params.profile.storyGraph?.visibleThreads ?? []).map((thread) => ({
from: params.campaignPack.id,
to: thread.id,
reason: 'campaign advances thread',
})),
];
return { nodes, edges };
}