diff --git a/docs/audits/README.md b/docs/audits/README.md index 75db7b50..af0182cb 100644 --- a/docs/audits/README.md +++ b/docs/audits/README.md @@ -15,6 +15,7 @@ - [FUNCTION_RUNTIME_FULL_TEST_AUDIT_2026-04-16.md](./FUNCTION_RUNTIME_FULL_TEST_AUDIT_2026-04-16.md):Function 运行时完整测试、服务端承接验证与当前门禁缺口。 - [ITEM_AND_BUILD_PRD_AUDIT_2026-04-05.md](./ITEM_AND_BUILD_PRD_AUDIT_2026-04-05.md):物品生成与 Build 标签系统对 PRD 的落地情况。 - [CUSTOM_WORLD_CREATOR_TOOL_AUDIT_2026-04-08.md](./CUSTOM_WORLD_CREATOR_TOOL_AUDIT_2026-04-08.md):自定义世界创作工具当前问题、体验断层和优化优先级审计。 +- [engineering/ENGINEERING_CLEANUP_AND_BACKEND_BOUNDARY_AUDIT_2026-04-20.md](./engineering/ENGINEERING_CLEANUP_AND_BACKEND_BOUNDARY_AUDIT_2026-04-20.md):对 `2026-04-19` 工程清理审计的当前仓库复核,区分已完成项、仍存边界问题和新的热点迁移。 - [engineering/ENGINEERING_CLEANUP_AND_BACKEND_BOUNDARY_AUDIT_2026-04-19.md](./engineering/ENGINEERING_CLEANUP_AND_BACKEND_BOUNDARY_AUDIT_2026-04-19.md):未引用垃圾、旧入口残留、前后端双份真相与后端迁移项的专项审计。 ## 推荐使用方式 diff --git a/docs/audits/engineering/ENGINEERING_CLEANUP_AND_BACKEND_BOUNDARY_AUDIT_2026-04-20.md b/docs/audits/engineering/ENGINEERING_CLEANUP_AND_BACKEND_BOUNDARY_AUDIT_2026-04-20.md new file mode 100644 index 00000000..a7538437 --- /dev/null +++ b/docs/audits/engineering/ENGINEERING_CLEANUP_AND_BACKEND_BOUNDARY_AUDIT_2026-04-20.md @@ -0,0 +1,384 @@ +# 工程清理与后端边界复核审计(2026-04-20) + +更新时间:`2026-04-20` + +## 0. 审计目标 + +这份文档不是重复 `2026-04-19` 的原始扫描,而是基于当前仓库状态做一轮复核,重点回答三个问题: + +1. 昨天审计里已经提出的问题,哪些今天已经真正落地。 +2. 哪些结论在当前代码里仍然成立,哪些表述需要纠正。 +3. 当前工程热点和边界问题有没有发生迁移。 + +--- + +## 1. 结论先行 + +和 `2026-04-19` 那份基线相比,当前仓库已经有一批明确进展: + +1. **旧 Vite 本地 API 链路已经真正出清。** + `scripts/dev-server/` 当前只剩一份 `README.md`,旧的 `localApiPlugins.ts`、角色资产插件、精灵表插件都不在仓库里了。 +2. **根目录噪音产物已经清理完成。** + 当前根目录临时日志/扫描产物扫描结果为空,`temp-build-goal-check/` 也不存在。 +3. **`server-node -> src/**` 反向依赖已经收掉。** + 当前复核没有再发现 `server-node/src/**` 直接 import 前端 `src/**` 的情况。 +4. **runtime option interaction 已经收口成后端单一真相。** + 这部分现在由 `server-node/src/modules/story/runtimeSession.ts` 统一构造,前端 `src/services/runtimeStoryService.ts` 不再本地再建一份映射表。 + +但这不代表边界问题已经结束,当前剩余问题主要集中在三块: + +1. **前端仍保留运行时镜像与登录凭证本地真相。** + `runtimeStoryCoordinator.ts` 仍会先写本地快照,`apiClient.ts` 仍把 token/自动登录凭证放在 `localStorage`。 +2. **NPC 聊天任务链路还没有完全后端化。** + “聊天后挂出待接委托”已经移到后端,但“更换待接委托”这条分支仍由前端 `npcEncounterActions.ts` 触发 `generateQuestForNpcEncounter(...)`。 +3. **未接线孤岛和热点文件问题仍然明显。** + 一批 UI/Hook/Prompt 残留模块还没有正式入口;同时热点已经从已删除的旧插件链路,转移到 `CustomWorldEntityEditorModal.tsx`、`storyPromptBuilders.ts`、`runtimeProfile.ts`、`PreGameSelectionFlow.tsx`、`PlatformHomeView.tsx` 等新中心。 + +一句话判断: + +**当前仓库已经完成“清垃圾、拆旧入口、切断后端反向依赖”的第一阶段,但还没有完成“前端退出运行时真相”和“未接线孤岛归档”的第二阶段。** + +--- + +## 2. 已完成项复核 + +## 2.1 旧 dev-server 链路已经不是“逻辑上废弃”,而是“代码上删除” + +### 当前证据 + +| 项目 | 当前状态 | 结论 | +| --- | --- | --- | +| `scripts/dev-server/` | 当前只剩 `README.md` 一份说明文件 | 旧 Vite 本地 API 链路已从仓库代码层出清 | +| `scripts/dev-server/README.md` | 已明确声明当前正式入口为 `scripts/dev-node.mjs + server-node/src/modules/**` | 文档与代码状态一致 | + +### 结论 + +`2026-04-19` 文档里关于旧本地 API 插件链路的清理结论,在当前仓库里已经可以确认成立,不再只是“计划删除”。 + +--- + +## 2.2 根目录噪音产物已经从当前工作区移除 + +### 当前证据 + +| 项目 | 当前状态 | 结论 | +| --- | --- | --- | +| 根目录历史日志/扫描产物 | 本轮扫描结果为空 | 之前的 `.codex-*.log`、`tmp_*`、旧截图/HTML 不再占据当前工作区 | +| `temp-build-goal-check/` | 当前不存在 | 大体量检查产物已移出当前仓库视野 | + +### 结论 + +`2026-04-19` 文档中关于“仓库噪音产物”的问题,在当前工作区层面已经完成首轮治理。 +这部分不再是当前工程第一优先级。 + +--- + +## 2.3 `server-node -> src/**` 反向依赖已清零 + +### 当前证据 + +本轮用脚本复核 `server-node/src/**` 中所有 `import` 后,当前结果为: + +`NO_DIRECT_SERVER_TO_FRONTEND_SRC_IMPORTS` + +同时,仓库里已经看不到类似下面这类旧反向依赖: + +1. `server-node -> src/services/customWorld.js` +2. `server-node -> src/services/customWorldBuilder.js` +3. `server-node -> src/services/customWorldCreatorIntent.js` +4. `server-node -> src/types.js` + +### 结论 + +`2026-04-19` 文档里“清理 `server-node -> src/**` 反向依赖”的阶段性目标,在当前仓库里已经真正落地。 + +--- + +## 2.4 runtime option interaction 已经收口到后端 + +### 当前证据 + +1. `server-node/src/modules/story/runtimeSession.ts` 当前仍保留 `buildOptionInteraction(...)`,负责构造: + - `npcActionMap` + - `treasureActionMap` +2. `src/services/runtimeStoryService.ts` 当前只做: + - 直接读取 `option.interaction` + - 把后端返回的 interaction 投影成 `StoryOption` +3. 前端文件里已经找不到旧的 `buildRuntimeOptionInteraction` / `npcActionMap` / `treasureActionMap` 实现。 + +### 结论 + +这项收口已经成立,当前不会再出现“前后端各维护一份 interaction 映射表”的旧问题。 + +--- + +## 2.5 浏览器端的 quest/runtime item 本地 LLM fallback 已移除 + +### 当前证据 + +1. `src/services/questDirector.ts` + - 浏览器路径先请求 `/api/runtime/quests/generate` + - 后端失败时只走 deterministic fallback compile +2. `src/services/runtimeItemAiDirector.ts` + - 浏览器路径先请求 `/api/runtime/items/runtime-intent` + - 后端失败时只返回 deterministic fallback intents +3. 这两个文件虽然仍保留 `requestChatMessageContent(...)` 分支,但那是非浏览器分支,不再是浏览器端正式兜底链路。 + +### 结论 + +`2026-04-19` 文档里关于“浏览器本地 LLM fallback”这部分,当前应更新为: + +**浏览器端本地 LLM fallback 已移除,但这两个模块仍然是双环境混合实现,还没有彻底后端化。** + +--- + +## 3. 需要纠正的旧文档表述 + +## 3.1 NPC 任务链路不是“全部后端化”,而是“挂单已后移、换单仍前触发” + +### 需要纠正的点 + +`2026-04-19` 文档中的回填里有一条表述是: + +“`src/hooks/story/npcEncounterActions.ts` 不再在 NPC 单轮聊天完成后本地调用 `generateQuestForNpcEncounter(...)` 再决定是否挂出待接委托。” + +### 当前代码状态 + +这句话对“聊天后挂出待接委托”这条主链是成立的,因为当前后端 `server-node/src/modules/ai/chatOrchestrator.ts` 已经会回填 `pendingQuestOffer`。 + +但它对整条 NPC 任务链路来说并不完整,因为当前前端仍保留这条分支: + +1. `src/hooks/story/npcEncounterActions.ts` +2. `replacePendingNpcQuestOffer()` +3. `generateQuestForNpcEncounter(...)` + +也就是: + +**待接委托的“正式挂出”已后端化,但“更换委托”仍然由前端动作流发起。** + +### 当前应改成的结论 + +更准确的描述应该是: + +1. NPC 单轮聊天里“是否挂出待接委托”的决定权已收回后端。 +2. 但待接委托的“换单/重抽”分支仍通过前端 `npcEncounterActions.ts -> questDirector.ts` 发起。 + +--- + +## 4. 当前仍然成立的遗留问题 + +## 4.1 未接线/仅测试引用孤岛模块仍然明显 + +本轮依赖图复核后,当前仍能确认一批高置信度孤岛模块: + +| 模块 | 当前状态 | 说明 | +| --- | --- | --- | +| `src/components/GameShell.tsx` | `765` 行,无运行时引用 | 旧版壳层残留仍在 | +| `src/components/custom-world-home/CustomWorldCreationHub.tsx` | `161` 行,仅测试引用 | UI 已有完成度,但仍未进入正式入口 | +| `src/components/custom-world-home/CustomWorldCreationLauncherModal.tsx` | `147` 行,无运行时引用 | 未接线入口壳层 | +| `src/components/custom-world-agent/CustomWorldAgentLauncherModal.tsx` | `91` 行,无运行时引用 | agent UI 孤岛仍在 | +| `src/components/custom-world-agent/CustomWorldAgentDraftDrawer.tsx` | `116` 行,无运行时引用 | agent UI 孤岛仍在 | +| `src/hooks/story/storyBootstrap.ts` | `250` 行,无运行时引用 | 旧 bootstrap hook 仍未归档 | +| `src/hooks/useEquipmentFlow.ts` | `134` 行,无运行时引用 | 旧 flow hook 残留 | +| `src/hooks/useForgeFlow.ts` | `159` 行,无运行时引用 | 旧 flow hook 残留 | +| `src/hooks/useInventoryFlow.ts` | `100` 行,无运行时引用 | 旧 flow hook 残留 | +| `src/services/customWorldPresentation.stub.ts` | `55` 行,无运行时引用 | 占位 stub 仍在 | +| `src/services/typewriter.ts` | `7` 行,无运行时引用 | 小型 helper 残留 | +| `src/prompts/customWorldOrchestratorPrompts.ts` | `9` 行,无运行时引用 | prompt source 已迁走后留下的孤岛 | +| `src/prompts/storyOrchestratorPrompts.ts` | `6` 行,无运行时引用 | prompt source 已迁走后留下的孤岛 | +| `src/data/buildTagSimilarity.generated.ts` | `823` 行,无运行时引用 | 生成产物未接入正式业务链路 | + +### 说明 + +`src/data/itemOverrides.json`、`src/data/monsterOverrides.json` 这类文件虽然没有 import 引用,但会被脚本和 editor route 以路径消费,所以不计入垃圾判断。 + +### 结论 + +仓库已经完成“删旧插件”,但还没有完成“清未接线孤岛”。 +当前这批模块应该进入明确处置表: + +1. 直接归档/删除 +2. 正式接回入口 +3. 改名/迁目录,标记为实验稿 + +--- + +## 4.2 前端仍保留运行时镜像真相 + +### 当前证据 + +1. `src/hooks/story/runtimeStoryCoordinator.ts` + - 仍会在读状态和提交动作前先 `putSaveSnapshot(...)` + - 仍会在响应后多次 `rehydrateSavedSnapshot(...)` +2. `src/services/runtimeStoryService.ts` + - 仍对响应快照做 `rehydrateSavedSnapshot(...)` + +### 结论 + +当前运行时已经不是“前端主算”,但仍然是: + +**前端先写一份本地镜像,再和后端会话互相回填。** + +这说明前端还没有完全退出正式运行时状态解释层。 + +--- + +## 4.3 前端仍保留本地登录凭证真相 + +### 当前证据 + +`src/services/apiClient.ts` 当前仍把以下内容写入 `window.localStorage`: + +1. `ACCESS_TOKEN_KEY` +2. `AUTO_AUTH_USERNAME_KEY` +3. `AUTO_AUTH_PASSWORD_KEY` + +对应代码仍包括: + +1. `window.localStorage.getItem(...)` +2. `window.localStorage.setItem(...)` +3. `window.localStorage.removeItem(...)` + +### 结论 + +这一点和“前端只做表现、后端负责鉴权”的目标仍然不一致。 +尤其是自动登录用户名/密码继续存本地,风险和边界问题都还在。 + +--- + +## 4.4 quest/runtime item 仍是双环境混合实现 + +### 当前证据 + +1. `src/services/questDirector.ts` + - 浏览器路径走 `requestJson('/api/runtime/quests/generate')` + - 非浏览器路径仍有 `requestChatMessageContent(...)` +2. `src/services/runtimeItemAiDirector.ts` + - 浏览器路径走 `requestJson('/api/runtime/items/runtime-intent')` + - 非浏览器路径仍有 `requestChatMessageContent(...)` +3. `src/hooks/story/npcEncounterActions.ts` + - 当前仍 import `generateQuestForNpcEncounter` + - `replacePendingNpcQuestOffer()` 仍会调用它 + +### 结论 + +浏览器兜底已经收掉,但模块职责仍然是混合的: + +1. 同一个文件同时承担前端 SDK 和非浏览器编排逻辑 +2. NPC 换单动作仍由前端发起服务调用 + +这部分还不能算真正后端化完成。 + +--- + +## 4.5 `src/services/ai.ts` 仍然是浏览器端正式 AI orchestration 热点 + +### 当前证据 + +`src/services/ai.ts` 当前约 `2608` 行,仍直接使用: + +1. `requestChatMessageContent` +2. `requestPlainTextCompletion` +3. `streamPlainTextCompletion` + +### 结论 + +这说明浏览器侧的大型 AI orchestration 仍然没有真正退出主工程。 +虽然部分链路已经迁走,但整体边界还没有收完。 + +--- + +## 5. 当前热点已经发生迁移 + +## 5.1 当前主要大文件快照 + +| 文件 | 当前行数 | 判断 | +| --- | --- | --- | +| `src/components/CustomWorldEntityEditorModal.tsx` | `4898` | 仍是前端最大热点 | +| `server-node/src/modules/assets/characterAssetRoutes.ts` | `3181` | 仍是后端资产链路最大热点 | +| `src/services/ai.ts` | `2608` | 浏览器 AI orchestration 热点仍在 | +| `src/data/npcInteractions.ts` | `2409` | 仍是大型规则数据中心 | +| `server-node/src/services/customWorldAgentFoundationDraftService.ts` | `1902` | custom world agent 后端热点上升 | +| `src/prompts/storyPromptBuilders.ts` | `1882` | prompt source 已成为新的前端热点 | +| `server-node/src/modules/custom-world/runtimeProfile.ts` | `1735` | custom world runtime 编译中心已转到后端 | +| `src/components/game-shell/PreGameSelectionFlow.tsx` | `1547` | 平台/入口流程热点上升 | +| `src/components/game-shell/PlatformHomeView.tsx` | `1522` | 平台首页热点上升 | +| `src/services/customWorld.ts` | `1489` | 仍然大,但已明显缩小 | +| `src/hooks/story/npcEncounterActions.ts` | `1434` | 仍然是前端 action 热点 | + +--- + +## 5.2 热点变化判断 + +和 `2026-04-19` 相比,当前热点不是单纯“没变”,而是出现了明显迁移: + +1. `characterAssetRoutes.ts` 从 `3579` 行降到 `3181` 行,说明资产路由已经有过一轮拆分,但仍然偏大。 +2. `src/services/customWorld.ts` 从 `2413` 行降到 `1489` 行,说明自定义世界规则已拆出一部分。 +3. `src/hooks/story/npcEncounterActions.ts` 从 `1623` 行降到 `1434` 行,说明 NPC 运行时逻辑也有收口。 +4. 新的复杂度中心开始转移到: + - `src/prompts/storyPromptBuilders.ts` + - `server-node/src/modules/custom-world/runtimeProfile.ts` + - `src/components/game-shell/PreGameSelectionFlow.tsx` + - `src/components/game-shell/PlatformHomeView.tsx` + +### 结论 + +当前问题已经不再是“原来的热点完全没动”,而是: + +**部分旧热点正在缩小,但复杂度正在向 prompt source、custom world runtime profile、平台入口壳层继续迁移。** + +--- + +## 6. 最新建议执行顺序 + +### 第一阶段:先清理当前仍明确无入口的孤岛 + +1. 处理 `GameShell.tsx` +2. 处理 `custom-world-home/*` +3. 处理 `custom-world-agent/*` +4. 处理 `storyBootstrap.ts`、`useEquipmentFlow.ts`、`useForgeFlow.ts`、`useInventoryFlow.ts` +5. 处理已脱钩的 `src/prompts/*OrchestratorPrompts.ts` + +### 第二阶段:再收运行时和鉴权真相 + +1. 收掉 `runtimeStoryCoordinator.ts` 的本地快照前置写入 +2. 收掉 `apiClient.ts` 中的自动登录用户名/密码本地持久化 +3. 优先把 token/session 统一到服务端鉴权边界 + +### 第三阶段:补完 NPC 任务链路的后端化 + +1. 把“更换待接委托”从 `npcEncounterActions.ts -> questDirector.ts` 继续迁到后端 +2. 把 `questDirector.ts` / `runtimeItemAiDirector.ts` 拆成明确的后端服务与前端 SDK 两层 + +### 第四阶段:最后拆新热点 + +1. `CustomWorldEntityEditorModal.tsx` +2. `characterAssetRoutes.ts` +3. `storyPromptBuilders.ts` +4. `runtimeProfile.ts` +5. `PreGameSelectionFlow.tsx` +6. `PlatformHomeView.tsx` + +--- + +## 7. 本文依据 + +文档依据: + +1. `docs/audits/engineering/ENGINEERING_CLEANUP_AND_BACKEND_BOUNDARY_AUDIT_2026-04-19.md` +2. `docs/audits/engineering/CURRENT_ENGINEERING_OPTIMIZATION_PRIORITIES_2026-04-10.md` +3. `docs/planning/EXPRESS_BACKEND_REFACTOR_PLAN_2026-04-08.md` +4. `docs/planning/EXPRESS_BACKEND_PARALLEL_WORKSTREAM_PLAN_2026-04-08.md` + +当前仓库复核依据: + +1. `scripts/dev-server/README.md` +2. `server-node/src/modules/story/runtimeSession.ts` +3. `src/services/runtimeStoryService.ts` +4. `src/hooks/story/runtimeStoryCoordinator.ts` +5. `src/hooks/story/npcEncounterActions.ts` +6. `src/services/questDirector.ts` +7. `src/services/runtimeItemAiDirector.ts` +8. `src/services/apiClient.ts` +9. 当前依赖图扫描结果与当前大文件体量扫描结果 + diff --git a/docs/audits/engineering/README.md b/docs/audits/engineering/README.md index 2bfc211c..8d3891dc 100644 --- a/docs/audits/engineering/README.md +++ b/docs/audits/engineering/README.md @@ -4,21 +4,25 @@ ## 当前推荐入口 -1. [ENGINEERING_CLEANUP_AND_BACKEND_BOUNDARY_AUDIT_2026-04-19.md](./ENGINEERING_CLEANUP_AND_BACKEND_BOUNDARY_AUDIT_2026-04-19.md) - 这一版聚焦当前仓库里的垃圾/冗余代码、旧入口残留、前后端边界未闭合点,以及下一步最该清什么、迁什么、拆什么。 -2. [ENGINEERING_OPTIMIZATION_REVIEW_2026-04-01.md](./ENGINEERING_OPTIMIZATION_REVIEW_2026-04-01.md) +1. [ENGINEERING_CLEANUP_AND_BACKEND_BOUNDARY_AUDIT_2026-04-20.md](./ENGINEERING_CLEANUP_AND_BACKEND_BOUNDARY_AUDIT_2026-04-20.md) + 这一版是对 `2026-04-19` 基线的当前仓库复核,明确哪些问题已经处理、哪些表述需要纠正、热点又迁移到了哪里。 +2. [ENGINEERING_CLEANUP_AND_BACKEND_BOUNDARY_AUDIT_2026-04-19.md](./ENGINEERING_CLEANUP_AND_BACKEND_BOUNDARY_AUDIT_2026-04-19.md) + 这一版保留原始问题快照和执行回填,适合回看“为什么会有这轮清理与边界收口”。 +3. [ENGINEERING_OPTIMIZATION_REVIEW_2026-04-01.md](./ENGINEERING_OPTIMIZATION_REVIEW_2026-04-01.md) 这一版最适合作为当前工程基线,重点从“是否真正绿色”“门禁有没有覆盖真实风险”来判断仓库状态。 -3. [ENGINEERING_OPTIMIZATION_REVIEW_2026-03-30.md](./ENGINEERING_OPTIMIZATION_REVIEW_2026-03-30.md) +4. [ENGINEERING_OPTIMIZATION_REVIEW_2026-03-30.md](./ENGINEERING_OPTIMIZATION_REVIEW_2026-03-30.md) 适合回看运行时主链路、Story/Combat 边界、分层过渡期问题。 -4. [ENGINEERING_OPTIMIZATION_REVIEW_2026-03-29.md](./ENGINEERING_OPTIMIZATION_REVIEW_2026-03-29.md) +5. [ENGINEERING_OPTIMIZATION_REVIEW_2026-03-29.md](./ENGINEERING_OPTIMIZATION_REVIEW_2026-03-29.md) 适合看第一轮系统性工程扫描,了解最早的问题基线。 ## 融合结论 -- 当前仓库的新重点已经从“单纯补门禁”进一步演进到“清历史残留、清无入口模块、收前后端双份真相”。 +- 当前仓库已经完成“旧 dev 插件链路删除、根目录噪音清理、`server-node -> src/**` 反向依赖切断”这批第一阶段任务。 +- 当前的新重点已经进一步收敛到三类:未接线孤岛模块、前端残留的运行时/鉴权真相、热点向 prompt/runtime profile/平台入口壳层迁移。 - 三轮结论是一致收敛的:问题不在“有没有开始工程化”,而在“工程化是否真正覆盖了最危险的主链路”。 - 最新一轮已经把关注点集中到质量门禁、真实绿色基线、关键模块豁免和 build warning 上。 -- `2026-04-19` 这一轮进一步把问题压实到了四类:仓库噪音、旧 dev 入口残留、前端越界运行时逻辑、巨型热点文件。 +- `2026-04-19` 这一轮把问题压实到了四类:仓库噪音、旧 dev 入口残留、前端越界运行时逻辑、巨型热点文件。 +- `2026-04-20` 这一轮进一步确认:前两类已经阶段性完成,当前真正剩下的是边界尾巴和新热点迁移。 - 如果只是为了判断现在先做什么,直接从 `2026-04-01` 开始即可。 -- 如果是要做当前清理和边界收口,优先看 `2026-04-19`。 -- 如果是要做长期重构方案,再按 `2026-03-29 -> 2026-03-30 -> 2026-04-01 -> 2026-04-19` 的顺序回看演进。 +- 如果是要看当前清理和边界收口的最新状态,优先看 `2026-04-20`。 +- 如果是要做长期重构方案,再按 `2026-03-29 -> 2026-03-30 -> 2026-04-01 -> 2026-04-19 -> 2026-04-20` 的顺序回看演进。 diff --git a/docs/design/LEVEL_PROGRESS_AND_CHAPTER_NPC_AUTO_SCALING_DESIGN_2026-04-20.md b/docs/design/LEVEL_PROGRESS_AND_CHAPTER_NPC_AUTO_SCALING_DESIGN_2026-04-20.md new file mode 100644 index 00000000..8deb90f7 --- /dev/null +++ b/docs/design/LEVEL_PROGRESS_AND_CHAPTER_NPC_AUTO_SCALING_DESIGN_2026-04-20.md @@ -0,0 +1,870 @@ +# 等级成长、章节经验节奏与 NPC 自动定级设计 + +更新时间:`2026-04-20` + +## 实现进度(2026-04-20 第一批) + +当前仓库已按本设计先落地第一批稳定能力: + +1. 已新增 `playerProgression` 正式成长状态,包含等级、当前等级经验、总经验与下级阈值。 +2. 已新增等级基准与经验结算服务,并接入前后端存档归一化,旧存档默认回填为 `Lv.1 / 0 XP`。 +3. 已给 `QuestReward` 补上 `experience`,新生成任务会按当前等级与任务结构给出任务经验。 +4. 已将 Express 后端 `npc_quest_turn_in` 接入经验发放与升级处理,任务交付结果会反馈 `经验 +N` 与升级信息。 +5. 已在冒险主面板补充最小等级展示:`Lv.` 与细经验条;任务奖励面板可看到经验数值。 +6. 已收回任务日志里的直接领奖入口,任务奖励结算当前以 NPC 交付链路为准。 + +本轮仍未落地的部分: + +1. 击败敌对 NPC 经验。 +2. 章节经验预算 / ledger 统计。 +3. 按章节自动定级 NPC 与运行时敌对经验掉落。 + +## 0. 目标 + +这次设计解决 5 个必须同时成立的问题: + +1. 玩家需要正式拥有 `等级 / 当前经验 / 总经验 / 升级` 这条成长主链。 +2. 经验只从两类明确来源进入: + - 完成任务 + - 击败敌对 NPC +3. 同等级实体必须具备同一档 `参考强度`,不能再靠散落在各处的静态数值各自漂移。 +4. 系统需要能按章节评估玩家经验获取速度,而不是只在整体通关后回看“升太快/升太慢”。 +5. 不同章节里的 NPC 需要按章节目标等级自动定级,保证这一章的敌我强度、经验产出和升级节奏互相闭合。 + +一句话结论: + +**等级必须成为后端统一裁决的成长基线;章节必须先产出“目标玩家等级带 + 经验预算”,再由这套预算反推任务经验、击杀经验和本章 NPC 自动等级。** + +--- + +## 1. 基于当前仓库的判断 + +结合当前代码与文档,现状已经有足够好的骨架,但等级系统这一层还完全缺位。 + +### 1.1 已经具备的基础 + +1. `src/data/questFlow.ts` + + - 已有 `QuestLogEntry / QuestStep / QuestProgressSignal / chapter quest`。 + - 已经能把场景章节任务接到运行时主链。 + +2. `server-node/src/modules/quest/questStoryActionService.ts` + + - 已经把 `接任务 / 交任务` 收回后端。 + - 任务结算时已经集中处理货币、背包、好感变化。 + +3. `server-node/src/modules/quest/questRuntimeSignalService.ts` + + - 已经会在 `npc_chat / 击败敌对 NPC / 宝藏 / 切磋` 后投递 quest signal。 + +4. `src/services/storyEngine/chapterDirector.ts` + + - 已经能用当前场景章节任务推导 `opening -> expansion -> turning_point -> climax -> aftermath`。 + +5. `src/types/customWorld.ts` + + - 已经有 `sceneChapterBlueprints`,说明章节顺序、幕推进和 NPC 编排已经有正式挂点。 + +6. `src/types/attributes.ts`、`src/data/hostileNpcPresets.ts` + - 已经有统一属性画像、怪物/NPC 统一实体方向。 + - 当前敌对实体已有 `baseStats / attributeProfile / behaviorVectors`,可以继续向“同级同参考强度”收束。 + +### 1.2 当前缺口 + +当前最核心的缺口有 6 个: + +1. `GameState` 没有玩家等级成长状态。 +2. `QuestReward` 没有经验字段。 +3. `SceneHostileNpc / SceneNpc` 没有正式等级和击杀经验字段。 +4. 当前 hostile preset 的 `hp/maxHp` 仍是静态绝对值,不受章节节奏控制。 +5. 章节系统没有“本章目标入场等级 / 出章等级 / 经验预算”的结构。 +6. 没有“按章节自动定级”的编译器,也没有“本章经验是否超发/欠发”的记账面板。 + +一句话总结: + +**现在仓库里已经有章节、任务、NPC 和属性系统,但还没有“成长预算层”,所以强度、奖励和章节节奏仍然缺少同一把尺。** + +--- + +## 2. 核心决策 + +## 2.1 等级、经验与 NPC 定级全部由 Express 后端裁决 + +必须坚持: + +1. 前端只展示 `等级 / 经验条 / 升级结果 / NPC 等级徽标`。 +2. 经验发放、升级、章节经验预算、NPC 自动定级全部在 Express 后端计算。 +3. 前端不本地推演“这次应该升几级”“这个 NPC 应该是多少级”。 + +推荐新增领域目录: + +- `server-node/src/modules/progression/` + +建议首批模块: + +- `levelBenchmarks.ts` +- `playerProgressionService.ts` +- `chapterProgressionPlanner.ts` +- `chapterExperienceLedger.ts` +- `npcLevelResolver.ts` +- `progressionRuntimeSignalService.ts` + +## 2.2 MVP 经验来源只认两类事件 + +首版只允许两类正式经验来源: + +1. `quest_turned_in` + + - 任务真正交付时发经验。 + - 不在“接任务”“任务 ready_to_turn_in”时发经验。 + +2. `hostile_npc_defeated` + - 仅限敌对 NPC / 怪物胜利结算后发经验。 + - 不对 `npc_spar_completed`、普通聊天、观察、宝藏直接发经验。 + +这样做的原因是: + +1. 最容易和当前后端任务/战斗链路接上。 +2. 经验来源清晰,便于做章节预算。 +3. 避免系统一开始就被碎片经验源冲散。 + +## 2.3 同等级 = 同参考强度 + +这是本次设计最重要的规则: + +1. 等级是所有可比较实体共享的强度基线。 +2. 同等级玩家、敌对 NPC、可战斗剧情 NPC,必须共享同一档 `参考强度`。 +3. 世界属性 schema 只决定“强在哪种风格上”,不决定“同级谁天然强一截”。 + +也就是说: + +- `Lv.8` 的重甲敌人和 `Lv.8` 的迅捷刺客可以打法不同 +- 但两者的 `参考强度预算` 必须是同一档 + +真正的强弱差只允许来自: + +1. 等级差 +2. 装备 / Build / Buff / Debuff +3. 章节中明确声明的 `boss / elite` 角色通过更高等级体现,而不是同级偷加隐藏倍数 + +## 2.4 章节先出经验预算,再反推等级 + +章节设计从这次开始必须按下面顺序计算: + +```text +章节顺序 +-> 本章玩家目标入场等级 / 出章等级 +-> 本章总经验预算 +-> 任务经验份额 / 击杀经验份额 +-> 本章 NPC 自动等级 +-> 本章实际经验记账与偏差评估 +``` + +不能反过来先手写一堆 NPC 强度,再看玩家能不能接住。 + +## 2.5 UI 只做极简表达 + +为了符合当前项目“UI 不默认堆规则说明”的约束,前台只建议新增 4 个轻量展示: + +1. 玩家信息区: + + - `Lv. X` + - 一条细经验条 + +2. 敌对 NPC 名牌: + + - `Lv. X` + +3. 任务交付结果: + + - `经验 +N` + +4. 升级提示: + - 单条 toast 或单行系统反馈 + +不在界面里默认放: + +- 经验公式说明 +- 章节经验预算说明 +- 等级规则解释文案 + +--- + +## 3. 数据结构设计 + +## 3.1 玩家成长状态 + +建议新增: + +```ts +export interface PlayerProgressionState { + level: number; + currentLevelXp: number; + totalXp: number; + xpToNextLevel: number; + pendingLevelUps?: number; + lastGrantedSource?: 'quest' | 'hostile_npc' | null; +} +``` + +挂载位置建议: + +- `src/types/game.ts` +- `GameState.playerProgression` + +原则: + +1. 这不是 `runtimeStats` 的一部分。 +2. `runtimeStats` 继续做统计计数。 +3. `playerProgression` 是正式玩法状态。 + +## 3.2 等级基准表 + +建议新增: + +```ts +export interface LevelBenchmark { + level: number; + xpToNextLevel: number; + cumulativeXpRequired: number; + referenceStrength: number; + baseHp: number; + baseMana: number; + baselineDamageScale: number; +} +``` + +单一真相源建议放在: + +- `server-node/src/modules/progression/levelBenchmarks.ts` + +前端只通过后端投影拿结果,不自己保存第二份表。 + +## 3.3 实体等级档案 + +建议新增: + +```ts +export type ProgressionRole = + | 'guide' + | 'ambient' + | 'support' + | 'hostile_standard' + | 'hostile_elite' + | 'hostile_boss' + | 'rival'; + +export interface EntityLevelProfile { + level: number; + referenceStrength: number; + chapterId?: string | null; + chapterIndex?: number | null; + progressionRole: ProgressionRole; + source: 'chapter_auto' | 'preset_override' | 'manual'; +} +``` + +建议接入: + +- `src/types/scene.ts` + - `SceneNpc.levelProfile?: EntityLevelProfile` + - `SceneHostileNpc.levelProfile?: EntityLevelProfile` + +## 3.4 任务奖励扩展 + +建议扩展: + +```ts +export interface QuestReward { + affinityBonus: number; + currency: number; + experience: number; + items: InventoryItem[]; + storyHint?: string; + intel?: { ... }; +} +``` + +说明: + +1. 经验是任务奖励的一等字段。 +2. 经验文本不走 story hint 兜底。 +3. 任务经验由后端编译,不交给 AI 决定。 + +## 3.5 敌对 NPC 经验掉落 + +建议扩展: + +```ts +export interface SceneHostileNpc { + ... + experienceReward?: number; +} +``` + +首版只给运行时敌对 NPC 挂经验值,不强行把它沉到所有 preset 原始数据中。 + +原因: + +1. 经验应该跟章节定级一起编译。 +2. 同一个 hostile preset 出现在不同章节时,等级和经验都应不同。 +3. 静态 preset 继续只表达“风格”和“原型”,不再表达最终强度。 + +## 3.6 章节成长计划 + +建议新增运行时编译结果: + +```ts +export interface ChapterProgressionPlan { + chapterId: string; + chapterIndex: number; + totalChapters: number; + entryPseudoLevel: number; + exitPseudoLevel: number; + entryLevel: number; + exitLevel: number; + totalXpBudget: number; + questXpBudget: number; + hostileXpBudget: number; + expectedHostileDefeatCount: number; + paceBand: 'opening_fast' | 'steady' | 'pressure' | 'finale_dense'; +} +``` + +建议作为后端运行时编译结果缓存,不作为创作者直接编辑字段。 + +## 3.7 章节经验记账 + +建议新增: + +```ts +export interface ChapterExperienceLedger { + chapterId: string; + chapterIndex: number; + levelAtEntry: number; + levelAtExit?: number | null; + plannedTotalXp: number; + plannedQuestXp: number; + plannedHostileXp: number; + actualQuestXp: number; + actualHostileXp: number; + expectedHostileDefeatCount: number; + actualHostileDefeatCount: number; +} +``` + +用途: + +1. 评估每一章经验速度。 +2. 判断本章是否超发/欠发。 +3. 为下一轮调参提供依据。 + +--- + +## 4. 等级曲线与参考强度 + +## 4.1 首版等级目标 + +首版建议: + +1. 系统支持 `Lv.1 ~ Lv.20` +2. 当前主线正常通章目标不是满级 +3. 标准单轮战役通关目标等级建议落在 `Lv.14 ~ Lv.15` + +这样做的原因是: + +1. 级差足够表达章节成长 +2. 不会让前期升级过细、后期又没有空间 +3. 还保留后续营地、精英支线、长期养成的余量 + +## 4.2 升级经验公式 + +建议基线公式: + +```ts +xpToNextLevel(level) = 60 + 20 * (level - 1) + 8 * (level - 1) * (level - 1); +``` + +由此生成 `LevelBenchmark[]`,不在业务代码里散落重复公式。 + +说明: + +1. 前期升级快,便于建立成长反馈 +2. 中后期门槛逐步拉开,避免章节尾段失控 +3. 可直接序列化成常量表用于测试 + +## 4.3 参考强度公式 + +建议基线公式: + +```ts +referenceStrength(level) = + 100 + 16 * (level - 1) + 6 * (level - 1) * (level - 1); +``` + +并同步产出: + +```ts +baseHp(level); +baseMana(level); +baselineDamageScale(level); +``` + +重要约束: + +1. `referenceStrength` 是同级比较标尺。 +2. style 只允许在同一档预算内重分布,不允许抬高总强度。 +3. `elite / boss` 不允许用同级隐藏倍率偷强度,必须通过更高等级体现。 + +## 4.4 现有静态数值如何迁移 + +当前 `src/data/hostileNpcPresets.ts` 里的: + +- `baseStats.hp` +- `baseStats.maxHp` +- `speed` +- `attackRange` + +不建议继续全部视为最终强度。 + +迁移原则: + +1. `attackRange / speed` 继续保留为战斗风格参数。 +2. `hp / maxHp` 改为“风格形状参考”,最终值由 `等级基准 + 风格分布` 决定。 +3. 现有 preset 的高血量、高机动、高压制,只用于决定“同级下怎么分布”,不改变同级总参考强度。 + +--- + +## 5. 经验发放规则 + +## 5.1 任务经验 + +任务经验只在 `turn_in` 时发放。 + +建议公式: + +```ts +baseQuestXp(targetLevel) = xpToNextLevel(targetLevel) * 0.45; + +questXp = + baseQuestXp(targetLevel) * + stepCountMultiplier * + narrativeTypeMultiplier * + urgencyMultiplier; +``` + +建议倍率: + +| 条件 | 倍率 | +| ------------------------------------------ | ------ | +| `steps = 1` | `0.85` | +| `steps = 2` | `1.0` | +| `steps >= 3` | `1.12` | +| `investigation / retrieval / relationship` | `1.0` | +| `trial / bounty` | `1.08` | +| `urgency = high` | `1.05` | + +最终规则: + +1. 结果四舍五入到 `5` 的倍数。 +2. 章节主任务优先从本章 `questXpBudget` 出数。 +3. 普通 NPC 支线如果不绑定章节,则按 `targetLevel` 单独计算。 + +## 5.2 击败敌对 NPC 经验 + +建议公式: + +```ts +baseKillXp(targetLevel) = xpToNextLevel(targetLevel) * 0.08; + +killXp = + baseKillXp(targetLevel) * + stageMultiplier * + levelDeltaMultiplier * + repeatPenalty; +``` + +建议倍率: + +| 条件 | 倍率 | +| -------------------------------- | ----------------- | +| `opening` | `0.9` | +| `expansion` | `1.0` | +| `turning_point` | `1.05` | +| `climax` | `1.15` | +| 玩家高于目标 `2` 级 | `0.7` | +| 玩家高于目标 `4` 级 | `0.3` | +| 玩家低于目标 `2` 级 | `1.15` | +| 同章同类敌对实体超过预计击杀数后 | `0.5 -> 0.2 -> 0` | + +解释: + +1. 同章重复刷怪必须衰减。 +2. 击杀经验要响应等级差,避免低章 farming。 +3. 高潮压轴敌人可以给更多经验,但仍受章节预算约束。 + +## 5.3 经验发放顺序 + +推荐统一顺序: + +```text +规则动作成功 +-> 生成经验 grant +-> 写入 playerProgression.totalXp / currentLevelXp +-> 处理升级 +-> 回写章节 ledger +-> 生成前端提示 +``` + +不要把经验结算拆在前端多个回调里各自加一次。 + +--- + +## 6. 章节经验速度评估 + +## 6.1 章节顺序来源 + +章节索引 `chapterIndex` 建议按下面顺序解析: + +1. 有 `campaign pack` 时,优先用 campaign 正式顺序 +2. 否则有 `sceneChapterBlueprints` 时,用蓝图顺序 +3. 再否则,对 `landmarks` 从营地出发做最短路径排序 +4. 若存在并列,则回退到稳定的 landmark 原始顺序 + +这样才能给每章一个稳定的“这是第几章”。 + +## 6.2 目标等级带 + +建议先计算“伪等级进度”,再换算成经验预算: + +```ts +chapterBoundaryPseudoLevel(i) = + 1 + curve(i / totalChapters) * (terminalStoryLevel - 1); +``` + +建议 `curve` 用轻微前快后稳的函数: + +```ts +curve(progress) = Math.pow(progress, 0.92); +``` + +随后: + +```ts +entryPseudoLevel = chapterBoundaryPseudoLevel(chapterIndex - 1); +exitPseudoLevel = chapterBoundaryPseudoLevel(chapterIndex); +chapterXpBudget = + xpForPseudoLevel(exitPseudoLevel) - xpForPseudoLevel(entryPseudoLevel); +``` + +这样做的好处是: + +1. 每一章都有明确的入章/出章目标 +2. 等级增幅随章节自然变慢 +3. 经验速度评估可以直接落成表格 + +## 6.3 章节经验份额 + +默认建议: + +| 章节类型 | 任务经验占比 | 击杀经验占比 | +| --------------- | ------------ | ------------ | +| 调查/关系型章节 | `75%` | `25%` | +| 平衡型章节 | `65%` | `35%` | +| 战斗/试炼型章节 | `55%` | `45%` | + +章节类型判定可由下面几项共同决定: + +1. `SceneChapterBlueprint.acts` 数量 +2. 当前章节 hostile NPC 数量 +3. 当前章节任务 step 中战斗目标占比 +4. `dangerLevel` +5. linked thread 是否为主线高压线程 + +## 6.4 实际速度评估规则 + +每章结束后,至少计算下面三个值: + +1. `actualTotalXp / plannedTotalXp` +2. `actualHostileXp / plannedHostileXp` +3. `levelAtExit - plannedExitLevel` + +建议判定: + +| 偏差 | 判断 | +| ----------- | -------- | +| `±10%` 内 | 正常 | +| `10% ~ 20%` | 需观察 | +| `> 20%` | 必须调参 | + +这就是“评估每一章获得经验速度”的正式口径,不再用主观感觉判断。 + +--- + +## 7. NPC 自动定级规则 + +## 7.1 默认角色分类 + +建议默认按当前幕和敌我属性推导 `progressionRole`: + +1. 当前幕 `primaryNpcId` + + - 若 hostile:`hostile_elite` 或 `hostile_boss` + - 若非 hostile:`guide` 或 `rival` + +2. 非主角色 hostile NPC + + - `hostile_standard` + +3. 非主角色友方 NPC + - `support` 或 `ambient` + +如需修正,再允许章节蓝图加可选 override,但不要求创作者每次手填。 + +## 7.2 等级锚点 + +每章先得到: + +1. `entryLevel` +2. `exitLevel` + +然后按当前阶段得到阶段锚点: + +| 阶段 | 目标锚点 | +| --------------- | ----------------------------- | +| `opening` | 接近 `entryLevel` | +| `expansion` | `entryLevel ~ exitLevel` 中段 | +| `turning_point` | 接近 `exitLevel` | +| `climax` | `exitLevel` | +| `aftermath` | `exitLevel - 1` 或持平 | + +## 7.3 最终定级 + +建议公式: + +```ts +baseStageLevel = interpolate(entryLevel, exitLevel, stageProgress); + +npcLevel = round(baseStageLevel) + roleOffset(progressionRole); +``` + +建议 offset: + +| role | offset | +| ------------------ | -------- | +| `ambient` | `-1` | +| `support` | `0` | +| `guide` | `0` | +| `rival` | `0 ~ +1` | +| `hostile_standard` | `0` | +| `hostile_elite` | `+1` | +| `hostile_boss` | `+2` | + +约束: + +1. 统一 clamp 到 `1 ~ terminalStoryLevel + 2` +2. 不允许出现“第 3 章普通怪高于第 6 章精英”的跨章倒挂 +3. `hostile_boss` 如果需要更强,必须给更高等级,不准同级偷倍数 + +## 7.4 同级不同风格 + +NPC 等级确定后,再把 `referenceStrength` 套到具体风格: + +1. 重装型: + + - 生命占比更高 + - 爆发占比更低 + +2. 迅捷型: + + - 生命占比更低 + - 出手与压制占比更高 + +3. 控场型: + - 法力/控制预算更高 + +但这一步只能做“分布调整”,不能改变同级总参考强度。 + +--- + +## 8. 与当前仓库的接入点 + +## 8.1 第一批必须改的类型 + +1. `src/types/game.ts` + + - 新增 `playerProgression` + +2. `src/types/story.ts` + + - `QuestReward.experience` + +3. `src/types/scene.ts` + + - `SceneNpc.levelProfile` + - `SceneHostileNpc.levelProfile` + - `SceneHostileNpc.experienceReward` + +4. `packages/shared/src/contracts/story.ts` + - 如果需要让前后端合同正式共享等级展示字段,在这里补最小契约 + +## 8.2 第一批必须改的后端模块 + +1. `server-node/src/modules/quest/questStoryActionService.ts` + + - `resolveQuestTurnInAction(...)` 里追加任务经验发放 + +2. `server-node/src/modules/quest/questRuntimeSignalService.ts` + + - 保持 quest signal 职责 + - 不直接负责经验裁决,只把可用信号交给 progression 模块 + +3. `server-node/src/modules/combat/**` + + - 在胜利结算后发 hostile NPC 经验 + +4. `server-node/src/modules/story/**` + + - 在切章、进场、恢复场景时接入章节成长计划与 ledger + +5. 新增 `server-node/src/modules/progression/**` + - 成为等级、经验、章节定级唯一真相源 + +## 8.3 第一批不建议重写的部分 + +这轮不建议一开始就重写: + +1. 整套前端战斗 UI +2. 整套属性系统 +3. Quest UI 大面板结构 +4. 所有 hostile preset 原始配置文件 + +更稳的做法是: + +1. 先让后端算出等级与经验 +2. 再把结果投影到现有运行时字段 +3. 最后再逐步清理旧静态强度残留 + +--- + +## 9. 迁移策略 + +## 9.1 旧存档兼容 + +旧存档没有 `playerProgression` 时: + +1. 默认初始化为 `Lv.1` +2. `totalXp = 0` +3. `currentLevelXp = 0` +4. `xpToNextLevel = benchmark[1].xpToNextLevel` + +如果后续希望更平滑,可在第二轮增加“按当前章节进度反推起始等级”的迁移脚本,但首版先不要让迁移复杂化。 + +## 9.2 旧 hostile preset 兼容 + +旧 preset 里的 `hp/maxHp` 首版处理建议: + +1. 先保留原字段作为 style hint +2. 运行时用 level benchmark 覆盖最终 `hp/maxHp` +3. 保证当前素材和行为标签不需要重做 + +## 9.3 旧任务兼容 + +旧任务没有 `reward.experience` 时: + +1. 默认按 `0` 处理 +2. 仅新生成或重新编译的任务带经验 +3. 章节主任务优先切到新编译链 + +--- + +## 10. 开发顺序 + +## 阶段 A:先把等级状态立住 + +先做: + +1. `PlayerProgressionState` +2. `LevelBenchmark[]` +3. 经验加点与升级服务 + +验收: + +1. 后端能正确加经验与升级 +2. 前端能稳定展示 `Lv. X / 经验条` + +## 阶段 B:接任务经验 + +先做: + +1. `QuestReward.experience` +2. `quest turn-in` 经验发放 +3. 任务结果文案里补 `经验 +N` + +验收: + +1. 交付任务后能加经验 +2. 升级时能正确连跳 + +## 阶段 C:接章节预算与 NPC 自动定级 + +先做: + +1. `ChapterProgressionPlan` +2. `npcLevelResolver` +3. runtime hostile NPC 经验值生成 + +验收: + +1. 进入不同章节时 NPC 等级自动变化 +2. 同级不同风格但参考强度一致 + +## 阶段 D:接击败敌对 NPC 经验与章节 ledger + +先做: + +1. hostile defeat 经验 +2. `ChapterExperienceLedger` +3. 章节偏差评估输出 + +验收: + +1. 每章都能看到计划/实际经验偏差 +2. 重复刷同章敌对 NPC 不会破坏曲线 + +--- + +## 11. 验收标准 + +做到下面这些,才算这次等级系统设计真正落地: + +1. 玩家正式拥有 `等级 + 经验 + 升级` 主链。 +2. 经验来源只通过后端发放,前端不本地算经验。 +3. 同等级实体共享同一档 `参考强度`。 +4. 每章都能生成 `入章等级 / 出章等级 / 经验预算`。 +5. 每章的 NPC 都能按章节自动定级。 +6. 完成任务、击败敌对 NPC 都能稳定获得经验。 +7. 章节结束后能评估“这一章经验速度是否正常”。 +8. 现有任务、章节、属性和 hostile NPC 主链不被推翻,只是在其上新增成长预算层。 + +--- + +## 12. 最后结论 + +这次等级系统设计的重点,不是简单在 UI 上加一个 `Lv.1`,而是把当前仓库里已经存在的: + +1. 章节闭环 +2. 任务结算 +3. 敌对 NPC 胜利事件 +4. 统一属性与 hostile preset + +收束到一条新的成长主链: + +**章节先给出目标等级与经验速度,系统再按这套速度自动设置 NPC 等级,并把任务交付与击败敌对 NPC 统一变成可控的经验入口。** + +这样之后,等级不再只是一个展示数字,而会真正变成: + +- 玩家成长速度的刻度 +- 同级参考强度的刻度 +- 章节节奏是否合理的刻度 +- 不同章节 NPC 强度自动落位的刻度 diff --git a/docs/design/PLATFORM_UI_NON_PIXEL_REFRESH_2026-04-19.md b/docs/design/PLATFORM_UI_NON_PIXEL_REFRESH_2026-04-19.md index eb6f214f..e77883a2 100644 --- a/docs/design/PLATFORM_UI_NON_PIXEL_REFRESH_2026-04-19.md +++ b/docs/design/PLATFORM_UI_NON_PIXEL_REFRESH_2026-04-19.md @@ -1,6 +1,6 @@ # 平台层 UI 去像素化刷新设计 -更新时间:`2026-04-19` +更新时间:`2026-04-20` ## 1. 目标 @@ -80,6 +80,7 @@ - 设置面板必须支持平台亮色 / 暗色主题切换,并复用同一套平台 token 驱动登录页、首页、详情页与二三级面板 - 首页移动端底部 Tab 与桌面侧边导航的图标底座、图标颜色、文字状态必须全部由平台 token 驱动;暗色主题下不得出现过浅底座和错误文字色,亮色主题下不得残留旧灰蓝 inactive 状态 - 首页、存档页、作品详情这类平台主导航与局部 Tab 的 active fill、active shadow、icon shell fill 必须全部来自主题 token;暗色主题禁止继续复用亮色主题的粉橘高光、白色 active 底座 +- 创作链路中的吸顶返回栏、目录 Tab 条、搜索工具条也必须走平台亮暗主题 token;暗色主题禁止继续写死暖白渐变或浅粉背景作为顶部衬底 - “我的”页账号主卡必须跟随平台亮 / 暗主题联动,不允许继续写死浅色渐变卡面与 `slate` 系按钮 ## 4. 交互与布局约束 @@ -99,6 +100,8 @@ - 新样式优先沉淀为平台专用 class / theme token,避免把游戏内像素 class 改坏 - 平台默认挂载亮色主题 class,旧紫蓝方案保留为暗色主题 class - 亮色主题需要补齐统一的 overlay、progress track、status pill token,登录弹层与二三级功能面板禁止继续沿用旧深色遮罩与紫蓝强调残留 +- 亮色主题下平台壳层与各个 Tab 页的 page stage 必须以暖白底为主,禁止继续让高饱和深粉底或旧深色底透成页面主背景 +- 亮色主题下平台主内容区、page stage、移动端底部 Tab 容器都必须使用接近实色的暖白底,禁止继续用高透明度浅色层叠在深底上造成整体发灰 - 平台态中仍保留旧 Tailwind 深色类的历史组件,必须通过平台 remap 容器或平台专用 class 统一收口,不能放任 `bg-[#111318]`、`bg-black/*`、`bg-white/*` 这类旧类在亮色主题下直接裸露 - 编辑弹窗保留业务结构与表单逻辑,只替换壳层样式 diff --git a/docs/design/README.md b/docs/design/README.md index 232f9698..38ebb042 100644 --- a/docs/design/README.md +++ b/docs/design/README.md @@ -10,6 +10,7 @@ - [CUSTOM_WORLD_TEMPLATE_DECOUPLING_AND_CROSS_GENRE_GENERALIZATION_DESIGN_2026-04-08.md](./CUSTOM_WORLD_TEMPLATE_DECOUPLING_AND_CROSS_GENRE_GENERALIZATION_DESIGN_2026-04-08.md):把自定义世界从武侠/仙侠模板依赖迁到跨题材通用设定层的优化设计。 - [CUSTOM_WORLD_SELF_OWNED_SETTING_LAYER_OPTIMIZATION_2026-04-08.md](./CUSTOM_WORLD_SELF_OWNED_SETTING_LAYER_OPTIMIZATION_2026-04-08.md):把模板依赖逐步迁成自定义世界自有设定层,并保证不破坏当前生成流程的优化方案。 - [AI_NATIVE_RUNTIME_ITEM_SYSTEM_REDESIGN_2026-04-02.md](./AI_NATIVE_RUNTIME_ITEM_SYSTEM_REDESIGN_2026-04-02.md):运行时物品生成系统重设计。 +- [LEVEL_PROGRESS_AND_CHAPTER_NPC_AUTO_SCALING_DESIGN_2026-04-20.md](./LEVEL_PROGRESS_AND_CHAPTER_NPC_AUTO_SCALING_DESIGN_2026-04-20.md):等级成长、章节经验节奏与 NPC 自动定级设计。 - [RPG_NARRATIVE_PLANNING_FULL_PIPELINE_WORKFLOW_2026-04-12.md](./RPG_NARRATIVE_PLANNING_FULL_PIPELINE_WORKFLOW_2026-04-12.md):专业剧情策划构建 RPG 游戏全剧情的工作流程与交付模板。 - [EQUIPMENT_BUILD_AND_FORGE_LOOP_SYSTEM_DESIGN.md](./EQUIPMENT_BUILD_AND_FORGE_LOOP_SYSTEM_DESIGN.md):配装构筑与合成/锻造闭环设计。 - [COMPANION_FIRST_CONTACT_RELATIONSHIP_AND_PRIVATE_CHAT_DESIGN_2026-04-04.md](./COMPANION_FIRST_CONTACT_RELATIONSHIP_AND_PRIVATE_CHAT_DESIGN_2026-04-04.md):角色首遇感、关系分层解锁、私聊系统设计。 @@ -31,4 +32,5 @@ - 做“高好感聊天里如何顺着上下文自然抛出委托、并让任务在聊天内领取”的需求时,优先看新增的聊天委托流程设计稿。 - 做剧情引擎章节化、场景闭环、章节任务接入时,优先看新增的场景章节设计稿。 - 做“单章节体验还缺什么、该补哪种情感 / 抉择 / 试炼模块”时,优先看新增的章节对标补强设计稿。 +- 做等级成长、任务/击败敌对 NPC 发经验、章节经验速度评估、NPC 自动定级时,优先看新增的等级系统设计稿。 - 如果要判断是否符合目标,再和 `docs/prd/` 中对应 PRD 对照阅读。 diff --git a/docs/prd/AI_CHARACTER_VISUAL_ANIMATION_MVP_PRD_2026-04-04.md b/docs/prd/AI_CHARACTER_VISUAL_ANIMATION_MVP_PRD_2026-04-04.md index b9c9b559..e50d1951 100644 --- a/docs/prd/AI_CHARACTER_VISUAL_ANIMATION_MVP_PRD_2026-04-04.md +++ b/docs/prd/AI_CHARACTER_VISUAL_ANIMATION_MVP_PRD_2026-04-04.md @@ -254,58 +254,49 @@ MVP 支持三种主形象输入方式: MVP 必须与当前项目可扮演角色动作槽位对齐。 -当前落地实现补充约束(`2026-04-19`): +当前落地实现补充约束(`2026-04-20`): -- 角色资产工坊默认固定生成入口收敛为 `idle / run / attack / die` -- `hurt` 不再作为固定按钮动作 +- 角色资产工坊固定生成入口仍为 `idle / run / attack / die` +- `run / attack` 是固定基础必生成动作 +- `idle / die` 改为固定可选动作,不再作为发布硬门槛 +- `idle` 未生成时默认直接使用主图静止显示 +- `die` 未生成时默认播放一段基于主图的倒地过渡动画,并最终停在翻转倒地姿态 +- 角色已配置的每个技能,都必须在技能编辑面板里补出对应动作预览 - 图生视频默认走火山方舟 `Seedance` 首尾帧方案 - 接口请求体中的两张参考图分别固定为 `first_frame / last_frame` - 固定参数为 `1:1`、`480p`、`4 秒`、单次 `1` 个视频 - 提示词中的动作名统一传英文动作名 -第一版要求以下基础动作槽位不能为空: +第一版动作生成按下面两层规则落地: -| 动作槽位 | 是否必填 | 备注 | -| --- | --- | --- | -| `idle` | 必填 | 循环动作 | -| `acquire` | 必填 | 可由短变体衍生 | -| `attack` | 必填 | 一次性动作 | -| `run` | 必填 | 循环动作 | -| `jump` | 必填 | 一次性动作 | -| `double_jump` | 必填 | 可由跳跃二次变体生成 | -| `jump_attack` | 必填 | 一次性动作 | -| `dash` | 必填 | 一次性动作 | -| `hurt` | 必填 | 一次性动作 | -| `die` | 必填 | 一次性动作 | -| `climb` | 必填 | 可由模板生成 | -| `wall_slide` | 必填 | 可由攀爬停帧变体生成 | +| 类别 | 动作槽位 | 是否必填 | 备注 | +| -------- | ------------------------------- | -------- | -------------------------------------------------- | +| 基础动作 | `run` | 必填 | 角色移动主循环动作 | +| 基础动作 | `attack` | 必填 | 角色普通攻击主动作 | +| 技能动作 | `skills[*].actionPreviewConfig` | 必填 | 当前角色每个已配置技能都要有独立动作资源 | +| 可选动作 | `idle` | 可选 | 缺失时默认走主图静止待机 | +| 可选动作 | `die` | 可选 | 缺失时默认走主图倒地过渡动画,最终停在翻转倒地姿态 | -这里“不能为空”指的是: +这里“必生成”指的是: -- 每个槽位必须最终指向一套可播放的资源 -- 允许少量槽位由近似动作衍生 -- 但不允许在运行时读到空动画映射 +- `run / attack` 必须最终指向可播放资源 +- 每个已配置技能都必须带独立 `actionPreviewConfig` +- 发布判定不再要求 `idle / die` 一定存在动画映射 +- 运行时仍然不能出现无可用表现;`idle / die` 的缺口由默认兜底承担 ## 8.2 技能动作要求 -本期不要求自动补齐: +本期不再要求把整套固定技能枚举一次性自动补齐,但对“角色当前实际配置的技能”改为必做: -- `skill1` -- `skill1_jump` -- `skill1_bullet` -- `skill1_bullet_fx` -- `skill2` -- `skill2_jump` -- `skill3` -- `skill3_jump` -- `skill3_bullet` -- `skill3_bullet_fx` -- `skill4` +- 不要求预先把 `skill1 / skill2 / skill3 / skill4` 这套历史枚举全部补满 +- 只要求当前角色 `skills` 数组里的每个技能都生成独立动作预览 +- 技能动作生成入口继续放在技能编辑面板逐个处理,不塞进固定四按钮里 结论: -- 技能动作本期可选 -- 基础动作本期必做 +- 技能动作从“固定枚举可选”调整为“按角色已配技能必做” +- 固定基础动作收敛为 `run / attack` +- `idle / die` 保留为可选增强动作 ## 8.3 动作生成方式 @@ -606,7 +597,7 @@ type GeneratedCharacterAnimationAsset = { 目标: -- 让基础动作槽位全部非空,并可一键发布 +- 让必生成动作全部就绪,并为 `idle / die` 提供明确默认兜底 产出: diff --git a/docs/prd/AI_NATIVE_AGENT_FIRST_CUSTOM_WORLD_CREATOR_PHASE5_IMPLEMENTATION_PLAN_2026-04-14.md b/docs/prd/AI_NATIVE_AGENT_FIRST_CUSTOM_WORLD_CREATOR_PHASE5_IMPLEMENTATION_PLAN_2026-04-14.md index 50d32193..d03205f8 100644 --- a/docs/prd/AI_NATIVE_AGENT_FIRST_CUSTOM_WORLD_CREATOR_PHASE5_IMPLEMENTATION_PLAN_2026-04-14.md +++ b/docs/prd/AI_NATIVE_AGENT_FIRST_CUSTOM_WORLD_CREATOR_PHASE5_IMPLEMENTATION_PLAN_2026-04-14.md @@ -200,7 +200,7 @@ 在 `CustomWorldAgentDraftDetailPanel` 中,当当前卡类型为: ```ts -kind === 'character' +kind === 'character'; ``` 显示按钮: @@ -239,11 +239,19 @@ kind === 'character' 基于主图生成当前工坊支持的核心动作: +1. `run` +2. `attack` + +可选增强动作: + 1. `idle` -2. `run` -3. `attack` -4. `hurt` -5. `die` +2. `die` + +补充约束: + +1. `run / attack` 为固定必生成动作 +2. 角色已配置技能时,对应技能动作也属于必生成动作 +3. `idle / die` 只作为可选增强,缺失时分别走主图静止 / 主图倒地过渡动画兜底,死亡动画最终停在翻转倒地姿态 ### 阶段 D:动作发布 @@ -350,15 +358,15 @@ type CustomWorldRoleAssetStatus = 发布主图成功后,必须写回: ```ts -imageSrc -generatedVisualAssetId +imageSrc; +generatedVisualAssetId; ``` 发布动作成功后,必须写回: ```ts -generatedAnimationSetId -animationMap +generatedAnimationSetId; +animationMap; ``` ### 明确要求 @@ -440,8 +448,8 @@ type SyncRoleAssetsResult = { ### 输入 ```ts -buildRoleAssetStudioContext(snapshot, roleId) -applyRoleAssetPublishResult(snapshot, payload) +buildRoleAssetStudioContext(snapshot, roleId); +applyRoleAssetPublishResult(snapshot, payload); ``` ### 说明 @@ -465,8 +473,8 @@ applyRoleAssetPublishResult(snapshot, payload) ### 导出函数建议 ```ts -rebuildRoleAssetCoverage(draftProfile) -mergeRoleAssetIntoDraftProfile(draftProfile, payload) +rebuildRoleAssetCoverage(draftProfile); +mergeRoleAssetIntoDraftProfile(draftProfile, payload); ``` ## 10.3 修改 `customWorldAgentOrchestrator.ts` @@ -598,7 +606,7 @@ showRoleAssetStudio: boolean; 3. 统一回调: ```ts -onPublishSuccess(payload) +onPublishSuccess(payload); ``` ### `onPublishSuccess` 最小字段 diff --git a/docs/prd/AI_NATIVE_AGENT_FIRST_CUSTOM_WORLD_CREATOR_PRD_2026-04-12.md b/docs/prd/AI_NATIVE_AGENT_FIRST_CUSTOM_WORLD_CREATOR_PRD_2026-04-12.md index cfa80262..eb8c9b46 100644 --- a/docs/prd/AI_NATIVE_AGENT_FIRST_CUSTOM_WORLD_CREATOR_PRD_2026-04-12.md +++ b/docs/prd/AI_NATIVE_AGENT_FIRST_CUSTOM_WORLD_CREATOR_PRD_2026-04-12.md @@ -39,9 +39,11 @@ 目标用户分三类: 1. 轻创作者 + - 有世界灵感,但不擅长结构化填表 2. 中度创作者 + - 愿意精修角色、地点、主线第一幕,但不想维护大量底层字段 3. 重度创作者 @@ -138,37 +140,48 @@ 本次 PRD 必须复用以下现有基础: 1. `src/services/customWorldCreatorIntent.ts` + - 已有创作者意图、锚点包、锁定状态的基础结构 2. `src/types/customWorld.ts` + - 已有 `creatorIntent / anchorPack / lockState / generationMode / generationStatus` 3. `src/services/aiService.ts` + - 已有自定义世界 session 与生成 API 客户端 4. `server-node/src/services/customWorldSessionStore.ts` + - 已有澄清问题与 session 的基础概念 5. `server-node/src/services/customWorldGenerationService.ts` + - 已有分阶段生成骨架 6. `src/components/game-shell/PreGameSelectionFlow.tsx` + - 已有世界创建流程入口 7. `src/components/CustomWorldResultView.tsx` + - 已有结果页壳层 8. `src/components/CustomWorldRoleAssetStudioModal.tsx` + - 已有角色主图与核心动作资产工坊原型 9. `src/services/ai.ts` + - 已有 `generateCustomWorldSceneImage(...)` 场景图生成入口 10. `server-node/src/modules/assets/characterAssetRoutes.ts` - - 已有角色主图发布、角色动作发布、动作模板等资产路由 + +- 已有角色主图发布、角色动作发布、动作模板等资产路由 11. `server-node/src/routes/runtimeRoutes.ts` - - 已有 `/custom-world/scene-image` 场景背景图生成路由 + +- 已有 `/custom-world/scene-image` 场景背景图生成路由 ## 3.2 必须替换或重构的现有行为 @@ -220,6 +233,7 @@ 最终必须输出两类产物: 1. 创作工作产物 + - 世界圣经摘要 - 关键角色卡 - 关键地点卡 @@ -478,9 +492,11 @@ type CustomWorldAssetPriorityTier = 'hero' | 'featured' | 'supporting'; 判定规则: 1. `hero` + - 所有 `playableNpcs` 2. `featured` + - 被锁定的 `storyNpcs` - 主线第一幕直接关联的 `storyNpcs` - 势力代表角色 @@ -499,6 +515,7 @@ type CustomWorldScenePriorityTier = 'key' | 'supporting'; 判定规则: 1. `key` + - `camp` - 被锁定的 `landmark` - 主线第一幕直接关联的 `landmark` @@ -530,32 +547,43 @@ type CustomWorldScenePriorityTier = 'key' | 'supporting'; ### 动作抽卡策略 -角色动作不能一开始就把完整核心动作集全部抽出来。 +角色动作不能一开始就把所有动作一次性抽完。 -必须采用两段式: +必须采用“先必需、再增强”的两层策略: -#### 阶段 A:动作试片 +#### 阶段 A:基础必需动作 -每个角色先只生成: +每个角色先生成: -1. `idle` +1. `run` 2. `attack` 用途: 1. 检查角色一致性是否稳定 -2. 检查动作风格是否匹配 +2. 检查移动和出手两条主动作是否可用 3. 检查武器、衣摆和轮廓是否容易漂移 -#### 阶段 B:完整核心动作集 +#### 阶段 B:技能动作补齐 -只有当动作试片确认通过后,才允许生成: +当角色基础动作通过后,再逐个补当前角色已经配置的技能动作。 -1. `run` -2. `hurt` -3. `die` +要求: -加上已确认的 `idle / attack`,组成当前阶段完整核心动作集。 +1. 每个技能都必须有独立 `actionPreviewConfig` +2. 技能动作入口放在技能编辑面板,不并入固定四按钮 + +#### 可选增强动作 + +以下动作不再作为发布硬门槛,可按需要补: + +1. `idle` +2. `die` + +默认兜底: + +1. `idle` 缺失时使用主图静止 +2. `die` 缺失时使用主图倒地过渡动画,最终停在翻转倒地姿态 ### 场景图抽卡策略 @@ -616,16 +644,26 @@ type CustomWorldScenePriorityTier = 'key' | 'supporting'; 发布前,每个角色至少需要以下动作槽位可用: -1. `idle` -2. `run` -3. `attack` -4. `hurt` -5. `die` +1. `run` +2. `attack` +3. 当前角色 `skills` 中每个技能的 `actionPreviewConfig` 判定方式: 1. `generatedAnimationSetId` 非空 -2. `animationMap` 中以上 5 个槽位都存在有效映射 +2. `animationMap` 中至少存在有效的 `run / attack` +3. `skills` 数组里的每个技能都带有效 `actionPreviewConfig` + +可选动作: + +1. `idle` +2. `die` + +说明: + +1. `idle / die` 不再是发布硬门槛 +2. `idle` 缺失时运行时默认使用主图静止 +3. `die` 缺失时运行时默认播放主图倒地过渡动画,最终停在翻转倒地姿态 说明: @@ -664,34 +702,34 @@ type CustomWorldAgentStage = ## 6.2 状态迁移规则 -| 当前阶段 | 触发 | 下一阶段 | -| --- | --- | --- | -| `collecting_intent` | 最小锚点不足,Agent 追问 | `clarifying` | -| `clarifying` | 用户补齐锚点 | `foundation_review` | -| `collecting_intent` | 用户信息已足够并请求底稿 | `foundation_review` | -| `foundation_review` | 用户精修关键对象 | `object_refining` | -| `object_refining` | 用户请求生成角色或场景资产 | `visual_refining` | -| `visual_refining` | 关键角色与场景资产进入可用状态 | `long_tail_review` | -| `object_refining` | 用户明确跳过人工精修并走自动补齐 | `long_tail_review` | -| `long_tail_review` | 用户请求发布 | `ready_to_publish` | -| `ready_to_publish` | 发布成功 | `published` | -| 任意阶段 | 发生不可恢复错误 | `error` | +| 当前阶段 | 触发 | 下一阶段 | +| ------------------- | -------------------------------- | ------------------- | +| `collecting_intent` | 最小锚点不足,Agent 追问 | `clarifying` | +| `clarifying` | 用户补齐锚点 | `foundation_review` | +| `collecting_intent` | 用户信息已足够并请求底稿 | `foundation_review` | +| `foundation_review` | 用户精修关键对象 | `object_refining` | +| `object_refining` | 用户请求生成角色或场景资产 | `visual_refining` | +| `visual_refining` | 关键角色与场景资产进入可用状态 | `long_tail_review` | +| `object_refining` | 用户明确跳过人工精修并走自动补齐 | `long_tail_review` | +| `long_tail_review` | 用户请求发布 | `ready_to_publish` | +| `ready_to_publish` | 发布成功 | `published` | +| 任意阶段 | 发生不可恢复错误 | `error` | ## 6.3 阶段显示规则 前端顶部摘要区必须展示当前阶段中文标签: -| 阶段 | 展示文案 | -| --- | --- | +| 阶段 | 展示文案 | +| ------------------- | ------------ | | `collecting_intent` | 收集世界锚点 | -| `clarifying` | 补充关键设定 | +| `clarifying` | 补充关键设定 | | `foundation_review` | 校对世界底稿 | -| `object_refining` | 精修关键对象 | -| `visual_refining` | 生成视觉资产 | -| `long_tail_review` | 补全长尾内容 | -| `ready_to_publish` | 准备发布 | -| `published` | 已发布 | -| `error` | 处理异常 | +| `object_refining` | 精修关键对象 | +| `visual_refining` | 生成视觉资产 | +| `long_tail_review` | 补全长尾内容 | +| `ready_to_publish` | 准备发布 | +| `published` | 已发布 | +| `error` | 处理异常 | --- @@ -980,7 +1018,15 @@ interface SendCustomWorldAgentMessageResponse { type CustomWorldAgentActionRequest = | { action: 'lock_cards'; cardIds: string[] } | { action: 'unlock_cards'; cardIds: string[] } - | { action: 'regenerate_scope'; scope: 'focus_card' | 'long_tail_npcs' | 'long_tail_landmarks' | 'sidequest_seeds'; targetCardId?: string | null } + | { + action: 'regenerate_scope'; + scope: + | 'focus_card' + | 'long_tail_npcs' + | 'long_tail_landmarks' + | 'sidequest_seeds'; + targetCardId?: string | null; + } | { action: 'draft_foundation' } | { action: 'generate_role_assets'; roleIds: string[] } | { @@ -1909,9 +1955,12 @@ Agent 会话每次 operation 完成后自动保存 session snapshot。 15. `src/components/custom-world-agent/CustomWorldAgentDraftDetailPanel.tsx` 16. `src/components/custom-world-agent/CustomWorldSceneAssetStudioModal.tsx` 17. `src/components/CustomWorldRoleAssetStudioModal.tsx` - - 改成 Agent 可调用版 + +- 改成 Agent 可调用版 + 18. `src/components/asset-studio/characterAssetWorkflowPersistence.ts` - - 继续复用现有资产接口客户端 + +- 继续复用现有资产接口客户端 ## 15.3 backend diff --git a/docs/prd/AI_NATIVE_BATTLE_SINGLE_ACTION_FUNCTION_PRD_2026-04-18.md b/docs/prd/AI_NATIVE_BATTLE_SINGLE_ACTION_FUNCTION_PRD_2026-04-18.md index a78a64b2..c3cfb4bb 100644 --- a/docs/prd/AI_NATIVE_BATTLE_SINGLE_ACTION_FUNCTION_PRD_2026-04-18.md +++ b/docs/prd/AI_NATIVE_BATTLE_SINGLE_ACTION_FUNCTION_PRD_2026-04-18.md @@ -109,6 +109,7 @@ - 技能冷却要按“本次动作结束后”推进 - 恢复类动作可额外提供冷却推进收益 - 物品动作在战斗态下也算一次战斗回合 +- 战斗中使用物品要先结算物品恢复 / buff / 额外冷却收益,再结算这一回合是否承受敌方单次反击 ### 4.3 结果文本 @@ -200,6 +201,12 @@ ongoing battle 的本地/后端结果文本只负责说明这一次动作结算 5. 前端支持 disabled battle option 展示 6. 文档、测试同步更新 +补充落地备注(2026-04-20): + +- `inventory_use` 在战斗中按战斗动作结算,而不是按非战斗库存动作直接短路返回 +- 战斗态 `inventory_use` 使用后要消费物品、累计 `itemsUsed`、推进 1 回合基础冷却,再叠加物品自带的 `cooldownReduction` +- 若物品动作结算后战斗仍在继续,`storyText` 直接等于本次战斗结果文本,不触发 AI 续写 + 本期不做: 1. 新增复杂目标选择 UI diff --git a/docs/prd/MY_TAB_SETTINGS_AND_SECURITY_PRD_2026-04-16.md b/docs/prd/MY_TAB_SETTINGS_AND_SECURITY_PRD_2026-04-16.md index 383070cb..86735fca 100644 --- a/docs/prd/MY_TAB_SETTINGS_AND_SECURITY_PRD_2026-04-16.md +++ b/docs/prd/MY_TAB_SETTINGS_AND_SECURITY_PRD_2026-04-16.md @@ -1,6 +1,6 @@ # “我的”Tab 设置与账号安全 PRD -更新时间:`2026-04-19` +更新时间:`2026-04-20` ## 0. 目标 @@ -56,7 +56,12 @@ ## 3. 信息架构 -设置中心建议固定为五段: +设置中心首层固定为两段: + +1. 主题外观 +2. 账号信息 + +其中“账号信息”二级面板固定承载以下内容: 1. 账号概况 2. 当前安全状态 @@ -66,10 +71,14 @@ 交互层级要求补充为: -1. 设置首页只展示分区入口与危险操作,不在首页内联展开具体详情 +1. 设置首页只展示“主题外观”“账号信息”两个分区入口与危险操作,不在首页内联展开具体详情 2. 点击任一分区入口后,必须进入独立二级面板 -3. 二级面板负责单一任务,不允许把详情继续堆在入口列表下面 -4. 更换手机号属于独立操作面板,不允许在账号概况面板内直接展开表单 +3. 安全状态、登录设备、操作记录不再作为首页独立入口,统一归入“账号信息”二级面板 +4. 更换手机号属于独立操作面板,不允许在账号信息面板内直接展开表单 +5. 设置首页头部只保留一套主标题,不允许在内容区再重复放置“设置首页”“选择要管理的内容”这类二次标题块 +6. 子面板导航动作必须单一明确;同一层面板内有“返回”时,不再同时展示“关闭” +7. 设置首页与各级子面板都必须定义单一滚动容器,列表内容必须可稳定滚动,禁止外层与内层同时争夺滚动 +8. 二级或三级面板打开后,下层内容必须进入不可交互状态,并把焦点主动转移到当前面板内;禁止对仍保留焦点的祖先节点使用 `aria-hidden` 底部保留两个危险操作按钮: @@ -87,7 +96,6 @@ - 登录方式 - 手机号脱敏值 - 微信绑定状态 -- 账号状态 这里只看信息,不做大编辑动作。 @@ -201,11 +209,14 @@ 1. 设置继续采用当前账号弹窗基础形态即可 2. 移动端优先底部弹层,桌面端可居中弹窗 -3. 设置首页只保留分区入口,不直接承载分区详情内容 -4. 分区详情必须通过独立子面板承载,移动端优先使用全宽底部子弹层,桌面端使用覆盖在设置首页之上的居中子面板 +3. 设置首页只保留“主题外观”“账号信息”两个入口,不再单独展示安全状态、登录设备、操作记录入口 +4. “账号信息”二级面板直接承载账号概况、安全状态、登录设备、操作记录四块内容,移动端优先纵向滚动,桌面端保持同一面板内稳定扫读 5. 更换手机号必须通过独立操作面板完成,不再使用当前面板内联展开表单 6. 危险操作按钮与普通按钮必须明显区分 7. 设置首页标题处禁止展示手机号、脱敏手机号或手机号形态的 displayName +8. 设置首页不额外堆砌规则说明文案,标题下直接进入可操作内容 +9. 子面板采用覆盖式独立面板承载详情,返回上一级时恢复首页,不在同层同时出现双导航动作 +10. 面板切换必须保证键盘焦点始终停留在当前活跃面板内,返回上一级后焦点恢复到触发入口 --- diff --git a/docs/prd/PLATFORM_SAVE_TAB_PRD_2026-04-19.md b/docs/prd/PLATFORM_SAVE_TAB_PRD_2026-04-19.md index 3f1e0961..e51924d9 100644 --- a/docs/prd/PLATFORM_SAVE_TAB_PRD_2026-04-19.md +++ b/docs/prd/PLATFORM_SAVE_TAB_PRD_2026-04-19.md @@ -1,6 +1,6 @@ # 平台“存档”Tab PRD -更新时间:`2026-04-19` +更新时间:`2026-04-20` ## 0. 目标 @@ -84,15 +84,13 @@ ## 3.1 存档 Tab 首屏结构 -页面由两部分组成: +页面首屏直接展示存档列表,不再单独保留顶部“最近存档”摘要卡。 -1. 顶部摘要卡 -2. 存档列表 +列表容器本身需要承担首屏入口作用: -顶部摘要卡用于表达: - -- 当前共有多少个可恢复存档 -- 最近一次更新的存档是谁 +- 用户进入“存档”Tab 后第一屏就看到可恢复存档列表 +- 不额外重复展示首个存档的大卡摘要 +- 存档数量、排序状态如需表达,应收敛在列表标题或轻量状态信息中 不要在 UI 中默认堆规则说明文案,只保留简洁的状态表达。 diff --git a/docs/technical/AI_CHARACTER_ANIMATION_TECHNICAL_SOLUTION_2026-04-04.md b/docs/technical/AI_CHARACTER_ANIMATION_TECHNICAL_SOLUTION_2026-04-04.md index f295840f..35669b1d 100644 --- a/docs/technical/AI_CHARACTER_ANIMATION_TECHNICAL_SOLUTION_2026-04-04.md +++ b/docs/technical/AI_CHARACTER_ANIMATION_TECHNICAL_SOLUTION_2026-04-04.md @@ -274,6 +274,14 @@ - 当前固定动作入口收敛为 `idle / run / attack / die`,不再内置固定 `hurt` - 提示词里传给视频模型的动作名统一使用英文动作名 +实现更新(`2026-04-20`): + +- `run / attack` 是当前固定动作入口里的基础必生成动作 +- `idle / die` 改为可选增强动作,不再作为资产完成度硬门槛 +- `idle` 缺失时运行时默认使用主图静止 +- `die` 缺失时运行时默认播放一段基于主图的倒地过渡动画,并最终停在翻转倒地姿态 +- 技能动作不走固定按钮,但对当前角色 `skills` 中的每个技能都属于必生成动作 + ## 5.3 补充路线:腾讯云相关能力 腾讯云相关接口里,`提交图片跳舞任务` 提供了: @@ -435,6 +443,11 @@ 系统自动选择对应参考视频模板。 +其中: + +- `run / attack` 属于固定必生成动作 +- `idle / die` 属于固定可选动作,未生成时走默认兜底 + `jump`、`hurt` 这类扩展动作不再作为当前编辑器固定按钮,改为后续扩展动作槽位或手动补齐。 ### B. 视频驱动 @@ -941,7 +954,7 @@ draft ### 12.1 基础动作槽位必须非空 -第一版要求以下基础动作槽位全部有内容: +第一版要求以下动作能力按“必生成 / 可选兜底”拆开: 当前编辑器固定生成入口补充说明(`2026-04-19`): @@ -949,47 +962,33 @@ draft - `hurt` 不再作为固定生成按钮 - 如果运行时仍需 `hurt` 资源,应通过后续扩展动作槽位或手动补齐 -| 动作槽位 | 是否必填 | 建议来源 | -| ------------- | -------- | ------------------------- | -| `idle` | 必填 | 模板生成 | -| `acquire` | 必填 | 可由短持物 / 抬手动作生成 | -| `attack` | 必填 | 模板生成 | -| `run` | 必填 | 模板生成 | -| `jump` | 必填 | 模板生成 | -| `double_jump` | 必填 | 可由跳跃二次变体生成 | -| `jump_attack` | 必填 | 跳跃攻击模板 | -| `dash` | 必填 | 冲刺模板 | -| `hurt` | 必填 | 受击模板 | -| `die` | 必填 | 倒地 / 消散模板 | -| `climb` | 必填 | 攀爬模板 | -| `wall_slide` | 必填 | 可由攀爬或停滞帧变体生成 | +| 动作能力 | 是否必填 | 建议来源 | +| ------------------------------- | -------- | ---------------------------------------------------------- | +| `run` | 必填 | 模板生成 | +| `attack` | 必填 | 模板生成 | +| `skills[*].actionPreviewConfig` | 必填 | 技能编辑面板逐个生成 | +| `idle` | 可选 | 模板生成;缺失时默认主图静止 | +| `die` | 可选 | 模板生成;缺失时默认主图倒地过渡动画,最终停在翻转倒地姿态 | -这里“不能为空”指的是: +这里“必填”指的是: -- 每个基础动作槽位必须能挂到一套可播放资产 -- 不允许在运行时出现 `null` 或空映射 -- 个别低优先动作允许由近似动作衍生,但槽位本身必须有有效资源 +- `run / attack` 必须能挂到一套可播放资产 +- 角色当前每个技能都必须有可播放的 `actionPreviewConfig` +- `idle / die` 不再进入“缺失即阻塞发布”的判断 +- 运行时表现仍然不能空白;`idle / die` 的缺口由默认兜底承接 -### 12.2 技能动作不是第一版强制项 +### 12.2 技能动作改为“按角色已配技能强制” -第一版可选: +第一版不再要求预留整套固定技能枚举,但要求: -- `skill1` -- `skill1_jump` -- `skill1_bullet` -- `skill1_bullet_fx` -- `skill2` -- `skill2_jump` -- `skill3` -- `skill3_jump` -- `skill3_bullet` -- `skill3_bullet_fx` -- `skill4` +- 当前角色 `skills` 数组里的每个技能都要补出 `actionPreviewConfig` +- 技能动作继续在技能编辑面板逐个生成,不并入固定四按钮 策略建议: -- 基础动作先全量补齐 -- 技能动作后续按角色职业差异再补 +- 先补 `run / attack` +- 再逐个补当前角色已有技能动作 +- `idle / die` 作为可选增强按需要补 - 投射物与特效优先继续复用当前项目已有素材与技能特效系统 ### 12.3 第一阶段优先模板 diff --git a/docs/technical/SCENE_MULTI_ACT_CREATOR_IMPLEMENTATION_PROGRESS_2026-04-20.md b/docs/technical/SCENE_MULTI_ACT_CREATOR_IMPLEMENTATION_PROGRESS_2026-04-20.md new file mode 100644 index 00000000..f2ef4c67 --- /dev/null +++ b/docs/technical/SCENE_MULTI_ACT_CREATOR_IMPLEMENTATION_PROGRESS_2026-04-20.md @@ -0,0 +1,97 @@ +# 场景多幕创作与流程改造实施进度 2026-04-20 + +更新时间:`2026-04-20` + +## 1. 本轮落地范围 + +本轮先完成 `scene_chapter` 的第一批基础链路,让“场景章节 -> 多幕 -> 主角色 -> 幕背景/相遇 NPC”真正进入现有创作工具和草稿系统。 + +本轮目标不是一次性做完 PRD 全量能力,而是先把下面这条主干打通: + +1. 草稿层可以承载 `scene chapter / scene act` +2. 草稿编译器可以把 `scene_chapter` 编译成正式卡片 +3. 创作页可以看到、打开、编辑 `scene_chapter` +4. 编辑后的幕信息可以正确写回草稿 +5. 运行时共享层先具备读取幕背景、主角色、相遇 NPC 池的基础能力 + +## 2. 本轮已落地 + +## 2.1 草稿与运行时结构 + +已补齐多幕相关结构: + +1. `CustomWorldFoundationDraftProfile.sceneChapters` +2. `CustomWorldFoundationDraftSceneChapter` +3. `CustomWorldFoundationDraftSceneAct` +4. `CustomWorldProfile.sceneChapterBlueprints` +5. `StoryEngineMemoryState.currentSceneActState` + +同时补齐了地点/营地草稿里的 `imageSrc`,避免幕背景回落时丢失现有场景图资产引用。 + +## 2.2 scene_chapter 草稿编译 + +`server-node/src/services/customWorldAgentDraftCompiler.ts` 已完成第一批接入: + +1. `scene_chapter` 正式进入草稿编译结果 +2. 支持从显式 `sceneChapters` 或地点/章节数据回退生成场景章节卡 +3. 每张卡会编译出场景摘要、幕结构总览、每幕背景图、主角色、辅助 NPC、幕目标、过渡钩子 +4. 每幕生成动态可编辑 section id +5. 已增加基础警告: + - 幕数不足 + - 缺背景图 + - 缺相遇 NPC + - 主角色不在第一位 + - 缺线程挂钩 + - NPC 或线程引用失配 + +## 2.3 scene_chapter 草稿编辑 + +`server-node/src/services/customWorldAgentDraftEditService.ts` 已支持: + +1. 编辑场景章节标题、摘要 +2. 编辑每幕标题、摘要、背景图链接、相遇 NPC、幕目标、过渡钩子 +3. `encounterNpcIds` 支持用角色 id 或角色名回写 +4. 回写后自动用第一位 NPC 覆盖 `primaryNpcId` + +`server-node/src/services/customWorldAgentChangeSummaryService.ts` 也已支持解析 `scene_chapter` 标题。 + +## 2.4 创作页展示 + +前端已完成第一批接入: + +1. 草稿抽屉正式加入 `scene_chapter` 分组 +2. `scene_chapter` 分组顺序位于 `chapter` 后、`thread` 前 +3. 详情面板已支持 `场景章节` 类型标签 +4. 幕背景 section 在详情面板里会直接渲染图片预览 +5. 编辑面板已支持幕摘要 / 相遇 NPC / 幕目标 / 过渡钩子等动态多行字段 + +## 2.5 运行时基础层 + +本轮同步补齐了幕运行的基础读取能力,便于下一轮继续接游戏流程: + +1. 当前幕背景图优先覆盖场景默认背景 +2. 当前幕相遇 NPC 池可参与场景相遇过滤 +3. 当前幕主角色与负好感有限聊天的判定 helper 已建立 +4. 场景预览层已能识别“负好感主角色不直接自动开战”的基础分支 + +## 3. 当前仍未完成 + +下面这些仍属于 PRD 未完项,需要下一轮继续: + +1. 创作页里的“新增幕 / 删除幕 / 调整幕顺序”交互 +2. 背景图配置与 NPC 配置的独立面板化交互 +3. 发布期 `qualityFindings` / blocker 的正式接入 +4. `SceneActRuntimeState` 的完整推进与持久化 +5. 当前幕主角色负好感 `5` 轮聊天限制的前后端完整闭环 +6. 第 `5` 轮“铺垫式收束”提示与强制退出聊天态 +7. 幕切换后的系统提示与 Adventure 面板状态展示 + +## 4. 下一轮建议顺序 + +建议下一轮按下面顺序继续: + +1. 先补 `SceneActRuntimeState` 初始化与幕推进 +2. 再接 `npcEncounterActions / aiService / chatOrchestrator` 的负好感有限聊天闭环 +3. 最后补创作页的幕增删改序和独立配置面板 + +这样可以先把“能跑”补齐,再把“编辑体验”补完整。 diff --git a/packages/shared/src/contracts/customWorldAgent.ts b/packages/shared/src/contracts/customWorldAgent.ts index 2ba99892..9bb63bd9 100644 --- a/packages/shared/src/contracts/customWorldAgent.ts +++ b/packages/shared/src/contracts/customWorldAgent.ts @@ -197,6 +197,11 @@ export interface CustomWorldFoundationDraftCharacter { relationToPlayer: string; threadIds: string[]; summary: string; + skills?: Array<{ + id: string; + name: string; + actionPreviewConfig?: Record | null; + }>; imageSrc?: string | null; generatedVisualAssetId?: string | null; generatedAnimationSetId?: string | null; @@ -212,6 +217,7 @@ export interface CustomWorldFoundationDraftLandmark { importance: string; secret?: string; dangerLevel?: string; + imageSrc?: string | null; characterIds: string[]; threadIds: string[]; summary: string; @@ -246,9 +252,48 @@ export interface CustomWorldFoundationDraftCamp { description: string; mood: string; dangerLevel?: string; + imageSrc?: string | null; summary: string; } +export type CustomWorldSceneActStage = + | 'opening' + | 'expansion' + | 'turning_point' + | 'climax' + | 'aftermath'; + +export type CustomWorldSceneActAdvanceRule = + | 'after_primary_contact' + | 'after_active_step_complete' + | 'after_chapter_resolution'; + +export interface CustomWorldFoundationDraftSceneAct { + id: string; + title: string; + summary: string; + stageCoverage: CustomWorldSceneActStage[]; + backgroundImageSrc?: string | null; + backgroundAssetId?: string | null; + encounterNpcIds: string[]; + primaryNpcId: string; + linkedThreadIds: string[]; + actGoal: string; + transitionHook: string; + advanceRule: CustomWorldSceneActAdvanceRule; +} + +export interface CustomWorldFoundationDraftSceneChapter { + id: string; + sceneId: string; + sceneName: string; + title: string; + summary: string; + linkedThreadIds: string[]; + linkedLandmarkIds: string[]; + acts: CustomWorldFoundationDraftSceneAct[]; +} + export interface CustomWorldFoundationDraftProfile { name: string; subtitle: string; @@ -266,6 +311,7 @@ export interface CustomWorldFoundationDraftProfile { factions: CustomWorldFoundationDraftFaction[]; threads: CustomWorldFoundationDraftThread[]; chapters: CustomWorldFoundationDraftChapter[]; + sceneChapters: CustomWorldFoundationDraftSceneChapter[]; worldHook: string; playerPremise: string; openingSituation: string; diff --git a/packages/shared/src/contracts/story.ts b/packages/shared/src/contracts/story.ts index 4d9cb352..f21b109a 100644 --- a/packages/shared/src/contracts/story.ts +++ b/packages/shared/src/contracts/story.ts @@ -83,6 +83,26 @@ export type PlainTextResponse = { text: string; }; +export type NpcChatTurnLimitReason = 'negative_affinity'; + +export type NpcChatTurnClosingMode = 'free' | 'foreshadow_close'; + +export type NpcChatTurnDirective = { + sceneActId?: string | null; + turnLimit?: number | null; + remainingTurns?: number | null; + limitReason?: NpcChatTurnLimitReason | null; + closingMode?: NpcChatTurnClosingMode | null; + forceExitAfterTurn?: boolean; +}; + +export type NpcChatTurnCompletionDirective = { + turnLimit?: number | null; + remainingTurns?: number | null; + forceExit?: boolean; + closingMode?: NpcChatTurnClosingMode; +}; + export type CharacterChatReplyRequest< TCharacter = unknown, TStoryMoment = unknown, @@ -162,6 +182,7 @@ export type NpcChatTurnRequest< TNpcState = unknown, TQuestOfferState = unknown, TQuestOfferEncounter = unknown, + TChatDirective = NpcChatTurnDirective, > = { worldType: string; character?: TCharacter; @@ -179,6 +200,7 @@ export type NpcChatTurnRequest< encounter: TQuestOfferEncounter; turnCount: number; } | null; + chatDirective?: TChatDirective | null; }; export type NpcChatPendingQuestOffer = { @@ -192,6 +214,7 @@ export type NpcChatTurnResult = { affinityText: string; suggestions: string[]; pendingQuestOffer?: NpcChatPendingQuestOffer | null; + chatDirective?: NpcChatTurnCompletionDirective | null; }; export type NpcRecruitDialogueRequest< diff --git a/server-node/src/modules/combat/combatResolutionService.ts b/server-node/src/modules/combat/combatResolutionService.ts index f75b2c8c..21152e8b 100644 --- a/server-node/src/modules/combat/combatResolutionService.ts +++ b/server-node/src/modules/combat/combatResolutionService.ts @@ -3,6 +3,13 @@ import type { RuntimeStoryChoicePayload, RuntimeStoryPatch, } from '../../../../packages/shared/src/contracts/story.js'; +import { + buildInventoryUseResultText, + incrementGameRuntimeStats, + isInventoryItemUsable, + removeInventoryItem, + resolveInventoryItemUseEffect, +} from '../../bridges/legacyInventoryRuntimeBridge.js'; import { conflict } from '../../errors.js'; import { appendBuildBuffs, @@ -32,6 +39,9 @@ type CombatActionConfig = { tags: string[]; durationTurns: number; }>; + consumedItemId?: string | null; + usedItem?: RuntimeCombatInventoryItem | null; + itemEffect?: NonNullable> | null; }; export type CombatResolution = { @@ -50,6 +60,13 @@ const LEGACY_ATTACK_FUNCTION_IDS = new Set([ 'battle_finisher_window', ]); +type RuntimeCombatInventoryItem = Parameters< + typeof resolveInventoryItemUseEffect +>[0] & { + id: string; + quantity: number; +}; + function isObject(value: unknown): value is Record { return typeof value === 'object' && value !== null && !Array.isArray(value); } @@ -58,10 +75,57 @@ function readString(value: unknown) { return typeof value === 'string' && value.trim() ? value.trim() : ''; } +function readNumber(value: unknown, fallback = 0) { + return typeof value === 'number' && Number.isFinite(value) ? value : fallback; +} + +function readArray(value: unknown) { + return Array.isArray(value) ? value : []; +} + function getAliveTarget(session: RuntimeSession) { return session.sceneHostileNpcs.find((npc) => npc.hp > 0) ?? null; } +function getCombatInventoryItem( + session: RuntimeSession, + itemId: string, +): RuntimeCombatInventoryItem | null { + const rawItem = readArray(session.rawGameState.playerInventory).find( + (candidate) => isObject(candidate) && readString(candidate.id) === itemId, + ); + if (!rawItem || !isObject(rawItem)) { + return null; + } + + const name = readString(rawItem.name, itemId); + if (!name) { + return null; + } + + const rarity = readString(rawItem.rarity, 'common'); + const normalizedRarity = + rarity === 'legendary' || + rarity === 'epic' || + rarity === 'rare' || + rarity === 'uncommon' + ? rarity + : 'common'; + + return { + id: itemId, + name, + quantity: Math.max(0, Math.round(readNumber(rawItem.quantity, 0))), + rarity: normalizedRarity, + tags: readArray(rawItem.tags).filter( + (tag): tag is string => typeof tag === 'string' && tag.trim().length > 0, + ), + useProfile: isObject(rawItem.useProfile) + ? (rawItem.useProfile as RuntimeCombatInventoryItem['useProfile']) + : undefined, + }; +} + function applySparAffinityReward(session: RuntimeSession) { const npcState = getEncounterNpcState(session); const encounter = session.currentEncounter; @@ -210,6 +274,53 @@ function resolveCombatActionConfig(params: { } satisfies CombatActionConfig; } + if (functionId === 'inventory_use') { + const character = getPlayerCharacter(session); + if (!character) { + throw conflict('缺少玩家角色,无法结算战斗物品动作'); + } + + const itemId = readString(isObject(payload) ? payload.itemId : ''); + if (!itemId) { + throw conflict('inventory_use 缺少 itemId'); + } + + const item = getCombatInventoryItem(session, itemId); + if (!item || item.quantity <= 0) { + throw conflict('未找到可用于战斗结算的物品'); + } + + if (!isInventoryItemUsable(item)) { + throw conflict(`${item.name} 当前不可在战斗中直接使用`); + } + + const effect = resolveInventoryItemUseEffect(item, character); + if ( + !effect || + ((effect.hpRestore ?? 0) <= 0 && + (effect.manaRestore ?? 0) <= 0 && + (effect.cooldownReduction ?? 0) <= 0 && + (effect.buildBuffs?.length ?? 0) <= 0) + ) { + throw conflict(`${item.name} 当前没有可直接结算的战斗效果`); + } + + return { + actionText: `使用${item.name}`, + manaCost: 0, + baseDamage: 0, + counterMultiplier: 0.72, + heal: effect.hpRestore, + manaRestore: effect.manaRestore, + cooldownBonus: effect.cooldownReduction, + selectedSkillId: null, + buildBuffs: effect.buildBuffs, + consumedItemId: item.id, + usedItem: item, + itemEffect: effect, + } satisfies CombatActionConfig; + } + throw conflict(`暂不支持的战斗动作:${functionId}`); } @@ -304,6 +415,25 @@ export function resolveCombatAction( } session.rawGameState.playerSkillCooldowns = nextCooldowns; + if (action.consumedItemId) { + session.rawGameState.playerInventory = removeInventoryItem( + session.rawGameState.playerInventory as Parameters[0], + action.consumedItemId, + 1, + ); + session.rawGameState.runtimeStats = incrementGameRuntimeStats( + (isObject(session.rawGameState.runtimeStats) + ? session.rawGameState.runtimeStats + : { + hostileNpcsDefeated: 0, + questsAccepted: 0, + itemsUsed: 0, + scenesTraveled: 0, + }) as Parameters[0], + { itemsUsed: 1 }, + ); + } + if (action.buildBuffs?.length) { session.rawGameState.activeBuildBuffs = appendBuildBuffs( (session.rawGameState.activeBuildBuffs as Parameters[0]) ?? @@ -354,7 +484,10 @@ export function resolveCombatAction( patches.push(affinityPatch); } outcome = 'spar_complete'; - resultText = `${target.name}也把你逼到了极限,这场切磋点到为止,双方都默认收手。`; + resultText = + params.functionId === 'inventory_use' && action.usedItem + ? `你刚用下${action.usedItem.name}稳住一口气,但${target.name}还是把你逼到了极限,这场切磋点到为止。` + : `${target.name}也把你逼到了极限,这场切磋点到为止,双方都默认收手。`; } else if (!isSpar && session.playerHp <= 0) { session.playerHp = 0; session.inBattle = false; @@ -363,9 +496,21 @@ export function resolveCombatAction( session.npcInteractionActive = false; session.currentEncounter = null; outcome = 'escaped'; - resultText = `你在和${target.name}的交锋里被压到失去战斗能力,这轮正面冲突只能先断开。`; + resultText = + params.functionId === 'inventory_use' && action.usedItem + ? `你刚把${action.usedItem.name}用下去,却还是被${target.name}压到失去战斗能力,这轮正面冲突只能先断开。` + : `你在和${target.name}的交锋里被压到失去战斗能力,这轮正面冲突只能先断开。`; } else if (params.functionId === 'battle_recover_breath') { resultText = `你先把伤势和气息稳住了一轮,但${target.name}仍在持续逼近。`; + } else if ( + params.functionId === 'inventory_use' && + action.usedItem && + action.itemEffect + ) { + resultText = `${buildInventoryUseResultText( + action.usedItem, + action.itemEffect, + ).replace(/。$/u, '')},但${target.name}仍在持续逼近。`; } else if (params.functionId === 'battle_use_skill') { resultText = `${action.actionText}命中了${target.name},这一轮技能效果已经直接结算。`; } else { diff --git a/server-node/src/modules/custom-world/runtimeTypes.ts b/server-node/src/modules/custom-world/runtimeTypes.ts index 77e2e117..2bc15047 100644 --- a/server-node/src/modules/custom-world/runtimeTypes.ts +++ b/server-node/src/modules/custom-world/runtimeTypes.ts @@ -225,6 +225,43 @@ export interface CustomWorldSceneConnection { summary: string; } +export type SceneActStage = + | 'opening' + | 'expansion' + | 'turning_point' + | 'climax' + | 'aftermath'; + +export type SceneActAdvanceRule = + | 'after_primary_contact' + | 'after_active_step_complete' + | 'after_chapter_resolution'; + +export interface SceneActBlueprint { + id: string; + sceneId: string; + title: string; + summary: string; + stageCoverage: SceneActStage[]; + backgroundImageSrc?: string | null; + encounterNpcIds: string[]; + primaryNpcId: string; + linkedThreadIds: string[]; + advanceRule: SceneActAdvanceRule; + actGoal: string; + transitionHook: string; +} + +export interface SceneChapterBlueprint { + id: string; + sceneId: string; + title: string; + summary: string; + linkedThreadIds: string[]; + linkedLandmarkIds: string[]; + acts: SceneActBlueprint[]; +} + export interface CustomWorldCampScene { name: string; description: string; @@ -323,6 +360,7 @@ export interface CustomWorldProfile { storyGraph?: WorldStoryGraph | null; knowledgeFacts?: Array> | null; threadContracts?: Array> | null; + sceneChapterBlueprints?: SceneChapterBlueprint[] | null; anchorContent?: Record | null; creatorIntent?: CustomWorldCreatorIntent | null; anchorPack?: CustomWorldAnchorPack | null; diff --git a/server-node/src/modules/progression/levelBenchmarks.ts b/server-node/src/modules/progression/levelBenchmarks.ts new file mode 100644 index 00000000..a0bebb88 --- /dev/null +++ b/server-node/src/modules/progression/levelBenchmarks.ts @@ -0,0 +1,63 @@ +export interface LevelBenchmark { + level: number; + xpToNextLevel: number; + cumulativeXpRequired: number; + referenceStrength: number; + baseHp: number; + baseMana: number; + baselineDamageScale: number; +} + +export const MAX_PLAYER_LEVEL = 20; + +function clampLevel(value: unknown) { + if (typeof value !== 'number' || !Number.isFinite(value)) { + return 1; + } + + return Math.min(MAX_PLAYER_LEVEL, Math.max(1, Math.floor(value))); +} + +function roundMetric(value: number, digits = 3) { + return Number(value.toFixed(digits)); +} + +function computeXpToNextLevel(level: number) { + const scale = Math.max(0, level - 1); + return 60 + 20 * scale + 8 * scale * scale; +} + +function buildLevelBenchmarks(maxLevel: number) { + const benchmarks: LevelBenchmark[] = []; + let cumulativeXpRequired = 0; + + for (let level = 1; level <= maxLevel; level += 1) { + const scale = level - 1; + const xpToNextLevel = level >= maxLevel ? 0 : computeXpToNextLevel(level); + + benchmarks.push({ + level, + xpToNextLevel, + cumulativeXpRequired, + referenceStrength: 100 + 16 * scale + 6 * scale * scale, + baseHp: 180 + 24 * scale + 10 * scale * scale, + baseMana: 80 + 14 * scale + 6 * scale * scale, + baselineDamageScale: roundMetric(1 + 0.12 * scale + 0.03 * scale * scale), + }); + + cumulativeXpRequired += xpToNextLevel; + } + + return benchmarks; +} + +const LEVEL_BENCHMARKS = buildLevelBenchmarks(MAX_PLAYER_LEVEL); +const LEVEL_BENCHMARKS_BY_LEVEL = new Map( + LEVEL_BENCHMARKS.map((benchmark) => [benchmark.level, benchmark]), +); + +export function getLevelBenchmark(level: number) { + return ( + LEVEL_BENCHMARKS_BY_LEVEL.get(clampLevel(level)) ?? LEVEL_BENCHMARKS[0]! + ); +} diff --git a/server-node/src/modules/progression/playerProgressionService.test.ts b/server-node/src/modules/progression/playerProgressionService.test.ts new file mode 100644 index 00000000..26a717da --- /dev/null +++ b/server-node/src/modules/progression/playerProgressionService.test.ts @@ -0,0 +1,58 @@ +import assert from 'node:assert/strict'; +import test from 'node:test'; + +import { + createInitialPlayerProgressionState, + grantPlayerExperience, + normalizePlayerProgressionState, +} from './playerProgressionService.js'; + +test('player progression starts at level 1 with the first upgrade threshold', () => { + const initialState = createInitialPlayerProgressionState(); + + assert.deepEqual(initialState, { + level: 1, + currentLevelXp: 0, + totalXp: 0, + xpToNextLevel: 60, + pendingLevelUps: 0, + lastGrantedSource: null, + }); +}); + +test('grantPlayerExperience upgrades level state from quest rewards', () => { + const result = grantPlayerExperience( + { + level: 1, + currentLevelXp: 50, + totalXp: 50, + xpToNextLevel: 60, + }, + 40, + { + source: 'quest', + }, + ); + + assert.equal(result.grantedXp, 40); + assert.equal(result.previousLevel, 1); + assert.equal(result.nextLevel, 2); + assert.equal(result.levelUps, 1); + assert.equal(result.state.level, 2); + assert.equal(result.state.currentLevelXp, 30); + assert.equal(result.state.totalXp, 90); + assert.equal(result.state.xpToNextLevel, 88); + assert.equal(result.state.lastGrantedSource, 'quest'); +}); + +test('normalizePlayerProgressionState backfills legacy partial progression payloads', () => { + const normalized = normalizePlayerProgressionState({ + level: 3, + currentLevelXp: 15, + }); + + assert.equal(normalized.level, 3); + assert.equal(normalized.currentLevelXp, 15); + assert.equal(normalized.totalXp, 163); + assert.equal(normalized.xpToNextLevel, 132); +}); diff --git a/server-node/src/modules/progression/playerProgressionService.ts b/server-node/src/modules/progression/playerProgressionService.ts new file mode 100644 index 00000000..2e66174f --- /dev/null +++ b/server-node/src/modules/progression/playerProgressionService.ts @@ -0,0 +1,192 @@ +import { getLevelBenchmark, MAX_PLAYER_LEVEL } from './levelBenchmarks.js'; + +type JsonRecord = Record; + +export type PlayerProgressionGrantSource = 'quest' | 'hostile_npc'; + +export interface PlayerProgressionState { + level: number; + currentLevelXp: number; + totalXp: number; + xpToNextLevel: number; + pendingLevelUps?: number; + lastGrantedSource?: PlayerProgressionGrantSource | null; +} + +export interface PlayerExperienceGrantResult { + state: PlayerProgressionState; + grantedXp: number; + previousLevel: number; + nextLevel: number; + levelUps: number; + leveledUp: boolean; + reachedMaxLevel: boolean; +} + +function isRecord(value: unknown): value is JsonRecord { + return typeof value === 'object' && value !== null && !Array.isArray(value); +} + +function clampNonNegativeInteger(value: unknown) { + if (typeof value !== 'number' || !Number.isFinite(value)) { + return 0; + } + + return Math.max(0, Math.floor(value)); +} + +function clampLevel(value: unknown) { + if (typeof value !== 'number' || !Number.isFinite(value)) { + return 1; + } + + return Math.min(MAX_PLAYER_LEVEL, Math.max(1, Math.floor(value))); +} + +function normalizeLastGrantedSource(value: unknown) { + return value === 'quest' || value === 'hostile_npc' ? value : null; +} + +function resolveLevelFromTotalXp(totalXp: number) { + let resolvedLevel = 1; + + for (let level = 2; level <= MAX_PLAYER_LEVEL; level += 1) { + if (totalXp < getLevelBenchmark(level).cumulativeXpRequired) { + break; + } + + resolvedLevel = level; + } + + return resolvedLevel; +} + +function buildProgressionStateFromTotalXp( + totalXp: number, + lastGrantedSource: PlayerProgressionGrantSource | null = null, +): PlayerProgressionState { + const normalizedTotalXp = clampNonNegativeInteger(totalXp); + const level = resolveLevelFromTotalXp(normalizedTotalXp); + const benchmark = getLevelBenchmark(level); + + if (level >= MAX_PLAYER_LEVEL) { + return { + level, + currentLevelXp: 0, + totalXp: normalizedTotalXp, + xpToNextLevel: 0, + pendingLevelUps: 0, + lastGrantedSource, + }; + } + + return { + level, + currentLevelXp: Math.max( + 0, + normalizedTotalXp - benchmark.cumulativeXpRequired, + ), + totalXp: normalizedTotalXp, + xpToNextLevel: benchmark.xpToNextLevel, + pendingLevelUps: 0, + lastGrantedSource, + }; +} + +export function createInitialPlayerProgressionState(): PlayerProgressionState { + return buildProgressionStateFromTotalXp(0); +} + +export function normalizePlayerProgressionState( + value: unknown, +): PlayerProgressionState { + if (!isRecord(value)) { + return createInitialPlayerProgressionState(); + } + + const explicitLevel = clampLevel(value.level); + const explicitCurrentLevelXp = clampNonNegativeInteger(value.currentLevelXp); + const totalXp = clampNonNegativeInteger(value.totalXp); + const hasExplicitProgress = explicitLevel > 1 || explicitCurrentLevelXp > 0; + const derivedTotalXp = + totalXp > 0 || !hasExplicitProgress + ? totalXp + : getLevelBenchmark(explicitLevel).cumulativeXpRequired + + Math.min( + explicitCurrentLevelXp, + getLevelBenchmark(explicitLevel).xpToNextLevel, + ); + + return { + ...buildProgressionStateFromTotalXp( + derivedTotalXp, + normalizeLastGrantedSource(value.lastGrantedSource), + ), + pendingLevelUps: clampNonNegativeInteger(value.pendingLevelUps), + }; +} + +export function grantPlayerExperience( + value: unknown, + amount: number, + options: { + source: PlayerProgressionGrantSource; + }, +): PlayerExperienceGrantResult { + const currentState = normalizePlayerProgressionState(value); + const grantedXp = clampNonNegativeInteger(amount); + + if (grantedXp <= 0) { + return { + state: { + ...currentState, + pendingLevelUps: 0, + }, + grantedXp: 0, + previousLevel: currentState.level, + nextLevel: currentState.level, + levelUps: 0, + leveledUp: false, + reachedMaxLevel: currentState.level >= MAX_PLAYER_LEVEL, + }; + } + + const nextState = buildProgressionStateFromTotalXp( + currentState.totalXp + grantedXp, + options.source, + ); + const levelUps = Math.max(0, nextState.level - currentState.level); + + return { + state: { + ...nextState, + pendingLevelUps: 0, + }, + grantedXp, + previousLevel: currentState.level, + nextLevel: nextState.level, + levelUps, + leveledUp: levelUps > 0, + reachedMaxLevel: nextState.level >= MAX_PLAYER_LEVEL, + }; +} + +export function buildExperienceGrantResultText( + result: PlayerExperienceGrantResult, +) { + if (result.grantedXp <= 0) { + return ''; + } + + const parts = [`经验 +${result.grantedXp}`]; + + if (result.leveledUp) { + parts.push( + result.levelUps > 1 + ? `连升 ${result.levelUps} 级,达到 Lv.${result.nextLevel}` + : `升至 Lv.${result.nextLevel}`, + ); + } + + return `${parts.join(',')}。`; +} diff --git a/server-node/src/modules/quest/questProgressionService.ts b/server-node/src/modules/quest/questProgressionService.ts index ae190f43..8e6ef575 100644 --- a/server-node/src/modules/quest/questProgressionService.ts +++ b/server-node/src/modules/quest/questProgressionService.ts @@ -3,10 +3,16 @@ import { normalizeQuestLogEntries, } from '../../bridges/legacyQuestProgressBridge.js'; -export type QuestLogEntry = Parameters[0][number]; -export type QuestProgressSignal = Parameters[1]; +export type QuestLogEntry = Parameters< + typeof normalizeQuestLogEntries +>[0][number]; +export type QuestProgressSignal = Parameters< + typeof applyQuestProgressSignal +>[1]; -type QuestMutationFailureCode = 'quest_not_found' | 'quest_not_ready_to_turn_in'; +type QuestMutationFailureCode = + | 'quest_not_found' + | 'quest_not_ready_to_turn_in'; export type QuestMutationFailure = { ok: false; @@ -61,7 +67,9 @@ function buildSuccess( }; } -export function normalizeQuestEntries(quests: QuestLogEntry[]): QuestLogEntry[] { +export function normalizeQuestEntries( + quests: QuestLogEntry[], +): QuestLogEntry[] { return normalizeQuestLogEntries(quests); } @@ -116,10 +124,7 @@ export function getQuestForIssuer( ); } -export function acceptQuest( - quests: QuestLogEntry[], - quest: QuestLogEntry, -) { +export function acceptQuest(quests: QuestLogEntry[], quest: QuestLogEntry) { const normalizedQuests = normalizeQuestEntries(quests); if (findQuestById(normalizedQuests, quest.id)) { return normalizedQuests; @@ -136,17 +141,26 @@ export function buildQuestAcceptResultText(quest: QuestLogEntry) { }`; } -export function buildQuestTurnInResultText(quest: QuestLogEntry) { +export function buildQuestTurnInResultText( + quest: QuestLogEntry, + options: { + experienceText?: string | null; + } = {}, +) { const normalizedQuest = normalizeQuestEntries([quest])[0]!; - const itemText = normalizedQuest.reward.items.map((item) => item.name).join('、'); + const itemText = + normalizedQuest.reward.items.map((item) => item.name).join('、') || '补给'; const intelText = normalizedQuest.reward.intel?.rumorText ? `,并额外告诉了你一条消息:${normalizedQuest.reward.intel.rumorText}` : ''; const storyHintText = normalizedQuest.reward.storyHint ? ` ${normalizedQuest.reward.storyHint}` : ''; + const experienceText = options.experienceText?.trim() + ? ` ${options.experienceText.trim()}` + : ''; - return `${normalizedQuest.issuerNpcName} 确认你已经完成委托,交给了你 ${normalizedQuest.reward.currency} 赏金和 ${itemText}${intelText}。${storyHintText}`; + return `${normalizedQuest.issuerNpcName} 确认你已经完成委托,交给了你 ${normalizedQuest.reward.currency} 赏金和${itemText}${intelText}。${experienceText}${storyHintText}`; } export function isQuestReadyToClaim(quest: QuestLogEntry) { @@ -154,10 +168,7 @@ export function isQuestReadyToClaim(quest: QuestLogEntry) { return status === 'ready_to_turn_in' || status === 'completed'; } -export function markQuestTurnedIn( - quests: QuestLogEntry[], - questId: string, -) { +export function markQuestTurnedIn(quests: QuestLogEntry[], questId: string) { return quests.map((quest) => quest.id === questId ? normalizeQuestEntries([ diff --git a/server-node/src/modules/quest/questStoryActionService.ts b/server-node/src/modules/quest/questStoryActionService.ts index f8b23a7a..42f35395 100644 --- a/server-node/src/modules/quest/questStoryActionService.ts +++ b/server-node/src/modules/quest/questStoryActionService.ts @@ -2,6 +2,10 @@ import type { RuntimeStoryActionRequest, RuntimeStoryPatch, } from '../../../../packages/shared/src/contracts/story.js'; +import { + buildExperienceGrantResultText, + grantPlayerExperience, +} from '../progression/playerProgressionService.js'; import { conflict, invalidRequest } from '../../errors.js'; import { appendStoryEngineCarrierMemory, @@ -37,7 +41,9 @@ type QuestStoryResolution = { type JsonRecord = Record; type RuntimeGameState = Parameters[0]; -type RuntimeQuestLogEntry = NonNullable>; +type RuntimeQuestLogEntry = NonNullable< + ReturnType +>; type RuntimeNpcState = Parameters< typeof markNpcFirstMeaningfulContactResolved >[0]; @@ -159,7 +165,8 @@ function resolveQuestAcceptAction( session: RuntimeSession, currentStory?: unknown, ): QuestStoryResolution { - const { state, encounter, npcKey, npcState } = ensureEncounterQuestContext(session); + const { state, encounter, npcKey, npcState } = + ensureEncounterQuestContext(session); const quests = Array.isArray(state.quests) ? state.quests : []; const existingQuest = getQuestForIssuer(quests, npcKey); if (existingQuest) { @@ -174,6 +181,14 @@ function resolveQuestAcceptAction( roleText: encounter.context, scene: state.currentScenePreset, worldType: state.worldType, + context: { + worldType: state.worldType, + recentStoryMoments: Array.isArray(state.storyHistory) + ? state.storyHistory.slice(-6) + : [], + playerCharacter: state.playerCharacter ?? null, + playerProgression: state.playerProgression ?? null, + }, currentQuests: quests.map((item) => ({ id: item.id, issuerNpcId: item.issuerNpcId, @@ -214,7 +229,8 @@ function resolveQuestTurnInAction( session: RuntimeSession, request: RuntimeStoryActionRequest, ): QuestStoryResolution { - const { state, encounter, npcKey, npcState } = ensureEncounterQuestContext(session); + const { state, encounter, npcKey, npcState } = + ensureEncounterQuestContext(session); const quests = Array.isArray(state.quests) ? state.quests : []; const questId = readQuestId(request); const quest = @@ -235,11 +251,22 @@ function resolveQuestTurnInAction( } const nextAffinity = npcState.affinity + quest.reward.affinityBonus; + const experienceGrant = grantPlayerExperience( + state.playerProgression, + quest.reward.experience ?? 0, + { + source: 'quest', + }, + ); let nextState = { ...state, quests: turnInResult.nextQuests, + playerProgression: experienceGrant.state, playerCurrency: state.playerCurrency + quest.reward.currency, - playerInventory: addInventoryItems(state.playerInventory, quest.reward.items), + playerInventory: addInventoryItems( + state.playerInventory, + quest.reward.items, + ), npcStates: { ...state.npcStates, [npcKey]: { @@ -258,7 +285,9 @@ function resolveQuestTurnInAction( return { actionText: `向${encounter.npcName}交付委托`, - resultText: buildQuestTurnInResultText(quest), + resultText: buildQuestTurnInResultText(quest, { + experienceText: buildExperienceGrantResultText(experienceGrant), + }), patches: [ { type: 'npc_affinity_changed', diff --git a/server-node/src/modules/quest/runtimeQuestModule.ts b/server-node/src/modules/quest/runtimeQuestModule.ts index 6e327891..66a97e2f 100644 --- a/server-node/src/modules/quest/runtimeQuestModule.ts +++ b/server-node/src/modules/quest/runtimeQuestModule.ts @@ -38,6 +38,7 @@ export type QuestRewardItem = { export type QuestReward = { affinityBonus: number; currency: number; + experience?: number; items: QuestRewardItem[]; intel?: { rumorText: string; @@ -150,7 +151,11 @@ export type QuestOpportunity = { }; export type QuestProgressSignal = - | { kind: 'hostile_npc_defeated'; sceneId?: string | null; hostileNpcId: string } + | { + kind: 'hostile_npc_defeated'; + sceneId?: string | null; + hostileNpcId: string; + } | { kind: 'treasure_inspected'; sceneId?: string | null } | { kind: 'npc_spar_completed'; npcId: string } | { kind: 'npc_talk_completed'; npcId: string } @@ -189,6 +194,12 @@ export type QuestGenerationContext = { name?: string; title?: string; } | null; + playerProgression?: { + level?: number; + currentLevelXp?: number; + totalXp?: number; + xpToNextLevel?: number; + } | null; playerHp?: number; playerMaxHp?: number; playerMana?: number; @@ -254,6 +265,7 @@ type RuntimeStateLike = { currentScenePreset?: RuntimeSceneLike | null; storyHistory: Array<{ text: string }>; playerCharacter?: QuestGenerationContext['playerCharacter']; + playerProgression?: QuestGenerationContext['playerProgression']; playerHp?: number; playerMaxHp?: number; playerMana?: number; @@ -267,7 +279,11 @@ type RuntimeStateLike = { }; const REWARD_READY_STATUSES: QuestStatus[] = ['ready_to_turn_in', 'completed']; -const TERMINAL_QUEST_STATUSES: QuestStatus[] = ['turned_in', 'failed', 'expired']; +const TERMINAL_QUEST_STATUSES: QuestStatus[] = [ + 'turned_in', + 'failed', + 'expired', +]; function clampProgress(progress: number | undefined, requiredCount: number) { return Math.max(0, Math.min(requiredCount, Math.round(progress ?? 0))); @@ -341,7 +357,8 @@ function getScenePrimaryThreat( } const hostileNpc = - scene.npcs.find((npc) => Boolean(npc.hostile || npc.monsterPresetId)) ?? null; + scene.npcs.find((npc) => Boolean(npc.hostile || npc.monsterPresetId)) ?? + null; if (hostileNpc) { const targetHostileNpcId = hostileNpc.monsterPresetId ?? hostileNpc.id; return { @@ -434,13 +451,71 @@ function buildRewardItems(params: { } } +function computeXpToNextLevel(level: number) { + const scale = Math.max(0, level - 1); + return 60 + 20 * scale + 8 * scale * scale; +} + +function resolveQuestTargetLevel(context?: QuestGenerationContext) { + const level = context?.playerProgression?.level; + if (typeof level !== 'number' || !Number.isFinite(level)) { + return 1; + } + + return Math.max(1, Math.floor(level)); +} + +function resolveQuestStepCountMultiplier(stepCount: number) { + if (stepCount <= 1) { + return 0.85; + } + + if (stepCount === 2) { + return 1; + } + + return 1.12; +} + +function resolveQuestNarrativeXpMultiplier(narrativeType: QuestNarrativeType) { + return narrativeType === 'trial' || narrativeType === 'bounty' ? 1.08 : 1; +} + +function resolveQuestUrgencyXpMultiplier(urgency: QuestUrgency) { + return urgency === 'high' ? 1.05 : 1; +} + +function buildQuestExperienceReward(params: { + context?: QuestGenerationContext; + narrativeType: QuestNarrativeType; + urgency: QuestUrgency; + stepCount: number; +}) { + const baseQuestXp = + computeXpToNextLevel(resolveQuestTargetLevel(params.context)) * 0.45; + + return Math.max( + 5, + Math.round( + (baseQuestXp * + resolveQuestStepCountMultiplier(params.stepCount) * + resolveQuestNarrativeXpMultiplier(params.narrativeType) * + resolveQuestUrgencyXpMultiplier(params.urgency)) / + 5, + ) * 5, + ); +} + function buildQuestReward(params: { issuerNpcId: string; issuerNpcName: string; worldType: string | null | undefined; rewardTheme: QuestRewardTheme; narrativeType: QuestNarrativeType; + urgency: QuestUrgency; + stepCount: number; scene: QuestSceneSnapshot | null; + context?: QuestGenerationContext; }) { const baseCurrency = params.rewardTheme === 'intel' @@ -453,10 +528,17 @@ function buildQuestReward(params: { const reward: QuestReward = { affinityBonus: - params.narrativeType === 'relationship' || params.narrativeType === 'trial' + params.narrativeType === 'relationship' || + params.narrativeType === 'trial' ? 14 : 12, currency: baseCurrency, + experience: buildQuestExperienceReward({ + context: params.context, + narrativeType: params.narrativeType, + urgency: params.urgency, + stepCount: params.stepCount, + }), items: buildRewardItems(params), storyHint: `${params.issuerNpcName}把和眼前局势最相关的收获留给了你。`, }; @@ -479,10 +561,12 @@ function buildRewardText( ) { const itemText = reward.items.map((item) => item.name).join('、') || '当前局势相关的补给'; + const experienceText = + (reward.experience ?? 0) > 0 ? `、经验 +${reward.experience}` : ''; const intelText = reward.intel?.rumorText ? `,以及情报“${reward.intel.rumorText}”` : ''; - return `完成后可获得好感 +${reward.affinityBonus}、${formatCurrency( + return `完成后可获得好感 +${reward.affinityBonus}${experienceText}、${formatCurrency( reward.currency, worldType, )}、${itemText}${intelText}。`; @@ -522,7 +606,7 @@ function buildPrimaryQuestStep(params: { : [threat.kind]; const chosenKind = preferredKinds.includes(threat.kind) ? threat.kind - : preferredKinds[0] ?? threat.kind; + : (preferredKinds[0] ?? threat.kind); if (chosenKind === 'inspect_treasure' && scene) { return { @@ -606,7 +690,9 @@ function normalizeQuestTitle(rawTitle: string, fallbackTitle: string) { return title; } - return fallbackTitle.length <= 12 ? fallbackTitle : fallbackTitle.slice(0, 10); + return fallbackTitle.length <= 12 + ? fallbackTitle + : fallbackTitle.slice(0, 10); } function normalizeQuestLogEntry(quest: QuestLogEntry): QuestLogEntry { @@ -618,7 +704,8 @@ function normalizeQuestLogEntry(quest: QuestLogEntry): QuestLogEntry { Math.max(1, Math.round(step.requiredCount ?? 1)), ), })); - const activeStep = steps.find((step) => step.progress < step.requiredCount) ?? null; + const activeStep = + steps.find((step) => step.progress < step.requiredCount) ?? null; const terminal = isTerminalStatus(quest.status); const rewardReady = !terminal && !activeStep ? 'completed' : quest.status; @@ -626,10 +713,21 @@ function normalizeQuestLogEntry(quest: QuestLogEntry): QuestLogEntry { ...quest, title: normalizeQuestTitle(quest.title, quest.title), summary: quest.summary.trim() || quest.description.trim(), - progress: activeStep?.progress ?? steps[steps.length - 1]?.requiredCount ?? 0, - objective: deriveObjectiveFromStep(activeStep ?? steps[steps.length - 1] ?? null), + progress: + activeStep?.progress ?? steps[steps.length - 1]?.requiredCount ?? 0, + objective: deriveObjectiveFromStep( + activeStep ?? steps[steps.length - 1] ?? null, + ), status: terminal ? quest.status : rewardReady, completionNotified: quest.completionNotified ?? false, + reward: { + affinityBonus: Math.round(quest.reward.affinityBonus ?? 0), + currency: Math.max(0, Math.round(quest.reward.currency ?? 0)), + experience: Math.max(0, Math.round(quest.reward.experience ?? 0)), + items: quest.reward.items ?? [], + intel: quest.reward.intel, + storyHint: quest.reward.storyHint, + }, rewardText: quest.rewardText.trim(), steps, activeStepId: activeStep?.id ?? null, @@ -659,7 +757,9 @@ function stepMatchesSignal(step: QuestStep, signal: QuestProgressSignal) { case 'npc_talk_completed': return step.kind === 'talk_to_npc' && step.targetNpcId === signal.npcId; case 'scene_reached': - return step.kind === 'reach_scene' && step.targetSceneId === signal.sceneId; + return ( + step.kind === 'reach_scene' && step.targetSceneId === signal.sceneId + ); case 'item_delivered': return ( step.kind === 'deliver_item' && @@ -701,7 +801,8 @@ export function buildQuestGenerationContextFromState(params: { issuerWarmthStage: getNpcWarmthStage(issuerState?.affinity ?? 0, { recruited: issuerState?.recruited, }), - activeThreadIds: state.storyEngineMemory?.activeThreadIds?.slice(0, 4) ?? [], + activeThreadIds: + state.storyEngineMemory?.activeThreadIds?.slice(0, 4) ?? [], encounterKind: encounter.kind ?? 'npc', currentSceneTreasureHintCount: state.currentScenePreset?.treasureHints?.length ?? 0, @@ -710,6 +811,7 @@ export function buildQuestGenerationContextFromState(params: { .map((npc) => npc.monsterPresetId ?? npc.id), recentStoryMoments: state.storyHistory.slice(-6), playerCharacter: state.playerCharacter ?? null, + playerProgression: state.playerProgression ?? null, playerHp: state.playerHp, playerMaxHp: state.playerMaxHp, playerMana: state.playerMana, @@ -731,15 +833,21 @@ export function findQuestById(quests: QuestLogEntry[], questId: string) { return quests.find((quest) => quest.id === questId) ?? null; } -export function getQuestForIssuer(quests: QuestLogEntry[], issuerNpcId: string) { +export function getQuestForIssuer( + quests: QuestLogEntry[], + issuerNpcId: string, +) { return ( normalizeQuestLogEntries(quests).find( - (quest) => quest.issuerNpcId === issuerNpcId && quest.status !== 'turned_in', + (quest) => + quest.issuerNpcId === issuerNpcId && quest.status !== 'turned_in', ) ?? null ); } -export function evaluateQuestOpportunity(params: QuestPreviewRequest): QuestOpportunity { +export function evaluateQuestOpportunity( + params: QuestPreviewRequest, +): QuestOpportunity { const { issuerNpcId, scene, currentQuests = [] } = params; if (!scene) { return { @@ -750,7 +858,8 @@ export function evaluateQuestOpportunity(params: QuestPreviewRequest): QuestOppo if ( currentQuests.some( - (quest) => quest.issuerNpcId === issuerNpcId && quest.status !== 'turned_in', + (quest) => + quest.issuerNpcId === issuerNpcId && quest.status !== 'turned_in', ) ) { return { @@ -888,7 +997,10 @@ export function compileQuestIntentToQuest( worldType: params.worldType, rewardTheme: intent.rewardTheme, narrativeType: intent.narrativeType, + urgency: intent.urgency, + stepCount: steps.length, scene: params.scene, + context: params.context, }); const rewardText = buildRewardText(reward, params.worldType); diff --git a/server-node/src/modules/runtime/runtimeSnapshotHydration.test.ts b/server-node/src/modules/runtime/runtimeSnapshotHydration.test.ts index 23fb56f8..577e50e0 100644 --- a/server-node/src/modules/runtime/runtimeSnapshotHydration.test.ts +++ b/server-node/src/modules/runtime/runtimeSnapshotHydration.test.ts @@ -67,6 +67,7 @@ test('runtime snapshot hydration normalizes server snapshots for frontend restor rewardText: '完成后可领取测试奖励。', reward: { currency: 10, + experience: 0, items: [], }, steps: [ @@ -128,6 +129,8 @@ test('runtime snapshot hydration normalizes server snapshots for frontend restor assert.equal(snapshot.gameState.playerMaxMana, 95); assert.equal(snapshot.gameState.playerMana, 22); assert.equal(snapshot.gameState.playerCurrency, 160); + assert.equal(snapshot.gameState.playerProgression.level, 1); + assert.equal(snapshot.gameState.playerProgression.totalXp, 0); assert.deepEqual(snapshot.gameState.roster, []); assert.deepEqual(snapshot.gameState.storyEngineMemory.activeThreadIds, []); assert.equal( @@ -200,7 +203,16 @@ test('runtime snapshot hydration backfills starter loadout when legacy saves omi assert.ok(snapshot); assert.equal(snapshot.gameState.playerMaxHp, 208); assert.equal(snapshot.gameState.playerMaxMana, 1009); - assert.equal(snapshot.gameState.playerEquipment.weapon?.id, 'starter:hero:weapon'); - assert.equal(snapshot.gameState.playerEquipment.armor?.id, 'starter:hero:armor'); - assert.equal(snapshot.gameState.playerEquipment.relic?.id, 'starter:hero:relic'); + assert.equal( + snapshot.gameState.playerEquipment.weapon?.id, + 'starter:hero:weapon', + ); + assert.equal( + snapshot.gameState.playerEquipment.armor?.id, + 'starter:hero:armor', + ); + assert.equal( + snapshot.gameState.playerEquipment.relic?.id, + 'starter:hero:relic', + ); }); diff --git a/server-node/src/modules/runtime/runtimeSnapshotHydration.ts b/server-node/src/modules/runtime/runtimeSnapshotHydration.ts index 583ec99d..7e81f92d 100644 --- a/server-node/src/modules/runtime/runtimeSnapshotHydration.ts +++ b/server-node/src/modules/runtime/runtimeSnapshotHydration.ts @@ -1,5 +1,6 @@ import { jsonClone } from '../../http.js'; import type { SavedSnapshot } from '../../repositories/runtimeRepository.js'; +import { normalizePlayerProgressionState } from '../progression/playerProgressionService.js'; import { normalizeQuestEntries } from '../quest/questProgressionService.js'; import { createEmptyEquipmentLoadout, @@ -61,9 +62,7 @@ function clampNonNegativeInteger(value: unknown) { } function normalizeBottomTab(value: unknown) { - return value === 'character' || value === 'inventory' - ? value - : 'adventure'; + return value === 'character' || value === 'inventory' ? value : 'adventure'; } function buildSaveMigrationManifest() { @@ -135,9 +134,7 @@ function normalizeRuntimeStats( ? Math.max(0, rawStats.playTimeMs) : 0, lastPlayTickAt: options.isActiveRun ? new Date(now).toISOString() : null, - hostileNpcsDefeated: clampNonNegativeInteger( - rawStats.hostileNpcsDefeated, - ), + hostileNpcsDefeated: clampNonNegativeInteger(rawStats.hostileNpcsDefeated), questsAccepted: clampNonNegativeInteger(rawStats.questsAccepted), itemsUsed: clampNonNegativeInteger(rawStats.itemsUsed), scenesTraveled: clampNonNegativeInteger(rawStats.scenesTraveled), @@ -146,28 +143,30 @@ function normalizeRuntimeStats( function normalizeCharacterChats(value: unknown) { return Object.fromEntries( - Object.entries(isRecord(value) ? value : {}).map(([characterId, record]) => { - const rawRecord = isRecord(record) ? record : {}; + Object.entries(isRecord(value) ? value : {}).map( + ([characterId, record]) => { + const rawRecord = isRecord(record) ? record : {}; - return [ - characterId, - { - history: readArray(rawRecord.history) - .filter( - (turn) => - isRecord(turn) && - typeof turn.text === 'string' && - (turn.speaker === 'player' || turn.speaker === 'character'), - ) - .map((turn) => ({ - speaker: turn.speaker, - text: turn.text, - })), - summary: readString(rawRecord.summary), - updatedAt: readString(rawRecord.updatedAt) || null, - }, - ]; - }), + return [ + characterId, + { + history: readArray(rawRecord.history) + .filter( + (turn) => + isRecord(turn) && + typeof turn.text === 'string' && + (turn.speaker === 'player' || turn.speaker === 'character'), + ) + .map((turn) => ({ + speaker: turn.speaker, + text: turn.text, + })), + summary: readString(rawRecord.summary), + updatedAt: readString(rawRecord.updatedAt) || null, + }, + ]; + }, + ), ); } @@ -194,14 +193,18 @@ function dedupeCompanions(value: unknown) { return readArray(value) .map((entry) => normalizeCompanionState(entry)) - .filter((entry): entry is NonNullable> => { - if (!entry || seenNpcIds.has(entry.npcId)) { - return false; - } + .filter( + ( + entry, + ): entry is NonNullable> => { + if (!entry || seenNpcIds.has(entry.npcId)) { + return false; + } - seenNpcIds.add(entry.npcId); - return true; - }); + seenNpcIds.add(entry.npcId); + return true; + }, + ); } function normalizeRoster( @@ -258,9 +261,8 @@ function resolveInitialPlayerCurrency(gameState: JsonRecord) { ) ? ( ( - ( - customWorldProfile.ownedSettingLayers as JsonRecord - ).ruleProfile as JsonRecord + (customWorldProfile.ownedSettingLayers as JsonRecord) + .ruleProfile as JsonRecord ).economyProfile as JsonRecord ).initialCurrency : undefined, @@ -270,7 +272,9 @@ function resolveInitialPlayerCurrency(gameState: JsonRecord) { return Math.max(0, Math.round(customWorldInitialCurrency)); } - return readString(gameState.worldType).toUpperCase() === 'XIANXIA' ? 140 : 160; + return readString(gameState.worldType).toUpperCase() === 'XIANXIA' + ? 140 + : 160; } function normalizeEquipmentLoadout(value: unknown) { @@ -319,7 +323,9 @@ function inferEquipmentTags(slot: RuntimeEquipmentSlotId, name: string) { return [...tags]; } -function getLegacyCharacterEquipment(character: JsonRecord): LegacyCharacterEquipmentItem[] { +function getLegacyCharacterEquipment( + character: JsonRecord, +): LegacyCharacterEquipmentItem[] { const equipmentById: Record = { 'sword-princess': [ { slot: '武器', item: '王庭剑', rarity: '稀有' }, @@ -495,7 +501,9 @@ function normalizeGameState(gameState: unknown) { ); const resolvedEquipment = normalizeEquipmentLoadout(rawState.playerEquipment) ?? - (playerCharacter ? buildLegacyStarterEquipmentLoadout(playerCharacter) : null); + (playerCharacter + ? buildLegacyStarterEquipmentLoadout(playerCharacter) + : null); const baseResourceProfile = playerCharacter ? buildCharacterResourceProfile(playerCharacter) : null; @@ -512,14 +520,18 @@ function normalizeGameState(gameState: unknown) { const normalizedCommonState = { ...rawStateWithoutEquipment, customWorldProfile: - isRecord(rawState.customWorldProfile) || rawState.customWorldProfile === null - ? rawState.customWorldProfile ?? null + isRecord(rawState.customWorldProfile) || + rawState.customWorldProfile === null + ? (rawState.customWorldProfile ?? null) : null, runtimeStats: normalizeRuntimeStats(rawState.runtimeStats, { isActiveRun: Boolean( rawState.playerCharacter && rawState.currentScene === 'Story', ), }), + playerProgression: normalizePlayerProgressionState( + rawState.playerProgression, + ), storyEngineMemory, chapterState: rawState.chapterState ?? @@ -530,7 +542,7 @@ function normalizeGameState(gameState: unknown) { rawState.campaignState ?? (isRecord(storyEngineMemory.campaignState) ? storyEngineMemory.campaignState - : storyEngineMemory.campaignState ?? null), + : (storyEngineMemory.campaignState ?? null)), activeScenarioPackId: readString(rawState.activeScenarioPackId) || readString( @@ -623,7 +635,9 @@ function normalizeGameState(gameState: unknown) { }; } -export function normalizeSavedSnapshotPayload(snapshot: T) { +export function normalizeSavedSnapshotPayload( + snapshot: T, +) { return { ...snapshot, bottomTab: normalizeBottomTab(snapshot.bottomTab), diff --git a/server-node/src/modules/story/runtimeSession.ts b/server-node/src/modules/story/runtimeSession.ts index 15d09f8c..f307a000 100644 --- a/server-node/src/modules/story/runtimeSession.ts +++ b/server-node/src/modules/story/runtimeSession.ts @@ -165,6 +165,7 @@ const COMBAT_FUNCTION_IDS = new Set([ 'battle_guard_break', 'battle_probe_pressure', 'battle_recover_breath', + 'inventory_use', ]); const NPC_FUNCTION_IDS = new Set([ diff --git a/server-node/src/modules/story/storyActionRoutes.test.ts b/server-node/src/modules/story/storyActionRoutes.test.ts index 82a8f441..337f7b13 100644 --- a/server-node/src/modules/story/storyActionRoutes.test.ts +++ b/server-node/src/modules/story/storyActionRoutes.test.ts @@ -985,6 +985,193 @@ test('runtime story actions resolve battle_use_skill as a single ongoing combat }); }); +test('runtime story actions resolve inventory_use as a single ongoing combat turn', async () => { + await withTestServer('combat-use-item', async ({ baseUrl }) => { + const entry = await authEntry(baseUrl, 'story_combat_item', 'secret123'); + + await putSnapshot( + baseUrl, + entry.token, + createTask6GameState({ + currentEncounter: { + kind: 'npc', + id: 'npc_bandit_01', + npcName: '断桥匪首', + npcDescription: '手提短刀的拦路匪徒', + context: '桥口劫匪', + hostile: true, + }, + npcInteractionActive: false, + sceneHostileNpcs: [ + { + id: 'npc_bandit_01', + name: '断桥匪首', + hp: 80, + maxHp: 80, + description: '桥口劫匪', + }, + ], + inBattle: true, + playerHp: 20, + playerMaxHp: 40, + playerMana: 4, + playerMaxMana: 16, + playerSkillCooldowns: { + slash: 2, + }, + activeBuildBuffs: [], + playerInventory: [ + { + id: 'focus-tonic', + category: '消耗品', + name: '凝神灵液', + quantity: 1, + rarity: 'rare', + tags: ['mana', 'healing'], + useProfile: { + hpRestore: 12, + manaRestore: 6, + cooldownReduction: 1, + buildBuffs: [ + { + id: 'focus-tonic:buff', + sourceType: 'item', + sourceId: 'focus-tonic', + name: '凝神增益', + tags: ['快剑'], + durationTurns: 2, + }, + ], + }, + }, + ], + npcStates: { + npc_bandit_01: { + affinity: -12, + chattedCount: 0, + helpUsed: false, + giftsGiven: 0, + inventory: [], + recruited: false, + }, + }, + currentNpcBattleMode: 'fight', + }), + ); + + const response = await httpRequest( + `${baseUrl}/api/runtime/story/actions/resolve`, + withBearer(entry.token, { + method: 'POST', + body: JSON.stringify({ + sessionId: 'runtime-main', + clientVersion: 0, + action: { + type: 'story_choice', + functionId: 'inventory_use', + payload: { + itemId: 'focus-tonic', + }, + }, + }), + }), + ); + const payload = (await response.json()) as { + serverVersion: number; + viewModel: { + player: { + hp: number; + mana: number; + }; + status: { + inBattle: boolean; + }; + availableOptions: Array<{ + functionId: string; + actionText: string; + payload?: { + skillId?: string; + itemId?: string; + }; + disabled?: boolean; + reason?: string; + }>; + }; + presentation: { + resultText: string; + storyText: string; + battle: { + outcome: string; + damageTaken: number; + } | null; + }; + snapshot: { + gameState: { + playerHp: number; + playerMana: number; + playerSkillCooldowns: Record; + runtimeStats: { + itemsUsed: number; + }; + playerInventory: unknown[]; + activeBuildBuffs: Array<{ + id: string; + }>; + }; + }; + patches: Array<{ + type: string; + functionId?: string; + }>; + }; + + assert.equal(response.status, 200); + assert.equal(payload.serverVersion, 1); + assert.equal(payload.presentation.battle?.outcome, 'ongoing'); + assert.equal(payload.presentation.battle?.damageTaken, 8); + assert.equal( + payload.presentation.storyText, + payload.presentation.resultText, + ); + assert.match(payload.presentation.storyText, /凝神灵液/u); + assert.equal(payload.viewModel.status.inBattle, true); + assert.equal(payload.viewModel.player.hp, 24); + assert.equal(payload.viewModel.player.mana, 10); + assert.equal(payload.snapshot.gameState.playerHp, 24); + assert.equal(payload.snapshot.gameState.playerMana, 10); + assert.equal(payload.snapshot.gameState.playerSkillCooldowns.slash, 0); + assert.equal(payload.snapshot.gameState.runtimeStats.itemsUsed, 1); + assert.deepEqual(payload.snapshot.gameState.playerInventory, []); + assert.equal( + payload.snapshot.gameState.activeBuildBuffs[0]?.id, + 'focus-tonic:buff', + ); + assert.ok( + payload.patches.some( + (patch) => + patch.type === 'battle_resolved' && + patch.functionId === 'inventory_use', + ), + ); + + const inventoryOption = payload.viewModel.availableOptions.find( + (option) => option.functionId === 'inventory_use', + ); + assert.ok(inventoryOption); + assert.equal(inventoryOption.disabled, true); + assert.match(inventoryOption.reason ?? '', /暂无可用物品/u); + + const skillOption = payload.viewModel.availableOptions.find( + (option) => + option.functionId === 'battle_use_skill' && + option.payload?.skillId === 'slash', + ); + assert.ok(skillOption); + assert.equal(skillOption.actionText, '试锋斩'); + assert.equal(skillOption.disabled, undefined); + }); +}); + test('runtime story actions resolve inventory_use and persist updated resources', async () => { await withTestServer('task6-inventory-use', async ({ baseUrl }) => { const entry = await authEntry( @@ -1418,116 +1605,121 @@ test('runtime story actions resolve npc_quest_accept and persist accepted quests }); test('runtime story actions accept pending npc quest offers from saved chat state', async () => { - await withTestServer('task6-quest-accept-pending-offer', async ({ baseUrl }) => { - const entry = await authEntry( - baseUrl, - 'story_q_accept_pending', - 'secret123', - ); - const seededQuest = buildQuestForEncounter({ - issuerNpcId: 'npc_scout_01', - issuerNpcName: '巡路人', - roleText: '巡路人', - scene: QUEST_BATTLE_SCENE, - worldType: 'WUXIA', - currentQuests: [], - }); - assert.ok(seededQuest); - const pendingQuest = { - ...seededQuest, - id: 'quest-pending-offer', - }; + await withTestServer( + 'task6-quest-accept-pending-offer', + async ({ baseUrl }) => { + const entry = await authEntry( + baseUrl, + 'story_q_accept_pending', + 'secret123', + ); + const seededQuest = buildQuestForEncounter({ + issuerNpcId: 'npc_scout_01', + issuerNpcName: '巡路人', + roleText: '巡路人', + scene: QUEST_BATTLE_SCENE, + worldType: 'WUXIA', + currentQuests: [], + }); + assert.ok(seededQuest); + const pendingQuest = { + ...seededQuest, + id: 'quest-pending-offer', + }; - await putSnapshot( - baseUrl, - entry.token, - createTask6GameState({ - currentEncounter: { - kind: 'npc', - id: 'npc_scout_01', - npcName: '巡路人', - npcDescription: '熟悉桥口风向的探子', - context: '巡路人', - characterId: 'scout-quest', - }, - currentScenePreset: QUEST_BATTLE_SCENE, - npcInteractionActive: true, - npcStates: { - npc_scout_01: { - affinity: 16, - chattedCount: 0, - helpUsed: false, - giftsGiven: 0, - inventory: [], - recruited: false, + await putSnapshot( + baseUrl, + entry.token, + createTask6GameState({ + currentEncounter: { + kind: 'npc', + id: 'npc_scout_01', + npcName: '巡路人', + npcDescription: '熟悉桥口风向的探子', + context: '巡路人', + characterId: 'scout-quest', }, - }, - }), - createPendingQuestOfferCurrentStory(pendingQuest), - ); - - const response = await httpRequest( - `${baseUrl}/api/runtime/story/actions/resolve`, - withBearer(entry.token, { - method: 'POST', - body: JSON.stringify({ - sessionId: 'runtime-main', - clientVersion: 0, - action: { - type: 'story_choice', - functionId: 'npc_quest_accept', + currentScenePreset: QUEST_BATTLE_SCENE, + npcInteractionActive: true, + npcStates: { + npc_scout_01: { + affinity: 16, + chattedCount: 0, + helpUsed: false, + giftsGiven: 0, + inventory: [], + recruited: false, + }, }, }), - }), - ); - const payload = (await response.json()) as { - snapshot: { - gameState: { - quests: Array<{ id: string; issuerNpcId: string; status: string }>; - }; - currentStory: { - displayMode?: string; - options?: Array<{ actionText?: string }>; - dialogue?: Array<{ speaker?: string; text?: string }>; - npcChatState?: { - pendingQuestOffer?: unknown; + createPendingQuestOfferCurrentStory(pendingQuest), + ); + + const response = await httpRequest( + `${baseUrl}/api/runtime/story/actions/resolve`, + withBearer(entry.token, { + method: 'POST', + body: JSON.stringify({ + sessionId: 'runtime-main', + clientVersion: 0, + action: { + type: 'story_choice', + functionId: 'npc_quest_accept', + }, + }), + }), + ); + const payload = (await response.json()) as { + snapshot: { + gameState: { + quests: Array<{ id: string; issuerNpcId: string; status: string }>; + }; + currentStory: { + displayMode?: string; + options?: Array<{ actionText?: string }>; + dialogue?: Array<{ speaker?: string; text?: string }>; + npcChatState?: { + pendingQuestOffer?: unknown; + }; }; }; }; - }; - assert.equal(response.status, 200); - assert.equal(payload.snapshot.gameState.quests.length, 1); - assert.equal( - payload.snapshot.gameState.quests[0]?.id, - 'quest-pending-offer', - ); - assert.equal( - payload.snapshot.gameState.quests[0]?.issuerNpcId, - 'npc_scout_01', - ); - assert.equal(payload.snapshot.currentStory.displayMode, 'dialogue'); - assert.equal( - payload.snapshot.currentStory.npcChatState?.pendingQuestOffer ?? null, - null, - ); - assert.deepEqual( - payload.snapshot.currentStory.options?.map((option) => option.actionText), - [ - '这件事里你最担心哪一步', - '我回来时你最想先知道什么', - '除了这份委托,你还想提醒我什么', - ], - ); - assert.equal( - payload.snapshot.currentStory.dialogue?.at(-2)?.text, - '这件事我愿意接下,你把关键要点交给我。', - ); - assert.match( - payload.snapshot.currentStory.dialogue?.at(-1)?.text ?? '', - /那就拜托你了。/u, - ); - }); + assert.equal(response.status, 200); + assert.equal(payload.snapshot.gameState.quests.length, 1); + assert.equal( + payload.snapshot.gameState.quests[0]?.id, + 'quest-pending-offer', + ); + assert.equal( + payload.snapshot.gameState.quests[0]?.issuerNpcId, + 'npc_scout_01', + ); + assert.equal(payload.snapshot.currentStory.displayMode, 'dialogue'); + assert.equal( + payload.snapshot.currentStory.npcChatState?.pendingQuestOffer ?? null, + null, + ); + assert.deepEqual( + payload.snapshot.currentStory.options?.map( + (option) => option.actionText, + ), + [ + '这件事里你最担心哪一步', + '我回来时你最想先知道什么', + '除了这份委托,你还想提醒我什么', + ], + ); + assert.equal( + payload.snapshot.currentStory.dialogue?.at(-2)?.text, + '这件事我愿意接下,你把关键要点交给我。', + ); + assert.match( + payload.snapshot.currentStory.dialogue?.at(-1)?.text ?? '', + /那就拜托你了。/u, + ); + }, + ); }); test('runtime story actions progress quests from combat victories and npc turn-ins', async () => { @@ -1678,6 +1870,10 @@ test('runtime story actions progress quests from combat victories and npc turn-i gameState: { quests: Array<{ status: string }>; playerCurrency: number; + playerProgression: { + level: number; + totalXp: number; + }; playerInventory: Array<{ name: string }>; npcStates: { npc_bandit_01: { @@ -1694,6 +1890,8 @@ test('runtime story actions progress quests from combat victories and npc turn-i 'turned_in', ); assert.ok(turnInPayload.snapshot.gameState.playerCurrency > 12); + assert.ok(turnInPayload.snapshot.gameState.playerProgression.totalXp > 0); + assert.ok(turnInPayload.snapshot.gameState.playerProgression.level >= 1); assert.ok(turnInPayload.snapshot.gameState.playerInventory.length > 0); assert.ok( turnInPayload.snapshot.gameState.npcStates.npc_bandit_01.affinity > 6, diff --git a/server-node/src/modules/story/storyActionService.ts b/server-node/src/modules/story/storyActionService.ts index c12c731a..15c84ef6 100644 --- a/server-node/src/modules/story/storyActionService.ts +++ b/server-node/src/modules/story/storyActionService.ts @@ -572,13 +572,8 @@ function normalizeStatusPatch(session: RuntimeSession) { } function shouldGenerateReasonedCombatStory( - functionId: string, resolution: StoryResolution, ) { - if (!isCombatFunctionId(functionId)) { - return false; - } - const outcome = resolution.battle?.outcome; return ( outcome === 'victory' || @@ -919,7 +914,9 @@ export async function resolveRuntimeStoryAction(params: { const previousEncounter = session.currentEncounter ? { ...session.currentEncounter } : null; - if (isCombatFunctionId(functionId)) { + const shouldResolveAsCombat = + functionId === 'inventory_use' ? session.inBattle : isCombatFunctionId(functionId); + if (shouldResolveAsCombat) { resolution = resolveCombatAction(session, { functionId, payload: isObject(params.request.action.payload) @@ -1003,7 +1000,7 @@ export async function resolveRuntimeStoryAction(params: { } } else if ( params.llmClient && - shouldGenerateReasonedCombatStory(functionId, resolution) + shouldGenerateReasonedCombatStory(resolution) ) { try { const generatedPayload = await generateReasonedStoryPayload({ diff --git a/server-node/src/services/chatService.test.ts b/server-node/src/services/chatService.test.ts index 7f439ad3..55c02d01 100644 --- a/server-node/src/services/chatService.test.ts +++ b/server-node/src/services/chatService.test.ts @@ -31,6 +31,26 @@ test('npc chat turn schema normalizes player and dialogue aliases', () => { chattedCount: 1, recruited: false, }, + questOfferContext: { + state: { + currentScenePreset: { + id: 'scene-inn', + }, + }, + encounter: { + id: 'npc-liu', + npcName: '柳无声', + }, + turnCount: 2, + }, + chatDirective: { + sceneActId: 'scene-inn-act-1', + turnLimit: 5, + remainingTurns: 3, + limitReason: 'negative_affinity', + closingMode: 'free', + forceExitAfterTurn: false, + }, }); assert.equal(payload.character.name, '沈行'); @@ -40,4 +60,7 @@ test('npc chat turn schema normalizes player and dialogue aliases', () => { text: '你刚才那句话是什么意思?', }, ]); + assert.equal(payload.questOfferContext?.turnCount, 2); + assert.equal(payload.chatDirective?.sceneActId, 'scene-inn-act-1'); + assert.equal(payload.chatDirective?.remainingTurns, 3); }); diff --git a/server-node/src/services/chatService.ts b/server-node/src/services/chatService.ts index 83d4cf1e..020ecb6f 100644 --- a/server-node/src/services/chatService.ts +++ b/server-node/src/services/chatService.ts @@ -31,6 +31,21 @@ const baseNpcChatSchema = z.object({ context: jsonObjectSchema, }); +const npcChatDirectiveSchema = z.object({ + sceneActId: z.string().trim().min(1).nullable().optional(), + turnLimit: z.number().int().nonnegative().nullable().optional(), + remainingTurns: z.number().int().nonnegative().nullable().optional(), + limitReason: z.enum(['negative_affinity']).nullable().optional(), + closingMode: z.enum(['free', 'foreshadow_close']).nullable().optional(), + forceExitAfterTurn: z.boolean().optional(), +}); + +const npcChatQuestOfferContextSchema = z.object({ + state: jsonObjectSchema, + encounter: jsonObjectSchema, + turnCount: z.number().int().nonnegative(), +}); + export const characterChatReplyRequestSchema = baseCharacterChatSchema.extend({ conversationSummary: z.string().optional().default(''), playerMessage: z.string().trim().min(1), @@ -59,6 +74,8 @@ export const npcChatTurnRequestSchema = baseNpcChatSchema dialogue: z.array(jsonObjectSchema).optional(), playerMessage: z.string().trim().min(1), npcState: jsonObjectSchema, + questOfferContext: npcChatQuestOfferContextSchema.nullable().optional(), + chatDirective: npcChatDirectiveSchema.nullable().optional(), }) .superRefine((value, ctx) => { if (!value.character && !value.player) { diff --git a/server-node/src/services/customWorldAgentChangeSummaryService.ts b/server-node/src/services/customWorldAgentChangeSummaryService.ts index 2ac6c56e..50885e5d 100644 --- a/server-node/src/services/customWorldAgentChangeSummaryService.ts +++ b/server-node/src/services/customWorldAgentChangeSummaryService.ts @@ -45,6 +45,7 @@ function resolveCardTitle( draftProfile.landmarks.find((entry) => entry.id === cardId)?.name || draftProfile.threads.find((entry) => entry.id === cardId)?.title || draftProfile.chapters.find((entry) => entry.id === cardId)?.title || + draftProfile.sceneChapters.find((entry) => entry.id === cardId)?.title || (draftProfile.camp?.id === cardId ? draftProfile.camp.name : '') || '当前卡片' ); diff --git a/server-node/src/services/customWorldAgentDraftCompiler.test.ts b/server-node/src/services/customWorldAgentDraftCompiler.test.ts new file mode 100644 index 00000000..72f34471 --- /dev/null +++ b/server-node/src/services/customWorldAgentDraftCompiler.test.ts @@ -0,0 +1,211 @@ +import assert from 'node:assert/strict'; +import test from 'node:test'; + +import { updateDraftCardSections } from './customWorldAgentDraftEditService.js'; +import { + CustomWorldAgentDraftCompiler, + normalizeFoundationDraftProfile, +} from './customWorldAgentDraftCompiler.js'; + +function createSceneChapterDraftProfile() { + return { + name: '雾港列岛', + summary: '潮雾、旧航道和失序港口缠在一起的海岛世界。', + tone: '冷峻、克制、带着海盐和旧铁锈味道。', + playerGoal: '先在失序的港口里站稳,再找出谁在提前布网。', + coreConflicts: ['旧航道解释权正在被重新争夺'], + iconicElements: ['潮雾钟声', '盐火灯塔'], + playableNpcs: [ + { + id: 'npc-lin', + name: '林潮', + title: '守潮人', + role: '码头引路人', + publicIdentity: '码头上最懂回潮时间的人。', + publicMask: '码头上最懂回潮时间的人。', + currentPressure: '必须决定今晚要不要帮玩家进港。', + hiddenHook: '他知道第一批被转移的货不是普通货。', + relationToPlayer: '对玩家保持试探,但还愿意给一次机会。', + threadIds: ['thread-smuggling'], + summary: '他像向导,也像仍在权衡站位的守门人。', + }, + ], + storyNpcs: [ + { + id: 'npc-yan', + name: '晏九', + title: '黑市中间人', + role: '封锁码头的人', + publicIdentity: '他负责把不该上岸的东西挡在潮线外。', + publicMask: '他负责把不该上岸的东西挡在潮线外。', + currentPressure: '必须让今晚的码头保持沉默。', + hiddenHook: '他已经替更大的势力提前清过一次场。', + relationToPlayer: '对玩家带着明显敌意,但又不想立刻翻脸。', + threadIds: ['thread-smuggling'], + summary: '他像威胁,也像握着下一跳线索的人。', + }, + ], + landmarks: [ + { + id: 'landmark-docks', + name: '潮汐码头', + description: '涨潮时会吞没半条旧栈桥的码头。', + purpose: '承接玩家和封锁者的第一次正式碰撞。', + mood: '潮声压低,空气里有明显不欢迎的意味。', + importance: '这里是玩家第一章必须破开的门槛。', + secret: '今晚靠岸的货和旧航道失踪案有关。', + dangerLevel: '中高', + imageSrc: '/images/scene/docks-base.webp', + characterIds: ['npc-lin', 'npc-yan'], + threadIds: ['thread-smuggling'], + summary: '这里不是背景,而是第一章真正开始收紧的地方。', + }, + ], + factions: [], + threads: [ + { + id: 'thread-smuggling', + title: '失踪货船去哪了', + type: 'main', + conflictType: '明线', + conflict: '有人在重写旧航道的夜间进出规则。', + stakes: '如果玩家跟不上这条线,整个港口都会先把他排除在外。', + characterIds: ['npc-lin', 'npc-yan'], + landmarkIds: ['landmark-docks'], + summary: '旧航道的解释权正在被重新洗牌。', + }, + ], + chapters: [ + { + id: 'chapter-docks', + title: '码头开场', + openingEvent: '一艘不该靠岸的船提前抵达潮线外。', + playerGoal: '先确认谁在码头上拥有发言权。', + characterIds: ['npc-lin', 'npc-yan'], + landmarkIds: ['landmark-docks'], + understandingShift: '玩家会意识到这不是简单的港口封锁。', + summary: '码头上的第一次碰撞会直接决定后续节奏。', + }, + ], + sceneChapters: [ + { + id: 'scene-chapter-docks', + sceneId: 'landmark-docks', + sceneName: '潮汐码头', + title: '潮汐码头章节', + summary: '玩家会在这里完成试探、逼问和第一次局部收束。', + linkedThreadIds: ['thread-smuggling'], + linkedLandmarkIds: ['landmark-docks'], + acts: [ + { + id: 'act-docks-1', + title: '雾里靠岸', + summary: '玩家刚抵达时,林潮先决定要不要放行。', + stageCoverage: ['opening'], + backgroundImageSrc: '/images/scene/docks-act-1.webp', + encounterNpcIds: ['npc-lin', 'npc-yan'], + primaryNpcId: 'npc-lin', + linkedThreadIds: ['thread-smuggling'], + actGoal: '先让玩家拿到码头里的第一句真话。', + transitionHook: '确认站位后,真正的封锁者会压上来。', + advanceRule: 'after_primary_contact', + }, + { + id: 'act-docks-2', + title: '封锁加压', + summary: '晏九开始把玩家往更危险的方向逼。', + stageCoverage: ['turning_point', 'climax', 'aftermath'], + backgroundImageSrc: '/images/scene/docks-act-2.webp', + encounterNpcIds: ['npc-yan', 'npc-lin'], + primaryNpcId: 'npc-yan', + linkedThreadIds: ['thread-smuggling'], + actGoal: '把矛盾推向必须接住的下一跳。', + transitionHook: '第 2 幕收束时必须把下一步追踪方向抛出来。', + advanceRule: 'after_chapter_resolution', + }, + ], + }, + ], + }; +} + +test('draft compiler compiles scene chapter cards with act-level editable sections', () => { + const draftProfile = createSceneChapterDraftProfile(); + const compiler = new CustomWorldAgentDraftCompiler(); + + const draftCards = compiler.compileDraftCards(draftProfile); + const sceneChapterCard = draftCards.find((entry) => entry.kind === 'scene_chapter'); + const detail = compiler.getDraftCardDetail(draftProfile, 'scene-chapter-docks'); + + assert.ok(sceneChapterCard); + assert.equal(sceneChapterCard?.title, '潮汐码头章节'); + assert.match(sceneChapterCard?.subtitle ?? '', /2 幕/u); + assert.ok(detail); + assert.equal(detail?.kind, 'scene_chapter'); + assert.ok(detail?.editableSectionIds.includes('title')); + assert.ok(detail?.editableSectionIds.includes('act:act-docks-1:title')); + assert.ok( + detail?.sections.some( + (section) => + section.id === 'act:act-docks-1:backgroundImageSrc' && + section.value === '/images/scene/docks-act-1.webp', + ), + ); + assert.ok( + detail?.sections.some( + (section) => + section.id === 'act:act-docks-2:primaryNpcId' && + section.value.includes('晏九'), + ), + ); +}); + +test('updateDraftCardSections rewrites scene chapter act NPC order and primary npc', () => { + const updatedDraftProfile = updateDraftCardSections({ + draftProfile: JSON.parse(JSON.stringify(createSceneChapterDraftProfile())), + cardId: 'scene-chapter-docks', + sections: [ + { + sectionId: 'title', + value: '潮汐码头对峙章', + }, + { + sectionId: 'act:act-docks-1:title', + value: '封港前夜', + }, + { + sectionId: 'act:act-docks-1:backgroundImageSrc', + value: '/images/scene/docks-act-1-night.webp', + }, + { + sectionId: 'act:act-docks-1:encounterNpcIds', + value: '晏九\n林潮', + }, + { + sectionId: 'act:act-docks-1:transitionHook', + value: '第 1 幕最后要把玩家逼到必须继续追的方向上。', + }, + ], + }); + + const normalized = normalizeFoundationDraftProfile(updatedDraftProfile); + const updatedSceneChapter = normalized?.sceneChapters.find( + (entry) => entry.id === 'scene-chapter-docks', + ); + const updatedAct = updatedSceneChapter?.acts.find((entry) => entry.id === 'act-docks-1'); + + assert.ok(updatedSceneChapter); + assert.ok(updatedAct); + assert.equal(updatedSceneChapter?.title, '潮汐码头对峙章'); + assert.equal(updatedAct?.title, '封港前夜'); + assert.equal( + updatedAct?.backgroundImageSrc, + '/images/scene/docks-act-1-night.webp', + ); + assert.deepEqual(updatedAct?.encounterNpcIds, ['npc-yan', 'npc-lin']); + assert.equal(updatedAct?.primaryNpcId, 'npc-yan'); + assert.equal( + updatedAct?.transitionHook, + '第 1 幕最后要把玩家逼到必须继续追的方向上。', + ); +}); diff --git a/server-node/src/services/customWorldAgentDraftCompiler.ts b/server-node/src/services/customWorldAgentDraftCompiler.ts index 2d459f0a..648fd663 100644 --- a/server-node/src/services/customWorldAgentDraftCompiler.ts +++ b/server-node/src/services/customWorldAgentDraftCompiler.ts @@ -10,6 +10,8 @@ import type { CustomWorldFoundationDraftFaction, CustomWorldFoundationDraftLandmark, CustomWorldFoundationDraftProfile, + CustomWorldFoundationDraftSceneAct, + CustomWorldFoundationDraftSceneChapter, CustomWorldFoundationDraftThread, } from '../../../packages/shared/src/contracts/customWorldAgent.js'; import { @@ -74,6 +76,39 @@ const EDITABLE_CAMP_SECTION_IDS = [ 'dangerLevel', ] as const; +const EDITABLE_SCENE_CHAPTER_BASE_SECTION_IDS = [ + 'title', + 'summary', +] as const; + +const SCENE_ACT_STAGE_ORDER = [ + 'opening', + 'expansion', + 'turning_point', + 'climax', + 'aftermath', +] as const; + +const SCENE_ACT_STAGE_LABELS: Record< + CustomWorldFoundationDraftSceneAct['stageCoverage'][number], + string +> = { + opening: '开场', + expansion: '铺展', + turning_point: '转折', + climax: '高潮', + aftermath: '余波', +}; + +const SCENE_ACT_ADVANCE_RULE_LABELS: Record< + CustomWorldFoundationDraftSceneAct['advanceRule'], + string +> = { + after_primary_contact: '主角色首次有效接触后推进', + after_active_step_complete: '当前主动步骤完成后推进', + after_chapter_resolution: '章节进入收束后推进', +}; + function toText(value: unknown) { return typeof value === 'string' ? value.trim() : ''; } @@ -101,6 +136,28 @@ function toStringArray(value: unknown, maxCount = 8) { ); } +function normalizeCharacterSkills(value: unknown, fallbackName: string) { + const skills = toRecordArray(value) + .map((item, index) => ({ + id: toText(item.id) || `skill-${index + 1}`, + name: toText(item.name) || `技能${index + 1}`, + actionPreviewConfig: toRecord(item.actionPreviewConfig), + })) + .filter((item) => Boolean(item.id)); + + if (skills.length > 0) { + return skills; + } + + return [ + { + id: 'skill-1', + name: `${clampText(fallbackName, 10) || '角色'}招牌动作`, + actionPreviewConfig: null, + }, + ]; +} + function slugify(value: string) { const normalized = value .trim() @@ -149,9 +206,40 @@ function resolveEditableSectionIds(kind: CustomWorldDraftCardKind) { if (kind === 'thread') return [...EDITABLE_THREAD_SECTION_IDS]; if (kind === 'chapter') return [...EDITABLE_CHAPTER_SECTION_IDS]; if (kind === 'camp') return [...EDITABLE_CAMP_SECTION_IDS]; + if (kind === 'scene_chapter') return [...EDITABLE_SCENE_CHAPTER_BASE_SECTION_IDS]; return []; } +function resolveSceneChapterEditableSectionIds( + sceneChapter: CustomWorldFoundationDraftSceneChapter, +) { + return [ + ...EDITABLE_SCENE_CHAPTER_BASE_SECTION_IDS, + ...sceneChapter.acts.flatMap((act) => [ + `act:${act.id}:title`, + `act:${act.id}:summary`, + `act:${act.id}:backgroundImageSrc`, + `act:${act.id}:encounterNpcIds`, + `act:${act.id}:actGoal`, + `act:${act.id}:transitionHook`, + ]), + ]; +} + +function resolveSceneActStageCoverageLabel( + stageCoverage: CustomWorldFoundationDraftSceneAct['stageCoverage'], +) { + return stageCoverage + .map((stage) => SCENE_ACT_STAGE_LABELS[stage] || stage) + .join('、'); +} + +function resolveSceneActAdvanceRuleLabel( + advanceRule: CustomWorldFoundationDraftSceneAct['advanceRule'], +) { + return SCENE_ACT_ADVANCE_RULE_LABELS[advanceRule] || advanceRule; +} + function normalizeFaction( value: unknown, index: number, @@ -243,6 +331,7 @@ function normalizeCharacter( ].join(';'), 120, ), + skills: normalizeCharacterSkills(record.skills, role || title || name || '角色'), imageSrc: toText(record.imageSrc) || null, generatedVisualAssetId: toText(record.generatedVisualAssetId) || null, generatedAnimationSetId: toText(record.generatedAnimationSetId) || null, @@ -287,6 +376,7 @@ function normalizeLandmark( importance: secret || '玩家第一次抵达就会意识到它不只是背景', secret: secret || '玩家第一次抵达就会意识到它不只是背景', dangerLevel: dangerLevel || '中', + imageSrc: toText(record.imageSrc) || null, characterIds: toStringArray(record.characterIds, 8), threadIds: toStringArray(record.threadIds, 8), summary: @@ -410,6 +500,7 @@ function normalizeCamp(value: unknown): CustomWorldFoundationDraftCamp | null { description: description || '玩家暂时还能整顿情报和喘口气的地方', mood: dangerLevel || '克制、紧绷,但还能暂时收拢局势', dangerLevel: dangerLevel || '克制、紧绷,但还能暂时收拢局势', + imageSrc: toText(record.imageSrc) || null, summary: summary || clampText( @@ -422,6 +513,342 @@ function normalizeCamp(value: unknown): CustomWorldFoundationDraftCamp | null { }; } +function normalizeStageCoverage(value: unknown) { + const stageCoverage = Array.isArray(value) + ? value + .filter((entry): entry is string => typeof entry === 'string') + .map((entry) => entry.trim()) + .filter( + ( + entry, + ): entry is CustomWorldFoundationDraftSceneAct['stageCoverage'][number] => + SCENE_ACT_STAGE_ORDER.includes( + entry as (typeof SCENE_ACT_STAGE_ORDER)[number], + ), + ) + : []; + + return [...new Set(stageCoverage)]; +} + +function buildFallbackSceneActStageCoverage(index: number, actCount: number) { + if (actCount <= 2) { + return index === 0 + ? (['opening', 'expansion'] as CustomWorldFoundationDraftSceneAct['stageCoverage']) + : (['turning_point', 'climax', 'aftermath'] as CustomWorldFoundationDraftSceneAct['stageCoverage']); + } + + if (actCount === 3) { + if (index === 0) { + return ['opening'] as CustomWorldFoundationDraftSceneAct['stageCoverage']; + } + if (index === 1) { + return ['expansion', 'turning_point'] as CustomWorldFoundationDraftSceneAct['stageCoverage']; + } + return ['climax', 'aftermath'] as CustomWorldFoundationDraftSceneAct['stageCoverage']; + } + + if (actCount === 4) { + if (index === 0) return ['opening'] as CustomWorldFoundationDraftSceneAct['stageCoverage']; + if (index === 1) return ['expansion'] as CustomWorldFoundationDraftSceneAct['stageCoverage']; + if (index === 2) return ['turning_point'] as CustomWorldFoundationDraftSceneAct['stageCoverage']; + return ['climax', 'aftermath'] as CustomWorldFoundationDraftSceneAct['stageCoverage']; + } + + return [SCENE_ACT_STAGE_ORDER[Math.min(index, SCENE_ACT_STAGE_ORDER.length - 1)]]; +} + +function normalizeSceneAct( + value: unknown, + index: number, + fallback: { + sceneId: string; + sceneName: string; + backgroundImageSrc?: string | null; + encounterNpcIds: string[]; + linkedThreadIds: string[]; + actCount: number; + }, +): CustomWorldFoundationDraftSceneAct | null { + const record = toRecord(value); + if (!record) { + return null; + } + + const title = toText(record.title); + const summary = toText(record.summary); + const encounterNpcIds = toStringArray( + record.encounterNpcIds, + Math.max(1, fallback.encounterNpcIds.length || 8), + ); + const stageCoverage = normalizeStageCoverage(record.stageCoverage); + + if (!title && !summary && encounterNpcIds.length === 0) { + return null; + } + + const resolvedEncounterNpcIds = + encounterNpcIds.length > 0 ? encounterNpcIds : fallback.encounterNpcIds; + const primaryNpcId = toText(record.primaryNpcId) || resolvedEncounterNpcIds[0] || ''; + + return { + id: + toText(record.id) || + createId(`scene-act-${fallback.sceneId}`, title || fallback.sceneName, index), + title: title || `第 ${index + 1} 幕`, + summary: + summary || + clampText( + [ + title || `第 ${index + 1} 幕`, + toText(record.actGoal) || '这一幕仍需继续精修', + ].join(';'), + 120, + ), + stageCoverage: + stageCoverage.length > 0 + ? stageCoverage + : buildFallbackSceneActStageCoverage(index, fallback.actCount), + backgroundImageSrc: + toText(record.backgroundImageSrc) || fallback.backgroundImageSrc || null, + backgroundAssetId: toText(record.backgroundAssetId) || null, + encounterNpcIds: resolvedEncounterNpcIds, + primaryNpcId, + linkedThreadIds: + toStringArray(record.linkedThreadIds, 8).length > 0 + ? toStringArray(record.linkedThreadIds, 8) + : fallback.linkedThreadIds, + actGoal: + toText(record.actGoal) || + (index === 0 + ? `先在${fallback.sceneName}接住开场 lead` + : index === fallback.actCount - 1 + ? `把${fallback.sceneName}这一章收住` + : `继续逼近${fallback.sceneName}的核心压力`), + transitionHook: + toText(record.transitionHook) || + (index === fallback.actCount - 1 + ? '这一幕结束后会把问题推向下一跳。' + : '完成当前推进后,局势会进入下一幕。'), + advanceRule: + toText(record.advanceRule) === 'after_primary_contact' || + toText(record.advanceRule) === 'after_active_step_complete' || + toText(record.advanceRule) === 'after_chapter_resolution' + ? (toText(record.advanceRule) as CustomWorldFoundationDraftSceneAct['advanceRule']) + : index === 0 + ? 'after_primary_contact' + : index === fallback.actCount - 1 + ? 'after_chapter_resolution' + : 'after_active_step_complete', + }; +} + +function buildFallbackSceneActs(params: { + sceneId: string; + sceneName: string; + sceneSummary: string; + backgroundImageSrc?: string | null; + encounterNpcIds: string[]; + linkedThreadIds: string[]; +}) { + const actCount = 3; + + return [ + { + id: `${params.sceneId}-act-1`, + title: `初见 ${params.sceneName}`, + summary: clampText( + `玩家第一次真正接住${params.sceneName}这一章的入口。${params.sceneSummary}`, + 120, + ), + stageCoverage: buildFallbackSceneActStageCoverage(0, actCount), + backgroundImageSrc: params.backgroundImageSrc || null, + backgroundAssetId: null, + encounterNpcIds: params.encounterNpcIds, + primaryNpcId: params.encounterNpcIds[0] || '', + linkedThreadIds: params.linkedThreadIds, + actGoal: `先在${params.sceneName}接住开场 lead`, + transitionHook: '和主角色完成首次有效接触后,局势会继续加压。', + advanceRule: 'after_primary_contact', + }, + { + id: `${params.sceneId}-act-2`, + title: `${params.sceneName}承压`, + summary: clampText( + `玩家开始确认${params.sceneName}不只是背景,而是这一章真正承压的地方。`, + 120, + ), + stageCoverage: buildFallbackSceneActStageCoverage(1, actCount), + backgroundImageSrc: params.backgroundImageSrc || null, + backgroundAssetId: null, + encounterNpcIds: params.encounterNpcIds, + primaryNpcId: params.encounterNpcIds[0] || '', + linkedThreadIds: params.linkedThreadIds, + actGoal: `继续逼近${params.sceneName}的核心压力`, + transitionHook: '完成当前主动 step 后,这一章会转向收束。', + advanceRule: 'after_active_step_complete', + }, + { + id: `${params.sceneId}-act-3`, + title: `${params.sceneName}收束`, + summary: clampText( + `这一幕承担${params.sceneName}的局部收束和下一跳 handoff。`, + 120, + ), + stageCoverage: buildFallbackSceneActStageCoverage(2, actCount), + backgroundImageSrc: params.backgroundImageSrc || null, + backgroundAssetId: null, + encounterNpcIds: params.encounterNpcIds, + primaryNpcId: params.encounterNpcIds[0] || '', + linkedThreadIds: params.linkedThreadIds, + actGoal: `把${params.sceneName}这一章收住`, + transitionHook: '这一幕结束后需要把后续方向明确抛给玩家。', + advanceRule: 'after_chapter_resolution', + }, + ] satisfies CustomWorldFoundationDraftSceneAct[]; +} + +function normalizeSceneChapter( + value: unknown, + index: number, + fallback: { + sceneId: string; + sceneName: string; + sceneSummary: string; + linkedThreadIds: string[]; + linkedLandmarkIds: string[]; + backgroundImageSrc?: string | null; + encounterNpcIds: string[]; + }, +): CustomWorldFoundationDraftSceneChapter | null { + const record = toRecord(value); + if (!record) { + return null; + } + + const sceneId = toText(record.sceneId) || fallback.sceneId; + const sceneName = toText(record.sceneName) || fallback.sceneName; + const title = toText(record.title); + const summary = toText(record.summary); + const actsInput = Array.isArray(record.acts) ? record.acts : []; + const actCount = Math.min(5, Math.max(2, actsInput.length || 3)); + const linkedThreadIds = + toStringArray(record.linkedThreadIds, 8).length > 0 + ? toStringArray(record.linkedThreadIds, 8) + : fallback.linkedThreadIds; + const linkedLandmarkIds = + toStringArray(record.linkedLandmarkIds, 8).length > 0 + ? toStringArray(record.linkedLandmarkIds, 8) + : fallback.linkedLandmarkIds; + + const acts = actsInput + .map((entry, actIndex) => + normalizeSceneAct(entry, actIndex, { + sceneId, + sceneName, + backgroundImageSrc: fallback.backgroundImageSrc, + encounterNpcIds: fallback.encounterNpcIds, + linkedThreadIds, + actCount, + }), + ) + .filter((entry): entry is CustomWorldFoundationDraftSceneAct => Boolean(entry)) + .slice(0, 5); + + return { + id: toText(record.id) || createId('scene-chapter', sceneName || title, index), + sceneId, + sceneName, + title: title || `${sceneName}章节`, + summary: + summary || + clampText( + [ + sceneName, + fallback.sceneSummary || '这一章的场景节拍仍可继续收紧', + ].join(':'), + 140, + ), + linkedThreadIds, + linkedLandmarkIds, + acts: acts.length >= 2 ? acts : buildFallbackSceneActs({ + sceneId, + sceneName, + sceneSummary: fallback.sceneSummary, + backgroundImageSrc: fallback.backgroundImageSrc, + encounterNpcIds: fallback.encounterNpcIds, + linkedThreadIds, + }), + }; +} + +function buildFallbackSceneChapters(params: { + landmarks: CustomWorldFoundationDraftLandmark[]; + characters: CustomWorldFoundationDraftCharacter[]; + threads: CustomWorldFoundationDraftThread[]; + chapters: CustomWorldFoundationDraftChapter[]; +}) { + const fallbackCharacterIds = params.characters.slice(0, 3).map((entry) => entry.id); + + return params.landmarks.map((landmark, index) => { + const matchingChapter = + params.chapters.find((chapter) => chapter.landmarkIds.includes(landmark.id)) ?? null; + const encounterNpcIds = + landmark.characterIds.length > 0 ? landmark.characterIds : fallbackCharacterIds; + const linkedThreadIds = + landmark.threadIds.length > 0 + ? landmark.threadIds + : params.threads + .filter((thread) => thread.landmarkIds.includes(landmark.id)) + .map((thread) => thread.id) + .slice(0, 4); + + return { + id: `scene-chapter-${landmark.id}`, + sceneId: landmark.id, + sceneName: landmark.name, + title: matchingChapter?.title || `${landmark.name}章节`, + summary: + matchingChapter?.summary || + clampText( + [landmark.summary, matchingChapter?.openingEvent || '这一章会从这里真正展开'] + .filter(Boolean) + .join(';'), + 140, + ), + linkedThreadIds, + linkedLandmarkIds: [landmark.id], + acts: buildFallbackSceneActs({ + sceneId: landmark.id, + sceneName: landmark.name, + sceneSummary: landmark.summary, + backgroundImageSrc: landmark.imageSrc || null, + encounterNpcIds, + linkedThreadIds, + }), + } satisfies CustomWorldFoundationDraftSceneChapter; + }); +} + +function resolveSceneChapterFallbackFromRecord(item: unknown, index: number) { + const record = toRecord(item); + const linkedLandmarkIds = toStringArray(record?.linkedLandmarkIds, 8); + return { + sceneId: toText(record?.sceneId) || linkedLandmarkIds[0] || `scene-${index + 1}`, + sceneName: + toText(record?.sceneName) || + toText(record?.title) || + `场景章节 ${index + 1}`, + sceneSummary: + toText(record?.summary) || + '这一章仍可继续精修场景幕结构。', + linkedThreadIds: toStringArray(record?.linkedThreadIds, 8), + linkedLandmarkIds, + backgroundImageSrc: toText(record?.backgroundImageSrc) || null, + encounterNpcIds: toStringArray(record?.encounterNpcIds, 8), + }; +} + export function normalizeFoundationDraftProfile( value: unknown, ): CustomWorldFoundationDraftProfile | null { @@ -474,6 +901,28 @@ export function normalizeFoundationDraftProfile( Boolean(item), ), ); + const mergedCharacters = dedupeById([...playableNpcs, ...storyNpcs]); + const explicitSceneChapters = toRecordArray(record.sceneChapters) + .map((item, index) => + normalizeSceneChapter( + item, + index, + resolveSceneChapterFallbackFromRecord(item, index), + ), + ) + .filter((item): item is CustomWorldFoundationDraftSceneChapter => + Boolean(item), + ); + const sceneChapters = dedupeById( + explicitSceneChapters.length > 0 + ? explicitSceneChapters + : buildFallbackSceneChapters({ + landmarks, + characters: mergedCharacters, + threads, + chapters, + }) + ); const camp = normalizeCamp(record.camp); const hasStructuredFoundationContent = playableNpcs.length > 0 || @@ -482,13 +931,12 @@ export function normalizeFoundationDraftProfile( factions.length > 0 || threads.length > 0 || chapters.length > 0 || + sceneChapters.length > 0 || Boolean(camp); if (!hasStructuredFoundationContent) { return null; } - - const mergedCharacters = dedupeById([...playableNpcs, ...storyNpcs]); const coreConflicts = toStringArray(record.coreConflicts, 6); return { @@ -539,6 +987,7 @@ export function normalizeFoundationDraftProfile( factions, threads, chapters, + sceneChapters, worldHook: toText(record.worldHook) || name || summary, playerPremise: toText(record.playerPremise), openingSituation: toText(record.openingSituation), @@ -636,6 +1085,84 @@ function buildChapterWarnings(chapter: CustomWorldFoundationDraftChapter) { return warnings; } +function buildSceneChapterWarnings(params: { + sceneChapter: CustomWorldFoundationDraftSceneChapter; + characterById: Map; + threadById: Map; + landmarkById: Map; +}) { + const { sceneChapter, characterById, threadById, landmarkById } = params; + const warnings: string[] = []; + + if (sceneChapter.acts.length < 2) { + warnings.push('这个场景章节至少需要 2 幕。'); + } + if (sceneChapter.acts.length > 5) { + warnings.push('这个场景章节当前超过 5 幕,建议先收束到 5 幕以内。'); + } + + const linkedLandmarks = sceneChapter.linkedLandmarkIds + .map((id) => landmarkById.get(id)) + .filter((entry): entry is CustomWorldFoundationDraftLandmark => Boolean(entry)); + + sceneChapter.acts.forEach((act, index) => { + const actLabel = `第 ${index + 1} 幕`; + const primaryNpcId = act.encounterNpcIds[0] || act.primaryNpcId; + const actThreadIds = + act.linkedThreadIds.length > 0 + ? act.linkedThreadIds + : sceneChapter.linkedThreadIds; + + if (!act.backgroundImageSrc && !act.backgroundAssetId) { + warnings.push(`${actLabel}还没有绑定背景图。`); + } + if (act.encounterNpcIds.length === 0) { + warnings.push(`${actLabel}还没有配置相遇 NPC。`); + } + if (!primaryNpcId) { + warnings.push(`${actLabel}缺少主角色。`); + } + if (act.primaryNpcId && act.primaryNpcId !== (act.encounterNpcIds[0] ?? '')) { + warnings.push(`${actLabel}的主角色必须放在相遇 NPC 的第一位。`); + } + if (actThreadIds.length === 0) { + warnings.push(`${actLabel}还没有挂到明确线程。`); + } + + const unresolvedNpcIds = act.encounterNpcIds.filter((id) => !characterById.has(id)); + if (unresolvedNpcIds.length > 0) { + warnings.push( + `${actLabel}存在未进入当前世界角色池的 NPC:${unresolvedNpcIds + .slice(0, 3) + .join('、')}。`, + ); + } + + const unresolvedThreadIds = actThreadIds.filter((id) => !threadById.has(id)); + if (unresolvedThreadIds.length > 0) { + warnings.push( + `${actLabel}存在未绑定的线程引用:${unresolvedThreadIds + .slice(0, 3) + .join('、')}。`, + ); + } + + if (primaryNpcId && characterById.has(primaryNpcId)) { + const linkedToLandmark = linkedLandmarks.some((landmark) => + landmark.characterIds.includes(primaryNpcId), + ); + const linkedToThread = actThreadIds.some((threadId) => + threadById.get(threadId)?.characterIds.includes(primaryNpcId), + ); + if (!linkedToLandmark && !linkedToThread) { + warnings.push(`${actLabel}的主角色和当前场景/线程的关联还不够明确。`); + } + } + }); + + return warnings; +} + function buildCampWarnings() { return [] as string[]; } @@ -650,6 +1177,7 @@ function buildCharacterAssetHeadline(character: CustomWorldFoundationDraftCharac generatedVisualAssetId: character.generatedVisualAssetId, generatedAnimationSetId: character.generatedAnimationSetId, animationMap: character.animationMap, + skills: character.skills ?? [], }, roleKind: 'story', }); @@ -773,6 +1301,7 @@ export class CustomWorldAgentDraftCompiler { ...profile.landmarks.map((entry) => entry.id), ...profile.threads.map((entry) => entry.id), ...profile.chapters.map((entry) => entry.id), + ...profile.sceneChapters.map((entry) => entry.id), ].slice(0, 12), sections: [ buildSection('title', '标题', profile.name), @@ -1025,6 +1554,129 @@ export class CustomWorldAgentDraftCompiler { }); }); + profile.sceneChapters.forEach((sceneChapter) => { + const uniqueNpcIds = [...new Set(sceneChapter.acts.flatMap((act) => act.encounterNpcIds))]; + const readyBackgroundCount = sceneChapter.acts.filter( + (act) => Boolean(act.backgroundImageSrc || act.backgroundAssetId), + ).length; + const warnings = buildSceneChapterWarnings({ + sceneChapter, + characterById, + threadById, + landmarkById, + }); + + pushCard({ + id: sceneChapter.id, + kind: 'scene_chapter', + title: sceneChapter.title, + subtitle: clampText( + `${sceneChapter.sceneName} · ${sceneChapter.acts.length} 幕 · 背景 ${readyBackgroundCount}/${sceneChapter.acts.length}`, + 40, + ), + summary: sceneChapter.summary, + linkedIds: [ + ...sceneChapter.linkedLandmarkIds, + ...sceneChapter.linkedThreadIds, + ...uniqueNpcIds, + ].slice(0, 12), + sections: [ + buildSection('sceneName', '所属场景', sceneChapter.sceneName), + buildSection('title', '场景章节标题', sceneChapter.title), + buildSection('summary', '场景章节摘要', sceneChapter.summary), + buildSection( + 'actOverview', + '幕结构总览', + sceneChapter.acts + .map((act, index) => { + const primaryNpcName = + resolveCharacterNames([act.encounterNpcIds[0] || act.primaryNpcId]) || + '待补主角色'; + const supportNpcNames = + resolveCharacterNames(act.encounterNpcIds.slice(1)) || '当前没有辅助 NPC'; + return [ + `第 ${index + 1} 幕|${act.title}`, + `主角色:${primaryNpcName}`, + `辅助 NPC:${supportNpcNames}`, + `目标:${act.actGoal}`, + `过渡:${act.transitionHook}`, + ].join('\n'); + }) + .join('\n\n'), + ), + buildSection( + 'linkedLandmarkIds', + '关联地点', + resolveLandmarkNames(sceneChapter.linkedLandmarkIds), + ), + buildSection( + 'linkedThreadIds', + '关联线程', + resolveThreadTitles(sceneChapter.linkedThreadIds), + ), + ...sceneChapter.acts.flatMap((act, index) => { + const actLabel = `第 ${index + 1} 幕`; + const encounterNpcValue = + resolveCharacterNames(act.encounterNpcIds) || + act.encounterNpcIds.join('、'); + const primaryNpcValue = + resolveCharacterNames([act.encounterNpcIds[0] || act.primaryNpcId]) || + act.encounterNpcIds[0] || + act.primaryNpcId; + const actThreadTitles = + resolveThreadTitles( + act.linkedThreadIds.length > 0 + ? act.linkedThreadIds + : sceneChapter.linkedThreadIds, + ) || '待补线程挂钩'; + + return [ + buildSection(`act:${act.id}:title`, `${actLabel}标题`, act.title), + buildSection(`act:${act.id}:summary`, `${actLabel}摘要`, act.summary), + buildSection( + `act:${act.id}:backgroundImageSrc`, + `${actLabel}背景图`, + act.backgroundImageSrc || act.backgroundAssetId || '', + ), + buildSection( + `act:${act.id}:encounterNpcIds`, + `${actLabel}相遇 NPC`, + encounterNpcValue, + ), + buildSection( + `act:${act.id}:primaryNpcId`, + `${actLabel}主角色`, + primaryNpcValue, + ), + buildSection( + `act:${act.id}:stageCoverage`, + `${actLabel}阶段覆盖`, + resolveSceneActStageCoverageLabel(act.stageCoverage), + ), + buildSection(`act:${act.id}:actGoal`, `${actLabel}目标`, act.actGoal), + buildSection( + `act:${act.id}:transitionHook`, + `${actLabel}过渡钩子`, + act.transitionHook, + ), + buildSection( + `act:${act.id}:linkedThreadIds`, + `${actLabel}关联线程`, + actThreadTitles, + ), + buildSection( + `act:${act.id}:advanceRule`, + `${actLabel}推进规则`, + resolveSceneActAdvanceRuleLabel(act.advanceRule), + ), + ]; + }), + ], + editableSectionIds: resolveSceneChapterEditableSectionIds(sceneChapter), + warningMessages: warnings, + }); + }); + return cards; } } diff --git a/server-node/src/services/customWorldAgentDraftEditService.ts b/server-node/src/services/customWorldAgentDraftEditService.ts index f01dd7a5..c81c0aaa 100644 --- a/server-node/src/services/customWorldAgentDraftEditService.ts +++ b/server-node/src/services/customWorldAgentDraftEditService.ts @@ -23,6 +23,7 @@ const EDITABLE_SECTION_IDS = { thread: new Set(['title', 'summary', 'conflictType', 'stakes']), chapter: new Set(['title', 'summary', 'openingEvent', 'playerGoal', 'understandingShift']), camp: new Set(['name', 'description', 'dangerLevel']), + sceneChapter: new Set(['title', 'summary']), } as const; function normalizePatches(sections: DraftSectionPatch[]) { @@ -52,6 +53,17 @@ function parseStringList(value: string) { return [...new Set(value.split(/[\n;;]+/u).map((item) => item.trim()).filter(Boolean))]; } +function parseReferenceList(value: string) { + return [ + ...new Set( + value + .split(/[\n,,、;;]+/u) + .map((item) => item.trim()) + .filter(Boolean), + ), + ]; +} + function resolveThreadType(value: string) { if (value.includes('暗') || value.toLowerCase() === 'hidden') { return 'hidden' as const; @@ -60,6 +72,61 @@ function resolveThreadType(value: string) { return 'main' as const; } +function parseSceneActSectionId(sectionId: string) { + const match = sectionId.match( + /^act:([^:]+):(title|summary|backgroundImageSrc|encounterNpcIds|actGoal|transitionHook)$/u, + ); + if (!match) { + return null; + } + + return { + actId: match[1], + field: match[2] as + | 'title' + | 'summary' + | 'backgroundImageSrc' + | 'encounterNpcIds' + | 'actGoal' + | 'transitionHook', + }; +} + +function resolveCharacterIdByReference( + value: string, + draftProfile: NonNullable>, +) { + const characters = [...draftProfile.playableNpcs, ...draftProfile.storyNpcs]; + return ( + characters.find((entry) => entry.id === value)?.id || + characters.find((entry) => entry.name === value)?.id || + '' + ); +} + +function parseEncounterNpcIds( + value: string, + draftProfile: NonNullable>, +) { + const references = parseReferenceList(value); + if (references.length === 0) { + throw badRequest('scene act requires at least one encounter NPC'); + } + + const unresolvedReferences = references.filter( + (reference) => !resolveCharacterIdByReference(reference, draftProfile), + ); + if (unresolvedReferences.length > 0) { + throw badRequest( + `unknown scene act NPC reference: ${unresolvedReferences.join('、')}`, + ); + } + + return references.map((reference) => + resolveCharacterIdByReference(reference, draftProfile), + ); +} + export function updateDraftCardSections(params: UpdateDraftCardSectionsParams) { const draftProfile = normalizeFoundationDraftProfile(params.draftProfile); if (!draftProfile) { @@ -293,6 +360,70 @@ export function updateDraftCardSections(params: UpdateDraftCardSectionsParams) { return draftProfile as unknown as Record; } + const sceneChapter = draftProfile.sceneChapters.find( + (entry) => entry.id === params.cardId, + ); + if (sceneChapter) { + patches.forEach(({ sectionId, value }) => { + if (EDITABLE_SECTION_IDS.sceneChapter.has(sectionId as never)) { + if (sectionId === 'title') { + sceneChapter.title = value; + return; + } + + if (sectionId === 'summary') { + sceneChapter.summary = value; + } + return; + } + + const parsedSceneActSection = parseSceneActSectionId(sectionId); + if (!parsedSceneActSection) { + throw badRequest(`section ${sectionId} is not editable for scene_chapter`); + } + + const targetAct = sceneChapter.acts.find( + (entry) => entry.id === parsedSceneActSection.actId, + ); + if (!targetAct) { + throw notFound(`scene act ${parsedSceneActSection.actId} not found`); + } + + if (parsedSceneActSection.field === 'title') { + targetAct.title = value; + return; + } + + if (parsedSceneActSection.field === 'summary') { + targetAct.summary = value; + return; + } + + if (parsedSceneActSection.field === 'backgroundImageSrc') { + targetAct.backgroundImageSrc = value || null; + return; + } + + if (parsedSceneActSection.field === 'encounterNpcIds') { + const encounterNpcIds = parseEncounterNpcIds(value, draftProfile); + targetAct.encounterNpcIds = encounterNpcIds; + targetAct.primaryNpcId = encounterNpcIds[0] || ''; + return; + } + + if (parsedSceneActSection.field === 'actGoal') { + targetAct.actGoal = value; + return; + } + + if (parsedSceneActSection.field === 'transitionHook') { + targetAct.transitionHook = value; + } + }); + + return draftProfile as unknown as Record; + } + if (draftProfile.camp?.id === params.cardId) { patches.forEach(({ sectionId, value }) => { if (!EDITABLE_SECTION_IDS.camp.has(sectionId as never)) { diff --git a/server-node/src/services/customWorldAgentPhase5.test.ts b/server-node/src/services/customWorldAgentPhase5.test.ts index e68710ee..5e3559f6 100644 --- a/server-node/src/services/customWorldAgentPhase5.test.ts +++ b/server-node/src/services/customWorldAgentPhase5.test.ts @@ -119,12 +119,16 @@ async function createObjectRefiningSession( seedText: '一个被潮雾切开的列岛世界。', }); - const message1 = await orchestrator.submitMessage(userId, createdSession.sessionId, { - clientMessageId: 'phase5-ready-1', - text: '玩家是被迫返乡的失职守灯人,开局时正站在即将熄灭的旧灯塔上。', - focusCardId: null, - selectedCardIds: [], - }); + const message1 = await orchestrator.submitMessage( + userId, + createdSession.sessionId, + { + clientMessageId: 'phase5-ready-1', + text: '玩家是被迫返乡的失职守灯人,开局时正站在即将熄灭的旧灯塔上。', + focusCardId: null, + selectedCardIds: [], + }, + ); await waitForOperation( orchestrator, userId, @@ -132,12 +136,16 @@ async function createObjectRefiningSession( message1.operation.operationId, ); - const message2 = await orchestrator.submitMessage(userId, createdSession.sessionId, { - clientMessageId: 'phase5-ready-2', - text: '整体主题是海岛悬疑,气质冷峻克制。核心冲突是守灯会与沉船商盟争夺航道解释权。关键人物叫沈砺,是玩家的旧友兼宿敌,其实暗地里在为沉船商盟引路。标志性元素是潮雾钟声、盐火灯塔。', - focusCardId: null, - selectedCardIds: [], - }); + const message2 = await orchestrator.submitMessage( + userId, + createdSession.sessionId, + { + clientMessageId: 'phase5-ready-2', + text: '整体主题是海岛悬疑,气质冷峻克制。核心冲突是守灯会与沉船商盟争夺航道解释权。关键人物叫沈砺,是玩家的旧友兼宿敌,其实暗地里在为沉船商盟引路。标志性元素是潮雾钟声、盐火灯塔。', + focusCardId: null, + selectedCardIds: [], + }, + ); await waitForOperation( orchestrator, userId, @@ -194,7 +202,10 @@ test('phase5 generate_role_assets only allows a single role and moves session in session.sessionId, response.operation.operationId, ); - const snapshot = await orchestrator.getSessionSnapshot(userId, session.sessionId); + const snapshot = await orchestrator.getSessionSnapshot( + userId, + session.sessionId, + ); assert.equal(operation?.status, 'completed'); assert.equal(snapshot?.stage, 'visual_refining'); @@ -216,7 +227,9 @@ test('phase5 sync_role_assets writes fields back, updates coverage and recompile }); const userId = 'user-phase5-sync-role-assets'; const session = await createObjectRefiningSession(orchestrator, userId); - const characterCard = session.draftCards.find((card) => card.kind === 'character'); + const characterCard = session.draftCards.find( + (card) => card.kind === 'character', + ); assert.ok(characterCard); @@ -255,33 +268,48 @@ test('phase5 sync_role_assets writes fields back, updates coverage and recompile session.sessionId, response.operation.operationId, ); - const snapshot = await orchestrator.getSessionSnapshot(userId, session.sessionId); - const profile = normalizeFoundationDraftProfile(snapshot?.draftProfile); - const syncedRole = [...(profile?.playableNpcs ?? []), ...(profile?.storyNpcs ?? [])].find( - (entry) => entry.id === characterCard!.id, + const snapshot = await orchestrator.getSessionSnapshot( + userId, + session.sessionId, + ); + const profile = normalizeFoundationDraftProfile(snapshot?.draftProfile); + const syncedRole = [ + ...(profile?.playableNpcs ?? []), + ...(profile?.storyNpcs ?? []), + ].find((entry) => entry.id === characterCard!.id); + const syncedCard = snapshot?.draftCards.find( + (card) => card.id === characterCard!.id, ); - const syncedCard = snapshot?.draftCards.find((card) => card.id === characterCard!.id); const syncedAssetSummary = snapshot?.assetCoverage.roleAssets.find( (entry) => entry.roleId === characterCard!.id, ); const latestRecord = await sessionStore.get(userId, session.sessionId); assert.equal(operation?.status, 'completed'); - assert.equal(syncedRole?.imageSrc, '/generated/characters/shenli-portrait.png'); + assert.equal( + syncedRole?.imageSrc, + '/generated/characters/shenli-portrait.png', + ); assert.equal(syncedRole?.generatedVisualAssetId, 'visual-shenli-1'); assert.equal(syncedRole?.generatedAnimationSetId, 'animation-set-shenli-1'); assert.equal( - (syncedRole?.animationMap as Record | null)?.idle - ?.basePath, + (syncedRole?.animationMap as Record | null) + ?.idle?.basePath, '/generated/characters/shenli/idle', ); - assert.equal(syncedAssetSummary?.status, 'complete'); - assert.equal(syncedCard?.assetStatusLabel, '动作已就绪'); - assert.ok(syncedCard?.subtitle.includes('动作已就绪')); + const syncedSkillIds = syncedRole?.skills.map((skill) => skill.id) ?? []; + assert.ok(syncedSkillIds.length > 0); + assert.equal(syncedAssetSummary?.status, 'animations_ready'); + assert.deepEqual( + syncedAssetSummary?.missingAnimations, + syncedSkillIds.map((skillId) => `skill:${skillId}`), + ); + assert.equal(syncedCard?.assetStatusLabel, '动作补齐中'); + assert.ok(syncedCard?.subtitle.includes('动作补齐中')); assert.ok( snapshot?.messages.some( (message) => - message.kind === 'action_result' && message.text.includes('动作已就绪'), + message.kind === 'action_result' && message.text.includes('动作补齐中'), ), ); assert.ok((latestRecord?.checkpoints.length ?? 0) >= 2); diff --git a/server-node/src/services/customWorldAgentRoleAssetStateService.test.ts b/server-node/src/services/customWorldAgentRoleAssetStateService.test.ts new file mode 100644 index 00000000..cdecaa36 --- /dev/null +++ b/server-node/src/services/customWorldAgentRoleAssetStateService.test.ts @@ -0,0 +1,84 @@ +import assert from 'node:assert/strict'; +import test from 'node:test'; + +import { buildRoleAssetSummary } from './customWorldAgentRoleAssetStateService.js'; + +test('role asset summary only requires run attack and configured skill actions', () => { + const summary = buildRoleAssetSummary({ + role: { + id: 'role-shenli', + name: '沈砺', + threadIds: ['thread-1'], + imageSrc: '/generated/shenli/portrait.png', + generatedVisualAssetId: 'visual-shenli', + generatedAnimationSetId: 'animation-shenli', + animationMap: { + run: { basePath: '/generated/shenli/run' }, + attack: { basePath: '/generated/shenli/attack' }, + }, + skills: [ + { + id: 'skill-tidelight', + name: '潮灯斩', + actionPreviewConfig: { + basePath: '/generated/shenli/skill-tidelight', + }, + }, + ], + }, + roleKind: 'playable', + }); + + assert.equal(summary.status, 'complete'); + assert.deepEqual(summary.missingAnimations, []); +}); + +test('role asset summary marks missing skill actions as required gaps', () => { + const summary = buildRoleAssetSummary({ + role: { + id: 'role-yunhe', + name: '云禾', + threadIds: [], + imageSrc: '/generated/yunhe/portrait.png', + generatedVisualAssetId: 'visual-yunhe', + generatedAnimationSetId: 'animation-yunhe', + animationMap: { + run: { basePath: '/generated/yunhe/run' }, + attack: { basePath: '/generated/yunhe/attack' }, + }, + skills: [ + { + id: 'skill-wave', + name: '断潮步', + actionPreviewConfig: null, + }, + ], + }, + roleKind: 'story', + }); + + assert.equal(summary.status, 'animations_ready'); + assert.deepEqual(summary.missingAnimations, ['skill:skill-wave']); +}); + +test('role asset summary treats idle and die as optional', () => { + const summary = buildRoleAssetSummary({ + role: { + id: 'role-lin', + name: '林砂', + threadIds: [], + imageSrc: '/generated/lin/portrait.png', + generatedVisualAssetId: 'visual-lin', + generatedAnimationSetId: 'animation-lin', + animationMap: { + run: { basePath: '/generated/lin/run' }, + attack: { basePath: '/generated/lin/attack' }, + }, + skills: [], + }, + roleKind: 'story', + }); + + assert.equal(summary.status, 'complete'); + assert.deepEqual(summary.missingAnimations, []); +}); diff --git a/server-node/src/services/customWorldAgentRoleAssetStateService.ts b/server-node/src/services/customWorldAgentRoleAssetStateService.ts index d8ca5b02..95c9df67 100644 --- a/server-node/src/services/customWorldAgentRoleAssetStateService.ts +++ b/server-node/src/services/customWorldAgentRoleAssetStateService.ts @@ -5,13 +5,13 @@ import type { CustomWorldRoleAssetSummary, } from '../../../packages/shared/src/contracts/customWorldAgent.js'; -const CORE_ROLE_ANIMATION_KEYS = [ - 'idle', - 'run', - 'attack', - 'hurt', - 'die', -] as const; +const REQUIRED_ROLE_ANIMATION_KEYS = ['run', 'attack'] as const; + +type DraftRoleSkillRecord = { + id: string; + name: string; + actionPreviewConfig?: Record | null; +}; type DraftRoleRecord = { id: string; @@ -21,6 +21,7 @@ type DraftRoleRecord = { generatedVisualAssetId?: string | null; generatedAnimationSetId?: string | null; animationMap?: Record | null; + skills: DraftRoleSkillRecord[]; }; type DraftRoleKind = 'playable' | 'story'; @@ -65,11 +66,8 @@ function toAnimationMap(value: unknown) { return toRecord(value); } -function hasAnimationSlot( - animationMap: Record | null | undefined, - slot: string, -) { - const entry = toRecord(animationMap?.[slot]); +function hasAnimationAsset(entryValue: unknown) { + const entry = toRecord(entryValue); if (!entry) { return false; } @@ -77,6 +75,41 @@ function hasAnimationSlot( return Boolean(toText(entry.basePath) || toText(entry.spriteSheetPath)); } +function hasAnimationSlot( + animationMap: Record | null | undefined, + slot: string, +) { + return hasAnimationAsset(animationMap?.[slot]); +} + +function normalizeRoleSkills(value: unknown, fallbackName = '角色') { + const skills = toRecordArray(value) + .map((item, index) => ({ + id: toText(item.id) || `skill-${index + 1}`, + name: toText(item.name) || `技能${index + 1}`, + actionPreviewConfig: toRecord(item.actionPreviewConfig), + })) + .filter((item) => Boolean(item.id)); + + if (skills.length > 0) { + return skills; + } + + return [ + { + id: 'skill-1', + name: `${toText(fallbackName).slice(0, 10) || '角色'}招牌动作`, + actionPreviewConfig: null, + }, + ]; +} + +function collectMissingSkillActions(role: DraftRoleRecord) { + return role.skills + .filter((skill) => !hasAnimationAsset(skill.actionPreviewConfig)) + .map((skill) => `skill:${skill.id}`); +} + function resolvePriorityTier( role: DraftRoleRecord, roleKind: DraftRoleKind, @@ -127,6 +160,7 @@ function collectDraftRoles(profileInput: unknown) { generatedVisualAssetId: toText(item.generatedVisualAssetId) || null, generatedAnimationSetId: toText(item.generatedAnimationSetId) || null, animationMap: toAnimationMap(item.animationMap), + skills: normalizeRoleSkills(item.skills, toText(item.role) || name), }; }; @@ -160,7 +194,9 @@ function collectDraftRoles(profileInput: unknown) { ]; } -export function resolveRoleAssetStatusLabel(status: CustomWorldRoleAssetStatus) { +export function resolveRoleAssetStatusLabel( + status: CustomWorldRoleAssetStatus, +) { if (status === 'complete') { return '动作已就绪'; } @@ -182,9 +218,12 @@ export function buildRoleAssetSummary(params: { }): CustomWorldRoleAssetSummary { const { role, roleKind } = params; const priorityTier = resolvePriorityTier(role, roleKind); - const missingAnimations = CORE_ROLE_ANIMATION_KEYS.filter( - (slot) => !hasAnimationSlot(role.animationMap, slot), - ); + const missingAnimations = [ + ...REQUIRED_ROLE_ANIMATION_KEYS.filter( + (slot) => !hasAnimationSlot(role.animationMap, slot), + ), + ...collectMissingSkillActions(role), + ]; const hasPortrait = Boolean(role.imageSrc) && Boolean(role.generatedVisualAssetId); const hasAnimationSet = Boolean(role.generatedAnimationSetId); @@ -210,10 +249,7 @@ export function buildRoleAssetSummary(params: { }; } -export function getRoleAssetSummaryById( - draftProfile: unknown, - roleId: string, -) { +export function getRoleAssetSummaryById(draftProfile: unknown, roleId: string) { const roleEntry = collectDraftRoles(draftProfile).find( (entry) => entry.role.id === roleId, ); @@ -281,8 +317,7 @@ export function mergeRoleAssetIntoDraftProfile( return touched; }; - const touched = - updateRoleList('playableNpcs') || updateRoleList('storyNpcs'); + const touched = updateRoleList('playableNpcs') || updateRoleList('storyNpcs'); if (!touched || !updatedRole) { throw new Error('目标角色不存在,无法同步角色资产。'); diff --git a/src/components/AdventurePanel.tsx b/src/components/AdventurePanel.tsx index eebc3769..c5665c81 100644 --- a/src/components/AdventurePanel.tsx +++ b/src/components/AdventurePanel.tsx @@ -96,6 +96,10 @@ interface AdventurePanelProps { scenesTraveled: number; currentSceneName: string; playerCurrency: number; + playerLevel?: number; + playerCurrentLevelXp?: number; + playerXpToNextLevel?: number; + playerTotalXp?: number; inventoryItemCount: number; inventoryStackCount: number; activeCompanionCount: number; @@ -276,6 +280,19 @@ function formatPlayTime(playTimeMs: number) { return `${minutes}分${String(seconds).padStart(2, '0')}秒`; } +function getPlayerProgressionRatio( + statistics: AdventurePanelProps['statistics'], +) { + const currentLevelXp = Math.max(0, statistics.playerCurrentLevelXp ?? 0); + const xpToNextLevel = Math.max(0, statistics.playerXpToNextLevel ?? 0); + + if (xpToNextLevel <= 0) { + return 1; + } + + return Math.max(0, Math.min(1, currentLevelXp / xpToNextLevel)); +} + function getOptionGoalAffordanceClass(option: StoryOption) { switch (option.goalAffordance?.relation) { case 'advance': @@ -467,6 +484,17 @@ function QuestRewardGrid({ 货币 +
+
+ + + +{quest.reward.experience ?? 0} + +
+
+ 经验 +
+
(null); const [rewardQuestId, setRewardQuestId] = useState(null); - const [rewardQuestHandoff, setRewardQuestHandoff] = useState(null); + const [rewardQuestHandoff, setRewardQuestHandoff] = + useState(null); const [selectedRewardItemQuestId, setSelectedRewardItemQuestId] = useState< string | null >(null); @@ -699,7 +729,9 @@ export function AdventurePanel({ const selectedQuest = useMemo( () => quests.find((quest) => quest.id === selectedQuestId) ?? - (pendingNpcQuestOffer?.id === selectedQuestId ? pendingNpcQuestOffer : null), + (pendingNpcQuestOffer?.id === selectedQuestId + ? pendingNpcQuestOffer + : null), [pendingNpcQuestOffer, quests, selectedQuestId], ); const rewardQuest = useMemo( @@ -899,6 +931,13 @@ export function AdventurePanel({ ], [statistics], ); + const playerLevel = Math.max(1, statistics.playerLevel ?? 1); + const playerCurrentLevelXp = Math.max( + 0, + statistics.playerCurrentLevelXp ?? 0, + ); + const playerXpToNextLevel = Math.max(0, statistics.playerXpToNextLevel ?? 0); + const playerProgressionRatio = getPlayerProgressionRatio(statistics); const shouldMountAdventureOverlays = isGoalPanelOpen || isSettingsPanelOpen || @@ -1060,6 +1099,27 @@ export function AdventurePanel({
+
+
+
Lv.{playerLevel}
+
+ {playerXpToNextLevel > 0 + ? `${playerCurrentLevelXp}/${playerXpToNextLevel}` + : 'MAX'} +
+
+
+
+
+
- ); - })} - {isNpcChatMode && !isNpcQuestOfferMode ? ( -
-
- setNpcChatDraft(event.target.value)} - onKeyDown={(event) => { - if ( - event.key === 'Enter' && - !event.nativeEvent.isComposing - ) { - event.preventDefault(); - submitNpcChatDraft(); - } - }} - placeholder={ - npcChatState?.customInputPlaceholder ?? - '输入你想说的话' - } - className="h-9 min-w-0 flex-1 rounded-md border border-white/10 bg-black/35 px-3 text-sm text-zinc-100 outline-none placeholder:text-zinc-500 focus:border-amber-200/40" - maxLength={80} - disabled={isLoading} - /> - + ); + })} + {isNpcChatMode && !isNpcQuestOfferMode ? ( +
+
+ setNpcChatDraft(event.target.value)} + onKeyDown={(event) => { + if ( + event.key === 'Enter' && + !event.nativeEvent.isComposing + ) { + event.preventDefault(); + submitNpcChatDraft(); + } + }} + placeholder={ + npcChatState?.customInputPlaceholder ?? '输入你想说的话' + } + className="h-9 min-w-0 flex-1 rounded-md border border-white/10 bg-black/35 px-3 text-sm text-zinc-100 outline-none placeholder:text-zinc-500 focus:border-amber-200/40" + maxLength={80} + disabled={isLoading} + /> + +
-
- ) : null} + ) : null} )}
@@ -1307,4 +1370,3 @@ export function AdventurePanel({
); } - diff --git a/src/components/CharacterAnimator.test.tsx b/src/components/CharacterAnimator.test.tsx new file mode 100644 index 00000000..9479bd6b --- /dev/null +++ b/src/components/CharacterAnimator.test.tsx @@ -0,0 +1,64 @@ +// @vitest-environment jsdom + +import { render, screen } from '@testing-library/react'; +import { describe, expect, it } from 'vitest'; + +import { AnimationState, type Character } from '../types'; +import { CharacterAnimator } from './CharacterAnimator'; + +function buildCharacter(overrides: Partial = {}): Character { + return { + id: 'generated-role', + name: '沈砺', + title: '守灯人', + description: '', + backstory: '', + avatar: '/generated/portrait.png', + portrait: '/generated/portrait.png', + assetFolder: 'custom-world', + assetVariant: 'generated', + attributes: {} as Character['attributes'], + personality: '', + skills: [], + adventureOpenings: {}, + ...overrides, + }; +} + +describe('CharacterAnimator portrait fallbacks', () => { + it('keeps idle fallback static on the portrait when idle animation is missing', () => { + render( + , + ); + + const image = screen.getByRole('img', { + name: /沈砺 idle animation/i, + }) as HTMLImageElement; + + expect(image.getAttribute('src')).toBe('/generated/portrait.png'); + expect(image.style.transform).toBe(''); + }); + + it('uses a fallen portrait fallback when death animation is missing', () => { + render( + , + ); + + const image = screen.getByRole('img', { + name: /沈砺 die animation/i, + }) as HTMLImageElement; + + expect(image.getAttribute('src')).toBe('/generated/portrait.png'); + expect(image.style.animation).toContain( + 'character-animator-portrait-death-fall', + ); + expect(image.style.transform).toContain('rotate(90deg)'); + expect(image.style.transform).toContain('scaleX(-1)'); + }); +}); diff --git a/src/components/CharacterAnimator.tsx b/src/components/CharacterAnimator.tsx index c540c6ff..9e890b6e 100644 --- a/src/components/CharacterAnimator.tsx +++ b/src/components/CharacterAnimator.tsx @@ -15,28 +15,88 @@ const DEFAULT_ANIMATIONS: Record = { [AnimationState.ACQUIRE]: { frames: 1, prefix: 'acquire', folder: 'acquire' }, [AnimationState.ATTACK]: { frames: 1, prefix: 'Attack', folder: 'attack' }, [AnimationState.RUN]: { frames: 1, prefix: 'Run', folder: 'run' }, - [AnimationState.DOUBLE_JUMP]: { frames: 1, prefix: 'double jump', folder: 'double jump' }, - [AnimationState.JUMP_ATTACK]: { frames: 1, prefix: 'jump attack', folder: 'jump attack' }, + [AnimationState.DOUBLE_JUMP]: { + frames: 1, + prefix: 'double jump', + folder: 'double jump', + }, + [AnimationState.JUMP_ATTACK]: { + frames: 1, + prefix: 'jump attack', + folder: 'jump attack', + }, [AnimationState.DASH]: { frames: 1, prefix: 'dash', folder: 'dash' }, [AnimationState.HURT]: { frames: 1, prefix: 'hurt', folder: 'hurt' }, [AnimationState.DIE]: { frames: 1, prefix: 'die', folder: 'die' }, [AnimationState.CLIMB]: { frames: 1, prefix: 'Climb', folder: 'climb' }, [AnimationState.SKILL1]: { frames: 1, prefix: 'skill1', folder: 'skill1' }, - [AnimationState.SKILL1_JUMP]: { frames: 1, prefix: 'skill1 jump', folder: 'skill1 jump' }, - [AnimationState.SKILL1_BULLET]: { frames: 1, prefix: 'skill1 bullet', folder: 'skill1 bullet' }, - [AnimationState.SKILL1_BULLET_FX]: { frames: 1, prefix: 'skill1 bullet FX', folder: 'skill1 bullet FX' }, + [AnimationState.SKILL1_JUMP]: { + frames: 1, + prefix: 'skill1 jump', + folder: 'skill1 jump', + }, + [AnimationState.SKILL1_BULLET]: { + frames: 1, + prefix: 'skill1 bullet', + folder: 'skill1 bullet', + }, + [AnimationState.SKILL1_BULLET_FX]: { + frames: 1, + prefix: 'skill1 bullet FX', + folder: 'skill1 bullet FX', + }, [AnimationState.SKILL2]: { frames: 1, prefix: 'skill2', folder: 'skill2' }, - [AnimationState.SKILL2_JUMP]: { frames: 1, prefix: 'skill2 jump', folder: 'skill2 jump' }, + [AnimationState.SKILL2_JUMP]: { + frames: 1, + prefix: 'skill2 jump', + folder: 'skill2 jump', + }, [AnimationState.SKILL3]: { frames: 1, prefix: 'skill3', folder: 'skill3' }, - [AnimationState.SKILL3_JUMP]: { frames: 1, prefix: 'skill3 jump', folder: 'skill3 jump' }, - [AnimationState.SKILL3_BULLET]: { frames: 1, prefix: 'skill3 bullet', folder: 'skill3 bullet' }, - [AnimationState.SKILL3_BULLET_FX]: { frames: 1, prefix: 'skill3 bullet FX', folder: 'skill3 bullet FX' }, + [AnimationState.SKILL3_JUMP]: { + frames: 1, + prefix: 'skill3 jump', + folder: 'skill3 jump', + }, + [AnimationState.SKILL3_BULLET]: { + frames: 1, + prefix: 'skill3 bullet', + folder: 'skill3 bullet', + }, + [AnimationState.SKILL3_BULLET_FX]: { + frames: 1, + prefix: 'skill3 bullet FX', + folder: 'skill3 bullet FX', + }, [AnimationState.SKILL4]: { frames: 1, prefix: 'skill4', folder: 'skill4' }, - [AnimationState.WALL_SLIDE]: { frames: 1, prefix: 'Wall Slide', folder: 'Wall Slide' }, + [AnimationState.WALL_SLIDE]: { + frames: 1, + prefix: 'Wall Slide', + folder: 'Wall Slide', + }, [AnimationState.IDLE]: { frames: 1, prefix: 'Idle', folder: 'idle' }, [AnimationState.JUMP]: { frames: 1, prefix: 'Jump', folder: 'jump' }, }; +const PORTRAIT_FALLBACK_ANIMATION: CharacterAnimationConfig = { + frames: 1, + prefix: 'portrait', + folder: 'portrait', + fps: 1, + loop: false, +}; + +const FALLEN_PORTRAIT_STYLE: React.CSSProperties = { + imageRendering: 'pixelated', + transform: 'translateY(16%) rotate(90deg) scaleX(-1) scale(0.82)', + transformOrigin: '50% 85%', + animation: + 'character-animator-portrait-death-fall 680ms cubic-bezier(0.22, 0.7, 0.18, 1) forwards', +}; + +const DEFAULT_IMAGE_STYLE: React.CSSProperties = { + imageRendering: 'pixelated', +}; + export const CharacterAnimator: React.FC = ({ state, character, @@ -45,11 +105,20 @@ export const CharacterAnimator: React.FC = ({ imageClassName, playbackRate = 1, }) => { - const config = - character.animationMap?.[state] ?? + const explicitConfig = character.animationMap?.[state]; + const usePortraitIdleFallback = + !explicitConfig && state === AnimationState.IDLE; + const usePortraitDeathFallback = + !explicitConfig && state === AnimationState.DIE; + const [hasRenderError, setHasRenderError] = useState(false); + const baseConfig = + explicitConfig ?? DEFAULT_ANIMATIONS[state] ?? character.animationMap?.[AnimationState.IDLE] ?? DEFAULT_ANIMATIONS[AnimationState.IDLE]; + const fallbackToPortrait = + usePortraitIdleFallback || usePortraitDeathFallback || hasRenderError; + const config = fallbackToPortrait ? PORTRAIT_FALLBACK_ANIMATION : baseConfig; const startFrame = typeof config.startFrame === 'number' && Number.isFinite(config.startFrame) ? Math.max(1, Math.floor(config.startFrame)) @@ -66,6 +135,20 @@ export const CharacterAnimator: React.FC = ({ const effectivePlaybackRate = Number.isFinite(playbackRate) ? Math.max(0.1, playbackRate) : 1; + const requestedAnimationSignature = [ + state, + character.id, + character.portrait, + baseConfig.basePath ?? '', + baseConfig.folder, + baseConfig.prefix, + baseConfig.file ?? '', + baseConfig.extension ?? 'png', + baseConfig.startFrame ?? 1, + baseConfig.frames, + baseConfig.fps ?? 10, + effectivePlaybackRate, + ].join('::'); const animationSignature = [ state, config.basePath ?? '', @@ -78,6 +161,11 @@ export const CharacterAnimator: React.FC = ({ fps, effectivePlaybackRate, ].join('::'); + + useEffect(() => { + setHasRenderError(false); + }, [requestedAnimationSignature]); + const endFrame = startFrame + frameCount - 1; const intervalDelay = Math.max( 40, @@ -101,16 +189,11 @@ export const CharacterAnimator: React.FC = ({ }, intervalDelay); return () => window.clearInterval(interval); - }, [ - endFrame, - frameCount, - intervalDelay, - startFrame, - ]); + }, [endFrame, frameCount, intervalDelay, startFrame]); const frameNumber = frameIndex.toString().padStart(2, '0'); const normalizedBasePath = config.basePath?.replace(/\/+$/u, ''); - const imagePath = normalizedBasePath + const generatedImagePath = normalizedBasePath ? config.file ? `${normalizedBasePath}/${encodeURIComponent(config.file)}` : `${normalizedBasePath}/${config.prefix}${frameNumber}.${config.extension ?? 'png'}` @@ -122,7 +205,15 @@ export const CharacterAnimator: React.FC = ({ ? `/character/${folder}/${variant}/Hero/${animationFolder}/${encodeURIComponent(config.file)}` : `/character/${folder}/${variant}/Hero/${animationFolder}/${config.prefix}${frameNumber}.${config.extension ?? 'png'}`; })(); - const resolvedImageClassName = `h-full w-full object-contain pixelated ${imageClassName ?? ''}`.trim(); + const imagePath = fallbackToPortrait + ? character.portrait + : generatedImagePath; + const resolvedImageClassName = + `h-full w-full object-contain pixelated ${imageClassName ?? ''}`.trim(); + const imageStyle = + state === AnimationState.DIE && (usePortraitDeathFallback || hasRenderError) + ? FALLEN_PORTRAIT_STYLE + : DEFAULT_IMAGE_STYLE; return (
@@ -130,11 +221,11 @@ export const CharacterAnimator: React.FC = ({ src={imagePath} alt={`${character.name} ${state} animation`} className={resolvedImageClassName} - style={{ imageRendering: 'pixelated' }} + style={imageStyle} onError={(e) => { - const target = e.target as HTMLImageElement; - target.src = character.portrait; - target.className = resolvedImageClassName; + if (!hasRenderError) { + setHasRenderError(true); + } }} />
diff --git a/src/components/CustomWorldEntityCatalog.tsx b/src/components/CustomWorldEntityCatalog.tsx index f21256a1..a59df6d8 100644 --- a/src/components/CustomWorldEntityCatalog.tsx +++ b/src/components/CustomWorldEntityCatalog.tsx @@ -1038,7 +1038,7 @@ export function CustomWorldEntityCatalog({
-
+
{RESULT_TABS.map((tab) => (
diff --git a/src/components/CustomWorldGenerationView.tsx b/src/components/CustomWorldGenerationView.tsx index c42462ba..20d900f1 100644 --- a/src/components/CustomWorldGenerationView.tsx +++ b/src/components/CustomWorldGenerationView.tsx @@ -94,7 +94,7 @@ export function CustomWorldGenerationView({ className="flex h-full min-h-0 flex-col overflow-y-auto overscroll-y-contain pr-1 pb-[max(1rem,env(safe-area-inset-bottom))]" style={{ WebkitOverflowScrolling: 'touch' }} > -
+
diff --git a/src/components/SkillEffectPreview.tsx b/src/components/SkillEffectPreview.tsx index 9060d7f8..a2cc15fc 100644 --- a/src/components/SkillEffectPreview.tsx +++ b/src/components/SkillEffectPreview.tsx @@ -247,6 +247,8 @@ export function SkillEffectPreview({ encounter={null} currentScenePreset={scenePreset} worldType={worldType} + customWorldProfile={null} + storyEngineMemory={null} sceneHostileNpcs={sceneHostileNpcs} playerX={PLAYER_X} playerOffsetY={0} diff --git a/src/components/adventure-panel/AdventurePanelOverlays.tsx b/src/components/adventure-panel/AdventurePanelOverlays.tsx index 49a76955..ab0f4e6d 100644 --- a/src/components/adventure-panel/AdventurePanelOverlays.tsx +++ b/src/components/adventure-panel/AdventurePanelOverlays.tsx @@ -9,19 +9,25 @@ import { ScrollText, Volume2, } from 'lucide-react'; -import {AnimatePresence, motion} from 'motion/react'; +import { AnimatePresence, motion } from 'motion/react'; -import {formatCurrency} from '../../data/economy'; -import {getHostileNpcPresetById} from '../../data/hostileNpcPresets'; -import {type InventoryUseEffect, isInventoryItemUsable} from '../../data/inventoryEffects'; +import { formatCurrency } from '../../data/economy'; +import { getHostileNpcPresetById } from '../../data/hostileNpcPresets'; +import { + type InventoryUseEffect, + isInventoryItemUsable, +} from '../../data/inventoryEffects'; import { buildInventoryItemDescription, getInventoryTagLabels, } from '../../data/itemPresentation'; -import {getRarityLabel} from '../../data/npcInteractions'; -import {isQuestReadyToClaim} from '../../data/questFlow'; -import {getScenePresetById} from '../../data/scenePresets'; -import type {BattleRewardUi, QuestFlowUi} from '../../hooks/useStoryGeneration'; +import { getRarityLabel } from '../../data/npcInteractions'; +import { isQuestReadyToClaim } from '../../data/questFlow'; +import { getScenePresetById } from '../../data/scenePresets'; +import type { + BattleRewardUi, + QuestFlowUi, +} from '../../hooks/useStoryGeneration'; import { sortQuestsForGoalPanel } from '../../services/storyEngine/goalDirector'; import type { ChapterState, @@ -40,8 +46,8 @@ import { getNineSliceStyle, UI_CHROME, } from '../../uiAssets'; -import {HostileNpcAnimator} from '../HostileNpcAnimator'; -import {PixelIcon} from '../PixelIcon'; +import { HostileNpcAnimator } from '../HostileNpcAnimator'; +import { PixelIcon } from '../PixelIcon'; type AdventureStatisticCard = { key: string; @@ -114,7 +120,10 @@ interface AdventurePanelOverlaysProps { onAcceptPendingNpcQuestOffer: () => string | null; } -function compactSceneTaskLabel(sceneName: string | null | undefined, fallback: string) { +function compactSceneTaskLabel( + sceneName: string | null | undefined, + fallback: string, +) { if (!sceneName?.trim()) { return fallback; } @@ -228,7 +237,9 @@ function buildCurrentTaskCardCopy(params: { condition: stepGoal.nextStepText, progress: stepGoal.progressLabel ?? primaryGoal.progressLabel ?? '推进中', pulseNote: - goalPulse?.detail && goalPulse.detail !== description && goalPulse.detail !== stepGoal.nextStepText + goalPulse?.detail && + goalPulse.detail !== description && + goalPulse.detail !== stepGoal.nextStepText ? goalPulse.detail : null, }; @@ -247,7 +258,9 @@ function buildCurrentTaskCardCopy(params: { condition: journeyCopy.condition, progress: journeyCopy.progress, pulseNote: - goalPulse?.detail && goalPulse.detail !== journeyCopy.description && goalPulse.detail !== journeyCopy.condition + goalPulse?.detail && + goalPulse.detail !== journeyCopy.description && + goalPulse.detail !== journeyCopy.condition ? goalPulse.detail : null, }; @@ -265,14 +278,17 @@ function getQuestSceneName(quest: QuestLogEntry, worldType: WorldType | null) { return getScenePresetById(worldType, quest.sceneId)?.name ?? quest.sceneId; } -function getQuestHostileNpcName(quest: QuestLogEntry, worldType: WorldType | null) { +function getQuestHostileNpcName( + quest: QuestLogEntry, + worldType: WorldType | null, +) { if (!quest.objective.targetHostileNpcId) { return null; } return worldType - ? getHostileNpcPresetById(worldType, quest.objective.targetHostileNpcId)?.name - ?? quest.objective.targetHostileNpcId + ? (getHostileNpcPresetById(worldType, quest.objective.targetHostileNpcId) + ?.name ?? quest.objective.targetHostileNpcId) : quest.objective.targetHostileNpcId; } @@ -288,9 +304,10 @@ function buildQuestConditionText( return '任务已经交付。'; } - const activeStep = quest.steps?.find(step => step.id === quest.activeStepId) - ?? quest.steps?.find(step => step.progress < step.requiredCount) - ?? null; + const activeStep = + quest.steps?.find((step) => step.id === quest.activeStepId) ?? + quest.steps?.find((step) => step.progress < step.requiredCount) ?? + null; const objective = activeStep ?? quest.objective; const sceneName = getQuestSceneName(quest, worldType); @@ -321,9 +338,10 @@ function getQuestProgressText(quest: QuestLogEntry) { return '已交付'; } - const activeStep = quest.steps?.find(step => step.id === quest.activeStepId) - ?? quest.steps?.find(step => step.progress < step.requiredCount) - ?? null; + const activeStep = + quest.steps?.find((step) => step.id === quest.activeStepId) ?? + quest.steps?.find((step) => step.progress < step.requiredCount) ?? + null; const progressSource = activeStep ?? quest; const requiredCount = 'requiredCount' in progressSource @@ -365,7 +383,9 @@ function TaskTemplateCard({ }`} >
-
+
{eyebrow}
@@ -415,12 +435,14 @@ function QuestRewardIconStrip({ 任务奖励
- 好感 +{reward.affinityBonus} · {reward.currency} + 好感 +{reward.affinityBonus} + {(reward.experience ?? 0) > 0 ? ` · 经验 +${reward.experience}` : ''} + {` · ${reward.currency}`}
{hasItems ? (
- {reward.items.map(item => ( + {reward.items.map((item) => (
); @@ -487,16 +507,14 @@ function GoalFocusCard({ progress={cardCopy.progress} tone="main" /> - {( - (goalStack.immediateStepGoal ?? goalStack.activeGoal)?.sceneHint - || (goalStack.immediateStepGoal ?? goalStack.activeGoal)?.npcHint - ) ? ( + {(goalStack.immediateStepGoal ?? goalStack.activeGoal)?.sceneHint || + (goalStack.immediateStepGoal ?? goalStack.activeGoal)?.npcHint ? (
{(goalStack.immediateStepGoal ?? goalStack.activeGoal)?.sceneHint ? `地点:${(goalStack.immediateStepGoal ?? goalStack.activeGoal)?.sceneHint}` : null} - {(goalStack.immediateStepGoal ?? goalStack.activeGoal)?.sceneHint - && (goalStack.immediateStepGoal ?? goalStack.activeGoal)?.npcHint + {(goalStack.immediateStepGoal ?? goalStack.activeGoal)?.sceneHint && + (goalStack.immediateStepGoal ?? goalStack.activeGoal)?.npcHint ? ' · ' : null} {(goalStack.immediateStepGoal ?? goalStack.activeGoal)?.npcHint @@ -531,10 +549,15 @@ function buildRewardItemDescription(item: InventoryItem) { return buildInventoryItemDescription(item); } -function getQuestObjectivePresentation(quest: QuestLogEntry, worldType: WorldType | null, sceneName: string) { - const hostileNpcPreset = worldType && quest.objective.targetHostileNpcId - ? getHostileNpcPresetById(worldType, quest.objective.targetHostileNpcId) - : null; +function getQuestObjectivePresentation( + quest: QuestLogEntry, + worldType: WorldType | null, + sceneName: string, +) { + const hostileNpcPreset = + worldType && quest.objective.targetHostileNpcId + ? getHostileNpcPresetById(worldType, quest.objective.targetHostileNpcId) + : null; switch (quest.objective.kind) { case 'defeat_hostile_npc': @@ -542,7 +565,10 @@ function getQuestObjectivePresentation(quest: QuestLogEntry, worldType: WorldTyp eyebrow: '悬赏目标', accentClass: 'from-rose-500/20 via-orange-500/10 to-transparent', labelClass: 'border-rose-300/25 bg-rose-500/12 text-rose-50', - primaryLabel: hostileNpcPreset?.name ?? quest.objective.targetHostileNpcId ?? '未知敌对角色', + primaryLabel: + hostileNpcPreset?.name ?? + quest.objective.targetHostileNpcId ?? + '未知敌对角色', secondaryLabel: sceneName, hostileNpcPreset, iconSrc: null as string | null, @@ -585,24 +611,26 @@ function RewardItemIconGrid({ if (items.length === 0) { return (
- {emptyText} -
+ {emptyText} +
); } return (
- {items.map(item => ( + {items.map((item) => ( @@ -930,26 +1014,30 @@ export function AdventurePanelOverlays({ {isSettingsPanelOpen && ( setIsSettingsPanelOpen(false)} > event.stopPropagation()} + onClick={(event) => event.stopPropagation()} >
-
冒险设置
-
调整音乐音量,查看统计数据,或保存并退出。
+
+ 冒险设置
+
+ 调整音乐音量,查看统计数据,或保存并退出。 +
+
)} - {isQuestReadyToClaim(selectedQuest) && !isPendingSelectedQuest && ( -
- -
- )} + {isQuestReadyToClaim(selectedQuest) && + !isPendingSelectedQuest && ( +
+ +
+ )}
@@ -1317,9 +1454,9 @@ export function AdventurePanelOverlays({ {completionNoticeQuest && ( { questUi.acknowledgeQuestCompletion(completionNoticeQuest.id); @@ -1327,18 +1464,24 @@ export function AdventurePanelOverlays({ }} > event.stopPropagation()} + onClick={(event) => event.stopPropagation()} >
-
任务完成
-
奖励已准备
-
{completionNoticeQuest.title}
+
+ 任务完成 +
+
+ 奖励已准备 +
+
+ {completionNoticeQuest.title} +
{goalStack.immediateStepGoal?.sourceKind === 'quest' ? goalStack.immediateStepGoal.nextStepText @@ -1348,12 +1491,17 @@ export function AdventurePanelOverlays({ @@ -1367,9 +1515,9 @@ export function AdventurePanelOverlays({ {rewardQuest && ( { setRewardQuestId(null); @@ -1379,18 +1527,22 @@ export function AdventurePanelOverlays({ }} > event.stopPropagation()} + onClick={(event) => event.stopPropagation()} >
-
任务奖励已领取
-
{rewardQuest.title}
+
+ 任务奖励已领取 +
+
+ {rewardQuest.title} +
{action} - + {onBack ? null : ( + + )}
-
{children}
+
+ {children} +
); @@ -290,7 +290,9 @@ export function AccountModal({ onChangePhone, }: AccountModalProps) { const [activeSection, setActiveSection] = - useState(initialSection); + useState( + normalizeSettingsSection(initialSection), + ); const [isChangePhonePanelOpen, setIsChangePhonePanelOpen] = useState(false); const [phone, setPhone] = useState(''); const [code, setCode] = useState(''); @@ -301,6 +303,21 @@ export function AccountModal({ const [sendingCode, setSendingCode] = useState(false); const [changingPhone, setChangingPhone] = useState(false); const [cooldownSeconds, setCooldownSeconds] = useState(0); + const settingsHomeRef = useRef(null); + const sectionTriggerRef = useRef(null); + const changePhoneTriggerRef = useRef(null); + + const focusAfterNextPaint = useCallback((element: HTMLElement | null) => { + if (!element) { + return; + } + + window.requestAnimationFrame(() => { + if (element.isConnected) { + element.focus(); + } + }); + }, []); const resetChangePhoneDraft = useCallback(() => { setPhone(''); @@ -316,12 +333,27 @@ export function AccountModal({ return; } - setActiveSection(initialSection); + setActiveSection(normalizeSettingsSection(initialSection)); setIsChangePhonePanelOpen(false); setAccountNotice(''); + sectionTriggerRef.current = null; + changePhoneTriggerRef.current = null; resetChangePhoneDraft(); }, [initialSection, isOpen, resetChangePhoneDraft]); + useEffect(() => { + const settingsHome = settingsHomeRef.current; + if (!settingsHome) { + return; + } + + settingsHome.toggleAttribute('inert', activeSection !== null); + + return () => { + settingsHome.removeAttribute('inert'); + }; + }, [activeSection]); + useEffect(() => { if (cooldownSeconds <= 0) { return; @@ -337,15 +369,19 @@ export function AccountModal({ }, [cooldownSeconds]); const closeSectionPanel = useCallback(() => { + const sectionTrigger = sectionTriggerRef.current; setIsChangePhonePanelOpen(false); setActiveSection(null); resetChangePhoneDraft(); - }, [resetChangePhoneDraft]); + focusAfterNextPaint(sectionTrigger); + }, [focusAfterNextPaint, resetChangePhoneDraft]); const closeChangePhonePanel = useCallback(() => { + const changePhoneTrigger = changePhoneTriggerRef.current; setIsChangePhonePanelOpen(false); resetChangePhoneDraft(); - }, [resetChangePhoneDraft]); + focusAfterNextPaint(changePhoneTrigger); + }, [focusAfterNextPaint, resetChangePhoneDraft]); if (!isOpen) { return null; @@ -358,46 +394,25 @@ export function AccountModal({ : isPersistingSettings ? '正在同步平台设置...' : '平台设置已同步'; - const latestAuditLog = auditLogs[0]; const accountSummaryCards = [ ['登录方式', resolveLoginMethodLabel(user.loginMethod)], ['手机号', user.phoneNumberMasked || '未绑定'], ['微信绑定', user.wechatBound ? '已绑定' : '未绑定'], - [ - '账号状态', - user.bindingStatus === 'pending_bind_phone' ? '待绑定手机号' : '已激活', - ], ] as const; - const sectionSummaries: Record = { + const sectionSummaries: Record = { appearance: - platformTheme === 'dark' - ? '当前使用暗色主题。' - : '当前使用亮色主题。', - account: user.phoneNumberMasked - ? '查看账号身份与换绑入口。' - : '查看账号身份与绑定状态。', - security: loadingRiskBlocks - ? '正在读取安全状态。' - : riskBlocks.length > 0 - ? `当前有 ${riskBlocks.length} 项保护生效。` - : '当前没有生效中的安全限制。', - devices: loadingSessions - ? '正在读取设备会话。' - : sessions.length > 0 - ? `当前共有 ${sessions.length} 台设备会话。` - : '暂无可展示的登录设备。', - logs: loadingAuditLogs - ? '正在读取账号动态。' - : latestAuditLog - ? `最近一条记录:${formatSessionTime(latestAuditLog.createdAt)}` - : '暂无账号操作记录。', + platformTheme === 'dark' ? '当前使用暗色主题。' : '当前使用亮色主题。', + account: + user.phoneNumberMasked || user.wechatBound + ? '查看身份、安全状态、登录设备与操作记录。' + : '查看账号绑定状态与安全记录。', }; return (
event.stopPropagation()} >
-
- 设置 -
-
+
设置与账号安全
-
- 主题、账号与设备能力统一在独立面板中管理 -
-
-
-
- - -
- {SETTINGS_SECTIONS.map((section) => ( - { - setAccountNotice(''); - setActiveSection(section.id); - }} - /> - ))} -
- -
-
-
- 当前主题 -
-
- {platformTheme === 'dark' ? '暗色主题' : '亮色主题'} -
- - {themeStatusText} - -
- -
-
- 当前账号状态 -
-
- {user.bindingStatus === 'pending_bind_phone' - ? '待绑定手机号' - : '账号已激活'} -
-
- {resolveLoginMethodLabel(user.loginMethod)} -
-
-
- -
- - + /> + ))} +
+ +
+
+ 当前主题
+
+ {platformTheme === 'dark' ? '暗色主题' : '亮色主题'} +
+ + {themeStatusText} + +
+ +
+ +
@@ -515,7 +499,7 @@ export function AccountModal({ @@ -560,7 +544,7 @@ export function AccountModal({ @@ -600,7 +584,8 @@ export function AccountModal({
+ +
+
+
+
+ 安全状态 +
+
+ 查看当前生效中的账号保护与限制。 +
+
+ +
+ +
+ {loadingRiskBlocks ? ( +
+ 正在读取安全状态... +
+ ) : riskBlocks.length > 0 ? ( + riskBlocks.map((block) => ( +
+
+ {block.title} + + 剩余约{' '} + {Math.max(1, Math.ceil(block.remainingSeconds / 60))}{' '} + 分钟 + +
+
+ {block.detail} +
+ +
+ )) + ) : ( +
+ 当前没有生效中的安全限制。 +
+ )} +
+
+ +
+
+
+
+ 登录设备 +
+
+ 查看当前账号的设备会话与登录状态。 +
+
+ +
+ +
+ {loadingSessions ? ( +
+ 正在读取当前登录设备... +
+ ) : sessions.length > 0 ? ( + sessions.map((session) => ( +
+
+ {session.clientLabel} + + {session.isCurrent ? '当前设备' : '已登录'} + +
+
+ 最近活跃:{formatSessionTime(session.lastSeenAt)} +
+
+ 到期时间:{formatSessionTime(session.expiresAt)} +
+ {session.ipMasked ? ( +
+ IP:{session.ipMasked} +
+ ) : null} + {!session.isCurrent ? ( + + ) : null} +
+ )) + ) : ( +
+ 暂无可展示的登录设备。 +
+ )} +
+
+ +
+
+
+
+ 操作记录 +
+
+ 查看最近的账号登录与安全动作。 +
+
+ +
+ +
+ {loadingAuditLogs ? ( +
+ 正在读取账号操作记录... +
+ ) : auditLogs.length > 0 ? ( + auditLogs.map((log) => ( +
+
+ {log.title} + + {formatSessionTime(log.createdAt)} + +
+
+ {log.detail} +
+ {log.ipMasked ? ( +
+ IP:{log.ipMasked} +
+ ) : null} +
+ )) + ) : ( +
+ 暂无账号操作记录。 +
+ )} +
+
{isChangePhonePanelOpen ? ( @@ -644,18 +817,23 @@ export function AccountModal({ /> - )} - onBack={closeSectionPanel} - onClose={onClose} - > -
- {loadingRiskBlocks ? ( -
- 正在读取安全状态... -
- ) : riskBlocks.length > 0 ? ( - riskBlocks.map((block) => ( -
-
- {block.title} - - 剩余约 {Math.max(1, Math.ceil(block.remainingSeconds / 60))}{' '} - 分钟 - -
-
{block.detail}
- -
- )) - ) : ( -
- 当前没有生效中的安全限制。 -
- )} -
-
- ) : null} - - {activeSection === 'devices' ? ( - { - void onRefreshSessions(); - }} - > - 刷新 - - )} - onBack={closeSectionPanel} - onClose={onClose} - > -
-
- {loadingSessions ? ( -
- 正在读取当前登录设备... -
- ) : sessions.length > 0 ? ( - sessions.map((session) => ( -
-
- {session.clientLabel} - - {session.isCurrent ? '当前设备' : '已登录'} - -
-
- 最近活跃:{formatSessionTime(session.lastSeenAt)} -
-
- 到期时间:{formatSessionTime(session.expiresAt)} -
- {session.ipMasked ? ( -
- IP:{session.ipMasked} -
- ) : null} - {!session.isCurrent ? ( - - ) : null} -
- )) - ) : ( -
- 暂无可展示的登录设备。 -
- )} -
- - -
-
- ) : null} - - {activeSection === 'logs' ? ( - { - void onRefreshAuditLogs(); - }} - > - 刷新 - - )} - onBack={closeSectionPanel} - onClose={onClose} - > -
- {loadingAuditLogs ? ( -
- 正在读取账号操作记录... -
- ) : auditLogs.length > 0 ? ( - auditLogs.map((log) => ( -
-
- {log.title} - - {formatSessionTime(log.createdAt)} - -
-
- {log.detail} -
- {log.ipMasked ? ( -
- IP:{log.ipMasked} -
- ) : null} -
- )) - ) : ( -
- 暂无账号操作记录。 -
- )} -
-
- ) : null}
); diff --git a/src/components/custom-world-agent/CustomWorldAgentDraftDetailPanel.test.tsx b/src/components/custom-world-agent/CustomWorldAgentDraftDetailPanel.test.tsx index f770ee26..315e1c62 100644 --- a/src/components/custom-world-agent/CustomWorldAgentDraftDetailPanel.test.tsx +++ b/src/components/custom-world-agent/CustomWorldAgentDraftDetailPanel.test.tsx @@ -43,3 +43,39 @@ test('draft detail panel renders sections and warnings', () => { expect(html).toContain('编辑设定'); expect(html).toContain('新增角色'); }); + +test('draft detail panel renders scene chapter label and background preview', () => { + const html = renderToStaticMarkup( + {}} + onStartEdit={() => {}} + />, + ); + + expect(html).toContain('场景章节'); + expect(html).toContain('第 1 幕背景图'); + expect(html).toContain('img'); +}); diff --git a/src/components/custom-world-agent/CustomWorldAgentDraftDetailPanel.tsx b/src/components/custom-world-agent/CustomWorldAgentDraftDetailPanel.tsx index 4a2b02fa..85e1c001 100644 --- a/src/components/custom-world-agent/CustomWorldAgentDraftDetailPanel.tsx +++ b/src/components/custom-world-agent/CustomWorldAgentDraftDetailPanel.tsx @@ -28,6 +28,7 @@ function resolveKindLabel(kind: CustomWorldDraftCardDetail['kind']) { if (kind === 'landmark') return '地点'; if (kind === 'thread') return '线程'; if (kind === 'chapter') return '第一幕'; + if (kind === 'scene_chapter') return '场景章节'; return '草稿卡'; } @@ -72,6 +73,15 @@ export function CustomWorldAgentDraftDetailPanel({ onGenerateLandmark, onOpenRoleAssetStudio, }: CustomWorldAgentDraftDetailPanelProps) { + const shouldRenderImagePreview = ( + detailKind: CustomWorldDraftCardDetail['kind'], + sectionId: string, + value: string, + ) => + detailKind === 'scene_chapter' && + sectionId.endsWith(':backgroundImageSrc') && + value !== '待继续精修'; + return (
@@ -168,6 +178,13 @@ export function CustomWorldAgentDraftDetailPanel({
{section.label}
+ {shouldRenderImagePreview(detail.kind, section.id, section.value) ? ( + {section.label} + ) : null}
{section.value}
diff --git a/src/components/custom-world-agent/CustomWorldAgentDraftDrawer.tsx b/src/components/custom-world-agent/CustomWorldAgentDraftDrawer.tsx index 868adfd1..cd2a3812 100644 --- a/src/components/custom-world-agent/CustomWorldAgentDraftDrawer.tsx +++ b/src/components/custom-world-agent/CustomWorldAgentDraftDrawer.tsx @@ -9,6 +9,7 @@ type CustomWorldAgentDraftDrawerProps = { const DRAWER_KIND_ORDER: CustomWorldDraftCardSummary['kind'][] = [ 'world', 'chapter', + 'scene_chapter', 'thread', 'faction', 'character', @@ -19,6 +20,7 @@ const DRAWER_KIND_ORDER: CustomWorldDraftCardSummary['kind'][] = [ function resolveGroupLabel(kind: CustomWorldDraftCardSummary['kind']) { if (kind === 'world') return '世界总卡'; if (kind === 'chapter') return '第一幕'; + if (kind === 'scene_chapter') return '场景章节'; if (kind === 'thread') return '世界线程'; if (kind === 'faction') return '势力'; if (kind === 'character') return '关键角色'; diff --git a/src/components/custom-world-agent/CustomWorldDraftEditPanel.test.tsx b/src/components/custom-world-agent/CustomWorldDraftEditPanel.test.tsx index be204dcf..6d94c624 100644 --- a/src/components/custom-world-agent/CustomWorldDraftEditPanel.test.tsx +++ b/src/components/custom-world-agent/CustomWorldDraftEditPanel.test.tsx @@ -46,3 +46,57 @@ test('draft detail panel renders editable form in edit mode', () => { expect(html).toContain('角色名'); expect(html).toContain('textarea'); }); + +test('draft detail panel uses textarea for scene chapter act narrative fields', () => { + const html = renderToStaticMarkup( + {}} + onCancelEdit={() => {}} + onSave={() => {}} + />, + ); + + expect(html).toContain('第 1 幕摘要'); + expect(html).toContain('第 1 幕相遇 NPC'); + expect(html).toContain('第 1 幕过渡钩子'); + expect(html).toContain('textarea'); +}); diff --git a/src/components/custom-world-agent/CustomWorldDraftEditPanel.tsx b/src/components/custom-world-agent/CustomWorldDraftEditPanel.tsx index 983fe464..c6c63460 100644 --- a/src/components/custom-world-agent/CustomWorldDraftEditPanel.tsx +++ b/src/components/custom-world-agent/CustomWorldDraftEditPanel.tsx @@ -15,6 +15,7 @@ type CustomWorldDraftEditPanelProps = { }; function shouldUseTextarea(sectionId: string, value: string) { + const sceneActField = sectionId.match(/^act:[^:]+:(.+)$/u)?.[1] ?? null; return ( value.length > 28 || value.includes('\n') || @@ -26,7 +27,11 @@ function shouldUseTextarea(sectionId: string, value: string) { sectionId === 'stakes' || sectionId === 'openingEvent' || sectionId === 'understandingShift' || - sectionId === 'description' + sectionId === 'description' || + sceneActField === 'summary' || + sceneActField === 'encounterNpcIds' || + sceneActField === 'actGoal' || + sceneActField === 'transitionHook' ); } diff --git a/src/components/game-canvas/GameCanvasRuntime.tsx b/src/components/game-canvas/GameCanvasRuntime.tsx index 50c06f9c..e3762839 100644 --- a/src/components/game-canvas/GameCanvasRuntime.tsx +++ b/src/components/game-canvas/GameCanvasRuntime.tsx @@ -2,6 +2,7 @@ import {useEffect, useLayoutEffect, useRef, useState} from 'react'; import {resolveCompatibilityTemplateWorldType} from '../../data/customWorldRuntime'; import {MONSTERS_BY_WORLD, PLAYER_BASE_X_METERS} from '../../data/hostileNpcs'; +import {resolveActiveSceneActBackgroundImage} from '../../services/customWorldSceneActRuntime'; import {AnimationState, WorldType} from '../../types'; import {GameCanvasEffectLayer} from './GameCanvasEffectLayer'; import {GameCanvasEntityLayer} from './GameCanvasEntityLayer'; @@ -25,6 +26,8 @@ export function GameCanvasRuntime({ encounter, currentScenePreset, worldType, + customWorldProfile = null, + storyEngineMemory = null, sceneHostileNpcs, playerX, playerOffsetY, @@ -50,7 +53,16 @@ export function GameCanvasRuntime({ const resolvedWorldType = worldType ? resolveCompatibilityTemplateWorldType(worldType) ?? WorldType.WUXIA : null; - const backgroundSrc = currentScenePreset?.imageSrc + const activeSceneActBackground = + currentScenePreset?.id + ? resolveActiveSceneActBackgroundImage({ + profile: customWorldProfile, + sceneId: currentScenePreset.id, + storyEngineMemory, + }) + : null; + const backgroundSrc = activeSceneActBackground + || currentScenePreset?.imageSrc || (resolvedWorldType === WorldType.WUXIA ? '/scene_bg/45_PixelSky.png' : '/scene_bg/47_PixelSky.png'); const monsters = resolvedWorldType ? MONSTERS_BY_WORLD[resolvedWorldType] : []; const groundBottom = '18%'; diff --git a/src/components/game-canvas/GameCanvasShared.tsx b/src/components/game-canvas/GameCanvasShared.tsx index 5f6b3efb..e96f7f74 100644 --- a/src/components/game-canvas/GameCanvasShared.tsx +++ b/src/components/game-canvas/GameCanvasShared.tsx @@ -9,9 +9,11 @@ import { CombatActionMode, CombatVisualEffect, CompanionRenderState, + CustomWorldProfile, Encounter, SceneHostileNpc, ScenePresetInfo, + StoryEngineMemoryState, WorldType, } from '../../types'; import {CharacterAnimator} from '../CharacterAnimator'; @@ -29,6 +31,8 @@ export interface GameCanvasProps { encounter: Encounter | null; currentScenePreset: ScenePresetInfo | null; worldType: WorldType | null; + customWorldProfile?: CustomWorldProfile | null; + storyEngineMemory?: StoryEngineMemoryState | null; sceneHostileNpcs: SceneHostileNpc[]; playerX: number; playerOffsetY: number; diff --git a/src/components/game-shell/GameShellCanvasStage.tsx b/src/components/game-shell/GameShellCanvasStage.tsx index 36c63ffe..c35aacef 100644 --- a/src/components/game-shell/GameShellCanvasStage.tsx +++ b/src/components/game-shell/GameShellCanvasStage.tsx @@ -39,6 +39,8 @@ export function GameShellCanvasStage({ encounter={visibleGameState.currentEncounter} currentScenePreset={visibleGameState.currentScenePreset} worldType={visibleGameState.worldType} + customWorldProfile={visibleGameState.customWorldProfile} + storyEngineMemory={visibleGameState.storyEngineMemory} sceneHostileNpcs={visibleGameState.sceneHostileNpcs} playerX={visibleGameState.playerX} playerOffsetY={visibleGameState.playerOffsetY} diff --git a/src/components/game-shell/PlatformHomeView.tsx b/src/components/game-shell/PlatformHomeView.tsx index 7b8b2a6b..9fd71a80 100644 --- a/src/components/game-shell/PlatformHomeView.tsx +++ b/src/components/game-shell/PlatformHomeView.tsx @@ -703,7 +703,6 @@ export function PlatformHomeView({ saves: Archive, profile: UserRound, } as const; - const latestSaveEntry = saveEntries[0] ?? null; const openUserSurface = () => { if (authUi?.user) { authUi.openAccountModal(); @@ -876,41 +875,6 @@ export function PlatformHomeView({
{authUi?.user ? ( <> -
-
-
-
- - SAVE ARCHIVE - -
- {saveEntries.length > 0 ? `${saveEntries.length} 个存档` : '暂无存档'} -
-
-
-
-
- {latestSaveEntry ? latestSaveEntry.worldName : '存档'} -
-
- {latestSaveEntry - ? `最近更新于 ${formatSnapshotTime(latestSaveEntry.lastPlayedAt)},点开后可直接继续游玩。` - : '你在平台里留下的最近可恢复存档会显示在这里。'} -
-
- {latestSaveEntry ? ( - - ) : null} -
-
-
- {saveError ? (
{saveError} diff --git a/src/components/game-shell/PreGameSelectionFlow.agent.interaction.test.tsx b/src/components/game-shell/PreGameSelectionFlow.agent.interaction.test.tsx index 97c7e973..770c9c19 100644 --- a/src/components/game-shell/PreGameSelectionFlow.agent.interaction.test.tsx +++ b/src/components/game-shell/PreGameSelectionFlow.agent.interaction.test.tsx @@ -641,6 +641,7 @@ test('authenticated users with save archives default into the saves tab', async expect((await screen.findAllByText('全部存档')).length).toBeGreaterThan(0); expect((await screen.findAllByText('潮雾列岛')).length).toBeGreaterThan(0); expect(screen.getAllByText('最近更新时间排序').length).toBeGreaterThan(0); + expect(screen.queryByText('SAVE ARCHIVE')).toBeNull(); }); test('save tab can resume a selected archive directly into the game', async () => { diff --git a/src/components/game-shell/types.ts b/src/components/game-shell/types.ts index 7247a2a5..42486cfa 100644 --- a/src/components/game-shell/types.ts +++ b/src/components/game-shell/types.ts @@ -85,6 +85,10 @@ export interface GameShellAdventureStatistics { scenesTraveled: number; currentSceneName: string; playerCurrency: number; + playerLevel?: number; + playerCurrentLevelXp?: number; + playerXpToNextLevel?: number; + playerTotalXp?: number; inventoryItemCount: number; inventoryStackCount: number; activeCompanionCount: number; diff --git a/src/components/game-shell/useGameShellRuntimeViewModel.ts b/src/components/game-shell/useGameShellRuntimeViewModel.ts index fe9a2249..91e87fbe 100644 --- a/src/components/game-shell/useGameShellRuntimeViewModel.ts +++ b/src/components/game-shell/useGameShellRuntimeViewModel.ts @@ -1,5 +1,6 @@ import { useCallback, useEffect, useMemo, useState } from 'react'; +import { normalizePlayerProgressionState } from '../../data/playerProgression'; import { getLiveGamePlayTimeMs } from '../../data/runtimeStats'; import { getWorldCampScenePreset } from '../../data/scenePresets'; import type { @@ -41,7 +42,8 @@ export function buildGameShellDialogueIndicator(params: { return { showPlayer: true, showEncounter: true, - activeSpeaker: lastSpeaker === 'player' ? 'player' : lastSpeaker ? 'npc' : null, + activeSpeaker: + lastSpeaker === 'player' ? 'player' : lastSpeaker ? 'npc' : null, }; } @@ -62,7 +64,7 @@ export function buildCanvasCompanionRenderStates(params: { }) { const activeEncounterNpcId = params.visibleGameState.currentEncounter?.kind === 'npc' - ? params.visibleGameState.currentEncounter.id ?? null + ? (params.visibleGameState.currentEncounter.id ?? null) : null; if (!activeEncounterNpcId) { return params.visibleCompanionRenderStates; @@ -79,6 +81,9 @@ export function buildAdventureStatistics(params: { livePlayTimeMs: number; }): GameShellAdventureStatistics { const { gameState, visibleGameState, livePlayTimeMs } = params; + const playerProgression = normalizePlayerProgressionState( + visibleGameState.playerProgression ?? null, + ); return { playTimeMs: livePlayTimeMs, @@ -94,6 +99,10 @@ export function buildAdventureStatistics(params: { scenesTraveled: gameState.runtimeStats.scenesTraveled, currentSceneName: visibleGameState.currentScenePreset?.name ?? '当前区域', playerCurrency: visibleGameState.playerCurrency, + playerLevel: playerProgression.level, + playerCurrentLevelXp: playerProgression.currentLevelXp, + playerXpToNextLevel: playerProgression.xpToNextLevel, + playerTotalXp: playerProgression.totalXp, inventoryItemCount: visibleGameState.playerInventory.reduce( (sum, item) => sum + item.quantity, 0, @@ -104,17 +113,11 @@ export function buildAdventureStatistics(params: { }; } -export function useGameShellRuntimeViewModel(params: Pick< - GameShellProps, - 'session' | 'story' | 'companions' ->) { +export function useGameShellRuntimeViewModel( + params: Pick, +) { const { session, story, companions } = params; - const { - gameState, - currentStory, - isLoading, - isMapOpen, - } = session; + const { gameState, currentStory, isLoading, isMapOpen } = session; const { npcUi, characterChatUi, handleChoice } = story; const { buildCompanionRenderStates } = companions; @@ -122,7 +125,7 @@ export function useGameShellRuntimeViewModel(params: Pick< const openingCampSceneId = useMemo( () => gameState.worldType - ? getWorldCampScenePreset(gameState.worldType)?.id ?? null + ? (getWorldCampScenePreset(gameState.worldType)?.id ?? null) : null, [gameState.worldType], ); diff --git a/src/data/customWorldLibrary.ts b/src/data/customWorldLibrary.ts index 86e65682..121b0230 100644 --- a/src/data/customWorldLibrary.ts +++ b/src/data/customWorldLibrary.ts @@ -39,6 +39,8 @@ import { KnowledgeFact, RoleAttributeProfile, SceneNarrativeResidue, + SceneActBlueprint, + SceneChapterBlueprint, ThemePack, ThreadContract, WorldStoryGraph, @@ -85,6 +87,18 @@ const CUSTOM_WORLD_NPC_VISUAL_GEAR_TYPES = 'magic', 'ranged', ]); +const SCENE_ACT_STAGES = new Set([ + 'opening', + 'expansion', + 'turning_point', + 'climax', + 'aftermath', +] as const); +const SCENE_ACT_ADVANCE_RULES = new Set([ + 'after_primary_contact', + 'after_active_step_complete', + 'after_chapter_resolution', +] as const); const CUSTOM_WORLD_ROLE_ITEM_CATEGORIES = new Set([ '武器', '护甲', @@ -892,6 +906,97 @@ function normalizeLandmarkDraft( }; } +function normalizeSceneActStageCoverage(value: unknown) { + const stageCoverage = Array.isArray(value) + ? value + .filter((entry): entry is string => typeof entry === 'string') + .map((entry) => entry.trim()) + .filter((entry): entry is SceneActBlueprint['stageCoverage'][number] => + SCENE_ACT_STAGES.has(entry as never), + ) + : []; + + return [...new Set(stageCoverage)]; +} + +function normalizeSceneActBlueprint( + value: unknown, + index: number, + sceneId: string, +): SceneActBlueprint | null { + if (!isRecord(value)) { + return null; + } + + const encounterNpcIds = toStringArray(value.encounterNpcIds); + const stageCoverage = normalizeSceneActStageCoverage(value.stageCoverage); + const advanceRule = toText(value.advanceRule); + const title = toText(value.title); + const summary = toText(value.summary); + + if (!title && !summary && encounterNpcIds.length === 0) { + return null; + } + + return { + id: toText(value.id, `saved-scene-act-${sceneId}-${index + 1}`), + sceneId, + title: title || `第 ${index + 1} 幕`, + summary: summary || title || `围绕${sceneId}继续推进`, + stageCoverage: + stageCoverage.length > 0 + ? stageCoverage + : index === 0 + ? ['opening'] + : ['climax', 'aftermath'], + backgroundImageSrc: toText(value.backgroundImageSrc) || undefined, + encounterNpcIds, + primaryNpcId: toText(value.primaryNpcId, encounterNpcIds[0] ?? ''), + linkedThreadIds: toStringArray(value.linkedThreadIds), + advanceRule: SCENE_ACT_ADVANCE_RULES.has(advanceRule as never) + ? (advanceRule as SceneActBlueprint['advanceRule']) + : 'after_active_step_complete', + actGoal: toText(value.actGoal), + transitionHook: toText(value.transitionHook), + }; +} + +function normalizeSceneChapterBlueprints(value: unknown) { + if (!Array.isArray(value)) { + return null; + } + + const normalized = value + .filter(isRecord) + .map((entry, index) => { + const sceneId = toText(entry.sceneId); + if (!sceneId) { + return null; + } + + const acts = Array.isArray(entry.acts) + ? entry.acts + .map((act, actIndex) => + normalizeSceneActBlueprint(act, actIndex, sceneId), + ) + .filter((act): act is SceneActBlueprint => Boolean(act)) + : []; + + return { + id: toText(entry.id, `saved-scene-chapter-${sceneId}-${index + 1}`), + sceneId, + title: toText(entry.title, toText(entry.sceneName, sceneId)), + summary: toText(entry.summary), + linkedThreadIds: toStringArray(entry.linkedThreadIds), + linkedLandmarkIds: toStringArray(entry.linkedLandmarkIds), + acts, + } satisfies SceneChapterBlueprint; + }) + .filter((entry): entry is SceneChapterBlueprint => Boolean(entry)); + + return normalized.length > 0 ? normalized : null; +} + function normalizeProfile(value: unknown): CustomWorldProfile | null { if (!isRecord(value)) return null; @@ -979,15 +1084,18 @@ function normalizeProfile(value: unknown): CustomWorldProfile | null { landmarks: landmarkDrafts, storyNpcs, }), - themePack: preserveStructuredRecord(value.themePack), - storyGraph: preserveStructuredRecord(value.storyGraph), - knowledgeFacts: - preserveStructuredRecordArray(value.knowledgeFacts), - threadContracts: - preserveStructuredRecordArray(value.threadContracts), - anchorContent: preserveStructuredRecord( - value.anchorContent, - ), + themePack: preserveStructuredRecord(value.themePack), + storyGraph: preserveStructuredRecord(value.storyGraph), + knowledgeFacts: + preserveStructuredRecordArray(value.knowledgeFacts), + threadContracts: + preserveStructuredRecordArray(value.threadContracts), + sceneChapterBlueprints: normalizeSceneChapterBlueprints( + value.sceneChapterBlueprints, + ), + anchorContent: preserveStructuredRecord( + value.anchorContent, + ), creatorIntent: normalizeCustomWorldCreatorIntent(value.creatorIntent), anchorPack: value.anchorPack && typeof value.anchorPack === 'object' diff --git a/src/data/hostileNpcs.ts b/src/data/hostileNpcs.ts index 15e81782..884a130b 100644 --- a/src/data/hostileNpcs.ts +++ b/src/data/hostileNpcs.ts @@ -283,6 +283,8 @@ export function createSceneHostileNpcsFromEncounters( name: encounter.npcName, description: encounter.npcDescription, renderKind: 'npc' as const, + levelProfile: encounter.levelProfile, + experienceReward: encounter.experienceReward, encounter: { ...encounter, xMeters: monster.xMeters, diff --git a/src/data/npcInteractions.ts b/src/data/npcInteractions.ts index c03cf22e..2b41b0ba 100644 --- a/src/data/npcInteractions.ts +++ b/src/data/npcInteractions.ts @@ -1935,6 +1935,8 @@ export function createNpcBattleMonster( combatTags: monsterPreset.combatTags, attributeProfile: monsterPreset.attributeProfile, behaviorVectors: monsterPreset.behaviorVectors, + levelProfile: encounter.levelProfile, + experienceReward: encounter.experienceReward ?? 0, encounter: { ...encounter, hostile: true, @@ -1987,6 +1989,8 @@ export function createNpcBattleMonster( hp: maxHp, maxHp, renderKind: 'npc' as const, + levelProfile: encounter.levelProfile, + experienceReward: 0, encounter: { ...encounter, xMeters: 3.2, @@ -2008,6 +2012,8 @@ export function createNpcBattleMonster( hp: Math.max(baseHp, 80 + npcState.affinity), maxHp: Math.max(baseHp, 80 + npcState.affinity), renderKind: 'npc' as const, + levelProfile: encounter.levelProfile, + experienceReward: encounter.experienceReward ?? 0, encounter: { ...encounter, xMeters: 3.2, diff --git a/src/data/playerProgression.ts b/src/data/playerProgression.ts new file mode 100644 index 00000000..4446c371 --- /dev/null +++ b/src/data/playerProgression.ts @@ -0,0 +1,155 @@ +import type { PlayerProgressionState } from '../types'; + +export interface LevelBenchmark { + level: number; + xpToNextLevel: number; + cumulativeXpRequired: number; + referenceStrength: number; + baseHp: number; + baseMana: number; + baselineDamageScale: number; +} + +export const MAX_PLAYER_LEVEL = 20; + +function clampNonNegativeInteger(value: unknown) { + if (typeof value !== 'number' || !Number.isFinite(value)) { + return 0; + } + + return Math.max(0, Math.floor(value)); +} + +function clampLevel(value: unknown) { + if (typeof value !== 'number' || !Number.isFinite(value)) { + return 1; + } + + return Math.min(MAX_PLAYER_LEVEL, Math.max(1, Math.floor(value))); +} + +function roundMetric(value: number, digits = 3) { + return Number(value.toFixed(digits)); +} + +function computeXpToNextLevel(level: number) { + const scale = Math.max(0, level - 1); + return 60 + 20 * scale + 8 * scale * scale; +} + +function buildLevelBenchmarks(maxLevel: number) { + const benchmarks: LevelBenchmark[] = []; + let cumulativeXpRequired = 0; + + for (let level = 1; level <= maxLevel; level += 1) { + const scale = level - 1; + const xpToNextLevel = level >= maxLevel ? 0 : computeXpToNextLevel(level); + + benchmarks.push({ + level, + xpToNextLevel, + cumulativeXpRequired, + referenceStrength: 100 + 16 * scale + 6 * scale * scale, + baseHp: 180 + 24 * scale + 10 * scale * scale, + baseMana: 80 + 14 * scale + 6 * scale * scale, + baselineDamageScale: roundMetric(1 + 0.12 * scale + 0.03 * scale * scale), + }); + + cumulativeXpRequired += xpToNextLevel; + } + + return benchmarks; +} + +const LEVEL_BENCHMARKS = buildLevelBenchmarks(MAX_PLAYER_LEVEL); +const LEVEL_BENCHMARKS_BY_LEVEL = new Map( + LEVEL_BENCHMARKS.map((benchmark) => [benchmark.level, benchmark]), +); + +export function getLevelBenchmark(level: number) { + return ( + LEVEL_BENCHMARKS_BY_LEVEL.get(clampLevel(level)) ?? LEVEL_BENCHMARKS[0]! + ); +} + +export function getPlayerXpToNextLevel(level: number) { + return getLevelBenchmark(level).xpToNextLevel; +} + +function resolveLevelFromTotalXp(totalXp: number) { + let resolvedLevel = 1; + + for (let level = 2; level <= MAX_PLAYER_LEVEL; level += 1) { + if (totalXp < getLevelBenchmark(level).cumulativeXpRequired) { + break; + } + + resolvedLevel = level; + } + + return resolvedLevel; +} + +function buildProgressionStateFromTotalXp( + totalXp: number, + lastGrantedSource: PlayerProgressionState['lastGrantedSource'] = null, +): PlayerProgressionState { + const normalizedTotalXp = clampNonNegativeInteger(totalXp); + const level = resolveLevelFromTotalXp(normalizedTotalXp); + const benchmark = getLevelBenchmark(level); + + if (level >= MAX_PLAYER_LEVEL) { + return { + level, + currentLevelXp: 0, + totalXp: normalizedTotalXp, + xpToNextLevel: 0, + pendingLevelUps: 0, + lastGrantedSource, + }; + } + + return { + level, + currentLevelXp: Math.max( + 0, + normalizedTotalXp - benchmark.cumulativeXpRequired, + ), + totalXp: normalizedTotalXp, + xpToNextLevel: benchmark.xpToNextLevel, + pendingLevelUps: 0, + lastGrantedSource, + }; +} + +export function createInitialPlayerProgressionState(): PlayerProgressionState { + return buildProgressionStateFromTotalXp(0); +} + +export function normalizePlayerProgressionState( + value: Partial | null | undefined, +): PlayerProgressionState { + if (!value) { + return createInitialPlayerProgressionState(); + } + + const explicitLevel = clampLevel(value.level); + const explicitCurrentLevelXp = clampNonNegativeInteger(value.currentLevelXp); + const totalXp = clampNonNegativeInteger(value.totalXp); + const hasExplicitProgress = explicitLevel > 1 || explicitCurrentLevelXp > 0; + const derivedTotalXp = + totalXp > 0 || !hasExplicitProgress + ? totalXp + : getLevelBenchmark(explicitLevel).cumulativeXpRequired + + Math.min(explicitCurrentLevelXp, getPlayerXpToNextLevel(explicitLevel)); + const lastGrantedSource = + value.lastGrantedSource === 'quest' || + value.lastGrantedSource === 'hostile_npc' + ? value.lastGrantedSource + : null; + + return { + ...buildProgressionStateFromTotalXp(derivedTotalXp, lastGrantedSource), + pendingLevelUps: clampNonNegativeInteger(value.pendingLevelUps), + }; +} diff --git a/src/data/questFlow.test.ts b/src/data/questFlow.test.ts index 0d334e80..7030ac4a 100644 --- a/src/data/questFlow.test.ts +++ b/src/data/questFlow.test.ts @@ -1,7 +1,7 @@ -import {describe, expect, it} from 'vitest'; +import { describe, expect, it } from 'vitest'; -import type {QuestLogEntry, QuestStep, ScenePresetInfo} from '../types'; -import {WorldType} from '../types'; +import type { QuestLogEntry, QuestStep, ScenePresetInfo } from '../types'; +import { WorldType } from '../types'; import { applyQuestProgressFromHostileNpcDefeat, applyQuestProgressFromNpcTalk, @@ -28,7 +28,10 @@ const TEST_SCENE = { }, ], treasureHints: [], -} satisfies Pick; +} satisfies Pick< + ScenePresetInfo, + 'id' | 'name' | 'description' | 'npcs' | 'treasureHints' +>; const CHAPTER_SCENE = { id: 'palace_court', @@ -56,7 +59,10 @@ const CHAPTER_SCENE = { }, ], treasureHints: ['回廊暗格里的香囊', '花圃石座下的旧金牌'], -} satisfies Pick; +} satisfies Pick< + ScenePresetInfo, + 'id' | 'name' | 'description' | 'npcs' | 'treasureHints' +>; const OVERRIDDEN_SCENE = { id: 'wuxia-palace-court', @@ -84,10 +90,13 @@ const OVERRIDDEN_SCENE = { }, ], treasureHints: ['回廊暗格里的香囊', '花圃石座下的旧金牌'], -} satisfies Pick; +} satisfies Pick< + ScenePresetInfo, + 'id' | 'name' | 'description' | 'npcs' | 'treasureHints' +>; function requireStep(quest: QuestLogEntry, stepId: string): QuestStep { - const step = quest.steps?.find(item => item.id === stepId); + const step = quest.steps?.find((item) => item.id === stepId); expect(step).toBeTruthy(); return step!; } @@ -109,7 +118,11 @@ describe('questFlow', () => { expect(requireStep(quest!, 'step_primary').kind).toBe('defeat_hostile_npc'); expect(requireStep(quest!, 'step_report_back').kind).toBe('talk_to_npc'); expect(quest?.status).toBe('active'); - expect(quest?.reward.items[0]?.runtimeMetadata?.generationChannel).toBe('quest_reward'); + expect(quest?.reward.experience).toBeGreaterThan(0); + expect(quest?.rewardText).toContain('经验 +'); + expect(quest?.reward.items[0]?.runtimeMetadata?.generationChannel).toBe( + 'quest_reward', + ); }); it('advances from primary objective to report-back step and then reward-ready', () => { @@ -131,7 +144,10 @@ describe('questFlow', () => { expect(afterBattle?.objective.kind).toBe('talk_to_npc'); expect(afterBattle?.status).toBe('active'); - const afterReport = applyQuestProgressFromNpcTalk([afterBattle!], 'npc_scout')[0]; + const afterReport = applyQuestProgressFromNpcTalk( + [afterBattle!], + 'npc_scout', + )[0]; expect(afterReport?.status).toBe('ready_to_turn_in'); expect(isQuestReadyToClaim(afterReport!)).toBe(true); }); @@ -157,6 +173,7 @@ describe('questFlow', () => { reward: { affinityBonus: 10, currency: 20, + experience: 0, items: [], }, rewardText: 'Legacy reward text', @@ -178,6 +195,7 @@ describe('questFlow', () => { expect(quest).toBeTruthy(); expect(quest?.chapterId).toBe('chapter:scene:palace_court'); expect(quest?.sceneId).toBe('palace_court'); + expect(quest?.reward.experience).toBeGreaterThan(0); expect(quest?.steps?.map((step) => step.kind)).toEqual([ 'talk_to_npc', 'defeat_hostile_npc', @@ -192,7 +210,10 @@ describe('questFlow', () => { }); expect(quest).toBeTruthy(); - const afterOpeningTalk = applyQuestProgressFromNpcTalk([quest!], 'npc-maid')[0]; + const afterOpeningTalk = applyQuestProgressFromNpcTalk( + [quest!], + 'npc-maid', + )[0]; expect(afterOpeningTalk?.objective.kind).toBe('defeat_hostile_npc'); const afterPressure = applyQuestProgressFromHostileNpcDefeat( @@ -202,7 +223,10 @@ describe('questFlow', () => { )[0]; expect(afterPressure?.objective.kind).toBe('talk_to_npc'); - const afterTurningTalk = applyQuestProgressFromNpcTalk([afterPressure!], 'npc-maid')[0]; + const afterTurningTalk = applyQuestProgressFromNpcTalk( + [afterPressure!], + 'npc-maid', + )[0]; expect(afterTurningTalk?.status).toBe('ready_to_turn_in'); expect(isQuestReadyToClaim(afterTurningTalk!)).toBe(true); }); @@ -215,8 +239,14 @@ describe('questFlow', () => { expect(quest).toBeTruthy(); expect(quest?.title).toBe('查清内庭旧痕'); - expect(requireStep(quest!, 'step_scene_pressure').kind).toBe('inspect_treasure'); - expect(requireStep(quest!, 'step_scene_pressure').title).toBe('调查回廊暗格'); - expect(requireStep(quest!, 'step_scene_turning').title).toBe('拿旧金牌去对问侍女'); + expect(requireStep(quest!, 'step_scene_pressure').kind).toBe( + 'inspect_treasure', + ); + expect(requireStep(quest!, 'step_scene_pressure').title).toBe( + '调查回廊暗格', + ); + expect(requireStep(quest!, 'step_scene_turning').title).toBe( + '拿旧金牌去对问侍女', + ); }); }); diff --git a/src/data/questFlow.ts b/src/data/questFlow.ts index da127440..cd6976b7 100644 --- a/src/data/questFlow.ts +++ b/src/data/questFlow.ts @@ -1,4 +1,4 @@ -import type {QuestGenerationContext} from '../services/aiTypes'; +import type { QuestGenerationContext } from '../services/aiTypes'; import type { QuestCompilationRequest, QuestContract, @@ -20,18 +20,23 @@ import { type QuestStep, type WorldType, } from '../types'; -import {formatCurrency} from './economy'; -import {getHostileNpcPresetById} from './hostileNpcPresets'; +import { formatCurrency } from './economy'; +import { getHostileNpcPresetById } from './hostileNpcPresets'; +import { getPlayerXpToNextLevel } from './playerProgression'; import { buildLooseRuntimeItemGenerationContext, buildQuestRuntimeItemGenerationContext, } from './runtimeItemContext'; -import {buildDirectedRuntimeReward} from './runtimeItemDirector'; -import {flattenDirectedRuntimeRewardItems} from './runtimeItemNarrative'; -import {getSceneFriendlyNpcs, getSceneHostileNpcs} from './scenePresets'; +import { buildDirectedRuntimeReward } from './runtimeItemDirector'; +import { flattenDirectedRuntimeRewardItems } from './runtimeItemNarrative'; +import { getSceneFriendlyNpcs, getSceneHostileNpcs } from './scenePresets'; const REWARD_READY_STATUSES: QuestStatus[] = ['ready_to_turn_in', 'completed']; -const TERMINAL_QUEST_STATUSES: QuestStatus[] = ['turned_in', 'failed', 'expired']; +const TERMINAL_QUEST_STATUSES: QuestStatus[] = [ + 'turned_in', + 'failed', + 'expired', +]; type SceneQuestThreat = | { @@ -57,63 +62,81 @@ type SceneChapterOverride = { description?: string; summary?: string; preferredObjectiveKind?: QuestObjectiveKind; - openingTalk?: Partial>; - pressureStep?: Partial>; - turningTalk?: Partial>; + openingTalk?: Partial< + Pick + >; + pressureStep?: Partial< + Pick + >; + turningTalk?: Partial< + Pick + >; }; const SCENE_CHAPTER_OVERRIDES: Record = { 'wuxia-palace-court': { title: '查清内庭旧痕', - description: '旧宫侍女显然知道宫苑内庭近来的异动不只是一条禁行回廊那么简单,你需要顺着残痕把这一章真正翻开。', + description: + '旧宫侍女显然知道宫苑内庭近来的异动不只是一条禁行回廊那么简单,你需要顺着残痕把这一章真正翻开。', summary: '在宫苑内庭查清旧案残痕,并逼出侍女压着没说的那一层旧事', preferredObjectiveKind: 'inspect_treasure', openingTalk: { title: '追问禁行回廊', - revealText: '先问清旧宫侍女为什么总拦着那条回廊,这一章的开口多半就藏在她的口风里。', - completeText: '旧宫侍女已经把最表层的理由说出来了,但真正的旧事还压在更深处。', + revealText: + '先问清旧宫侍女为什么总拦着那条回廊,这一章的开口多半就藏在她的口风里。', + completeText: + '旧宫侍女已经把最表层的理由说出来了,但真正的旧事还压在更深处。', }, pressureStep: { title: '调查回廊暗格', - revealText: '先把回廊暗格里的香囊翻出来,确认内庭异动究竟是在遮人,还是在遮旧案。', + revealText: + '先把回廊暗格里的香囊翻出来,确认内庭异动究竟是在遮人,还是在遮旧案。', completeText: '回廊暗格已经给出了回应,内庭这章也开始逼近改判前的节点。', }, turningTalk: { title: '拿旧金牌去对问侍女', - revealText: '把你查到的旧金牌和暗格痕迹带回去,和旧宫侍女把这层旧事对清楚。', + revealText: + '把你查到的旧金牌和暗格痕迹带回去,和旧宫侍女把这层旧事对清楚。', completeText: '旧宫侍女已经接住你的追问,这章也被你真正推到了收束前夜。', }, }, 'wuxia-rain-street': { title: '追索雨街账册', - description: '夜灯摊主见过太多不该见的人,雨夜长街的异常更像一条被水汽和灯影压着的旧账,你得先把线索翻出来。', + description: + '夜灯摊主见过太多不该见的人,雨夜长街的异常更像一条被水汽和灯影压着的旧账,你得先把线索翻出来。', summary: '在雨夜长街查出湿布包和账册残页背后到底是谁在追索谁', preferredObjectiveKind: 'inspect_treasure', openingTalk: { title: '向摊主问清夜街异样', - revealText: '先和夜灯摊主把这条街最近不对劲的地方问清,别让这章一开始就被人带偏。', + revealText: + '先和夜灯摊主把这条街最近不对劲的地方问清,别让这章一开始就被人带偏。', }, pressureStep: { title: '翻出灯下残页', - revealText: '顺着浸湿的布包和账册残页查下去,这条长街真正压着的旧账就会冒头。', + revealText: + '顺着浸湿的布包和账册残页查下去,这条长街真正压着的旧账就会冒头。', }, turningTalk: { title: '拿账册回去对灯摊主', - revealText: '把你翻到的账册残页拿回去,逼夜灯摊主把没说透的那半句话补完。', + revealText: + '把你翻到的账册残页拿回去,逼夜灯摊主把没说透的那半句话补完。', }, }, 'wuxia-forge-works': { title: '追索失落兵谱', - description: '老铸匠一眼就认出你身上的杀气来源,铸坊工场里压着的旧兵谱和铁匣显然不只是废料,你得先把这章的火候逼出来。', + description: + '老铸匠一眼就认出你身上的杀气来源,铸坊工场里压着的旧兵谱和铁匣显然不只是废料,你得先把这章的火候逼出来。', summary: '在铸坊工场追出失落兵谱的去向,并问清是谁把旧匣压在风箱后面', preferredObjectiveKind: 'inspect_treasure', openingTalk: { title: '追问兵器缺口来路', - revealText: '先让老铸匠把兵器缺口看清楚,他多半已经从上面认出了这章的来路。', + revealText: + '先让老铸匠把兵器缺口看清楚,他多半已经从上面认出了这章的来路。', }, pressureStep: { title: '翻出风箱后兵谱', - revealText: '先把风箱后压着的旧兵谱和铁匣找出来,铸坊这章的真正火候就在那附近。', + revealText: + '先把风箱后压着的旧兵谱和铁匣找出来,铸坊这章的真正火候就在那附近。', }, turningTalk: { title: '拿兵谱回去问铸匠', @@ -122,12 +145,14 @@ const SCENE_CHAPTER_OVERRIDES: Record = { }, 'xianxia-cloud-gate': { title: '查明仙门符匣异动', - description: '守门灵官一直像在等一份迟迟未到的回报,云海仙门的符匣和玉牌显然牵着更深的禁制线,你得先把这章的入口找准。', + description: + '守门灵官一直像在等一份迟迟未到的回报,云海仙门的符匣和玉牌显然牵着更深的禁制线,你得先把这章的入口找准。', summary: '在云海仙门查清符匣和门阙阴影背后的异常,确认谁在借仙门遮掩旧事', preferredObjectiveKind: 'inspect_treasure', openingTalk: { title: '向灵官问清门阙异象', - revealText: '先让守门灵官把云海仙门最近的异象说清楚,这章的入口多半就藏在他守着不放的话头里。', + revealText: + '先让守门灵官把云海仙门最近的异象说清楚,这章的入口多半就藏在他守着不放的话头里。', }, pressureStep: { title: '调查云阶符匣', @@ -135,21 +160,25 @@ const SCENE_CHAPTER_OVERRIDES: Record = { }, turningTalk: { title: '带着符匣回去问灵官', - revealText: '把你查到的符匣线索带回去,逼守门灵官把没说完的禁制旧事补全。', + revealText: + '把你查到的符匣线索带回去,逼守门灵官把没说完的禁制旧事补全。', }, }, 'xianxia-star-vessel': { title: '追索星图旧航线', - description: '星舟舵手守着旧航线图不肯松手,甲板上压着的星图匣和灵罗盘像在等人把残缺航线拼起来,这一章更适合从调查切进去。', + description: + '星舟舵手守着旧航线图不肯松手,甲板上压着的星图匣和灵罗盘像在等人把残缺航线拼起来,这一章更适合从调查切进去。', summary: '在星舟甲板拼出失落航线的缺口,并问清是谁把旧坐标压在高空风压里', preferredObjectiveKind: 'inspect_treasure', openingTalk: { title: '追问失落航线', - revealText: '先和星舟舵手把旧航线的缺口问清楚,别让甲板上的风声把真正的方向吹散。', + revealText: + '先和星舟舵手把旧航线的缺口问清楚,别让甲板上的风声把真正的方向吹散。', }, pressureStep: { title: '调查舵台后星图匣', - revealText: '把舵台后的星图匣和灵罗盘先翻出来,这章的方向才会真正落到你手里。', + revealText: + '把舵台后的星图匣和灵罗盘先翻出来,这章的方向才会真正落到你手里。', }, turningTalk: { title: '带着航线回去问舵手', @@ -163,7 +192,7 @@ function resolveQuestRewardRuntimeConfig(params: { rewardTheme: QuestIntent['rewardTheme']; narrativeType: QuestIntent['narrativeType']; }) { - const {roleText, rewardTheme, narrativeType} = params; + const { roleText, rewardTheme, narrativeType } = params; if (rewardTheme === 'resource') { return { @@ -220,6 +249,59 @@ function resolveQuestRewardRuntimeConfig(params: { }; } +function roundQuestExperience(value: number) { + return Math.max(5, Math.round(value / 5) * 5); +} + +function resolveQuestTargetLevel(context?: QuestGenerationContext) { + const level = context?.playerProgression?.level; + + if (typeof level !== 'number' || !Number.isFinite(level)) { + return 1; + } + + return Math.max(1, Math.floor(level)); +} + +function resolveQuestStepCountMultiplier(stepCount: number) { + if (stepCount <= 1) { + return 0.85; + } + + if (stepCount === 2) { + return 1; + } + + return 1.12; +} + +function resolveQuestNarrativeXpMultiplier( + narrativeType: QuestIntent['narrativeType'], +) { + return narrativeType === 'trial' || narrativeType === 'bounty' ? 1.08 : 1; +} + +function resolveQuestUrgencyXpMultiplier(urgency: QuestIntent['urgency']) { + return urgency === 'high' ? 1.05 : 1; +} + +function buildQuestExperienceReward(params: { + context?: QuestGenerationContext; + narrativeType: QuestIntent['narrativeType']; + urgency: QuestIntent['urgency']; + stepCount: number; +}) { + const targetLevel = resolveQuestTargetLevel(params.context); + const baseQuestXp = getPlayerXpToNextLevel(targetLevel) * 0.45; + + return roundQuestExperience( + baseQuestXp * + resolveQuestStepCountMultiplier(params.stepCount) * + resolveQuestNarrativeXpMultiplier(params.narrativeType) * + resolveQuestUrgencyXpMultiplier(params.urgency), + ); +} + function buildQuestReward(params: { issuerNpcId: string; issuerNpcName: string; @@ -227,6 +309,8 @@ function buildQuestReward(params: { roleText: string; rewardTheme: QuestIntent['rewardTheme']; narrativeType: QuestIntent['narrativeType']; + urgency: QuestIntent['urgency']; + stepCount: number; scene: QuestSceneSnapshot | null; context?: QuestGenerationContext; }): QuestReward { @@ -237,6 +321,8 @@ function buildQuestReward(params: { roleText, rewardTheme, narrativeType, + urgency, + stepCount, scene, context, } = params; @@ -279,9 +365,10 @@ function buildQuestReward(params: { fixedKinds: [...runtimeConfig.fixedKinds], fixedPermanence: [...runtimeConfig.fixedPermanence], }); - const threadContract = context?.customWorldProfile?.threadContracts?.find((contract) => - (context.activeThreadIds ?? []).includes(contract.threadId), - ) ?? null; + const threadContract = + context?.customWorldProfile?.threadContracts?.find((contract) => + (context.activeThreadIds ?? []).includes(contract.threadId), + ) ?? null; const rewardItems = flattenDirectedRuntimeRewardItems(directedReward); const documentItem = rewardTheme === 'intel' && threadContract @@ -292,10 +379,22 @@ function buildQuestReward(params: { : null; const reward: QuestReward = { - affinityBonus: narrativeType === 'relationship' || narrativeType === 'trial' ? 14 : 12, - currency: rewardTheme === 'intel' - ? (worldType === 'XIANXIA' ? 40 : 58) - : (worldType === 'XIANXIA' ? 54 : 72), + affinityBonus: + narrativeType === 'relationship' || narrativeType === 'trial' ? 14 : 12, + currency: + rewardTheme === 'intel' + ? worldType === 'XIANXIA' + ? 40 + : 58 + : worldType === 'XIANXIA' + ? 54 + : 72, + experience: buildQuestExperienceReward({ + context, + narrativeType, + urgency, + stepCount, + }), items: documentItem ? [...rewardItems, documentItem] : rewardItems, storyHint: directedReward.storyHint, }; @@ -313,9 +412,14 @@ function buildQuestReward(params: { } function buildRewardText(reward: QuestReward, worldType: WorldType | null) { - const itemText = reward.items.map(item => item.name).join('、') || '当前局势相关的补给'; - const intelText = reward.intel?.rumorText ? `,以及情报“${reward.intel.rumorText}”` : ''; - return `完成后可获得好感 +${reward.affinityBonus}、${formatCurrency(reward.currency, worldType)}、${itemText}${intelText}。`; + const itemText = + reward.items.map((item) => item.name).join('、') || '当前局势相关的补给'; + const experienceText = + (reward.experience ?? 0) > 0 ? `、经验 +${reward.experience}` : ''; + const intelText = reward.intel?.rumorText + ? `,以及情报“${reward.intel.rumorText}”` + : ''; + return `完成后可获得好感 +${reward.affinityBonus}${experienceText}、${formatCurrency(reward.currency, worldType)}、${itemText}${intelText}。`; } function resolveQuestThreadContract(params: { @@ -333,20 +437,28 @@ function resolveQuestThreadContract(params: { ? profile.threadContracts : buildThreadContractsFromProfile(profile); const activeThreadIds = params.context?.activeThreadIds ?? []; - const contract = contracts.find((candidate) => - activeThreadIds.includes(candidate.threadId) - || candidate.issuerActorId === params.issuerNpcId - || candidate.steps.some((step) => - step.completionSignalIds.some((signalId) => - params.scene?.id ? signalId.includes(params.scene.id) : false, - ), - ), - ) ?? contracts[0] ?? null; + const contract = + contracts.find( + (candidate) => + activeThreadIds.includes(candidate.threadId) || + candidate.issuerActorId === params.issuerNpcId || + candidate.steps.some((step) => + step.completionSignalIds.some((signalId) => + params.scene?.id ? signalId.includes(params.scene.id) : false, + ), + ), + ) ?? + contracts[0] ?? + null; return contract; } -function buildQuestId(issuerNpcId: string, kind: QuestObjectiveKind, targetKey: string) { +function buildQuestId( + issuerNpcId: string, + kind: QuestObjectiveKind, + targetKey: string, +) { return `quest:${issuerNpcId}:${kind}:${targetKey}`; } @@ -367,7 +479,10 @@ function normalizeCount(rawCount: number | undefined) { } function clampProgress(progress: number | undefined, requiredCount: number) { - return Math.max(0, Math.min(normalizeCount(requiredCount), Math.round(progress ?? 0))); + return Math.max( + 0, + Math.min(normalizeCount(requiredCount), Math.round(progress ?? 0)), + ); } function compactQuestLabel(label: string, maxLength = 6) { @@ -385,10 +500,15 @@ function normalizeQuestTitle(rawTitle: string, fallbackTitle: string) { return title; } - return fallbackTitle.length <= 12 ? fallbackTitle : fallbackTitle.slice(0, 10); + return fallbackTitle.length <= 12 + ? fallbackTitle + : fallbackTitle.slice(0, 10); } -function getScenePrimaryThreat(scene: QuestSceneSnapshot | null, worldType: WorldType | null): SceneQuestThreat | null { +function getScenePrimaryThreat( + scene: QuestSceneSnapshot | null, + worldType: WorldType | null, +): SceneQuestThreat | null { if (!scene) { return null; } @@ -397,8 +517,10 @@ function getScenePrimaryThreat(scene: QuestSceneSnapshot | null, worldType: Worl if (hostileNpc) { const targetHostileNpcId = hostileNpc.monsterPresetId ?? hostileNpc.id; const targetHostileNpcName = worldType - ? getHostileNpcPresetById(worldType, targetHostileNpcId)?.name ?? hostileNpc.name ?? targetHostileNpcId - : hostileNpc.name ?? targetHostileNpcId; + ? (getHostileNpcPresetById(worldType, targetHostileNpcId)?.name ?? + hostileNpc.name ?? + targetHostileNpcId) + : (hostileNpc.name ?? targetHostileNpcId); return { kind: 'defeat_hostile_npc', @@ -424,7 +546,11 @@ function getScenePrimaryThreat(scene: QuestSceneSnapshot | null, worldType: Worl }; } -function buildStepRevealText(step: QuestStep, issuerNpcName: string, targetLabel: string) { +function buildStepRevealText( + step: QuestStep, + issuerNpcName: string, + targetLabel: string, +) { switch (step.kind) { case 'defeat_hostile_npc': return `${issuerNpcName} 希望你先压制 ${targetLabel},再回来说明局势。`; @@ -443,7 +569,11 @@ function buildStepRevealText(step: QuestStep, issuerNpcName: string, targetLabel } } -function buildStepCompleteText(step: QuestStep, issuerNpcName: string, targetLabel: string) { +function buildStepCompleteText( + step: QuestStep, + issuerNpcName: string, + targetLabel: string, +) { switch (step.kind) { case 'defeat_hostile_npc': return `${targetLabel} 已被压制,回去向 ${issuerNpcName} 汇报吧。`; @@ -469,18 +599,25 @@ function buildPrimaryQuestStep(params: { worldType: WorldType | null; intent: QuestIntent; }): QuestStep | null { - const {issuerNpcId, issuerNpcName, scene, worldType, intent} = params; + const { issuerNpcId, issuerNpcName, scene, worldType, intent } = params; const threat = getScenePrimaryThreat(scene, worldType); if (!threat) { return null; } - const preferredKinds = intent.recommendedObjectiveKinds.length > 0 - ? intent.recommendedObjectiveKinds - : [threat.kind]; - const chosenKind = preferredKinds.includes(threat.kind) ? threat.kind : preferredKinds[0] ?? threat.kind; + const preferredKinds = + intent.recommendedObjectiveKinds.length > 0 + ? intent.recommendedObjectiveKinds + : [threat.kind]; + const chosenKind = preferredKinds.includes(threat.kind) + ? threat.kind + : (preferredKinds[0] ?? threat.kind); - if (chosenKind === 'inspect_treasure' && threat.kind === 'inspect_treasure' && scene) { + if ( + chosenKind === 'inspect_treasure' && + threat.kind === 'inspect_treasure' && + scene + ) { const title = `调查 ${scene.name} 的异常`; return { id: 'step_primary', @@ -489,26 +626,34 @@ function buildPrimaryQuestStep(params: { requiredCount: 1, progress: 0, title, - revealText: buildStepRevealText({ - id: 'step_primary', - kind: 'inspect_treasure', - targetSceneId: scene.id, - requiredCount: 1, - progress: 0, - title, - revealText: '', - completeText: '', - }, issuerNpcName, scene.name), - completeText: buildStepCompleteText({ - id: 'step_primary', - kind: 'inspect_treasure', - targetSceneId: scene.id, - requiredCount: 1, - progress: 0, - title, - revealText: '', - completeText: '', - }, issuerNpcName, scene.name), + revealText: buildStepRevealText( + { + id: 'step_primary', + kind: 'inspect_treasure', + targetSceneId: scene.id, + requiredCount: 1, + progress: 0, + title, + revealText: '', + completeText: '', + }, + issuerNpcName, + scene.name, + ), + completeText: buildStepCompleteText( + { + id: 'step_primary', + kind: 'inspect_treasure', + targetSceneId: scene.id, + requiredCount: 1, + progress: 0, + title, + revealText: '', + completeText: '', + }, + issuerNpcName, + scene.name, + ), }; } @@ -521,26 +666,34 @@ function buildPrimaryQuestStep(params: { requiredCount: 1, progress: 0, title, - revealText: buildStepRevealText({ - id: 'step_primary', - kind: 'spar_with_npc', - targetNpcId: issuerNpcId, - requiredCount: 1, - progress: 0, - title, - revealText: '', - completeText: '', - }, issuerNpcName, issuerNpcName), - completeText: buildStepCompleteText({ - id: 'step_primary', - kind: 'spar_with_npc', - targetNpcId: issuerNpcId, - requiredCount: 1, - progress: 0, - title, - revealText: '', - completeText: '', - }, issuerNpcName, issuerNpcName), + revealText: buildStepRevealText( + { + id: 'step_primary', + kind: 'spar_with_npc', + targetNpcId: issuerNpcId, + requiredCount: 1, + progress: 0, + title, + revealText: '', + completeText: '', + }, + issuerNpcName, + issuerNpcName, + ), + completeText: buildStepCompleteText( + { + id: 'step_primary', + kind: 'spar_with_npc', + targetNpcId: issuerNpcId, + requiredCount: 1, + progress: 0, + title, + revealText: '', + completeText: '', + }, + issuerNpcName, + issuerNpcName, + ), }; } @@ -555,35 +708,46 @@ function buildPrimaryQuestStep(params: { requiredCount: 1, progress: 0, title, - revealText: buildStepRevealText({ - id: 'step_primary', - kind: 'defeat_hostile_npc', - targetHostileNpcId: threat.targetHostileNpcId, - targetSceneId: threat.targetSceneId, - requiredCount: 1, - progress: 0, - title, - revealText: '', - completeText: '', - }, issuerNpcName, hostileNpcName), - completeText: buildStepCompleteText({ - id: 'step_primary', - kind: 'defeat_hostile_npc', - targetHostileNpcId: threat.targetHostileNpcId, - targetSceneId: threat.targetSceneId, - requiredCount: 1, - progress: 0, - title, - revealText: '', - completeText: '', - }, issuerNpcName, hostileNpcName), + revealText: buildStepRevealText( + { + id: 'step_primary', + kind: 'defeat_hostile_npc', + targetHostileNpcId: threat.targetHostileNpcId, + targetSceneId: threat.targetSceneId, + requiredCount: 1, + progress: 0, + title, + revealText: '', + completeText: '', + }, + issuerNpcName, + hostileNpcName, + ), + completeText: buildStepCompleteText( + { + id: 'step_primary', + kind: 'defeat_hostile_npc', + targetHostileNpcId: threat.targetHostileNpcId, + targetSceneId: threat.targetSceneId, + requiredCount: 1, + progress: 0, + title, + revealText: '', + completeText: '', + }, + issuerNpcName, + hostileNpcName, + ), }; } return null; } -function buildTalkBackStep(issuerNpcId: string, issuerNpcName: string): QuestStep { +function buildTalkBackStep( + issuerNpcId: string, + issuerNpcName: string, +): QuestStep { const title = `返回与 ${issuerNpcName} 交谈`; return { id: 'step_report_back', @@ -592,26 +756,34 @@ function buildTalkBackStep(issuerNpcId: string, issuerNpcName: string): QuestSte requiredCount: 1, progress: 0, title, - revealText: buildStepRevealText({ - id: 'step_report_back', - kind: 'talk_to_npc', - targetNpcId: issuerNpcId, - requiredCount: 1, - progress: 0, - title, - revealText: '', - completeText: '', - }, issuerNpcName, issuerNpcName), - completeText: buildStepCompleteText({ - id: 'step_report_back', - kind: 'talk_to_npc', - targetNpcId: issuerNpcId, - requiredCount: 1, - progress: 0, - title, - revealText: '', - completeText: '', - }, issuerNpcName, issuerNpcName), + revealText: buildStepRevealText( + { + id: 'step_report_back', + kind: 'talk_to_npc', + targetNpcId: issuerNpcId, + requiredCount: 1, + progress: 0, + title, + revealText: '', + completeText: '', + }, + issuerNpcName, + issuerNpcName, + ), + completeText: buildStepCompleteText( + { + id: 'step_report_back', + kind: 'talk_to_npc', + targetNpcId: issuerNpcId, + requiredCount: 1, + progress: 0, + title, + revealText: '', + completeText: '', + }, + issuerNpcName, + issuerNpcName, + ), }; } @@ -621,7 +793,7 @@ function buildSceneOpeningTalkStep(params: { sceneName: string; override?: SceneChapterOverride | null; }) { - const {issuerNpcId, issuerNpcName, sceneName, override} = params; + const { issuerNpcId, issuerNpcName, sceneName, override } = params; const title = override?.openingTalk?.title ?? `向 ${issuerNpcName} 打听异动`; return { id: 'step_scene_opening', @@ -630,10 +802,12 @@ function buildSceneOpeningTalkStep(params: { requiredCount: 1, progress: 0, title, - revealText: override?.openingTalk?.revealText - ?? `${issuerNpcName} 明显知道 ${sceneName} 最近不对劲,先和她把眼前局势问清楚。`, - completeText: override?.openingTalk?.completeText - ?? `${issuerNpcName} 的回应已经把 ${sceneName} 这一章真正带进了正题。`, + revealText: + override?.openingTalk?.revealText ?? + `${issuerNpcName} 明显知道 ${sceneName} 最近不对劲,先和她把眼前局势问清楚。`, + completeText: + override?.openingTalk?.completeText ?? + `${issuerNpcName} 的回应已经把 ${sceneName} 这一章真正带进了正题。`, } satisfies QuestStep; } @@ -643,7 +817,7 @@ function buildSceneTurningTalkStep(params: { sceneName: string; override?: SceneChapterOverride | null; }) { - const {issuerNpcId, issuerNpcName, sceneName, override} = params; + const { issuerNpcId, issuerNpcName, sceneName, override } = params; const title = override?.turningTalk?.title ?? `回去与 ${issuerNpcName} 对证`; return { id: 'step_scene_turning', @@ -652,10 +826,12 @@ function buildSceneTurningTalkStep(params: { requiredCount: 1, progress: 0, title, - revealText: override?.turningTalk?.revealText - ?? `把你在 ${sceneName} 查到的情况带回去,和 ${issuerNpcName} 把这一层旧事对清楚。`, - completeText: override?.turningTalk?.completeText - ?? `${issuerNpcName} 已经接住你的回报,这一章也逼近最后的收束。`, + revealText: + override?.turningTalk?.revealText ?? + `把你在 ${sceneName} 查到的情况带回去,和 ${issuerNpcName} 把这一层旧事对清楚。`, + completeText: + override?.turningTalk?.completeText ?? + `${issuerNpcName} 已经接住你的回报,这一章也逼近最后的收束。`, } satisfies QuestStep; } @@ -663,7 +839,9 @@ function resolveSceneChapterIssuer(scene: QuestSceneSnapshot | null) { const friendlyNpc = getSceneFriendlyNpcs(scene)[0] ?? null; if (!friendlyNpc) { return { - issuerNpcId: scene?.id ? `scene-chapter:${scene.id}` : 'scene-chapter:unknown', + issuerNpcId: scene?.id + ? `scene-chapter:${scene.id}` + : 'scene-chapter:unknown', issuerNpcName: scene?.name ?? '当前区域', roleText: scene?.description ?? scene?.name ?? '场景章节', hasGuideNpc: false, @@ -673,7 +851,11 @@ function resolveSceneChapterIssuer(scene: QuestSceneSnapshot | null) { return { issuerNpcId: friendlyNpc.id, issuerNpcName: friendlyNpc.name, - roleText: friendlyNpc.role || friendlyNpc.description || scene?.description || friendlyNpc.name, + roleText: + friendlyNpc.role || + friendlyNpc.description || + scene?.description || + friendlyNpc.name, hasGuideNpc: true, }; } @@ -686,11 +868,21 @@ function buildSceneChapterPrimaryStep(params: { hasGuideNpc: boolean; override?: SceneChapterOverride | null; }) { - const {scene, worldType, issuerNpcId, issuerNpcName, hasGuideNpc, override} = params; + const { + scene, + worldType, + issuerNpcId, + issuerNpcName, + hasGuideNpc, + override, + } = params; const threat = getScenePrimaryThreat(scene, worldType); const preferredObjectiveKind = override?.preferredObjectiveKind ?? null; - if (preferredObjectiveKind === 'inspect_treasure' && (scene.treasureHints?.length ?? 0) > 0) { + if ( + preferredObjectiveKind === 'inspect_treasure' && + (scene.treasureHints?.length ?? 0) > 0 + ) { const clueLabel = scene.treasureHints?.[0] ?? scene.name; return { id: 'step_scene_pressure', @@ -699,17 +891,22 @@ function buildSceneChapterPrimaryStep(params: { requiredCount: 1, progress: 0, title: override?.pressureStep?.title ?? `调查 ${clueLabel}`, - revealText: override?.pressureStep?.revealText ?? ( - hasGuideNpc + revealText: + override?.pressureStep?.revealText ?? + (hasGuideNpc ? `${issuerNpcName} 提到的异样多半就藏在 ${clueLabel} 附近,先去把这一层旧痕翻出来。` - : `先把 ${clueLabel} 这条线索看清,${scene.name} 这一章才会真正往前走。` - ), - completeText: override?.pressureStep?.completeText - ?? `${clueLabel} 已经给出了回应,${scene.name} 这一章开始进入改判前的阶段。`, + : `先把 ${clueLabel} 这条线索看清,${scene.name} 这一章才会真正往前走。`), + completeText: + override?.pressureStep?.completeText ?? + `${clueLabel} 已经给出了回应,${scene.name} 这一章开始进入改判前的阶段。`, } satisfies QuestStep; } - if ((preferredObjectiveKind === 'defeat_hostile_npc' || !preferredObjectiveKind) && threat?.kind === 'defeat_hostile_npc') { + if ( + (preferredObjectiveKind === 'defeat_hostile_npc' || + !preferredObjectiveKind) && + threat?.kind === 'defeat_hostile_npc' + ) { const hostileNpcName = threat.targetHostileNpcName; return { id: 'step_scene_pressure', @@ -719,13 +916,14 @@ function buildSceneChapterPrimaryStep(params: { requiredCount: 1, progress: 0, title: override?.pressureStep?.title ?? `压制 ${hostileNpcName}`, - revealText: override?.pressureStep?.revealText ?? ( - hasGuideNpc + revealText: + override?.pressureStep?.revealText ?? + (hasGuideNpc ? `${issuerNpcName} 要你先压制 ${hostileNpcName},再回来确认 ${scene.name} 里的异动究竟是谁在推动。` - : `先压下 ${hostileNpcName} 带来的压力,才能把 ${scene.name} 这一章继续往下推。` - ), - completeText: override?.pressureStep?.completeText - ?? `${hostileNpcName} 已被压制,${scene.name} 这一章的核心压力开始松动。`, + : `先压下 ${hostileNpcName} 带来的压力,才能把 ${scene.name} 这一章继续往下推。`), + completeText: + override?.pressureStep?.completeText ?? + `${hostileNpcName} 已被压制,${scene.name} 这一章的核心压力开始松动。`, } satisfies QuestStep; } @@ -738,13 +936,14 @@ function buildSceneChapterPrimaryStep(params: { requiredCount: 1, progress: 0, title: override?.pressureStep?.title ?? `调查 ${clueLabel}`, - revealText: override?.pressureStep?.revealText ?? ( - hasGuideNpc + revealText: + override?.pressureStep?.revealText ?? + (hasGuideNpc ? `${issuerNpcName} 提到的异样多半就藏在 ${clueLabel} 附近,先去把这一层旧痕翻出来。` - : `先把 ${clueLabel} 这条线索看清,${scene.name} 这一章才会真正往前走。` - ), - completeText: override?.pressureStep?.completeText - ?? `${clueLabel} 已经给出了回应,${scene.name} 这一章开始进入改判前的阶段。`, + : `先把 ${clueLabel} 这条线索看清,${scene.name} 这一章才会真正往前走。`), + completeText: + override?.pressureStep?.completeText ?? + `${clueLabel} 已经给出了回应,${scene.name} 这一章开始进入改判前的阶段。`, } satisfies QuestStep; } @@ -755,8 +954,12 @@ function buildSceneChapterPrimaryStep(params: { requiredCount: 1, progress: 0, title: override?.pressureStep?.title ?? `继续逼问 ${issuerNpcName}`, - revealText: override?.pressureStep?.revealText ?? `${issuerNpcName} 还压着一层没说透的话,把这章的中段压力继续顶上去。`, - completeText: override?.pressureStep?.completeText ?? `${issuerNpcName} 的口风终于松了一层,这章也开始逼近转折。`, + revealText: + override?.pressureStep?.revealText ?? + `${issuerNpcName} 还压着一层没说透的话,把这章的中段压力继续顶上去。`, + completeText: + override?.pressureStep?.completeText ?? + `${issuerNpcName} 的口风终于松了一层,这章也开始逼近转折。`, } satisfies QuestStep; } @@ -768,40 +971,56 @@ function buildSceneChapterSteps(params: { hasGuideNpc: boolean; override?: SceneChapterOverride | null; }) { - const {scene, worldType, issuerNpcId, issuerNpcName, hasGuideNpc, override} = params; - const steps: QuestStep[] = []; - - if (hasGuideNpc) { - steps.push(buildSceneOpeningTalkStep({ - issuerNpcId, - issuerNpcName, - sceneName: scene.name, - override, - })); - } - - steps.push(buildSceneChapterPrimaryStep({ + const { scene, worldType, issuerNpcId, issuerNpcName, hasGuideNpc, override, - })); + } = params; + const steps: QuestStep[] = []; if (hasGuideNpc) { - steps.push(buildSceneTurningTalkStep({ + steps.push( + buildSceneOpeningTalkStep({ + issuerNpcId, + issuerNpcName, + sceneName: scene.name, + override, + }), + ); + } + + steps.push( + buildSceneChapterPrimaryStep({ + scene, + worldType, issuerNpcId, issuerNpcName, - sceneName: scene.name, + hasGuideNpc, override, - })); + }), + ); + + if (hasGuideNpc) { + steps.push( + buildSceneTurningTalkStep({ + issuerNpcId, + issuerNpcName, + sceneName: scene.name, + override, + }), + ); } return steps; } -function resolveSceneChapterNarrativeType(scene: QuestSceneSnapshot, worldType: WorldType | null) { +function resolveSceneChapterNarrativeType( + scene: QuestSceneSnapshot, + worldType: WorldType | null, +) { const threat = getScenePrimaryThreat(scene, worldType); if (threat?.kind === 'defeat_hostile_npc') { return 'bounty' as const; @@ -812,7 +1031,10 @@ function resolveSceneChapterNarrativeType(scene: QuestSceneSnapshot, worldType: return 'relationship' as const; } -function resolveSceneChapterRewardTheme(scene: QuestSceneSnapshot, worldType: WorldType | null) { +function resolveSceneChapterRewardTheme( + scene: QuestSceneSnapshot, + worldType: WorldType | null, +) { const threat = getScenePrimaryThreat(scene, worldType); if ((scene.treasureHints?.length ?? 0) > 0) { return 'intel' as const; @@ -823,7 +1045,10 @@ function resolveSceneChapterRewardTheme(scene: QuestSceneSnapshot, worldType: Wo return 'relationship' as const; } -function deriveObjectiveFromStep(step: QuestStep | null, issuerNpcId: string): QuestObjective { +function deriveObjectiveFromStep( + step: QuestStep | null, + issuerNpcId: string, +): QuestObjective { if (!step) { return { kind: 'talk_to_npc', @@ -844,9 +1069,10 @@ function deriveObjectiveFromStep(step: QuestStep | null, issuerNpcId: string): Q function createLegacyStepFromQuest(quest: QuestLogEntry): QuestStep { const requiredCount = normalizeCount(quest.objective.requiredCount); - const progress = isRewardReadyStatus(quest.status) || quest.status === 'turned_in' - ? requiredCount - : clampProgress(quest.progress, requiredCount); + const progress = + isRewardReadyStatus(quest.status) || quest.status === 'turned_in' + ? requiredCount + : clampProgress(quest.progress, requiredCount); return { id: 'step_legacy_primary', @@ -866,21 +1092,33 @@ function createLegacyStepFromQuest(quest: QuestLogEntry): QuestStep { export function getQuestActiveStep( quest: Pick, ) { - if (!quest.steps?.length || isTerminalStatus(quest.status) || isRewardReadyStatus(quest.status)) { + if ( + !quest.steps?.length || + isTerminalStatus(quest.status) || + isRewardReadyStatus(quest.status) + ) { return null; } const explicitStep = quest.activeStepId - ? quest.steps.find(step => step.id === quest.activeStepId && step.progress < step.requiredCount) ?? null + ? (quest.steps.find( + (step) => + step.id === quest.activeStepId && step.progress < step.requiredCount, + ) ?? null) : null; if (explicitStep) { return explicitStep; } - return quest.steps.find(step => step.progress < step.requiredCount) ?? null; + return quest.steps.find((step) => step.progress < step.requiredCount) ?? null; } -function buildQuestStageSummary(quest: Pick) { +function buildQuestStageSummary( + quest: Pick< + QuestLogEntry, + 'issuerNpcName' | 'steps' | 'activeStepId' | 'status' | 'title' + >, +) { const activeStep = getQuestActiveStep(quest); if (activeStep) { return activeStep.title; @@ -895,7 +1133,17 @@ function buildQuestStageSummary(quest: Pick { + const reward = { + affinityBonus: Math.round(quest.reward?.affinityBonus ?? 0), + currency: Math.max(0, Math.round(quest.reward?.currency ?? 0)), + experience: Math.max(0, Math.round(quest.reward?.experience ?? 0)), + items: quest.reward?.items ?? [], + storyHint: quest.reward?.storyHint, + intel: quest.reward?.intel, + } satisfies QuestReward; + const steps = ( + quest.steps?.length ? quest.steps : [createLegacyStepFromQuest(quest)] + ).map((step) => { const requiredCount = normalizeCount(step.requiredCount); return { ...step, @@ -907,7 +1155,8 @@ export function normalizeQuestLogEntry(quest: QuestLogEntry): QuestLogEntry { } satisfies QuestStep; }); - const incompleteStep = steps.find(step => step.progress < step.requiredCount) ?? null; + const incompleteStep = + steps.find((step) => step.progress < step.requiredCount) ?? null; const activeStepId = incompleteStep?.id ?? null; let status = quest.status ?? 'active'; @@ -928,6 +1177,7 @@ export function normalizeQuestLogEntry(quest: QuestLogEntry): QuestLogEntry { const normalizedQuest: QuestLogEntry = { ...quest, chapterId: quest.chapterId ?? null, + reward, objective, progress, status, @@ -948,7 +1198,7 @@ export function normalizeQuestLogEntry(quest: QuestLogEntry): QuestLogEntry { } export function normalizeQuestLogEntries(quests: QuestLogEntry[]) { - return quests.map(quest => normalizeQuestLogEntry(quest)); + return quests.map((quest) => normalizeQuestLogEntry(quest)); } function withNormalizedQuest(quest: QuestLogEntry) { @@ -958,22 +1208,30 @@ function withNormalizedQuest(quest: QuestLogEntry) { function stepMatchesSignal(step: QuestStep, signal: QuestProgressSignal) { switch (signal.kind) { case 'hostile_npc_defeated': - return step.kind === 'defeat_hostile_npc' - && (!step.targetSceneId || step.targetSceneId === signal.sceneId) - && step.targetHostileNpcId === signal.hostileNpcId; + return ( + step.kind === 'defeat_hostile_npc' && + (!step.targetSceneId || step.targetSceneId === signal.sceneId) && + step.targetHostileNpcId === signal.hostileNpcId + ); case 'treasure_inspected': - return step.kind === 'inspect_treasure' - && (!step.targetSceneId || step.targetSceneId === signal.sceneId); + return ( + step.kind === 'inspect_treasure' && + (!step.targetSceneId || step.targetSceneId === signal.sceneId) + ); case 'npc_spar_completed': return step.kind === 'spar_with_npc' && step.targetNpcId === signal.npcId; case 'npc_talk_completed': return step.kind === 'talk_to_npc' && step.targetNpcId === signal.npcId; case 'scene_reached': - return step.kind === 'reach_scene' && step.targetSceneId === signal.sceneId; + return ( + step.kind === 'reach_scene' && step.targetSceneId === signal.sceneId + ); case 'item_delivered': - return step.kind === 'deliver_item' - && step.targetNpcId === signal.npcId - && step.targetItemId === signal.itemId; + return ( + step.kind === 'deliver_item' && + step.targetNpcId === signal.npcId && + step.targetItemId === signal.itemId + ); default: return false; } @@ -983,9 +1241,15 @@ function getSignalProgressIncrement(signal: QuestProgressSignal) { return signal.kind === 'item_delivered' ? Math.max(1, signal.quantity) : 1; } -function applyQuestProgressSignalToQuest(quest: QuestLogEntry, signal: QuestProgressSignal) { +function applyQuestProgressSignalToQuest( + quest: QuestLogEntry, + signal: QuestProgressSignal, +) { const normalizedQuest = withNormalizedQuest(quest); - if (isTerminalStatus(normalizedQuest.status) || isRewardReadyStatus(normalizedQuest.status)) { + if ( + isTerminalStatus(normalizedQuest.status) || + isRewardReadyStatus(normalizedQuest.status) + ) { return normalizedQuest; } @@ -995,7 +1259,7 @@ function applyQuestProgressSignalToQuest(quest: QuestLogEntry, signal: QuestProg } const increment = getSignalProgressIncrement(signal); - const nextSteps = normalizedQuest.steps!.map(step => { + const nextSteps = normalizedQuest.steps!.map((step) => { if (step.id !== activeStep.id) { return step; } @@ -1012,40 +1276,63 @@ function applyQuestProgressSignalToQuest(quest: QuestLogEntry, signal: QuestProg }); } -export function applyQuestProgressSignal(quests: QuestLogEntry[], signal: QuestProgressSignal) { - return quests.map(quest => applyQuestProgressSignalToQuest(quest, signal)); +export function applyQuestProgressSignal( + quests: QuestLogEntry[], + signal: QuestProgressSignal, +) { + return quests.map((quest) => applyQuestProgressSignalToQuest(quest, signal)); } -function resolveQuestIdTargetKey(primaryStep: QuestStep, scene: QuestSceneSnapshot | null) { - return primaryStep.targetHostileNpcId - ?? primaryStep.targetNpcId - ?? primaryStep.targetSceneId - ?? scene?.id - ?? primaryStep.id; +function resolveQuestIdTargetKey( + primaryStep: QuestStep, + scene: QuestSceneSnapshot | null, +) { + return ( + primaryStep.targetHostileNpcId ?? + primaryStep.targetNpcId ?? + primaryStep.targetSceneId ?? + scene?.id ?? + primaryStep.id + ); } export function findQuestById(quests: QuestLogEntry[], questId: string) { - return quests.find(quest => quest.id === questId) ?? null; + return quests.find((quest) => quest.id === questId) ?? null; } -export function getQuestForIssuer(quests: QuestLogEntry[], issuerNpcId: string) { - return quests.find(quest => quest.issuerNpcId === issuerNpcId && quest.status !== 'turned_in') ?? null; +export function getQuestForIssuer( + quests: QuestLogEntry[], + issuerNpcId: string, +) { + return ( + quests.find( + (quest) => + quest.issuerNpcId === issuerNpcId && quest.status !== 'turned_in', + ) ?? null + ); } -export function getChapterQuestForScene(quests: QuestLogEntry[], sceneId: string | null | undefined) { +export function getChapterQuestForScene( + quests: QuestLogEntry[], + sceneId: string | null | undefined, +) { if (!sceneId) { return null; } const chapterId = buildSceneChapterId(sceneId); - return quests.find((quest) => - quest.chapterId === chapterId - && !isTerminalStatus(quest.status), - ) ?? null; + return ( + quests.find( + (quest) => + quest.chapterId === chapterId && !isTerminalStatus(quest.status), + ) ?? null + ); } -export function evaluateQuestOpportunity(params: QuestPreviewRequest): QuestOpportunity { - const {issuerNpcId, scene, currentQuests = []} = params; +export function evaluateQuestOpportunity( + params: QuestPreviewRequest, +): QuestOpportunity { + const { issuerNpcId, scene, currentQuests = [] } = params; if (!scene) { return { shouldOffer: false, @@ -1053,7 +1340,12 @@ export function evaluateQuestOpportunity(params: QuestPreviewRequest): QuestOppo }; } - if (currentQuests.some(quest => quest.issuerNpcId === issuerNpcId && quest.status !== 'turned_in')) { + if ( + currentQuests.some( + (quest) => + quest.issuerNpcId === issuerNpcId && quest.status !== 'turned_in', + ) + ) { return { shouldOffer: false, reason: '这名角色还有尚未结清的委托。', @@ -1061,7 +1353,9 @@ export function evaluateQuestOpportunity(params: QuestPreviewRequest): QuestOppo }; } - const liveQuestCount = currentQuests.filter(quest => !isTerminalStatus(quest.status)).length; + const liveQuestCount = currentQuests.filter( + (quest) => !isTerminalStatus(quest.status), + ).length; if (liveQuestCount >= 4) { return { shouldOffer: false, @@ -1081,18 +1375,21 @@ export function evaluateQuestOpportunity(params: QuestPreviewRequest): QuestOppo return { shouldOffer: true, - reason: threat.kind === 'inspect_treasure' - ? `${scene.name} 附近出现了值得调查的异常。` - : threat.kind === 'spar_with_npc' - ? `${params.issuerNpcName} 更适合给出一份关系驱动的试炼型委托。` - : `${scene.name} 附近存在可以被明确指向的敌对角色威胁。`, + reason: + threat.kind === 'inspect_treasure' + ? `${scene.name} 附近出现了值得调查的异常。` + : threat.kind === 'spar_with_npc' + ? `${params.issuerNpcName} 更适合给出一份关系驱动的试炼型委托。` + : `${scene.name} 附近存在可以被明确指向的敌对角色威胁。`, suggestedIssuerNpcId: issuerNpcId, suggestedThreatType: threat.suggestedThreatType, }; } -export function buildFallbackQuestIntent(params: QuestCompilationRequest): QuestIntent { - const {issuerNpcName, scene} = params; +export function buildFallbackQuestIntent( + params: QuestCompilationRequest, +): QuestIntent { + const { issuerNpcName, scene } = params; const threat = getScenePrimaryThreat(scene, params.worldType); if (threat?.kind === 'defeat_hostile_npc') { @@ -1174,7 +1471,10 @@ export function compileQuestIntentToQuest( return null; } - const steps = [primaryStep, buildTalkBackStep(params.issuerNpcId, params.issuerNpcName)]; + const steps = [ + primaryStep, + buildTalkBackStep(params.issuerNpcId, params.issuerNpcName), + ]; const reward = buildQuestReward({ issuerNpcId: params.issuerNpcId, issuerNpcName: params.issuerNpcName, @@ -1182,12 +1482,18 @@ export function compileQuestIntentToQuest( roleText: params.roleText, rewardTheme: intent.rewardTheme, narrativeType: intent.narrativeType, + urgency: intent.urgency, + stepCount: steps.length, scene: params.scene, context: params.context, }); const rewardText = buildRewardText(reward, params.worldType); const contract: QuestContract = { - id: buildQuestId(params.issuerNpcId, primaryStep.kind, resolveQuestIdTargetKey(primaryStep, params.scene)), + id: buildQuestId( + params.issuerNpcId, + primaryStep.kind, + resolveQuestIdTargetKey(primaryStep, params.scene), + ), issuerNpcId: params.issuerNpcId, issuerNpcName: params.issuerNpcName, sceneId: params.scene?.id ?? null, @@ -1226,7 +1532,10 @@ export function compileQuestIntentToQuest( title: contract.title, description: contract.description, summary: contract.summary, - objective: deriveObjectiveFromStep(contract.steps[0] ?? null, contract.issuerNpcId), + objective: deriveObjectiveFromStep( + contract.steps[0] ?? null, + contract.issuerNpcId, + ), progress: 0, status: 'active', completionNotified: false, @@ -1243,7 +1552,9 @@ export function compileQuestIntentToQuest( }); } -export function buildQuestForEncounter(params: QuestPreviewRequest): QuestLogEntry | null { +export function buildQuestForEncounter( + params: QuestPreviewRequest, +): QuestLogEntry | null { const opportunity = evaluateQuestOpportunity(params); if (!opportunity.shouldOffer) { return null; @@ -1263,17 +1574,13 @@ export function buildChapterQuestForScene(params: { worldType: WorldType | null; context?: QuestGenerationContext; }) { - const {scene, worldType, context} = params; + const { scene, worldType, context } = params; if (!scene) { return null; } - const { - issuerNpcId, - issuerNpcName, - roleText, - hasGuideNpc, - } = resolveSceneChapterIssuer(scene); + const { issuerNpcId, issuerNpcName, roleText, hasGuideNpc } = + resolveSceneChapterIssuer(scene); const override = SCENE_CHAPTER_OVERRIDES[scene.id] ?? null; const steps = buildSceneChapterSteps({ scene, @@ -1289,6 +1596,7 @@ export function buildChapterQuestForScene(params: { const narrativeType = resolveSceneChapterNarrativeType(scene, worldType); const rewardTheme = resolveSceneChapterRewardTheme(scene, worldType); + const threat = getScenePrimaryThreat(scene, worldType); const reward = buildQuestReward({ issuerNpcId, issuerNpcName, @@ -1296,6 +1604,8 @@ export function buildChapterQuestForScene(params: { roleText, rewardTheme, narrativeType, + urgency: threat?.kind === 'defeat_hostile_npc' ? 'high' : 'medium', + stepCount: steps.length, scene, context, }); @@ -1306,7 +1616,6 @@ export function buildChapterQuestForScene(params: { scene, }); const chapterId = buildSceneChapterId(scene.id); - const threat = getScenePrimaryThreat(scene, worldType); const title = normalizeQuestTitle( override?.title ?? `${compactQuestLabel(scene.name, 6)}异动`, `查明${compactQuestLabel(scene.name, 6)}`, @@ -1322,11 +1631,11 @@ export function buildChapterQuestForScene(params: { threadId: threadContract?.threadId ?? null, contractId: threadContract?.id ?? null, title, - description: override?.description ?? ( - hasGuideNpc + description: + override?.description ?? + (hasGuideNpc ? `${issuerNpcName} 认为 ${scene.name} 这一带的异动并不简单,希望你把眼前的线索与压力真正查清。` - : `${scene.name} 当前的局势还没有收束,你需要把这一章的线索和压力真正接住。` - ), + : `${scene.name} 当前的局势还没有收束,你需要把这一章的线索和压力真正接住。`), summary: override?.summary ?? `在 ${scene.name} 接住这一章的线索并完成收束`, objective: deriveObjectiveFromStep(steps[0] ?? null, issuerNpcId), progress: 0, @@ -1344,9 +1653,10 @@ export function buildChapterQuestForScene(params: { ? `查清 ${scene.name} 的异动到底是谁、哪件旧事或哪层残痕在推动。` : `把 ${scene.name} 当前未收束的压力和线索梳理清楚。`, playerHook: `你已经进入 ${scene.name},这一章现在就落在你面前。`, - worldReason: threat?.kind === 'defeat_hostile_npc' - ? `${scene.name} 的敌对压力已经摆到了台前,不先处理就很难继续推进。` - : `${scene.name} 的线索和残痕已经堆到足以独立成章的程度。`, + worldReason: + threat?.kind === 'defeat_hostile_npc' + ? `${scene.name} 的敌对压力已经摆到了台前,不先处理就很难继续推进。` + : `${scene.name} 的线索和残痕已经堆到足以独立成章的程度。`, followupHooks: [ `${scene.name} 的这一章收束后,下一段 lead 会开始变得更明确。`, ], @@ -1379,29 +1689,39 @@ export function buildQuestAcceptResultText(quest: QuestLogEntry) { } export function buildQuestTurnInResultText(quest: QuestLogEntry) { - const itemText = quest.reward.items.map(item => item.name).join('、'); + const itemText = + quest.reward.items.map((item) => item.name).join('、') || '补给'; + const experienceText = + (quest.reward.experience ?? 0) > 0 + ? `、${quest.reward.experience} 经验` + : ''; const intelText = quest.reward.intel?.rumorText ? `,并额外告诉了你一条消息:${quest.reward.intel.rumorText}` : ''; - const storyHintText = quest.reward.storyHint ? ` ${quest.reward.storyHint}` : ''; - return `${quest.issuerNpcName} 确认你已经完成委托,交给了你 ${quest.reward.currency} 赏金和 ${itemText}${intelText}。${storyHintText}`; + const storyHintText = quest.reward.storyHint + ? ` ${quest.reward.storyHint}` + : ''; + return `${quest.issuerNpcName} 确认你已经完成委托,交给了你 ${quest.reward.currency} 赏金${experienceText}和${itemText}${intelText}。${storyHintText}`; } export function acceptQuest(quests: QuestLogEntry[], quest: QuestLogEntry) { if (findQuestById(quests, quest.id)) { - return quests.map(item => withNormalizedQuest(item)); + return quests.map((item) => withNormalizedQuest(item)); } - return [...quests.map(item => withNormalizedQuest(item)), withNormalizedQuest(quest)]; + return [ + ...quests.map((item) => withNormalizedQuest(item)), + withNormalizedQuest(quest), + ]; } export function markQuestTurnedIn(quests: QuestLogEntry[], questId: string) { - return quests.map(quest => + return quests.map((quest) => quest.id === questId ? withNormalizedQuest({ ...quest, status: 'turned_in', completionNotified: true, - steps: quest.steps?.map(step => ({ + steps: quest.steps?.map((step) => ({ ...step, progress: step.requiredCount, })), @@ -1410,8 +1730,11 @@ export function markQuestTurnedIn(quests: QuestLogEntry[], questId: string) { ); } -export function markQuestCompletionNotified(quests: QuestLogEntry[], questId: string) { - return quests.map(quest => +export function markQuestCompletionNotified( + quests: QuestLogEntry[], + questId: string, +) { + return quests.map((quest) => quest.id === questId ? withNormalizedQuest({ ...quest, @@ -1427,12 +1750,13 @@ export function applyQuestProgressFromHostileNpcDefeat( defeatedHostileNpcIds: string[], ) { return defeatedHostileNpcIds.reduce( - (currentQuests, hostileNpcId) => applyQuestProgressSignal(currentQuests, { - kind: 'hostile_npc_defeated', - sceneId, - hostileNpcId, - }), - quests.map(quest => withNormalizedQuest(quest)), + (currentQuests, hostileNpcId) => + applyQuestProgressSignal(currentQuests, { + kind: 'hostile_npc_defeated', + sceneId, + hostileNpcId, + }), + quests.map((quest) => withNormalizedQuest(quest)), ); } @@ -1441,7 +1765,7 @@ export function applyQuestProgressFromTreasure( sceneId: string | null, ) { if (!sceneId) { - return quests.map(quest => withNormalizedQuest(quest)); + return quests.map((quest) => withNormalizedQuest(quest)); } return applyQuestProgressSignal(quests, { @@ -1455,7 +1779,7 @@ export function applyQuestProgressFromSpar( npcId: string | null, ) { if (!npcId) { - return quests.map(quest => withNormalizedQuest(quest)); + return quests.map((quest) => withNormalizedQuest(quest)); } return applyQuestProgressSignal(quests, { @@ -1469,7 +1793,7 @@ export function applyQuestProgressFromNpcTalk( npcId: string | null, ) { if (!npcId) { - return quests.map(quest => withNormalizedQuest(quest)); + return quests.map((quest) => withNormalizedQuest(quest)); } return applyQuestProgressSignal(quests, { @@ -1483,7 +1807,7 @@ export function applyQuestProgressFromSceneReached( sceneId: string | null, ) { if (!sceneId) { - return quests.map(quest => withNormalizedQuest(quest)); + return quests.map((quest) => withNormalizedQuest(quest)); } return applyQuestProgressSignal(quests, { @@ -1496,7 +1820,9 @@ export function isQuestReadyToClaim(quest: QuestLogEntry) { return isRewardReadyStatus(withNormalizedQuest(quest).status); } -export function buildQuestGenerationSummary(customWorldProfile: CustomWorldProfile | null | undefined) { +export function buildQuestGenerationSummary( + customWorldProfile: CustomWorldProfile | null | undefined, +) { if (!customWorldProfile) { return null; } diff --git a/src/data/sceneEncounterPreviews.ts b/src/data/sceneEncounterPreviews.ts index 22fab374..d46171a2 100644 --- a/src/data/sceneEncounterPreviews.ts +++ b/src/data/sceneEncounterPreviews.ts @@ -15,6 +15,10 @@ import { getSceneHostileNpcs, getWorldCampScenePreset, } from './scenePresets'; +import { + canUseLimitedPrimaryNpcChat, + resolveActiveSceneActEncounterNpcIds, +} from '../services/customWorldSceneActRuntime'; export const EXPLORE_APPROACH_DURATION_MS = 4000; export const PREVIEW_ENTITY_X_METERS = 12; @@ -33,6 +37,18 @@ function getResolvedNpcState(state: GameState, encounter: Encounter) { function shouldAutoStartBattleForEncounter(state: GameState, encounter: Encounter) { if (encounter.kind !== 'npc') return false; const npcState = getResolvedNpcState(state, encounter); + const npcId = getNpcEncounterKey(encounter); + if ( + canUseLimitedPrimaryNpcChat({ + profile: state.customWorldProfile, + sceneId: state.currentScenePreset?.id ?? null, + storyEngineMemory: state.storyEngineMemory, + npcId, + affinity: npcState.affinity, + }) + ) { + return false; + } return npcState.affinity < 0 || (npcState.relationState?.affinity ?? npcState.affinity) < 0; } @@ -91,11 +107,23 @@ function getAvailableFriendlySceneNpcs(state: GameState) { && state.currentScenePreset?.id && getWorldCampScenePreset(state.worldType)?.id === state.currentScenePreset.id, ); + const activeActNpcIds = resolveActiveSceneActEncounterNpcIds({ + profile: state.customWorldProfile, + sceneId: state.currentScenePreset?.id ?? null, + storyEngineMemory: state.storyEngineMemory, + }); + const activeActNpcIdSet = new Set(activeActNpcIds); return getSceneFriendlyNpcs(state.currentScenePreset) .filter(candidate => !isCampScene || Boolean(candidate.characterId)) .filter(candidate => candidate.characterId !== state.playerCharacter?.id) - .filter(candidate => !recruitedNpcIds.has(candidate.id)); + .filter(candidate => !recruitedNpcIds.has(candidate.id)) + .filter(candidate => + activeActNpcIdSet.size === 0 + ? true + : activeActNpcIdSet.has(candidate.id) + || (candidate.characterId ? activeActNpcIdSet.has(candidate.characterId) : false), + ); } function getAvailableHostileSceneNpcs(state: GameState) { diff --git a/src/data/scenePresets.ts b/src/data/scenePresets.ts index 5e84badd..5e875db3 100644 --- a/src/data/scenePresets.ts +++ b/src/data/scenePresets.ts @@ -296,6 +296,7 @@ export function buildEncounterFromSceneNpc( imageSrc: npc.imageSrc, visual: npc.visual, narrativeProfile: npc.narrativeProfile, + levelProfile: npc.levelProfile, }; } diff --git a/src/hooks/story/progressionActions.ts b/src/hooks/story/progressionActions.ts index b591d25e..ee7bc756 100644 --- a/src/hooks/story/progressionActions.ts +++ b/src/hooks/story/progressionActions.ts @@ -1,7 +1,4 @@ -import type { - Dispatch, - SetStateAction, -} from 'react'; +import type { Dispatch, SetStateAction } from 'react'; import { acceptQuest, @@ -42,9 +39,7 @@ import { buildCompanionReactionBatch, } from '../../services/storyEngine/companionReactionDirector'; import { resolveAllCompanionResolutions } from '../../services/storyEngine/companionResolutionDirector'; -import { - appendConsequenceRecord, -} from '../../services/storyEngine/consequenceLedger'; +import { appendConsequenceRecord } from '../../services/storyEngine/consequenceLedger'; import { buildContentDiffReport } from '../../services/storyEngine/contentDiffReport'; import { resolveEndingState } from '../../services/storyEngine/endingResolver'; import { buildEpilogueSummary } from '../../services/storyEngine/epilogueComposer'; @@ -97,8 +92,9 @@ const ENCOUNTER_ENTRY_DURATION_MS = 1800; const ENCOUNTER_ENTRY_TICK_MS = 180; function dedupeStrings(values: Array, limit = 10) { - return [...new Set(values.map((value) => value?.trim() ?? '').filter(Boolean))] - .slice(0, limit); + return [ + ...new Set(values.map((value) => value?.trim() ?? '').filter(Boolean)), + ].slice(0, limit); } function hydrateStoryEngineMemory(state: GameState): GameState { @@ -112,11 +108,15 @@ function hydrateStoryEngineMemory(state: GameState): GameState { } const role = - state.customWorldProfile.storyNpcs.find((npc) => - npc.id === state.currentEncounter?.id || npc.name === state.currentEncounter?.npcName, - ) - ?? state.customWorldProfile.playableNpcs.find((npc) => - npc.id === state.currentEncounter?.id || npc.name === state.currentEncounter?.npcName, + state.customWorldProfile.storyNpcs.find( + (npc) => + npc.id === state.currentEncounter?.id || + npc.name === state.currentEncounter?.npcName, + ) ?? + state.customWorldProfile.playableNpcs.find( + (npc) => + npc.id === state.currentEncounter?.id || + npc.name === state.currentEncounter?.npcName, ); if (!role) { return { @@ -126,17 +126,19 @@ function hydrateStoryEngineMemory(state: GameState): GameState { } const themePack = - state.customWorldProfile.themePack - ?? buildThemePackFromWorldProfile(state.customWorldProfile); + state.customWorldProfile.themePack ?? + buildThemePackFromWorldProfile(state.customWorldProfile); const storyGraph = - state.customWorldProfile.storyGraph - ?? buildFallbackWorldStoryGraph(state.customWorldProfile, themePack); + state.customWorldProfile.storyGraph ?? + buildFallbackWorldStoryGraph(state.customWorldProfile, themePack); const narrativeProfile = normalizeActorNarrativeProfile( role.narrativeProfile, buildFallbackActorNarrativeProfile(role, storyGraph, themePack), ); const npcState = - state.npcStates[state.currentEncounter.id ?? state.currentEncounter.npcName]; + state.npcStates[ + state.currentEncounter.id ?? state.currentEncounter.npcName + ]; const activeThreadIds = storyEngineMemory.activeThreadIds.length > 0 ? storyEngineMemory.activeThreadIds @@ -164,20 +166,25 @@ function hydrateStoryEngineMemory(state: GameState): GameState { ...state, storyEngineMemory: { ...storyEngineMemory, - discoveredFactIds: dedupeStrings([ - ...storyEngineMemory.discoveredFactIds, - ...visibilitySlice.sayableFactIds, - ], 16), - activeThreadIds: dedupeStrings([ - ...storyEngineMemory.activeThreadIds, - ...activeThreadIds, - ], 6), + discoveredFactIds: dedupeStrings( + [ + ...storyEngineMemory.discoveredFactIds, + ...visibilitySlice.sayableFactIds, + ], + 16, + ), + activeThreadIds: dedupeStrings( + [...storyEngineMemory.activeThreadIds, ...activeThreadIds], + 6, + ), }, }; } function findNewInventoryItems(previousState: GameState, nextState: GameState) { - const previousIds = new Set(previousState.playerInventory.map((item) => item.id)); + const previousIds = new Set( + previousState.playerInventory.map((item) => item.id), + ); return nextState.playerInventory.filter((item) => !previousIds.has(item.id)); } @@ -189,9 +196,9 @@ function ensureSceneChapterQuestState(params: { params.nextState.storyEngineMemory ?? createEmptyStoryEngineMemoryState(); const scene = params.nextState.currentScenePreset; if ( - params.nextState.currentScene !== 'Story' - || !params.nextState.worldType - || !scene?.id + params.nextState.currentScene !== 'Story' || + !params.nextState.worldType || + !scene?.id ) { return { ...params.nextState, @@ -199,9 +206,10 @@ function ensureSceneChapterQuestState(params: { }; } - const openedSceneChapterIds = dedupeStrings([ - ...(storyEngineMemory.openedSceneChapterIds ?? []), - ], 64); + const openedSceneChapterIds = dedupeStrings( + [...(storyEngineMemory.openedSceneChapterIds ?? [])], + 64, + ); if (openedSceneChapterIds.includes(scene.id)) { return { ...params.nextState, @@ -216,7 +224,10 @@ function ensureSceneChapterQuestState(params: { ...storyEngineMemory, openedSceneChapterIds: [...openedSceneChapterIds, scene.id], }; - const existingChapterQuest = getChapterQuestForScene(params.nextState.quests, scene.id); + const existingChapterQuest = getChapterQuestForScene( + params.nextState.quests, + scene.id, + ); if (existingChapterQuest) { return { ...params.nextState, @@ -227,6 +238,13 @@ function ensureSceneChapterQuestState(params: { const chapterQuest = buildChapterQuestForScene({ scene, worldType: params.nextState.worldType, + context: { + worldType: params.nextState.worldType, + actState: params.nextState.storyEngineMemory?.actState ?? null, + recentStoryMoments: params.nextState.storyHistory.slice(-6), + playerCharacter: params.nextState.playerCharacter, + playerProgression: params.nextState.playerProgression ?? null, + }, }); if (!chapterQuest) { return { @@ -250,8 +268,8 @@ function applyStoryEngineEchoes(params: { }) { const hydratedState = hydrateStoryEngineMemory(params.nextState); const contracts = hydratedState.customWorldProfile - ? hydratedState.customWorldProfile.threadContracts - ?? buildThreadContractsFromProfile(hydratedState.customWorldProfile) + ? (hydratedState.customWorldProfile.threadContracts ?? + buildThreadContractsFromProfile(hydratedState.customWorldProfile)) : []; const newItems = findNewInventoryItems(params.previousState, hydratedState); const signals = collectStorySignals({ @@ -279,12 +297,13 @@ function applyStoryEngineEchoes(params: { state: stateWithSceneChapter, reactions, }); - const storyEngineMemory = stateWithReactions.storyEngineMemory ?? createEmptyStoryEngineMemoryState(); + const storyEngineMemory = + stateWithReactions.storyEngineMemory ?? createEmptyStoryEngineMemoryState(); const chapterState = advanceChapterState({ previousChapter: - stateWithReactions.chapterState - ?? storyEngineMemory.currentChapter - ?? null, + stateWithReactions.chapterState ?? + storyEngineMemory.currentChapter ?? + null, nextChapter: resolveCurrentChapterState({ state: stateWithReactions, }), @@ -358,7 +377,10 @@ function applyStoryEngineEchoes(params: { chapterState, }); const campaignState = advanceCampaignState({ - previous: storyEngineMemory.campaignState ?? stateWithMutations.campaignState ?? null, + previous: + storyEngineMemory.campaignState ?? + stateWithMutations.campaignState ?? + null, next: resolveCampaignState({ state: stateWithMutations, actState, @@ -380,9 +402,9 @@ function applyStoryEngineEchoes(params: { }) : null; const activeScenarioPack = - resolveScenarioPack(stateWithMutations.activeScenarioPackId) - ?? compiledPacks?.scenarioPack - ?? null; + resolveScenarioPack(stateWithMutations.activeScenarioPackId) ?? + compiledPacks?.scenarioPack ?? + null; const activeCampaignPack = compiledPacks?.campaignPack ?? null; const playerStyleProfile = updatePlayerStyleProfileFromAction({ current: storyEngineMemory.playerStyleProfile, @@ -401,15 +423,15 @@ function applyStoryEngineEchoes(params: { companionResolutions, factionTensionStates, }) - : storyEngineMemory.endingState ?? null; - const epilogueSummary = - endingState - ? buildEpilogueSummary({ - endingState, - companionResolutions, - }) - : null; - const currentJourneyBeatId = journeyBeat?.id ?? storyEngineMemory.currentJourneyBeatId ?? null; + : (storyEngineMemory.endingState ?? null); + const epilogueSummary = endingState + ? buildEpilogueSummary({ + endingState, + companionResolutions, + }) + : null; + const currentJourneyBeatId = + journeyBeat?.id ?? storyEngineMemory.currentJourneyBeatId ?? null; const branchBudgetStatus = evaluateBranchBudget({ consequenceLedger, authorialConstraintPack, @@ -455,20 +477,20 @@ function applyStoryEngineEchoes(params: { seeds: ['baseline', 'companion', 'explore'], }) : []; - const replaySummary = - simulationRunResults[0] - ? replayNarrativeRun({ - recordedSeed: recordReplaySeed({ - seed: simulationRunResults[0].seed, - label: `${activeCampaignPack?.title ?? 'campaign'} 基线回放`, - }), - result: simulationRunResults[0], - }).summary - : null; + const replaySummary = simulationRunResults[0] + ? replayNarrativeRun({ + recordedSeed: recordReplaySeed({ + seed: simulationRunResults[0].seed, + label: `${activeCampaignPack?.title ?? 'campaign'} 基线回放`, + }), + result: simulationRunResults[0], + }).summary + : null; const releaseGateReport = buildReleaseGateReport({ qaReport: narrativeQaReport, simulationResults: simulationRunResults, - unresolvedThreadCount: stateWithMutations.storyEngineMemory?.activeThreadIds.length ?? 0, + unresolvedThreadCount: + stateWithMutations.storyEngineMemory?.activeThreadIds.length ?? 0, }); const saveMigrationManifest = buildSaveMigrationManifest({ version: 'story-engine-v5', @@ -497,37 +519,41 @@ function applyStoryEngineEchoes(params: { simulationRunResults, }, }); - const continueDigest = buildContinueGameDigest({ - state: { - ...stateWithMutations, - chapterState, - campaignState, - storyEngineMemory: { - ...baseMemoryForQa, - currentJourneyBeatId, - narrativeQaReport, - releaseGateReport, - simulationRunResults, - narrativeCodex, - saveMigrationManifest, + const continueDigest = + buildContinueGameDigest({ + state: { + ...stateWithMutations, + chapterState, + campaignState, + storyEngineMemory: { + ...baseMemoryForQa, + currentJourneyBeatId, + narrativeQaReport, + releaseGateReport, + simulationRunResults, + narrativeCodex, + saveMigrationManifest, + }, }, - }, - }) + [ - epilogueSummary, - replaySummary, - telemetrySnapshot.summary, - contentDiffReport.summary, - `发布门禁:${releaseGateReport.status} / ${releaseGateReport.summary}`, - ] - .filter(Boolean) - .join('\n'); + }) + + [ + epilogueSummary, + replaySummary, + telemetrySnapshot.summary, + contentDiffReport.summary, + `发布门禁:${releaseGateReport.status} / ${releaseGateReport.summary}`, + ] + .filter(Boolean) + .join('\n'); return { ...stateWithMutations, chapterState, campaignState, - activeScenarioPackId: activeScenarioPack?.id ?? stateWithMutations.activeScenarioPackId ?? null, - activeCampaignPackId: activeCampaignPack?.id ?? stateWithMutations.activeCampaignPackId ?? null, + activeScenarioPackId: + activeScenarioPack?.id ?? stateWithMutations.activeScenarioPackId ?? null, + activeCampaignPackId: + activeCampaignPack?.id ?? stateWithMutations.activeCampaignPackId ?? null, storyEngineMemory: { ...baseMemoryForQa, currentJourneyBeatId, @@ -604,14 +630,14 @@ export function createStoryProgressionActions({ actionText, resultText, lastFunctionId, - ) => { - const nextHistory = appendStoryHistory(gameState, actionText, resultText); + ) => { + const nextHistory = appendStoryHistory(gameState, actionText, resultText); const stateWithHistory = applyStoryEngineEchoes({ previousState: gameState, nextState: { - ...nextState, - storyHistory: nextHistory, - } as GameState, + ...nextState, + storyHistory: nextHistory, + } as GameState, actionText, lastFunctionId, }); @@ -620,14 +646,14 @@ export function createStoryProgressionActions({ setAiError(null); setIsLoading(true); - try { - const nextStory = await generateStoryForState({ - state: stateWithHistory, + try { + const nextStory = await generateStoryForState({ + state: stateWithHistory, character, history: nextHistory, - choice: actionText, - lastFunctionId, - }); + choice: actionText, + lastFunctionId, + }); const recoveredState = applyStoryEngineEchoes({ previousState: gameState, nextState: applyStoryReasoningRecovery(stateWithHistory), @@ -639,72 +665,91 @@ export function createStoryProgressionActions({ } catch (error) { console.error('Failed to continue scripted story:', error); setAiError(error instanceof Error ? error.message : '未知智能生成错误'); - setCurrentStory(buildFallbackStoryForState(stateWithHistory, character, resultText)); + setCurrentStory( + buildFallbackStoryForState(stateWithHistory, character, resultText), + ); } finally { setIsLoading(false); } }; - const commitGeneratedStateWithEncounterEntry: CommitGeneratedStateWithEncounterEntry = async ( - entryState, - resolvedState, - character, - actionText, - resultText, - lastFunctionId, - ) => { - setGameState(entryState); - setAiError(null); - setIsLoading(true); - - if (hasEncounterEntity(resolvedState)) { - const runTicks = Math.max(1, Math.ceil(ENCOUNTER_ENTRY_DURATION_MS / ENCOUNTER_ENTRY_TICK_MS)); - const tickDurationMs = Math.max(1, Math.round(ENCOUNTER_ENTRY_DURATION_MS / runTicks)); - - for (let tick = 1; tick <= runTicks; tick += 1) { - const progress = tick / runTicks; - setGameState(interpolateEncounterTransitionState(entryState, resolvedState, progress)); - await new Promise(resolve => window.setTimeout(resolve, tickDurationMs)); - } - } - - const nextHistory = appendStoryHistory(gameState, actionText, resultText); - const stateWithHistory = applyStoryEngineEchoes({ - previousState: gameState, - nextState: { - ...resolvedState, - storyHistory: nextHistory, - } as GameState, + const commitGeneratedStateWithEncounterEntry: CommitGeneratedStateWithEncounterEntry = + async ( + entryState, + resolvedState, + character, actionText, + resultText, lastFunctionId, - }); + ) => { + setGameState(entryState); + setAiError(null); + setIsLoading(true); - setGameState(stateWithHistory); + if (hasEncounterEntity(resolvedState)) { + const runTicks = Math.max( + 1, + Math.ceil(ENCOUNTER_ENTRY_DURATION_MS / ENCOUNTER_ENTRY_TICK_MS), + ); + const tickDurationMs = Math.max( + 1, + Math.round(ENCOUNTER_ENTRY_DURATION_MS / runTicks), + ); - try { - const nextStory = await generateStoryForState({ - state: stateWithHistory, - character, - history: nextHistory, - choice: actionText, - lastFunctionId, - }); - const recoveredState = applyStoryEngineEchoes({ + for (let tick = 1; tick <= runTicks; tick += 1) { + const progress = tick / runTicks; + setGameState( + interpolateEncounterTransitionState( + entryState, + resolvedState, + progress, + ), + ); + await new Promise((resolve) => + window.setTimeout(resolve, tickDurationMs), + ); + } + } + + const nextHistory = appendStoryHistory(gameState, actionText, resultText); + const stateWithHistory = applyStoryEngineEchoes({ previousState: gameState, - nextState: applyStoryReasoningRecovery(stateWithHistory), + nextState: { + ...resolvedState, + storyHistory: nextHistory, + } as GameState, actionText, lastFunctionId, }); - setGameState(recoveredState); - setCurrentStory(nextStory); - } catch (error) { - console.error('Failed to continue encounter-entry story:', error); - setAiError(error instanceof Error ? error.message : '未知智能生成错误'); - setCurrentStory(buildFallbackStoryForState(stateWithHistory, character, resultText)); - } finally { - setIsLoading(false); - } - }; + + setGameState(stateWithHistory); + + try { + const nextStory = await generateStoryForState({ + state: stateWithHistory, + character, + history: nextHistory, + choice: actionText, + lastFunctionId, + }); + const recoveredState = applyStoryEngineEchoes({ + previousState: gameState, + nextState: applyStoryReasoningRecovery(stateWithHistory), + actionText, + lastFunctionId, + }); + setGameState(recoveredState); + setCurrentStory(nextStory); + } catch (error) { + console.error('Failed to continue encounter-entry story:', error); + setAiError(error instanceof Error ? error.message : '未知智能生成错误'); + setCurrentStory( + buildFallbackStoryForState(stateWithHistory, character, resultText), + ); + } finally { + setIsLoading(false); + } + }; return { commitGeneratedState, diff --git a/src/hooks/useGameFlow.ts b/src/hooks/useGameFlow.ts index b2471d32..a6097199 100644 --- a/src/hooks/useGameFlow.ts +++ b/src/hooks/useGameFlow.ts @@ -9,23 +9,43 @@ import { } from '../data/characterPresets'; import { setRuntimeCustomWorldProfile } from '../data/customWorldRuntime'; import { getInitialPlayerCurrency } from '../data/economy'; -import { applyEquipmentLoadoutToState, buildInitialEquipmentLoadout, createEmptyEquipmentLoadout } from '../data/equipmentEffects'; -import { buildInitialNpcState, buildInitialPlayerInventory } from '../data/npcInteractions'; +import { + applyEquipmentLoadoutToState, + buildInitialEquipmentLoadout, + createEmptyEquipmentLoadout, +} from '../data/equipmentEffects'; +import { + buildInitialNpcState, + buildInitialPlayerInventory, +} from '../data/npcInteractions'; +import { createInitialPlayerProgressionState } from '../data/playerProgression'; import { createInitialGameRuntimeStats } from '../data/runtimeStats'; -import { ensureSceneEncounterPreview,RESOLVED_ENTITY_X_METERS } from '../data/sceneEncounterPreviews'; -import { getScenePreset,getWorldCampScenePreset } from '../data/scenePresets'; +import { + ensureSceneEncounterPreview, + RESOLVED_ENTITY_X_METERS, +} from '../data/sceneEncounterPreviews'; +import { getScenePreset, getWorldCampScenePreset } from '../data/scenePresets'; import { createEmptyStoryEngineMemoryState } from '../services/storyEngine/visibilityEngine'; -import { AnimationState, Character, CustomWorldProfile, Encounter, EquipmentLoadout, GameState, InventoryItem, SceneNpc, WorldType } from '../types'; +import { + AnimationState, + Character, + CustomWorldProfile, + Encounter, + EquipmentLoadout, + GameState, + InventoryItem, + SceneNpc, + WorldType, +} from '../types'; import type { BottomTab } from '../types/navigation'; const PLAYER_BASE_MAX_HP = 180; -export type {BottomTab} from '../types/navigation'; +export type { BottomTab } from '../types/navigation'; -function mergeStarterInventoryItems( - explicitItems: T[], - fallbackItems: T[], -) { +function mergeStarterInventoryItems< + T extends { category: string; name: string }, +>(explicitItems: T[], fallbackItems: T[]) { const merged = new Map(); [...explicitItems, ...fallbackItems].forEach((item) => { @@ -117,13 +137,15 @@ function createInitialCampEncounter( ): Encounter | null { if (!worldType) return null; - const campScenePreset = getWorldCampScenePreset(worldType) ?? getScenePreset(worldType, 0); + const campScenePreset = + getWorldCampScenePreset(worldType) ?? getScenePreset(worldType, 0); const npcCandidates = (campScenePreset?.npcs ?? []) .filter((npc: SceneNpc) => Boolean(npc.characterId)) .filter((npc: SceneNpc) => npc.characterId !== playerCharacter.id); if (npcCandidates.length === 0) return null; - const npc = npcCandidates[Math.floor(Math.random() * npcCandidates.length)] ?? null; + const npc = + npcCandidates[Math.floor(Math.random() * npcCandidates.length)] ?? null; if (!npc) return null; return { @@ -145,6 +167,7 @@ function createInitialGameState(): GameState { customWorldProfile: null, playerCharacter: null, runtimeStats: createInitialGameRuntimeStats(), + playerProgression: createInitialPlayerProgressionState(), currentScene: 'Selection', storyHistory: [], storyEngineMemory: createEmptyStoryEngineMemoryState(), @@ -191,14 +214,18 @@ function createInitialGameState(): GameState { } export function useGameFlow() { - const [gameState, setGameState] = useState(() => createInitialGameState()); + const [gameState, setGameState] = useState(() => + createInitialGameState(), + ); const [bottomTab, setBottomTab] = useState('adventure'); const [isMapOpen, setIsMapOpen] = useState(false); useEffect(() => { setRuntimeCustomWorldProfile(gameState.customWorldProfile); setRuntimeCharacterOverrides( - gameState.customWorldProfile ? buildCustomWorldRuntimeCharacters(gameState.customWorldProfile) : null, + gameState.customWorldProfile + ? buildCustomWorldRuntimeCharacters(gameState.customWorldProfile) + : null, ); }, [gameState.customWorldProfile]); @@ -216,7 +243,7 @@ export function useGameFlow() { ); const initialScenePreset = getScenePreset(resolvedWorldType, 0) ?? null; setIsMapOpen(false); - setGameState(prev => + setGameState((prev) => ensureSceneEncounterPreview({ ...prev, worldType: resolvedWorldType, @@ -225,6 +252,7 @@ export function useGameFlow() { sceneHostileNpcs: [], currentEncounter: null, npcInteractionActive: false, + playerProgression: createInitialPlayerProgressionState(), storyEngineMemory: createEmptyStoryEngineMemoryState(), chapterState: null, campaignState: null, @@ -257,110 +285,114 @@ export function useGameFlow() { setBottomTab('adventure'); setIsMapOpen(false); - setGameState(prev => - { - const resolvedWorldType = prev.worldType; - const resolvedCustomWorldProfile = prev.customWorldProfile; - const initialScenePreset = resolvedWorldType - ? getWorldCampScenePreset(resolvedWorldType) ?? getScenePreset(resolvedWorldType, 0) - : null; - const initialEncounter = createInitialCampEncounter( - resolvedWorldType, - character, - ); - const initialNpcState = initialEncounter - ? buildInitialNpcState(initialEncounter, resolvedWorldType, prev) - : null; - const initialEquipment = buildInitialEquipmentLoadout( - character, - resolvedCustomWorldProfile, - ); - const explicitStarterItems = - resolvedWorldType === WorldType.CUSTOM - ? buildExplicitCustomWorldRoleStarterState( - resolvedCustomWorldProfile!, - character, - ) - : null; - const mergedStarterEquipment = { - weapon: - explicitStarterItems?.equipment.weapon ?? initialEquipment.weapon, - armor: - explicitStarterItems?.equipment.armor ?? initialEquipment.armor, - relic: - explicitStarterItems?.equipment.relic ?? initialEquipment.relic, - }; - const playerMaxHp = getCharacterMaxHp( - character, - resolvedWorldType, - resolvedCustomWorldProfile, - ); - - return ensureSceneEncounterPreview( - applyEquipmentLoadoutToState({ - ...prev, - playerCharacter: character, - runtimeStats: createInitialGameRuntimeStats({ isActiveRun: true }), - currentScene: 'Story', - storyHistory: [], - storyEngineMemory: createEmptyStoryEngineMemoryState(), - chapterState: null, - campaignState: null, - activeScenarioPackId: gameState.customWorldProfile?.scenarioPackId ?? null, - activeCampaignPackId: gameState.customWorldProfile?.campaignPackId ?? null, - characterChats: {}, - currentEncounter: initialEncounter, - npcInteractionActive: false, - currentScenePreset: initialScenePreset, - lastObserveSignsSceneId: null, - lastObserveSignsReport: null, - animationState: AnimationState.IDLE, - sceneHostileNpcs: [], - playerX: 0, - playerOffsetY: 0, - playerFacing: 'right', - playerActionMode: 'idle', - scrollWorld: false, - inBattle: false, - playerHp: playerMaxHp, - playerMaxHp: playerMaxHp, - playerMana: getCharacterMaxMana(character), - playerMaxMana: getCharacterMaxMana(character), - playerSkillCooldowns: createCharacterSkillCooldowns(character), - activeBuildBuffs: [], - activeCombatEffects: [], - playerCurrency: getInitialPlayerCurrency( - resolvedWorldType, - resolvedCustomWorldProfile, - ), - playerInventory: mergeStarterInventoryItems( - explicitStarterItems?.inventory ?? [], - buildInitialPlayerInventory( + setGameState((prev) => { + const resolvedWorldType = prev.worldType; + const resolvedCustomWorldProfile = prev.customWorldProfile; + const initialScenePreset = resolvedWorldType + ? (getWorldCampScenePreset(resolvedWorldType) ?? + getScenePreset(resolvedWorldType, 0)) + : null; + const initialEncounter = createInitialCampEncounter( + resolvedWorldType, + character, + ); + const initialNpcState = initialEncounter + ? buildInitialNpcState(initialEncounter, resolvedWorldType, prev) + : null; + const initialEquipment = buildInitialEquipmentLoadout( + character, + resolvedCustomWorldProfile, + ); + const explicitStarterItems = + resolvedWorldType === WorldType.CUSTOM + ? buildExplicitCustomWorldRoleStarterState( + resolvedCustomWorldProfile!, character, + ) + : null; + const mergedStarterEquipment = { + weapon: + explicitStarterItems?.equipment.weapon ?? initialEquipment.weapon, + armor: explicitStarterItems?.equipment.armor ?? initialEquipment.armor, + relic: explicitStarterItems?.equipment.relic ?? initialEquipment.relic, + }; + const playerMaxHp = getCharacterMaxHp( + character, + resolvedWorldType, + resolvedCustomWorldProfile, + ); + + return ensureSceneEncounterPreview( + applyEquipmentLoadoutToState( + { + ...prev, + playerCharacter: character, + runtimeStats: createInitialGameRuntimeStats({ isActiveRun: true }), + playerProgression: createInitialPlayerProgressionState(), + currentScene: 'Story', + storyHistory: [], + storyEngineMemory: createEmptyStoryEngineMemoryState(), + chapterState: null, + campaignState: null, + activeScenarioPackId: + gameState.customWorldProfile?.scenarioPackId ?? null, + activeCampaignPackId: + gameState.customWorldProfile?.campaignPackId ?? null, + characterChats: {}, + currentEncounter: initialEncounter, + npcInteractionActive: false, + currentScenePreset: initialScenePreset, + lastObserveSignsSceneId: null, + lastObserveSignsReport: null, + animationState: AnimationState.IDLE, + sceneHostileNpcs: [], + playerX: 0, + playerOffsetY: 0, + playerFacing: 'right', + playerActionMode: 'idle', + scrollWorld: false, + inBattle: false, + playerHp: playerMaxHp, + playerMaxHp: playerMaxHp, + playerMana: getCharacterMaxMana(character), + playerMaxMana: getCharacterMaxMana(character), + playerSkillCooldowns: createCharacterSkillCooldowns(character), + activeBuildBuffs: [], + activeCombatEffects: [], + playerCurrency: getInitialPlayerCurrency( resolvedWorldType, resolvedCustomWorldProfile, ), - ), - playerEquipment: createEmptyEquipmentLoadout(), - npcStates: initialEncounter && initialNpcState - ? { - [initialEncounter.id!]: initialNpcState, - } - : {}, - quests: [], - roster: [], - companions: [], - currentBattleNpcId: null, - currentNpcBattleMode: null, - currentNpcBattleOutcome: null, - sparReturnEncounter: null, - sparPlayerHpBefore: null, - sparPlayerMaxHpBefore: null, - sparStoryHistoryBefore: null, - }, mergedStarterEquipment), - ); - }, - ); + playerInventory: mergeStarterInventoryItems( + explicitStarterItems?.inventory ?? [], + buildInitialPlayerInventory( + character, + resolvedWorldType, + resolvedCustomWorldProfile, + ), + ), + playerEquipment: createEmptyEquipmentLoadout(), + npcStates: + initialEncounter && initialNpcState + ? { + [initialEncounter.id!]: initialNpcState, + } + : {}, + quests: [], + roster: [], + companions: [], + currentBattleNpcId: null, + currentNpcBattleMode: null, + currentNpcBattleOutcome: null, + sparReturnEncounter: null, + sparPlayerHpBefore: null, + sparPlayerMaxHpBefore: null, + sparStoryHistoryBefore: null, + }, + mergedStarterEquipment, + ), + ); + }); }; return { diff --git a/src/index.css b/src/index.css index ddbb8c84..91989c58 100644 --- a/src/index.css +++ b/src/index.css @@ -28,6 +28,24 @@ body { -webkit-font-smoothing: antialiased; } +@keyframes character-animator-portrait-death-fall { + 0% { + transform: translateY(0) rotate(0deg) scaleX(1) scale(1); + } + + 24% { + transform: translateY(3%) rotate(-8deg) scaleX(1) scale(0.99); + } + + 58% { + transform: translateY(12%) rotate(54deg) scaleX(-1) scale(0.9); + } + + 100% { + transform: translateY(16%) rotate(90deg) scaleX(-1) scale(0.82); + } +} + .fusion-pixel-app, .fusion-pixel-app * { font-family: "Fusion Pixel", "Inter", ui-sans-serif, system-ui, sans-serif !important; diff --git a/src/persistence/runtimeSnapshot.test.ts b/src/persistence/runtimeSnapshot.test.ts index 4d017449..222bb6d4 100644 --- a/src/persistence/runtimeSnapshot.test.ts +++ b/src/persistence/runtimeSnapshot.test.ts @@ -7,10 +7,7 @@ import { resolveHydratedSnapshotState, } from './runtimeSnapshot'; -function createStory( - text: string, - streaming = false, -): StoryMoment { +function createStory(text: string, streaming = false): StoryMoment { return { text, options: [], @@ -63,6 +60,13 @@ function createHydratedBattleSnapshot( hp: 18, maxHp: 32, description: '拦路的刀客', + levelProfile: { + level: 4, + referenceStrength: 202, + progressionRole: 'rival', + source: 'manual', + }, + experienceReward: 20, }, ], playerX: 0, @@ -160,6 +164,14 @@ describe('runtimeSnapshot', () => { armor: null, relic: null, }); + expect(hydrated.gameState.playerProgression).toEqual({ + level: 1, + currentLevelXp: 0, + totalXp: 0, + xpToNextLevel: 60, + pendingLevelUps: 0, + lastGrantedSource: null, + }); expect(hydrated.gameState.playerMaxHp).toBe(12); expect(hydrated.gameState.playerHp).toBe(12); expect(hydrated.gameState.playerMaxMana).toBe(12); @@ -180,6 +192,13 @@ describe('runtimeSnapshot', () => { description: '拦路的刀客', hp: 18, maxHp: 32, + levelProfile: { + level: 4, + referenceStrength: 202, + progressionRole: 'rival', + source: 'manual', + }, + experienceReward: 20, attackRange: expect.any(Number), speed: expect.any(Number), animation: 'idle', @@ -210,6 +229,13 @@ describe('runtimeSnapshot', () => { speed: 7, hp: 18, maxHp: 32, + levelProfile: { + level: 4, + referenceStrength: 202, + progressionRole: 'rival', + source: 'manual', + }, + experienceReward: 20, renderKind: 'npc', encounter: { kind: 'npc', diff --git a/src/persistence/runtimeSnapshot.ts b/src/persistence/runtimeSnapshot.ts index c42d469b..b45c946c 100644 --- a/src/persistence/runtimeSnapshot.ts +++ b/src/persistence/runtimeSnapshot.ts @@ -2,6 +2,7 @@ import { buildInitialNpcState, createNpcBattleMonster, } from '../data/npcInteractions'; +import { normalizePlayerProgressionState } from '../data/playerProgression'; import type { Encounter, GameState, @@ -18,9 +19,7 @@ import type { SnapshotState, } from './runtimeSnapshotTypes'; -function normalizeBottomTab( - bottomTab: string | null | undefined, -): BottomTab { +function normalizeBottomTab(bottomTab: string | null | undefined): BottomTab { return bottomTab === 'character' || bottomTab === 'inventory' ? bottomTab : 'adventure'; @@ -106,6 +105,8 @@ function normalizeRuntimeBattleEncounter( typeof encounter.npcAvatar === 'string' ? encounter.npcAvatar : '', context: typeof encounter.context === 'string' ? encounter.context : '', hostile: true, + levelProfile: encounter.levelProfile, + experienceReward: encounter.experienceReward, } satisfies Encounter; } @@ -126,9 +127,7 @@ function resolveRuntimeNpcBattleState( } const npcStateKey = - gameState.currentBattleNpcId ?? - encounter.id ?? - encounter.npcName; + gameState.currentBattleNpcId ?? encounter.id ?? encounter.npcName; const npcState = gameState.npcStates[npcStateKey] ?? buildInitialNpcState( @@ -161,9 +160,13 @@ function hydrateRuntimeNpcBattleMonster(params: { ); const candidate = params.hostileNpc as Partial; const xMeters = - typeof candidate.xMeters === 'number' ? candidate.xMeters : template.xMeters; + typeof candidate.xMeters === 'number' + ? candidate.xMeters + : template.xMeters; const yOffset = - typeof candidate.yOffset === 'number' ? candidate.yOffset : template.yOffset; + typeof candidate.yOffset === 'number' + ? candidate.yOffset + : template.yOffset; return { ...template, @@ -198,6 +201,11 @@ function hydrateRuntimeNpcBattleMonster(params: { : template.attackRange, speed: typeof candidate.speed === 'number' ? candidate.speed : template.speed, + levelProfile: candidate.levelProfile ?? template.levelProfile, + experienceReward: + typeof candidate.experienceReward === 'number' + ? candidate.experienceReward + : template.experienceReward, encounter: { ...template.encounter, xMeters, @@ -263,6 +271,9 @@ export function normalizeSavedGameState(gameState: GameState) { return hydrateRuntimeNpcBattleGameState({ ...hydratableState, + playerProgression: normalizePlayerProgressionState( + hydratableState.playerProgression ?? null, + ), playerMaxHp, playerHp: Math.min(hydratableState.playerHp, playerMaxHp), playerMaxMana, @@ -305,14 +316,19 @@ export function isHydratedSnapshotState( (gameState.runtimeSessionId === null || typeof gameState.runtimeSessionId === 'string') && (!gameState.playerCharacter || - Boolean(gameState.playerEquipment && typeof gameState.playerEquipment === 'object')), + Boolean( + gameState.playerEquipment && + typeof gameState.playerEquipment === 'object', + )), ); } export function rehydrateSavedSnapshot( snapshot: T, ): T { - const hydratedGameState = hydrateRuntimeNpcBattleGameState(snapshot.gameState); + const hydratedGameState = hydrateRuntimeNpcBattleGameState( + snapshot.gameState, + ); if (hydratedGameState === snapshot.gameState) { return snapshot; diff --git a/src/services/aiService.ts b/src/services/aiService.ts index 995a85b6..99085642 100644 --- a/src/services/aiService.ts +++ b/src/services/aiService.ts @@ -23,6 +23,7 @@ import type { CharacterChatSuggestionsRequest, CharacterChatSummaryRequest, NpcChatDialogueRequest, + NpcChatTurnDirective, NpcChatTurnRequest, NpcChatTurnResult, NpcRecruitDialogueRequest, @@ -977,6 +978,7 @@ export async function streamNpcChatTurn( state: GameState; turnCount: number; } | null; + chatDirective?: NpcChatTurnDirective | null; } = {}, ) { const payload = { @@ -998,6 +1000,7 @@ export async function streamNpcChatTurn( turnCount: options.questOfferContext.turnCount, } : null, + chatDirective: options.chatDirective ?? null, } satisfies NpcChatTurnRequest; const response = await fetchWithApiAuth( diff --git a/src/services/aiTypes.ts b/src/services/aiTypes.ts index cca9b47e..5ddc423f 100644 --- a/src/services/aiTypes.ts +++ b/src/services/aiTypes.ts @@ -30,6 +30,7 @@ import type { NpcDisclosureStage, NpcWarmthStage, PlayerStyleProfile, + PlayerProgressionState, QuestStatus, ReleaseGateReport, ScenarioPack, @@ -212,6 +213,7 @@ export interface QuestGenerationContext { currentSceneTreasureHintCount?: number; recentStoryMoments: StoryMoment[]; playerCharacter?: Character | null; + playerProgression?: PlayerProgressionState | null; playerHp?: number; playerMaxHp?: number; playerMana?: number; diff --git a/src/services/customWorldAgentDraftResult.ts b/src/services/customWorldAgentDraftResult.ts index 4c8cb9fa..5a4b4258 100644 --- a/src/services/customWorldAgentDraftResult.ts +++ b/src/services/customWorldAgentDraftResult.ts @@ -3,8 +3,8 @@ import { normalizeCustomWorldProfileRecord } from '../data/customWorldLibrary'; import { type CustomWorldProfile, WorldType } from '../types'; import { buildAgentDraftFoundationSettingText } from './customWorldAgentGenerationProgress'; -function toText(value: unknown) { - return typeof value === 'string' ? value.trim() : ''; +function toText(value: unknown, fallback = '') { + return typeof value === 'string' ? value.trim() : fallback; } function isRecord(value: unknown): value is Record { @@ -178,6 +178,88 @@ function adaptDraftLandmarks(value: unknown, storyNpcIdSet: Set) { .filter(Boolean) as AdaptedDraftLandmark[]; } +function toStageCoverage(value: unknown) { + const stageCoverage = Array.isArray(value) + ? value + .filter((entry): entry is string => typeof entry === 'string') + .map((entry) => entry.trim()) + .filter(Boolean) + : []; + + return [...new Set(stageCoverage)]; +} + +function adaptDraftSceneChapters( + value: unknown, + storyNpcIdSet: Set, + landmarkIdSet: Set, +) { + return toRecordArray(value) + .map((record, index) => { + const sceneId = toText(record.sceneId); + if (!sceneId) { + return null; + } + + const acts = toRecordArray(record.acts) + .map((actRecord, actIndex) => { + const encounterNpcIds = toStringArray( + actRecord.encounterNpcIds, + ).filter((entry) => storyNpcIdSet.has(entry)); + const primaryNpcId = toText( + actRecord.primaryNpcId, + encounterNpcIds[0] ?? '', + ); + + return { + id: toText(actRecord.id) || `scene-act-${sceneId}-${actIndex + 1}`, + sceneId, + title: toText(actRecord.title) || `第 ${actIndex + 1} 幕`, + summary: + toText(actRecord.summary) || + toText(actRecord.actGoal) || + `围绕${toText(record.sceneName, sceneId)}继续推进`, + stageCoverage: + toStageCoverage(actRecord.stageCoverage).length > 0 + ? toStageCoverage(actRecord.stageCoverage) + : actIndex === 0 + ? ['opening'] + : ['climax', 'aftermath'], + backgroundImageSrc: + toText(actRecord.backgroundImageSrc) || undefined, + encounterNpcIds, + primaryNpcId, + linkedThreadIds: toStringArray(actRecord.linkedThreadIds), + advanceRule: + toText(actRecord.advanceRule) || 'after_active_step_complete', + actGoal: toText(actRecord.actGoal), + transitionHook: toText(actRecord.transitionHook), + }; + }) + .filter( + (entry) => + entry.encounterNpcIds.length > 0 || entry.backgroundImageSrc, + ); + + return { + id: toText(record.id) || `scene-chapter-${sceneId}-${index + 1}`, + sceneId, + title: toText(record.title) || toText(record.sceneName) || sceneId, + summary: + toText(record.summary) || + toText(record.title) || + toText(record.sceneName) || + sceneId, + linkedThreadIds: toStringArray(record.linkedThreadIds, 8), + linkedLandmarkIds: toStringArray(record.linkedLandmarkIds, 8).filter( + (entry) => landmarkIdSet.has(entry), + ), + acts, + }; + }) + .filter(Boolean); +} + export function buildCustomWorldProfileFromAgentDraft( session: CustomWorldAgentSessionSnapshot | null | undefined, ): CustomWorldProfile | null { @@ -203,6 +285,13 @@ export function buildCustomWorldProfileFromAgentDraft( const storyNpcIdSet = new Set( storyNpcs.map((entry) => toText(entry.id)).filter(Boolean), ); + const adaptedLandmarks = adaptDraftLandmarks( + draftProfile.landmarks, + storyNpcIdSet, + ); + const landmarkIdSet = new Set( + adaptedLandmarks.map((entry) => toText(entry.id)).filter(Boolean), + ); const normalized = normalizeCustomWorldProfileRecord({ id: `agent-draft-${session.sessionId}`, settingText, @@ -220,7 +309,7 @@ export function buildCustomWorldProfileFromAgentDraft( coreConflicts: toStringArray(draftProfile.coreConflicts, 6), playableNpcs, storyNpcs, - landmarks: adaptDraftLandmarks(draftProfile.landmarks, storyNpcIdSet), + landmarks: adaptedLandmarks, camp: isRecord(draftProfile.camp) ? { name: toText(draftProfile.camp.name), @@ -231,6 +320,11 @@ export function buildCustomWorldProfileFromAgentDraft( imageSrc: toText(draftProfile.camp.imageSrc) || undefined, } : undefined, + sceneChapterBlueprints: adaptDraftSceneChapters( + draftProfile.sceneChapters, + storyNpcIdSet, + landmarkIdSet, + ), anchorContent: session.anchorContent, creatorIntent: session.creatorIntent, anchorPack: session.anchorPack, diff --git a/src/services/customWorldSceneActRuntime.ts b/src/services/customWorldSceneActRuntime.ts new file mode 100644 index 00000000..8e99463a --- /dev/null +++ b/src/services/customWorldSceneActRuntime.ts @@ -0,0 +1,175 @@ +import type { NpcChatTurnDirective } from '../../packages/shared/src/contracts/story'; +import type { + CustomWorldProfile, + GameState, + SceneActBlueprint, + SceneChapterBlueprint, + SceneActRuntimeState, + StoryEngineMemoryState, +} from '../types'; + +function toSet(values: string[]) { + return new Set(values.map((value) => value.trim()).filter(Boolean)); +} + +export function resolveSceneChapterBlueprint( + profile: CustomWorldProfile | null | undefined, + sceneId: string | null | undefined, +): SceneChapterBlueprint | null { + if (!profile || !sceneId) { + return null; + } + + return ( + profile.sceneChapterBlueprints?.find( + (entry) => entry.sceneId === sceneId || entry.linkedLandmarkIds.includes(sceneId), + ) ?? null + ); +} + +export function resolveActiveSceneActBlueprint(params: { + profile: CustomWorldProfile | null | undefined; + sceneId: string | null | undefined; + storyEngineMemory?: StoryEngineMemoryState | null; +}): SceneActBlueprint | null { + const chapter = resolveSceneChapterBlueprint(params.profile, params.sceneId); + if (!chapter || chapter.acts.length === 0) { + return null; + } + + const runtimeState = params.storyEngineMemory?.currentSceneActState; + if ( + runtimeState && + runtimeState.sceneId === chapter.sceneId && + runtimeState.chapterId === chapter.id + ) { + const matchedAct = chapter.acts.find((entry) => entry.id === runtimeState.currentActId); + if (matchedAct) { + return matchedAct; + } + } + + return chapter.acts[0] ?? null; +} + +export function buildInitialSceneActRuntimeState(params: { + profile: CustomWorldProfile | null | undefined; + sceneId: string | null | undefined; + storyEngineMemory?: StoryEngineMemoryState | null; +}): SceneActRuntimeState | null { + const chapter = resolveSceneChapterBlueprint(params.profile, params.sceneId); + if (!chapter || chapter.acts.length === 0) { + return null; + } + + const runtimeState = params.storyEngineMemory?.currentSceneActState; + if ( + runtimeState && + runtimeState.sceneId === chapter.sceneId && + runtimeState.chapterId === chapter.id && + chapter.acts.some((entry) => entry.id === runtimeState.currentActId) + ) { + return { + ...runtimeState, + completedActIds: [...toSet(runtimeState.completedActIds ?? [])], + visitedActIds: [...toSet(runtimeState.visitedActIds ?? [])], + }; + } + + const firstAct = chapter.acts[0]!; + return { + sceneId: chapter.sceneId, + chapterId: chapter.id, + currentActId: firstAct.id, + currentActIndex: 0, + completedActIds: [], + visitedActIds: [firstAct.id], + }; +} + +export function resolveActiveSceneActEncounterNpcIds(params: { + profile: CustomWorldProfile | null | undefined; + sceneId: string | null | undefined; + storyEngineMemory?: StoryEngineMemoryState | null; +}) { + return ( + resolveActiveSceneActBlueprint(params)?.encounterNpcIds + .map((entry) => entry.trim()) + .filter(Boolean) ?? [] + ); +} + +export function resolveActiveSceneActPrimaryNpcId(params: { + profile: CustomWorldProfile | null | undefined; + sceneId: string | null | undefined; + storyEngineMemory?: StoryEngineMemoryState | null; +}) { + return resolveActiveSceneActBlueprint(params)?.primaryNpcId?.trim() || null; +} + +export function resolveActiveSceneActBackgroundImage(params: { + profile: CustomWorldProfile | null | undefined; + sceneId: string | null | undefined; + storyEngineMemory?: StoryEngineMemoryState | null; +}) { + return resolveActiveSceneActBlueprint(params)?.backgroundImageSrc?.trim() || null; +} + +export function canUseLimitedPrimaryNpcChat(params: { + profile: CustomWorldProfile | null | undefined; + sceneId: string | null | undefined; + storyEngineMemory?: StoryEngineMemoryState | null; + npcId: string | null | undefined; + affinity: number; +}) { + if (params.affinity >= 0 || !params.npcId) { + return false; + } + + return ( + resolveActiveSceneActPrimaryNpcId({ + profile: params.profile, + sceneId: params.sceneId, + storyEngineMemory: params.storyEngineMemory, + }) === params.npcId + ); +} + +export function resolveLimitedPrimaryNpcChatState(params: { + state: Pick; + npcId: string | null | undefined; + affinity: number; + nextTurnCount: number; +}): NpcChatTurnDirective | null { + if ( + !canUseLimitedPrimaryNpcChat({ + profile: params.state.customWorldProfile, + sceneId: params.state.currentScenePreset?.id ?? null, + storyEngineMemory: params.state.storyEngineMemory, + npcId: params.npcId, + affinity: params.affinity, + }) + ) { + return null; + } + + const activeAct = resolveActiveSceneActBlueprint({ + profile: params.state.customWorldProfile, + sceneId: params.state.currentScenePreset?.id ?? null, + storyEngineMemory: params.state.storyEngineMemory, + }); + const turnLimit = 5; + const remainingTurns = Math.max(0, turnLimit - params.nextTurnCount); + + return { + sceneActId: activeAct?.id ?? null, + turnLimit, + remainingTurns, + limitReason: 'negative_affinity' as const, + closingMode: + params.nextTurnCount >= turnLimit + ? ('foreshadow_close' as const) + : ('free' as const), + forceExitAfterTurn: params.nextTurnCount >= turnLimit, + }; +} diff --git a/src/services/questDirector.ts b/src/services/questDirector.ts index 349e7c4d..45aa18a7 100644 --- a/src/services/questDirector.ts +++ b/src/services/questDirector.ts @@ -1,20 +1,22 @@ -import {getNpcDisclosureStage, getNpcWarmthStage} from '../data/npcInteractions'; +import { + getNpcDisclosureStage, + getNpcWarmthStage, +} from '../data/npcInteractions'; import { buildFallbackQuestIntent, compileQuestIntentToQuest, evaluateQuestOpportunity, } from '../data/questFlow'; -import type { - Encounter, - GameState, - QuestLogEntry, -} from '../types'; -import type {QuestGenerationContext} from './aiTypes'; +import type { Encounter, GameState, QuestLogEntry } from '../types'; +import type { QuestGenerationContext } from './aiTypes'; import { requestJson } from './apiClient'; -import {requestChatMessageContent} from './llmClient'; -import {parseJsonResponseText} from './llmParsers'; -import {buildQuestIntentPrompt, QUEST_INTENT_SYSTEM_PROMPT} from './questPrompt'; -import type {QuestIntent, QuestPreviewRequest} from './questTypes'; +import { requestChatMessageContent } from './llmClient'; +import { parseJsonResponseText } from './llmParsers'; +import { + buildQuestIntentPrompt, + QUEST_INTENT_SYSTEM_PROMPT, +} from './questPrompt'; +import type { QuestIntent, QuestPreviewRequest } from './questTypes'; import { buildFallbackActorNarrativeProfile, normalizeActorNarrativeProfile, @@ -47,16 +49,13 @@ function coerceStringArray(value: unknown, fallback: string[]) { } const items = value - .map(item => (typeof item === 'string' ? item.trim() : '')) + .map((item) => (typeof item === 'string' ? item.trim() : '')) .filter(Boolean); return items.length > 0 ? items : fallback; } -function resolveIssuerNarrativeProfile( - state: GameState, - encounter: Encounter, -) { +function resolveIssuerNarrativeProfile(state: GameState, encounter: Encounter) { if (encounter.narrativeProfile) { return encounter.narrativeProfile; } @@ -65,22 +64,22 @@ function resolveIssuerNarrativeProfile( } 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, + 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); + state.customWorldProfile.themePack ?? + buildThemePackFromWorldProfile(state.customWorldProfile); const storyGraph = - state.customWorldProfile.storyGraph - ?? buildFallbackWorldStoryGraph(state.customWorldProfile, themePack); + state.customWorldProfile.storyGraph ?? + buildFallbackWorldStoryGraph(state.customWorldProfile, themePack); return normalizeActorNarrativeProfile( role.narrativeProfile, @@ -88,7 +87,10 @@ function resolveIssuerNarrativeProfile( ); } -function sanitizeQuestIntent(rawIntent: unknown, fallback: QuestIntent): QuestIntent { +function sanitizeQuestIntent( + rawIntent: unknown, + fallback: QuestIntent, +): QuestIntent { if (!rawIntent || typeof rawIntent !== 'object') { return fallback; } @@ -99,44 +101,56 @@ function sanitizeQuestIntent(rawIntent: unknown, fallback: QuestIntent): QuestIn title: coerceQuestTitle(intent.title, fallback.title), description: coerceString(intent.description, fallback.description), summary: coerceString(intent.summary, fallback.summary), - narrativeType: ( - typeof intent.narrativeType === 'string' - && ['bounty', 'escort', 'investigation', 'retrieval', 'relationship', 'trial'].includes(intent.narrativeType) - ) - ? intent.narrativeType as QuestIntent['narrativeType'] - : fallback.narrativeType, + narrativeType: + typeof intent.narrativeType === 'string' && + [ + 'bounty', + 'escort', + 'investigation', + 'retrieval', + 'relationship', + 'trial', + ].includes(intent.narrativeType) + ? (intent.narrativeType as QuestIntent['narrativeType']) + : fallback.narrativeType, dramaticNeed: coerceString(intent.dramaticNeed, fallback.dramaticNeed), issuerGoal: coerceString(intent.issuerGoal, fallback.issuerGoal), playerHook: coerceString(intent.playerHook, fallback.playerHook), worldReason: coerceString(intent.worldReason, fallback.worldReason), - recommendedObjectiveKinds: coerceStringArray(intent.recommendedObjectiveKinds, fallback.recommendedObjectiveKinds) - .filter(kind => [ + recommendedObjectiveKinds: coerceStringArray( + intent.recommendedObjectiveKinds, + fallback.recommendedObjectiveKinds, + ).filter((kind) => + [ 'defeat_hostile_npc', 'inspect_treasure', 'spar_with_npc', 'talk_to_npc', 'reach_scene', 'deliver_item', - ].includes(kind)) as QuestIntent['recommendedObjectiveKinds'], - urgency: ( - typeof intent.urgency === 'string' - && ['low', 'medium', 'high'].includes(intent.urgency) - ) - ? intent.urgency as QuestIntent['urgency'] - : fallback.urgency, - intimacy: ( - typeof intent.intimacy === 'string' - && ['transactional', 'cooperative', 'trust_based'].includes(intent.intimacy) - ) - ? intent.intimacy as QuestIntent['intimacy'] - : fallback.intimacy, - rewardTheme: ( - typeof intent.rewardTheme === 'string' - && ['currency', 'resource', 'relationship', 'intel', 'rare_item'].includes(intent.rewardTheme) - ) - ? intent.rewardTheme as QuestIntent['rewardTheme'] - : fallback.rewardTheme, - followupHooks: coerceStringArray(intent.followupHooks, fallback.followupHooks), + ].includes(kind), + ) as QuestIntent['recommendedObjectiveKinds'], + urgency: + typeof intent.urgency === 'string' && + ['low', 'medium', 'high'].includes(intent.urgency) + ? (intent.urgency as QuestIntent['urgency']) + : fallback.urgency, + intimacy: + typeof intent.intimacy === 'string' && + ['transactional', 'cooperative', 'trust_based'].includes(intent.intimacy) + ? (intent.intimacy as QuestIntent['intimacy']) + : fallback.intimacy, + rewardTheme: + typeof intent.rewardTheme === 'string' && + ['currency', 'resource', 'relationship', 'intel', 'rare_item'].includes( + intent.rewardTheme, + ) + ? (intent.rewardTheme as QuestIntent['rewardTheme']) + : fallback.rewardTheme, + followupHooks: coerceStringArray( + intent.followupHooks, + fallback.followupHooks, + ), }; } @@ -144,10 +158,13 @@ export function buildQuestGenerationContextFromState(params: { state: GameState; encounter: Encounter; }): QuestGenerationContext { - const {state, encounter} = params; + const { state, encounter } = params; const issuerNpcId = encounter.id ?? encounter.npcName; const issuerState = state.npcStates[issuerNpcId]; - const issuerNarrativeProfile = resolveIssuerNarrativeProfile(state, encounter); + const issuerNarrativeProfile = resolveIssuerNarrativeProfile( + state, + encounter, + ); return { worldType: state.worldType, @@ -164,16 +181,18 @@ export function buildQuestGenerationContextFromState(params: { issuerDisclosureStage: getNpcDisclosureStage(issuerState?.affinity ?? 0), issuerWarmthStage: getNpcWarmthStage(issuerState?.affinity ?? 0), activeThreadIds: - state.storyEngineMemory?.activeThreadIds?.slice(0, 4) - ?? issuerNarrativeProfile?.relatedThreadIds?.slice(0, 4) - ?? [], + state.storyEngineMemory?.activeThreadIds?.slice(0, 4) ?? + issuerNarrativeProfile?.relatedThreadIds?.slice(0, 4) ?? + [], encounterKind: encounter.kind ?? 'npc', - currentSceneTreasureHintCount: state.currentScenePreset?.treasureHints?.length ?? 0, + currentSceneTreasureHintCount: + state.currentScenePreset?.treasureHints?.length ?? 0, currentSceneHostileNpcIds: (state.currentScenePreset?.npcs ?? []) - .filter(npc => Boolean(npc.hostile || npc.monsterPresetId)) - .map(npc => npc.id), + .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, @@ -182,7 +201,7 @@ export function buildQuestGenerationContextFromState(params: { playerEquipment: state.playerEquipment, activeCompanions: state.companions, rosterCompanions: state.roster, - currentQuestSummary: state.quests.map(quest => ({ + currentQuestSummary: state.quests.map((quest) => ({ id: quest.id, title: quest.title, status: quest.status, @@ -195,7 +214,7 @@ export async function generateQuestForNpcEncounter(params: { state: GameState; encounter: Encounter; }): Promise { - const {state, encounter} = params; + const { state, encounter } = params; const issuerNpcId = encounter.id ?? encounter.npcName; const request: QuestPreviewRequest = { issuerNpcId, @@ -203,12 +222,12 @@ export async function generateQuestForNpcEncounter(params: { roleText: encounter.context, scene: state.currentScenePreset, worldType: state.worldType, - currentQuests: state.quests.map(quest => ({ + currentQuests: state.quests.map((quest) => ({ id: quest.id, issuerNpcId: quest.issuerNpcId, status: quest.status, })), - context: buildQuestGenerationContextFromState({state, encounter}), + context: buildQuestGenerationContextFromState({ state, encounter }), origin: 'ai_compiled', }; const opportunity = evaluateQuestOpportunity(request); @@ -257,7 +276,7 @@ export async function generateQuestForNpcEncounter(params: { debugLabel: 'quest-intent', }, ); - const parsed = parseJsonResponseText(content) as {intent?: unknown}; + const parsed = parseJsonResponseText(content) as { intent?: unknown }; const intent = sanitizeQuestIntent(parsed.intent, fallbackIntent); return compileQuestIntentToQuest( { @@ -267,7 +286,10 @@ export async function generateQuestForNpcEncounter(params: { intent, ); } catch (error) { - console.warn('[QuestDirector] falling back to deterministic quest intent', error); + console.warn( + '[QuestDirector] falling back to deterministic quest intent', + error, + ); return compileQuestIntentToQuest( { ...request, diff --git a/src/services/storyEngine/visibilityEngine.ts b/src/services/storyEngine/visibilityEngine.ts index 3fc15127..40928264 100644 --- a/src/services/storyEngine/visibilityEngine.ts +++ b/src/services/storyEngine/visibilityEngine.ts @@ -46,6 +46,7 @@ export function createEmptyStoryEngineMemoryState(): StoryEngineMemoryState { resolvedScarIds: [], recentCarrierIds: [], openedSceneChapterIds: [], + currentSceneActState: null, recentSignalIds: [], recentCompanionReactions: [], currentChapter: null, diff --git a/src/types/customWorld.ts b/src/types/customWorld.ts index 5359dbf4..4d455f6a 100644 --- a/src/types/customWorld.ts +++ b/src/types/customWorld.ts @@ -318,6 +318,43 @@ export interface CustomWorldSceneConnection { summary: string; } +export type SceneActStage = + | 'opening' + | 'expansion' + | 'turning_point' + | 'climax' + | 'aftermath'; + +export type SceneActAdvanceRule = + | 'after_primary_contact' + | 'after_active_step_complete' + | 'after_chapter_resolution'; + +export interface SceneActBlueprint { + id: string; + sceneId: string; + title: string; + summary: string; + stageCoverage: SceneActStage[]; + backgroundImageSrc?: string | null; + encounterNpcIds: string[]; + primaryNpcId: string; + linkedThreadIds: string[]; + advanceRule: SceneActAdvanceRule; + actGoal: string; + transitionHook: string; +} + +export interface SceneChapterBlueprint { + id: string; + sceneId: string; + title: string; + summary: string; + linkedThreadIds: string[]; + linkedLandmarkIds: string[]; + acts: SceneActBlueprint[]; +} + export interface CustomWorldCampScene { name: string; description: string; @@ -360,6 +397,7 @@ export interface CustomWorldProfile { storyGraph?: WorldStoryGraph | null; knowledgeFacts?: KnowledgeFact[] | null; threadContracts?: ThreadContract[] | null; + sceneChapterBlueprints?: SceneChapterBlueprint[] | null; anchorContent?: EightAnchorContent | null; creatorIntent?: CustomWorldCreatorIntent | null; anchorPack?: CustomWorldAnchorPack | null; diff --git a/src/types/game.ts b/src/types/game.ts index c552f416..51f0c8ad 100644 --- a/src/types/game.ts +++ b/src/types/game.ts @@ -1,5 +1,5 @@ -import type {TimedBuildBuff} from './build'; -import type {Character} from './characters'; +import type { TimedBuildBuff } from './build'; +import type { Character } from './characters'; import { AnimationState, type CombatActionMode, @@ -7,8 +7,8 @@ import { type NpcBattleOutcome, WorldType, } from './core'; -import type {CustomWorldProfile} from './customWorld'; -import type {EquipmentLoadout, InventoryItem} from './items'; +import type { CustomWorldProfile } from './customWorld'; +import type { EquipmentLoadout, InventoryItem } from './items'; import type { CombatVisualEffect, CompanionState, @@ -18,8 +18,12 @@ import type { SceneHostileNpc, ScenePresetInfo, } from './scene'; -import type {CharacterChatRecord, QuestLogEntry, StoryMoment} from './story'; -import type {CampaignState, ChapterState, StoryEngineMemoryState} from './storyEngine'; +import type { CharacterChatRecord, QuestLogEntry, StoryMoment } from './story'; +import type { + CampaignState, + ChapterState, + StoryEngineMemoryState, +} from './storyEngine'; export interface GameRuntimeStats { playTimeMs: number; @@ -30,6 +34,17 @@ export interface GameRuntimeStats { scenesTraveled: number; } +export type PlayerProgressionGrantSource = 'quest' | 'hostile_npc'; + +export interface PlayerProgressionState { + level: number; + currentLevelXp: number; + totalXp: number; + xpToNextLevel: number; + pendingLevelUps?: number; + lastGrantedSource?: PlayerProgressionGrantSource | null; +} + export interface GameState { worldType: WorldType | null; customWorldProfile: CustomWorldProfile | null; @@ -37,6 +52,7 @@ export interface GameState { runtimeSessionId?: string | null; runtimeActionVersion?: number; runtimeStats: GameRuntimeStats; + playerProgression?: PlayerProgressionState | null; currentScene: string; storyHistory: StoryMoment[]; storyEngineMemory?: StoryEngineMemoryState; diff --git a/src/types/scene.ts b/src/types/scene.ts index bc5e3ea6..3c7ec701 100644 --- a/src/types/scene.ts +++ b/src/types/scene.ts @@ -107,6 +107,26 @@ export interface Encounter { imageSrc?: string; visual?: CustomWorldNpcVisual; narrativeProfile?: ActorNarrativeProfile | null; + levelProfile?: EntityLevelProfile; + experienceReward?: number; +} + +export type ProgressionRole = + | 'guide' + | 'ambient' + | 'support' + | 'hostile_standard' + | 'hostile_elite' + | 'hostile_boss' + | 'rival'; + +export interface EntityLevelProfile { + level: number; + referenceStrength: number; + chapterId?: string | null; + chapterIndex?: number | null; + progressionRole: ProgressionRole; + source: 'chapter_auto' | 'preset_override' | 'manual'; } export interface SceneHostileNpc { @@ -129,6 +149,8 @@ export interface SceneHostileNpc { combatTags?: string[]; attributeProfile?: RoleAttributeProfile; behaviorVectors?: RoleActionDefinition[]; + levelProfile?: EntityLevelProfile; + experienceReward?: number; } export interface SceneNpc { @@ -158,6 +180,7 @@ export interface SceneNpc { imageSrc?: string; visual?: CustomWorldNpcVisual; narrativeProfile?: ActorNarrativeProfile | null; + levelProfile?: EntityLevelProfile; } export type SceneEncounterKind = 'npc' | 'treasure' | 'none'; diff --git a/src/types/story.ts b/src/types/story.ts index a3eee461..69d9e959 100644 --- a/src/types/story.ts +++ b/src/types/story.ts @@ -4,8 +4,8 @@ import { type QuestStatus, type TreasureInteractionAction, } from './core'; -import type {InventoryItem} from './items'; -import type {SceneDirective} from './scene'; +import type { InventoryItem } from './items'; +import type { SceneDirective } from './scene'; export interface StoryOptionGoalAffordance { goalId: string; @@ -31,6 +31,7 @@ export interface StoryOption { export interface QuestReward { affinityBonus: number; currency: number; + experience?: number; items: InventoryItem[]; storyHint?: string; intel?: { @@ -115,6 +116,11 @@ export interface StoryNpcChatState { npcName: string; turnCount: number; customInputPlaceholder?: string; + sceneActId?: string | null; + turnLimit?: number | null; + remainingTurns?: number | null; + limitReason?: 'negative_affinity' | null; + forceExitAfterTurn?: boolean; pendingQuestOffer?: { quest: QuestLogEntry; } | null; diff --git a/src/types/storyEngine.ts b/src/types/storyEngine.ts index 9856c007..813b2e27 100644 --- a/src/types/storyEngine.ts +++ b/src/types/storyEngine.ts @@ -266,6 +266,15 @@ export interface ChapterState { chapterQuestId?: string | null; } +export interface SceneActRuntimeState { + sceneId: string; + chapterId: string; + currentActId: string; + currentActIndex: number; + completedActIds: string[]; + visitedActIds: string[]; +} + export interface JourneyBeat { id: string; beatType: @@ -522,6 +531,7 @@ export interface StoryEngineMemoryState { resolvedScarIds: string[]; recentCarrierIds: string[]; openedSceneChapterIds?: string[]; + currentSceneActState?: SceneActRuntimeState | null; recentSignalIds?: string[]; recentCompanionReactions?: CompanionReactionRecord[]; currentChapter?: ChapterState | null;