1
This commit is contained in:
@@ -0,0 +1,117 @@
|
|||||||
|
# 工程死分支清理执行记录 E(2026-04-21)
|
||||||
|
|
||||||
|
更新时间:`2026-04-21`
|
||||||
|
|
||||||
|
## 0. 本批次目标
|
||||||
|
|
||||||
|
本批次承接批次 D,继续清掉已经退出 RPG 游戏创作主流程、RPG 运行时玩法主流程、平台基本功能主流程的历史壳层。
|
||||||
|
|
||||||
|
本批次不处理仍需后端 contract 先收口的对象,例如:
|
||||||
|
|
||||||
|
1. `src/services/questDirector.ts`
|
||||||
|
2. `src/services/runtimeItemAiDirector.ts`
|
||||||
|
3. `src/hooks/rpg-runtime-story/runtimeStoryCoordinator.ts`
|
||||||
|
4. `src/services/apiClient.ts`
|
||||||
|
|
||||||
|
这些对象仍属于“前端越界逻辑继续后端化”的后续批次,不按无引用文件直接删除。
|
||||||
|
|
||||||
|
---
|
||||||
|
|
||||||
|
## 1. 删除判定口径
|
||||||
|
|
||||||
|
本批只删除满足下面条件之一的对象:
|
||||||
|
|
||||||
|
1. 无运行时入口、无脚本入口、无当前路由挂载。
|
||||||
|
2. 已有现行正式实现,旧文件只剩 re-export / facade / 兼容命名。
|
||||||
|
3. 只被测试验证旧壳自身,且该测试不再服务当前主流程门禁。
|
||||||
|
4. 文档已明确该对象处于“后续只允许收缩、不再接新逻辑”的兼容残留状态。
|
||||||
|
|
||||||
|
---
|
||||||
|
|
||||||
|
## 2. 本批次已处理对象
|
||||||
|
|
||||||
|
| 文件 | 判定 | 删除原因 | 替代路径 / 当前真相源 |
|
||||||
|
| --- | --- | --- | --- |
|
||||||
|
| `server-node/src/routes/rpgCreationAgentRoutes.ts` | 旧命名 re-export | 当前后端正式路由直接使用 `customWorldAgent.ts` | `server-node/src/routes/customWorldAgent.ts` |
|
||||||
|
| `server-node/src/routes/rpgWorldGalleryRoutes.ts` | 空路由骨架 | 世界广场实际列表和详情已经进入世界库路由 | `server-node/src/routes/rpg-entry/rpgWorldLibraryRoutes.ts` |
|
||||||
|
| `server-node/src/services/RpgAgentOrchestrator.ts` | 旧命名 re-export | 当前正式上下文直接使用 `CustomWorldAgentOrchestrator` | `server-node/src/services/customWorldAgentOrchestrator.ts` |
|
||||||
|
| `server-node/src/services/RpgAgentSessionStore.ts` | 旧命名 re-export | 当前正式上下文直接使用 `CustomWorldAgentSessionStore` | `server-node/src/services/customWorldAgentSessionStore.ts` |
|
||||||
|
| `server-node/src/services/customWorldWorkSummaryService.ts` | 旧兼容入口 | 测试和路由已改为直接使用 RPG 命名服务 | `server-node/src/services/RpgWorldWorkSummaryService.ts` |
|
||||||
|
| `server-node/src/services/customWorldAgentPublishGateService.ts` | 旧发布门禁实现 | 当前 action executor 与作品库发布链已统一走 PublishingService | `server-node/src/services/customWorldAgentPublishingService.ts` |
|
||||||
|
| `server-node/src/services/customWorldAgentPublishService.ts` | 旧发布实现 | 当前发布链不再编译旧 legacy result profile | `server-node/src/services/customWorldAgentPublishingService.ts` |
|
||||||
|
| `server-node/src/modules/custom-world/runtime-profile/runtimeProfileCompiler.ts` | 旧 facade | runtime profile 已拆到目录模块并由 `index.ts` / `runtimeProfile.ts` 承接 | `server-node/src/modules/custom-world/runtime-profile/index.ts` |
|
||||||
|
| `server-node/src/bridges/legacyBuildRuntimeBridge.ts` | 无引用旧桥 | 后端 runtime build / equipment 已直接在正式模块内使用 | `server-node/src/modules/runtime/**` |
|
||||||
|
| `server-node/src/bridges/legacyRuntimeItemResolutionBridge.ts` | 旧桥 | runtime item 解析服务一并删除,正式运行时使用 `runtimeItemModule.ts` | `server-node/src/modules/runtime-item/runtimeItemModule.ts` |
|
||||||
|
| `server-node/src/modules/runtime-item/runtimeItemResolutionService.ts` | 无正式入口 wrapper | 只被 barrel 和自身测试引用,未挂入 Express 运行时主链 | `server-node/src/modules/runtime-item/runtimeItemModule.ts` |
|
||||||
|
| `server-node/src/modules/**/index.ts` | 无引用 barrel | 这些 barrel 没有被当前后端入口消费,反而制造“公共模块入口仍存在”的错觉 | 直接 import 具体正式模块 |
|
||||||
|
| `server-node/src/routes/rpg-*/index.ts` | 无引用 barrel | 当前 Express app 直接 import 具体 route 文件 | `server-node/src/app.ts` 中的具体路由 |
|
||||||
|
| `server-node/src/repositories/rpg-*/index.ts` | 无引用 barrel | 当前上下文直接 import 具体 repository | `server-node/src/server.ts` 中的具体仓储 |
|
||||||
|
| `src/components/DeveloperTeamModal.tsx` | 无入口 UI | 平台主流程没有打开该弹窗的入口 | 无替代 UI,删除历史壳 |
|
||||||
|
| `src/components/LazySkillEffectPreview.tsx` | 无入口 lazy 壳 | 正式技能预览直接使用 `SkillEffectPreview` | `src/components/SkillEffectPreview.tsx` |
|
||||||
|
| `src/components/npcVisualEditorModel.ts` | 旧 NPC 形象写回模型 | 当前 RPG 创作编辑器使用 `CustomWorldNpcVisualEditor` 与结果页新入口 | `src/components/CustomWorldNpcVisualEditor.tsx`、`src/components/rpg-creation-editor/**` |
|
||||||
|
| `src/components/npcVisualEditorPersistence.ts` | 旧 NPC 形象写回持久层 | 只被旧持久化测试引用,正式编辑入口已迁移 | `src/components/rpg-creation-editor/**` |
|
||||||
|
| `src/components/rpg-creation-*/index.ts` | 无引用 barrel | 当前入口直接 import 具体 facade 文件,barrel 没有主流程消费 | 直接 import `RpgCreation*` 具体文件 |
|
||||||
|
| `src/components/rpg-creation-editor/CustomWorldSceneChapterEditorSection.tsx` | 旧 facade | 当前编辑器 section 直接在 `RpgCreationEntityEditorShared.tsx` 中分发 | `src/components/rpg-creation-editor/RpgCreationEntityEditorShared.tsx` |
|
||||||
|
| `src/data/editorValidation.ts` | 旧预设编辑器校验 | 当前主流程和内容门禁不再调用 | `scripts/validate-overrides.ts`、后端 editor API |
|
||||||
|
| `src/editor/shared/EditorNotice.tsx` | 无入口共享 UI | 只被同批删除的 FormFields 使用 | 无替代 UI,删除历史编辑器壳 |
|
||||||
|
| `src/editor/shared/FormFields.tsx` | 无入口共享 UI | 旧编辑器共享表单未接主流程 | 当前 RPG 编辑器组件内聚在 `rpg-creation-editor/**` |
|
||||||
|
| `src/editor/shared/SectionCard.tsx` | 无入口共享 UI | 旧编辑器卡片未接主流程 | 当前 RPG 编辑器组件内聚在 `rpg-creation-editor/**` |
|
||||||
|
| `src/hooks/rpg-runtime-story/npcEncounterActions.ts` | 旧 wrapper | 正式实现已在 `useRpgRuntimeNpcInteraction.ts`,测试已改到正式文件 | `src/hooks/rpg-runtime-story/useRpgRuntimeNpcInteraction.ts` |
|
||||||
|
| `src/hooks/rpg-runtime-story/openingAdventure.ts` | 旧前端开局特殊流程 | 开局营地对白已由后端 `RpgRuntimeStoryActionDomain` 和当前 story context 承接 | `server-node/src/modules/rpg-runtime-story/RpgRuntimeStoryActionDomain.ts` |
|
||||||
|
| `src/hooks/rpg-runtime-story/storyCampCompanion.ts` | 旧前端营地同伴 helper | 只剩旧开局流程和自身测试引用,正式开局上下文已迁到当前 runtime story 链 | 后端 runtime story action domain 与 `storyContextBuilder.ts` |
|
||||||
|
| `src/hooks/rpg-runtime-story/storyRenderingHelpers.ts` | 无入口旧渲染 helper | 当前正式 story presentation 不再 import | `src/hooks/rpg-runtime-story/storyPresentation.ts` |
|
||||||
|
| `src/prompts/questPrompts.ts` | 前端 prompt 残留 | Quest prompt 真相已迁到后端 | `server-node/src/prompts/questPrompts.ts` |
|
||||||
|
| `src/prompts/runtimeItemPrompts.ts` | 前端 prompt 残留 | Runtime item prompt 真相已迁到后端 | `server-node/src/prompts/runtimeItemPrompts.ts` |
|
||||||
|
| `src/services/questPrompt.ts` | 前端 prompt re-export | 只指向同批删除的前端 prompt | `server-node/src/prompts/questPrompts.ts` |
|
||||||
|
| `src/services/runtimeItemAiPrompt.ts` | 前端 prompt re-export | 只指向同批删除的前端 prompt | `server-node/src/prompts/runtimeItemPrompts.ts` |
|
||||||
|
| `src/services/storyEngine/contentDependencyGraph.ts` | 实验性孤岛 | 只被自身测试引用,没有主流程消费 | 后续如需要重新设计到后端 story graph 服务 |
|
||||||
|
|
||||||
|
---
|
||||||
|
|
||||||
|
## 3. 同步调整
|
||||||
|
|
||||||
|
1. `customWorldAgentPhase2/3/4` 测试改为直接实例化 `RpgWorldWorkSummaryService`。
|
||||||
|
2. `customWorldWorkSummaryService.integration.test.ts` 改为直接覆盖 `RpgWorldWorkSummaryService`。
|
||||||
|
3. `npcEncounterActions.test.ts` 改为直接覆盖 `useRpgRuntimeNpcInteraction.ts`,不再通过旧 wrapper。
|
||||||
|
4. `story_opening_camp_dialogue` 的 function catalog 执行路径改为后端 runtime action domain,不再指向已删除旧前端文件。
|
||||||
|
5. NPC function catalog 中 `npc_chat / npc_help / npc_leave / npc_fight / npc_spar / npc_preview_talk` 的 executor 路牌改到现行 `useRpgRuntimeNpcInteraction.ts`。
|
||||||
|
|
||||||
|
---
|
||||||
|
|
||||||
|
## 4. 本批次暂缓对象
|
||||||
|
|
||||||
|
以下对象仍然保留,原因是它们不是“无引用死代码”,而是需要下一轮按 contract 或主链职责迁移:
|
||||||
|
|
||||||
|
1. `src/services/questDirector.ts`
|
||||||
|
2. `src/services/runtimeItemAiDirector.ts`
|
||||||
|
3. `src/services/ai.ts`
|
||||||
|
4. `src/data/sceneObservation.ts`
|
||||||
|
5. `server-node/ecosystem.config.cjs`
|
||||||
|
6. `server-node/src/scripts/syncCustomWorldSavedProfileAssets.ts`
|
||||||
|
|
||||||
|
其中 `ecosystem.config.cjs` 被部署脚本直接使用;`sceneObservation.ts` 被内容 smoke 脚本验证;`syncCustomWorldSavedProfileAssets.ts` 是一次性运维脚本,后续要单独按运维脚本治理口径确认是否归档。
|
||||||
|
|
||||||
|
---
|
||||||
|
|
||||||
|
## 5. 验证口径
|
||||||
|
|
||||||
|
本批删除后建议验证:
|
||||||
|
|
||||||
|
1. `npm run check:encoding`
|
||||||
|
2. `npx tsx --test server-node/src/services/customWorldWorkSummaryService.integration.test.ts`
|
||||||
|
3. `npx vitest run src/hooks/rpg-runtime-story/npcEncounterActions.test.ts`
|
||||||
|
4. `npm run server-node:build`
|
||||||
|
5. `npm run build`
|
||||||
|
|
||||||
|
如果 `npm run build` 仍被既有 chunk warning 拦截,需要单独记录为既有门禁问题,不归因到本批删除。
|
||||||
|
|
||||||
|
---
|
||||||
|
|
||||||
|
## 6. 当前结论
|
||||||
|
|
||||||
|
本批次进一步删除了“旧命名入口、旧 facade、旧 prompt 前端镜像、无入口编辑器壳层”这批容易误导后续开发的文件。
|
||||||
|
|
||||||
|
后续清理不应继续按“静态无引用”直接推进,而应进入两类工作:
|
||||||
|
|
||||||
|
1. 运行时 / 任务 / 物品 / AI 的后端 contract 收口。
|
||||||
|
2. RPG 创作编辑器与运行时热点文件的职责拆分。
|
||||||
@@ -0,0 +1,91 @@
|
|||||||
|
# 工程死分支清理执行记录 F(2026-04-21)
|
||||||
|
|
||||||
|
更新时间:`2026-04-21`
|
||||||
|
|
||||||
|
## 0. 本批次目标
|
||||||
|
|
||||||
|
本批次承接批次 E 的验证结果,继续处理删除后暴露出的最后一组高置信残留:
|
||||||
|
|
||||||
|
1. 已经没有任何代码入口引用的前端任务生成 director。
|
||||||
|
2. 只被内容 smoke 牵住、但不再是正式运行时入口的旧观察文案 helper。
|
||||||
|
3. 带有固定用户、固定 session、固定 profile 的一次性历史同步脚本。
|
||||||
|
4. 清理后暴露出的 function catalog 契约覆盖缺口。
|
||||||
|
|
||||||
|
本批次仍然不按文件名直接删除 `legacy` 命名对象。经核对,`server-node/src/bridges/legacyInventoryRuntimeBridge.ts`、`legacyNpcTask6Bridge.ts`、`legacyQuestProgressBridge.ts`、`legacyQuestRuntimeBridge.ts`、`legacyRuntimeItemBridge.ts`、`legacyTreasureRuntimeBridge.ts` 仍被后端战斗、背包、任务、宝藏主链直接引用,不能按历史命名硬删。
|
||||||
|
|
||||||
|
---
|
||||||
|
|
||||||
|
## 1. 删除判定口径
|
||||||
|
|
||||||
|
本批删除对象必须同时满足:
|
||||||
|
|
||||||
|
1. 修正 `.js -> .ts` 后端源码解析、前端懒加载入口解析后,仍不可从正式入口到达。
|
||||||
|
2. 全仓库代码引用扫描没有正式入口引用。
|
||||||
|
3. 如只被 smoke 或测试牵住,先把 smoke / 测试改到当前正式主链,再删除旧对象。
|
||||||
|
4. 删除后通过对应门禁验证,没有新增悬空 import。
|
||||||
|
|
||||||
|
---
|
||||||
|
|
||||||
|
## 2. 本批次已处理对象
|
||||||
|
|
||||||
|
| 文件 | 判定 | 删除 / 调整原因 | 替代路径 / 当前真相源 |
|
||||||
|
| --- | --- | --- | --- |
|
||||||
|
| `src/services/questDirector.ts` | 无代码入口残留 | 正式 quest 生成已由后端 `/api/runtime/quests/generate` 与 `questService.ts` 承接,前端当前没有任何 import | `server-node/src/services/questService.ts`、`server-node/src/modules/quest/runtimeQuestModule.ts` |
|
||||||
|
| `src/data/sceneObservation.ts` | 旧观察文案 helper | 只被 `scripts/smoke-content.ts` 引用,正式观察动作已走 `idle_observe_signs` function 与运行时 story continuation | `src/data/functionCatalog/state/idleObserveSigns.ts`、`src/hooks/rpg-runtime-story/storyChoiceContinuation.ts` |
|
||||||
|
| `server-node/src/scripts/syncCustomWorldSavedProfileAssets.ts` | 一次性硬编码运维脚本 | 脚本内固定用户、session、profile,只服务历史补丁,没有 CLI 参数和当前运维入口 | 无替代;如未来需要,按参数化运维脚本重新设计 |
|
||||||
|
|
||||||
|
---
|
||||||
|
|
||||||
|
## 3. 同步调整
|
||||||
|
|
||||||
|
1. `scripts/smoke-content.ts` 不再 import 旧 `sceneObservation.ts`,改为通过 `resolveFunctionOption('idle_observe_signs', ...)` 验证当前正式 function 目录。
|
||||||
|
2. `packages/shared/src/contracts/rpgRuntimeContracts.test.ts` 不再验证已移除的旧 `story` façade,改为直接验证当前拆分契约。
|
||||||
|
3. `src/data/functionCatalog/` 补齐仍在后端运行时契约中的 function 文档:
|
||||||
|
- `battle_attack_basic`
|
||||||
|
- `battle_use_skill`
|
||||||
|
- `npc_chat_quest_offer_view`
|
||||||
|
- `npc_chat_quest_offer_replace`
|
||||||
|
- `npc_chat_quest_offer_abandon`
|
||||||
|
4. `battle_attack_basic` 与 `battle_use_skill` 只作为后端契约文档登记,不进入 `STATE_FUNCTION_DEFINITIONS`,避免前端本地候选池生成缺少 `runtimePayload.skillId` 的假技能 option。
|
||||||
|
|
||||||
|
---
|
||||||
|
|
||||||
|
## 4. 本批次暂缓对象
|
||||||
|
|
||||||
|
以下对象经本批复核后继续保留:
|
||||||
|
|
||||||
|
1. `server-node/src/services/customWorldAgentRepositoryTestHelpers.ts`
|
||||||
|
2. `server-node/src/services/customWorldAgentTestHelpers.ts`
|
||||||
|
3. `server-node/src/testFixtures/runtimeCharacter.ts`
|
||||||
|
4. `server-node/src/testHttp.ts`
|
||||||
|
|
||||||
|
这些文件不属于正式运行时入口,但当前被后端测试、smoke 与路由边界门禁使用。它们不是 RPG 创作 / 运行时玩法主流程代码,但仍是平台基本质量门禁的一部分,不能在“删除冗余业务代码”批次里直接硬删。
|
||||||
|
|
||||||
|
另保留:
|
||||||
|
|
||||||
|
1. `src/services/runtimeItemAiDirector.ts`
|
||||||
|
2. `src/services/ai.ts`
|
||||||
|
3. `src/services/apiClient.ts`
|
||||||
|
|
||||||
|
这些文件仍被当前主链或前端 SDK 入口引用,后续如继续压缩,必须先完成对应 contract / SDK 拆分,不按无引用规则删除。
|
||||||
|
|
||||||
|
---
|
||||||
|
|
||||||
|
## 5. 验证结果
|
||||||
|
|
||||||
|
本批已通过:
|
||||||
|
|
||||||
|
1. `npx vitest run src/data/functionCatalog/functionCatalog.test.ts packages/shared/src/contracts/rpgRuntimeContracts.test.ts`
|
||||||
|
2. `npx tsx scripts/smoke-content.ts`
|
||||||
|
3. `npm run check:encoding`
|
||||||
|
|
||||||
|
并额外确认:
|
||||||
|
|
||||||
|
1. 全仓库代码中不再引用 `sceneObservation`、`questDirector`、`syncCustomWorldSavedProfileAssets`。
|
||||||
|
2. `buildStateFunctionDefinitions()` 中不会出现 `battle_attack_basic` / `battle_use_skill`,这两个 function 只由后端运行时 option 池下发。
|
||||||
|
|
||||||
|
---
|
||||||
|
|
||||||
|
## 6. 当前结论
|
||||||
|
|
||||||
|
本批次后,静态入口扫描中剩余的高置信“不可达源码”已经收敛为测试辅助、测试夹具和 smoke helper。继续删除前需要先重构测试基础设施或迁移剩余前端 SDK,而不应再按文件名或历史命名直接硬删。
|
||||||
@@ -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/平台入口壳层迁移。
|
||||||
|
|||||||
@@ -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`。_
|
||||||
|
|||||||
@@ -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 时,建议先补独立脚本,再把运行时调用接进来,最后同步这份目录文档。
|
||||||
|
|||||||
@@ -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`,但入口没挂鉴权”的低层断线问题。
|
||||||
|
|||||||
@@ -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: {},
|
||||||
|
|||||||
@@ -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}`);
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
|
|||||||
@@ -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';
|
|
||||||
@@ -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';
|
|
||||||
@@ -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';
|
|
||||||
@@ -1 +0,0 @@
|
|||||||
export * from './inventoryMutationService.js';
|
|
||||||
@@ -1,2 +0,0 @@
|
|||||||
export * from './questProgressionService.js';
|
|
||||||
export { generateQuestForNpcEncounter } from '../../services/questService.js';
|
|
||||||
@@ -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';
|
|
||||||
@@ -1,2 +0,0 @@
|
|||||||
export * from './runtimeItemResolutionService.js';
|
|
||||||
export { generateRuntimeItemIntents } from '../../services/runtimeItemService.js';
|
|
||||||
@@ -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);
|
|
||||||
});
|
|
||||||
@@ -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);
|
|
||||||
}
|
|
||||||
@@ -1,8 +0,0 @@
|
|||||||
export {
|
|
||||||
RpgSaveArchiveRepository,
|
|
||||||
type RpgSaveArchiveRepositoryPort,
|
|
||||||
} from './RpgSaveArchiveRepository.js';
|
|
||||||
export {
|
|
||||||
RpgWorldLibraryRepository,
|
|
||||||
type RpgWorldLibraryRepositoryPort,
|
|
||||||
} from './RpgWorldLibraryRepository.js';
|
|
||||||
@@ -1,8 +0,0 @@
|
|||||||
export {
|
|
||||||
RpgBrowseHistoryRepository,
|
|
||||||
type RpgBrowseHistoryRepositoryPort,
|
|
||||||
} from './RpgBrowseHistoryRepository.js';
|
|
||||||
export {
|
|
||||||
RpgProfileDashboardRepository,
|
|
||||||
type RpgProfileDashboardRepositoryPort,
|
|
||||||
} from './RpgProfileDashboardRepository.js';
|
|
||||||
@@ -1,4 +0,0 @@
|
|||||||
export {
|
|
||||||
RpgRuntimeSnapshotRepository,
|
|
||||||
type RpgRuntimeSnapshotRepositoryPort,
|
|
||||||
} from './RpgRuntimeSnapshotRepository.js';
|
|
||||||
@@ -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',
|
||||||
|
|||||||
@@ -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';
|
|
||||||
@@ -1,4 +0,0 @@
|
|||||||
export {
|
|
||||||
createRpgProfileRoutes,
|
|
||||||
RPG_PROFILE_ROUTE_BASE_PATH,
|
|
||||||
} from './rpgProfileRoutes.js';
|
|
||||||
@@ -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';
|
|
||||||
@@ -1 +0,0 @@
|
|||||||
export { createCustomWorldAgentRoutes as createRpgCreationAgentRoutes } from './customWorldAgent.js';
|
|
||||||
@@ -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();
|
|
||||||
}
|
|
||||||
@@ -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);
|
|
||||||
});
|
|
||||||
@@ -1 +0,0 @@
|
|||||||
export { CustomWorldAgentOrchestrator as RpgAgentOrchestrator } from './customWorldAgentOrchestrator.js';
|
|
||||||
@@ -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';
|
|
||||||
@@ -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,
|
||||||
);
|
);
|
||||||
|
|||||||
@@ -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(
|
||||||
(
|
(
|
||||||
|
|||||||
@@ -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}`,
|
||||||
|
|||||||
@@ -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,
|
|
||||||
};
|
|
||||||
}
|
|
||||||
}
|
|
||||||
@@ -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,
|
|
||||||
};
|
|
||||||
}
|
|
||||||
}
|
|
||||||
@@ -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'),
|
||||||
|
|||||||
@@ -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);
|
|
||||||
}
|
|
||||||
@@ -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>
|
|
||||||
);
|
|
||||||
}
|
|
||||||
@@ -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>
|
|
||||||
);
|
|
||||||
}
|
|
||||||
@@ -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),
|
|
||||||
};
|
|
||||||
}
|
|
||||||
@@ -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('角色布局');
|
|
||||||
});
|
|
||||||
});
|
|
||||||
@@ -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: '已保存共享角色布局配置。',
|
|
||||||
};
|
|
||||||
}
|
|
||||||
@@ -1,4 +0,0 @@
|
|||||||
export {
|
|
||||||
RpgCreationRoleAssetStudioModal,
|
|
||||||
type RpgCreationRoleAssetStudioModalProps,
|
|
||||||
} from './RpgCreationRoleAssetStudioModal';
|
|
||||||
@@ -1 +0,0 @@
|
|||||||
export { SectionPanel as default } from './RpgCreationEntityEditorShared';
|
|
||||||
@@ -1,4 +0,0 @@
|
|||||||
export {
|
|
||||||
RpgCreationEntityEditorModal,
|
|
||||||
type RpgCreationEntityEditorModalProps,
|
|
||||||
} from './RpgCreationEntityEditorModal';
|
|
||||||
@@ -1,4 +0,0 @@
|
|||||||
export {
|
|
||||||
RpgCreationResultView,
|
|
||||||
type RpgCreationResultViewProps,
|
|
||||||
} from './RpgCreationResultView';
|
|
||||||
@@ -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();
|
||||||
|
|||||||
@@ -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) {
|
||||||
|
|||||||
@@ -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;
|
|
||||||
}
|
|
||||||
@@ -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,直接进入对白流。',
|
||||||
|
|||||||
@@ -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';
|
||||||
|
|||||||
@@ -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,
|
||||||
|
|||||||
@@ -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。',
|
||||||
|
|||||||
86
src/data/functionCatalog/npc/npcChatQuestOffer.ts
Normal file
86
src/data/functionCatalog/npc/npcChatQuestOffer.ts
Normal 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: '暂时不接',
|
||||||
|
},
|
||||||
|
};
|
||||||
@@ -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,直接进入战斗。',
|
||||||
|
|||||||
@@ -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,直接获得帮助反馈。',
|
||||||
|
|||||||
@@ -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,直接退出互动。',
|
||||||
|
|||||||
@@ -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:
|
||||||
|
|||||||
@@ -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,直接切磋。',
|
||||||
|
|||||||
35
src/data/functionCatalog/state/battleAttackBasic.ts
Normal file
35
src/data/functionCatalog/state/battleAttackBasic.ts
Normal 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: '直接攻击眼前敌人',
|
||||||
|
},
|
||||||
|
};
|
||||||
36
src/data/functionCatalog/state/battleUseSkill.ts
Normal file
36
src/data/functionCatalog/state/battleUseSkill.ts
Normal 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: '释放一个指定技能',
|
||||||
|
},
|
||||||
|
};
|
||||||
@@ -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),
|
||||||
|
];
|
||||||
|
|
||||||
|
|||||||
@@ -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}`;
|
|
||||||
}
|
|
||||||
@@ -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>;
|
|
||||||
}
|
|
||||||
@@ -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>
|
|
||||||
);
|
|
||||||
}
|
|
||||||
@@ -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>
|
|
||||||
);
|
|
||||||
}
|
|
||||||
@@ -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 {
|
||||||
|
|||||||
@@ -1,6 +0,0 @@
|
|||||||
export {
|
|
||||||
createRpgRuntimeNpcEncounterActions as createStoryNpcEncounterActions,
|
|
||||||
useRpgRuntimeNpcInteraction,
|
|
||||||
type RpgRuntimeNpcInteractionResult,
|
|
||||||
type UseRpgRuntimeNpcInteractionParams,
|
|
||||||
} from './useRpgRuntimeNpcInteraction';
|
|
||||||
@@ -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);
|
|
||||||
}
|
|
||||||
}
|
|
||||||
@@ -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',
|
|
||||||
]);
|
|
||||||
});
|
|
||||||
});
|
|
||||||
@@ -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,
|
|
||||||
};
|
|
||||||
}
|
|
||||||
@@ -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;
|
|
||||||
}
|
|
||||||
@@ -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');
|
|
||||||
}
|
|
||||||
@@ -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');
|
|
||||||
}
|
|
||||||
@@ -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),
|
|
||||||
},
|
|
||||||
'任务生成失败',
|
|
||||||
);
|
|
||||||
}
|
|
||||||
@@ -1 +0,0 @@
|
|||||||
export * from '../prompts/questPrompts';
|
|
||||||
@@ -1 +0,0 @@
|
|||||||
export * from '../prompts/runtimeItemPrompts';
|
|
||||||
@@ -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);
|
|
||||||
});
|
|
||||||
});
|
|
||||||
@@ -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 };
|
|
||||||
}
|
|
||||||
Reference in New Issue
Block a user