1
Some checks failed
CI / verify (push) Has been cancelled

This commit is contained in:
2026-04-20 11:30:19 +08:00
parent 50759f3c1e
commit 8a7bd90458
85 changed files with 7290 additions and 1903 deletions

View File

@@ -15,6 +15,7 @@
- [FUNCTION_RUNTIME_FULL_TEST_AUDIT_2026-04-16.md](./FUNCTION_RUNTIME_FULL_TEST_AUDIT_2026-04-16.md)Function 运行时完整测试、服务端承接验证与当前门禁缺口。 - [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 的落地情况。 - [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):自定义世界创作工具当前问题、体验断层和优化优先级审计。 - [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):未引用垃圾、旧入口残留、前后端双份真相与后端迁移项的专项审计。 - [engineering/ENGINEERING_CLEANUP_AND_BACKEND_BOUNDARY_AUDIT_2026-04-19.md](./engineering/ENGINEERING_CLEANUP_AND_BACKEND_BOUNDARY_AUDIT_2026-04-19.md):未引用垃圾、旧入口残留、前后端双份真相与后端迁移项的专项审计。
## 推荐使用方式 ## 推荐使用方式

View File

@@ -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. 当前依赖图扫描结果与当前大文件体量扫描结果

View File

@@ -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) 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_OPTIMIZATION_REVIEW_2026-04-01.md](./ENGINEERING_OPTIMIZATION_REVIEW_2026-04-01.md) 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 边界、分层过渡期问题。 适合回看运行时主链路、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 上。 - 最新一轮已经把关注点集中到质量门禁、真实绿色基线、关键模块豁免和 build warning 上。
- `2026-04-19` 这一轮进一步把问题压实到了四类:仓库噪音、旧 dev 入口残留、前端越界运行时逻辑、巨型热点文件。 - `2026-04-19` 这一轮把问题压实到了四类:仓库噪音、旧 dev 入口残留、前端越界运行时逻辑、巨型热点文件。
- `2026-04-20` 这一轮进一步确认:前两类已经阶段性完成,当前真正剩下的是边界尾巴和新热点迁移。
- 如果只是为了判断现在先做什么,直接从 `2026-04-01` 开始即可。 - 如果只是为了判断现在先做什么,直接从 `2026-04-01` 开始即可。
- 如果是要当前清理和边界收口,优先看 `2026-04-19` - 如果是要当前清理和边界收口的最新状态,优先看 `2026-04-20`
- 如果是要做长期重构方案,再按 `2026-03-29 -> 2026-03-30 -> 2026-04-01 -> 2026-04-19` 的顺序回看演进。 - 如果是要做长期重构方案,再按 `2026-03-29 -> 2026-03-30 -> 2026-04-01 -> 2026-04-19 -> 2026-04-20` 的顺序回看演进。

View File

@@ -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 强度自动落位的刻度

View File

@@ -1,6 +1,6 @@
# 平台层 UI 去像素化刷新设计 # 平台层 UI 去像素化刷新设计
更新时间:`2026-04-19` 更新时间:`2026-04-20`
## 1. 目标 ## 1. 目标
@@ -80,6 +80,7 @@
- 设置面板必须支持平台亮色 / 暗色主题切换,并复用同一套平台 token 驱动登录页、首页、详情页与二三级面板 - 设置面板必须支持平台亮色 / 暗色主题切换,并复用同一套平台 token 驱动登录页、首页、详情页与二三级面板
- 首页移动端底部 Tab 与桌面侧边导航的图标底座、图标颜色、文字状态必须全部由平台 token 驱动;暗色主题下不得出现过浅底座和错误文字色,亮色主题下不得残留旧灰蓝 inactive 状态 - 首页移动端底部 Tab 与桌面侧边导航的图标底座、图标颜色、文字状态必须全部由平台 token 驱动;暗色主题下不得出现过浅底座和错误文字色,亮色主题下不得残留旧灰蓝 inactive 状态
- 首页、存档页、作品详情这类平台主导航与局部 Tab 的 active fill、active shadow、icon shell fill 必须全部来自主题 token暗色主题禁止继续复用亮色主题的粉橘高光、白色 active 底座 - 首页、存档页、作品详情这类平台主导航与局部 Tab 的 active fill、active shadow、icon shell fill 必须全部来自主题 token暗色主题禁止继续复用亮色主题的粉橘高光、白色 active 底座
- 创作链路中的吸顶返回栏、目录 Tab 条、搜索工具条也必须走平台亮暗主题 token暗色主题禁止继续写死暖白渐变或浅粉背景作为顶部衬底
- “我的”页账号主卡必须跟随平台亮 / 暗主题联动,不允许继续写死浅色渐变卡面与 `slate` 系按钮 - “我的”页账号主卡必须跟随平台亮 / 暗主题联动,不允许继续写死浅色渐变卡面与 `slate` 系按钮
## 4. 交互与布局约束 ## 4. 交互与布局约束
@@ -99,6 +100,8 @@
- 新样式优先沉淀为平台专用 class / theme token避免把游戏内像素 class 改坏 - 新样式优先沉淀为平台专用 class / theme token避免把游戏内像素 class 改坏
- 平台默认挂载亮色主题 class旧紫蓝方案保留为暗色主题 class - 平台默认挂载亮色主题 class旧紫蓝方案保留为暗色主题 class
- 亮色主题需要补齐统一的 overlay、progress track、status pill token登录弹层与二三级功能面板禁止继续沿用旧深色遮罩与紫蓝强调残留 - 亮色主题需要补齐统一的 overlay、progress track、status pill token登录弹层与二三级功能面板禁止继续沿用旧深色遮罩与紫蓝强调残留
- 亮色主题下平台壳层与各个 Tab 页的 page stage 必须以暖白底为主,禁止继续让高饱和深粉底或旧深色底透成页面主背景
- 亮色主题下平台主内容区、page stage、移动端底部 Tab 容器都必须使用接近实色的暖白底,禁止继续用高透明度浅色层叠在深底上造成整体发灰
- 平台态中仍保留旧 Tailwind 深色类的历史组件,必须通过平台 remap 容器或平台专用 class 统一收口,不能放任 `bg-[#111318]``bg-black/*``bg-white/*` 这类旧类在亮色主题下直接裸露 - 平台态中仍保留旧 Tailwind 深色类的历史组件,必须通过平台 remap 容器或平台专用 class 统一收口,不能放任 `bg-[#111318]``bg-black/*``bg-white/*` 这类旧类在亮色主题下直接裸露
- 编辑弹窗保留业务结构与表单逻辑,只替换壳层样式 - 编辑弹窗保留业务结构与表单逻辑,只替换壳层样式

View File

@@ -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_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):把模板依赖逐步迁成自定义世界自有设定层,并保证不破坏当前生成流程的优化方案。 - [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):运行时物品生成系统重设计。 - [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 游戏全剧情的工作流程与交付模板。 - [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):配装构筑与合成/锻造闭环设计。 - [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):角色首遇感、关系分层解锁、私聊系统设计。 - [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 对照阅读。 - 如果要判断是否符合目标,再和 `docs/prd/` 中对应 PRD 对照阅读。

View File

@@ -254,58 +254,49 @@ MVP 支持三种主形象输入方式:
MVP 必须与当前项目可扮演角色动作槽位对齐。 MVP 必须与当前项目可扮演角色动作槽位对齐。
当前落地实现补充约束(`2026-04-19` 当前落地实现补充约束(`2026-04-20`
- 角色资产工坊默认固定生成入口收敛`idle / run / attack / die` - 角色资产工坊固定生成入口`idle / run / attack / die`
- `hurt` 不再作为固定按钮动作 - `run / attack` 是固定基础必生成动作
- `idle / die` 改为固定可选动作,不再作为发布硬门槛
- `idle` 未生成时默认直接使用主图静止显示
- `die` 未生成时默认播放一段基于主图的倒地过渡动画,并最终停在翻转倒地姿态
- 角色已配置的每个技能,都必须在技能编辑面板里补出对应动作预览
- 图生视频默认走火山方舟 `Seedance` 首尾帧方案 - 图生视频默认走火山方舟 `Seedance` 首尾帧方案
- 接口请求体中的两张参考图分别固定为 `first_frame / last_frame` - 接口请求体中的两张参考图分别固定为 `first_frame / last_frame`
- 固定参数为 `1:1``480p``4 秒`、单次 `1` 个视频 - 固定参数为 `1:1``480p``4 秒`、单次 `1` 个视频
- 提示词中的动作名统一传英文动作名 - 提示词中的动作名统一传英文动作名
第一版要求以下基础动作槽位不能为空 第一版动作生成按下面两层规则落地
| 动作槽位 | 是否必填 | 备注 | | 类别 | 动作槽位 | 是否必填 | 备注 |
| --- | --- | --- | | -------- | ------------------------------- | -------- | -------------------------------------------------- |
| `idle` | 必填 | 循环动作 | | 基础动作 | `run` | 必填 | 角色移动主循环动作 |
| `acquire` | 必填 | 可由短变体衍生 | | 基础动作 | `attack` | 必填 | 角色普通攻击主动作 |
| `attack` | 必填 | 一次性动作 | | 技能动作 | `skills[*].actionPreviewConfig` | 必填 | 当前角色每个已配置技能都要有独立动作资源 |
| `run` | 必填 | 循环动作 | | 可选动作 | `idle` | 可选 | 缺失时默认走主图静止待机 |
| `jump` | 必填 | 一次性动作 | | 可选动作 | `die` | 可选 | 缺失时默认走主图倒地过渡动画,最终停在翻转倒地姿态 |
| `double_jump` | 必填 | 可由跳跃二次变体生成 |
| `jump_attack` | 必填 | 一次性动作 |
| `dash` | 必填 | 一次性动作 |
| `hurt` | 必填 | 一次性动作 |
| `die` | 必填 | 一次性动作 |
| `climb` | 必填 | 可由模板生成 |
| `wall_slide` | 必填 | 可由攀爬停帧变体生成 |
这里“不能为空”指的是: 这里“必生成”指的是:
- 每个槽位必须最终指向一套可播放资源 - `run / attack` 必须最终指向可播放资源
- 允许少量槽位由近似动作衍生 - 每个已配置技能都必须带独立 `actionPreviewConfig`
- 但不允许在运行时读到空动画映射 - 发布判定不再要求 `idle / die` 一定存在动画映射
- 运行时仍然不能出现无可用表现;`idle / die` 的缺口由默认兜底承担
## 8.2 技能动作要求 ## 8.2 技能动作要求
本期不要求自动补齐 本期不要求把整套固定技能枚举一次性自动补齐,但对“角色当前实际配置的技能”改为必做
- `skill1` - 不要求预先把 `skill1 / skill2 / skill3 / skill4` 这套历史枚举全部补满
- `skill1_jump` - 只要求当前角色 `skills` 数组里的每个技能都生成独立动作预览
- `skill1_bullet` - 技能动作生成入口继续放在技能编辑面板逐个处理,不塞进固定四按钮里
- `skill1_bullet_fx`
- `skill2`
- `skill2_jump`
- `skill3`
- `skill3_jump`
- `skill3_bullet`
- `skill3_bullet_fx`
- `skill4`
结论: 结论:
- 技能动作本期可选 - 技能动作从“固定枚举可选”调整为“按角色已配技能必做”
- 基础动作本期必做 - 固定基础动作收敛为 `run / attack`
- `idle / die` 保留为可选增强动作
## 8.3 动作生成方式 ## 8.3 动作生成方式
@@ -606,7 +597,7 @@ type GeneratedCharacterAnimationAsset = {
目标: 目标:
-基础动作槽位全部非空,并可一键发布 -必生成动作全部就绪,并为 `idle / die` 提供明确默认兜底
产出: 产出:

View File

@@ -200,7 +200,7 @@
`CustomWorldAgentDraftDetailPanel` 中,当当前卡类型为: `CustomWorldAgentDraftDetailPanel` 中,当当前卡类型为:
```ts ```ts
kind === 'character' kind === 'character';
``` ```
显示按钮: 显示按钮:
@@ -239,11 +239,19 @@ kind === 'character'
基于主图生成当前工坊支持的核心动作: 基于主图生成当前工坊支持的核心动作:
1. `run`
2. `attack`
可选增强动作:
1. `idle` 1. `idle`
2. `run` 2. `die`
3. `attack`
4. `hurt` 补充约束:
5. `die`
1. `run / attack` 为固定必生成动作
2. 角色已配置技能时,对应技能动作也属于必生成动作
3. `idle / die` 只作为可选增强,缺失时分别走主图静止 / 主图倒地过渡动画兜底,死亡动画最终停在翻转倒地姿态
### 阶段 D动作发布 ### 阶段 D动作发布
@@ -350,15 +358,15 @@ type CustomWorldRoleAssetStatus =
发布主图成功后,必须写回: 发布主图成功后,必须写回:
```ts ```ts
imageSrc imageSrc;
generatedVisualAssetId generatedVisualAssetId;
``` ```
发布动作成功后,必须写回: 发布动作成功后,必须写回:
```ts ```ts
generatedAnimationSetId generatedAnimationSetId;
animationMap animationMap;
``` ```
### 明确要求 ### 明确要求
@@ -440,8 +448,8 @@ type SyncRoleAssetsResult = {
### 输入 ### 输入
```ts ```ts
buildRoleAssetStudioContext(snapshot, roleId) buildRoleAssetStudioContext(snapshot, roleId);
applyRoleAssetPublishResult(snapshot, payload) applyRoleAssetPublishResult(snapshot, payload);
``` ```
### 说明 ### 说明
@@ -465,8 +473,8 @@ applyRoleAssetPublishResult(snapshot, payload)
### 导出函数建议 ### 导出函数建议
```ts ```ts
rebuildRoleAssetCoverage(draftProfile) rebuildRoleAssetCoverage(draftProfile);
mergeRoleAssetIntoDraftProfile(draftProfile, payload) mergeRoleAssetIntoDraftProfile(draftProfile, payload);
``` ```
## 10.3 修改 `customWorldAgentOrchestrator.ts` ## 10.3 修改 `customWorldAgentOrchestrator.ts`
@@ -598,7 +606,7 @@ showRoleAssetStudio: boolean;
3. 统一回调: 3. 统一回调:
```ts ```ts
onPublishSuccess(payload) onPublishSuccess(payload);
``` ```
### `onPublishSuccess` 最小字段 ### `onPublishSuccess` 最小字段

View File

@@ -39,9 +39,11 @@
目标用户分三类: 目标用户分三类:
1. 轻创作者 1. 轻创作者
- 有世界灵感,但不擅长结构化填表 - 有世界灵感,但不擅长结构化填表
2. 中度创作者 2. 中度创作者
- 愿意精修角色、地点、主线第一幕,但不想维护大量底层字段 - 愿意精修角色、地点、主线第一幕,但不想维护大量底层字段
3. 重度创作者 3. 重度创作者
@@ -138,37 +140,48 @@
本次 PRD 必须复用以下现有基础: 本次 PRD 必须复用以下现有基础:
1. `src/services/customWorldCreatorIntent.ts` 1. `src/services/customWorldCreatorIntent.ts`
- 已有创作者意图、锚点包、锁定状态的基础结构 - 已有创作者意图、锚点包、锁定状态的基础结构
2. `src/types/customWorld.ts` 2. `src/types/customWorld.ts`
- 已有 `creatorIntent / anchorPack / lockState / generationMode / generationStatus` - 已有 `creatorIntent / anchorPack / lockState / generationMode / generationStatus`
3. `src/services/aiService.ts` 3. `src/services/aiService.ts`
- 已有自定义世界 session 与生成 API 客户端 - 已有自定义世界 session 与生成 API 客户端
4. `server-node/src/services/customWorldSessionStore.ts` 4. `server-node/src/services/customWorldSessionStore.ts`
- 已有澄清问题与 session 的基础概念 - 已有澄清问题与 session 的基础概念
5. `server-node/src/services/customWorldGenerationService.ts` 5. `server-node/src/services/customWorldGenerationService.ts`
- 已有分阶段生成骨架 - 已有分阶段生成骨架
6. `src/components/game-shell/PreGameSelectionFlow.tsx` 6. `src/components/game-shell/PreGameSelectionFlow.tsx`
- 已有世界创建流程入口 - 已有世界创建流程入口
7. `src/components/CustomWorldResultView.tsx` 7. `src/components/CustomWorldResultView.tsx`
- 已有结果页壳层 - 已有结果页壳层
8. `src/components/CustomWorldRoleAssetStudioModal.tsx` 8. `src/components/CustomWorldRoleAssetStudioModal.tsx`
- 已有角色主图与核心动作资产工坊原型 - 已有角色主图与核心动作资产工坊原型
9. `src/services/ai.ts` 9. `src/services/ai.ts`
- 已有 `generateCustomWorldSceneImage(...)` 场景图生成入口 - 已有 `generateCustomWorldSceneImage(...)` 场景图生成入口
10. `server-node/src/modules/assets/characterAssetRoutes.ts` 10. `server-node/src/modules/assets/characterAssetRoutes.ts`
- 已有角色主图发布、角色动作发布、动作模板等资产路由
- 已有角色主图发布、角色动作发布、动作模板等资产路由
11. `server-node/src/routes/runtimeRoutes.ts` 11. `server-node/src/routes/runtimeRoutes.ts`
- 已有 `/custom-world/scene-image` 场景背景图生成路由
- 已有 `/custom-world/scene-image` 场景背景图生成路由
## 3.2 必须替换或重构的现有行为 ## 3.2 必须替换或重构的现有行为
@@ -220,6 +233,7 @@
最终必须输出两类产物: 最终必须输出两类产物:
1. 创作工作产物 1. 创作工作产物
- 世界圣经摘要 - 世界圣经摘要
- 关键角色卡 - 关键角色卡
- 关键地点卡 - 关键地点卡
@@ -478,9 +492,11 @@ type CustomWorldAssetPriorityTier = 'hero' | 'featured' | 'supporting';
判定规则: 判定规则:
1. `hero` 1. `hero`
- 所有 `playableNpcs` - 所有 `playableNpcs`
2. `featured` 2. `featured`
- 被锁定的 `storyNpcs` - 被锁定的 `storyNpcs`
- 主线第一幕直接关联的 `storyNpcs` - 主线第一幕直接关联的 `storyNpcs`
- 势力代表角色 - 势力代表角色
@@ -499,6 +515,7 @@ type CustomWorldScenePriorityTier = 'key' | 'supporting';
判定规则: 判定规则:
1. `key` 1. `key`
- `camp` - `camp`
- 被锁定的 `landmark` - 被锁定的 `landmark`
- 主线第一幕直接关联的 `landmark` - 主线第一幕直接关联的 `landmark`
@@ -530,32 +547,43 @@ type CustomWorldScenePriorityTier = 'key' | 'supporting';
### 动作抽卡策略 ### 动作抽卡策略
角色动作不能一开始就把完整核心动作集全部抽出来 角色动作不能一开始就把所有动作一次性抽完
必须采用两段式 必须采用“先必需、再增强”的两层策略
#### 阶段 A动作试片 #### 阶段 A基础必需动作
每个角色先生成: 每个角色先生成:
1. `idle` 1. `run`
2. `attack` 2. `attack`
用途: 用途:
1. 检查角色一致性是否稳定 1. 检查角色一致性是否稳定
2. 检查动作风格是否匹配 2. 检查移动和出手两条主动作是否可用
3. 检查武器、衣摆和轮廓是否容易漂移 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` 1. `run`
2. `run` 2. `attack`
3. `attack` 3. 当前角色 `skills` 中每个技能的 `actionPreviewConfig`
4. `hurt`
5. `die`
判定方式: 判定方式:
1. `generatedAnimationSetId` 非空 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 状态迁移规则 ## 6.2 状态迁移规则
| 当前阶段 | 触发 | 下一阶段 | | 当前阶段 | 触发 | 下一阶段 |
| --- | --- | --- | | ------------------- | -------------------------------- | ------------------- |
| `collecting_intent` | 最小锚点不足Agent 追问 | `clarifying` | | `collecting_intent` | 最小锚点不足Agent 追问 | `clarifying` |
| `clarifying` | 用户补齐锚点 | `foundation_review` | | `clarifying` | 用户补齐锚点 | `foundation_review` |
| `collecting_intent` | 用户信息已足够并请求底稿 | `foundation_review` | | `collecting_intent` | 用户信息已足够并请求底稿 | `foundation_review` |
| `foundation_review` | 用户精修关键对象 | `object_refining` | | `foundation_review` | 用户精修关键对象 | `object_refining` |
| `object_refining` | 用户请求生成角色或场景资产 | `visual_refining` | | `object_refining` | 用户请求生成角色或场景资产 | `visual_refining` |
| `visual_refining` | 关键角色与场景资产进入可用状态 | `long_tail_review` | | `visual_refining` | 关键角色与场景资产进入可用状态 | `long_tail_review` |
| `object_refining` | 用户明确跳过人工精修并走自动补齐 | `long_tail_review` | | `object_refining` | 用户明确跳过人工精修并走自动补齐 | `long_tail_review` |
| `long_tail_review` | 用户请求发布 | `ready_to_publish` | | `long_tail_review` | 用户请求发布 | `ready_to_publish` |
| `ready_to_publish` | 发布成功 | `published` | | `ready_to_publish` | 发布成功 | `published` |
| 任意阶段 | 发生不可恢复错误 | `error` | | 任意阶段 | 发生不可恢复错误 | `error` |
## 6.3 阶段显示规则 ## 6.3 阶段显示规则
前端顶部摘要区必须展示当前阶段中文标签: 前端顶部摘要区必须展示当前阶段中文标签:
| 阶段 | 展示文案 | | 阶段 | 展示文案 |
| --- | --- | | ------------------- | ------------ |
| `collecting_intent` | 收集世界锚点 | | `collecting_intent` | 收集世界锚点 |
| `clarifying` | 补充关键设定 | | `clarifying` | 补充关键设定 |
| `foundation_review` | 校对世界底稿 | | `foundation_review` | 校对世界底稿 |
| `object_refining` | 精修关键对象 | | `object_refining` | 精修关键对象 |
| `visual_refining` | 生成视觉资产 | | `visual_refining` | 生成视觉资产 |
| `long_tail_review` | 补全长尾内容 | | `long_tail_review` | 补全长尾内容 |
| `ready_to_publish` | 准备发布 | | `ready_to_publish` | 准备发布 |
| `published` | 已发布 | | `published` | 已发布 |
| `error` | 处理异常 | | `error` | 处理异常 |
--- ---
@@ -980,7 +1018,15 @@ interface SendCustomWorldAgentMessageResponse {
type CustomWorldAgentActionRequest = type CustomWorldAgentActionRequest =
| { action: 'lock_cards'; cardIds: string[] } | { action: 'lock_cards'; cardIds: string[] }
| { action: 'unlock_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: 'draft_foundation' }
| { action: 'generate_role_assets'; roleIds: string[] } | { action: 'generate_role_assets'; roleIds: string[] }
| { | {
@@ -1909,9 +1955,12 @@ Agent 会话每次 operation 完成后自动保存 session snapshot。
15. `src/components/custom-world-agent/CustomWorldAgentDraftDetailPanel.tsx` 15. `src/components/custom-world-agent/CustomWorldAgentDraftDetailPanel.tsx`
16. `src/components/custom-world-agent/CustomWorldSceneAssetStudioModal.tsx` 16. `src/components/custom-world-agent/CustomWorldSceneAssetStudioModal.tsx`
17. `src/components/CustomWorldRoleAssetStudioModal.tsx` 17. `src/components/CustomWorldRoleAssetStudioModal.tsx`
- 改成 Agent 可调用版
- 改成 Agent 可调用版
18. `src/components/asset-studio/characterAssetWorkflowPersistence.ts` 18. `src/components/asset-studio/characterAssetWorkflowPersistence.ts`
- 继续复用现有资产接口客户端
- 继续复用现有资产接口客户端
## 15.3 backend ## 15.3 backend

View File

@@ -109,6 +109,7 @@
- 技能冷却要按“本次动作结束后”推进 - 技能冷却要按“本次动作结束后”推进
- 恢复类动作可额外提供冷却推进收益 - 恢复类动作可额外提供冷却推进收益
- 物品动作在战斗态下也算一次战斗回合 - 物品动作在战斗态下也算一次战斗回合
- 战斗中使用物品要先结算物品恢复 / buff / 额外冷却收益,再结算这一回合是否承受敌方单次反击
### 4.3 结果文本 ### 4.3 结果文本
@@ -200,6 +201,12 @@ ongoing battle 的本地/后端结果文本只负责说明这一次动作结算
5. 前端支持 disabled battle option 展示 5. 前端支持 disabled battle option 展示
6. 文档、测试同步更新 6. 文档、测试同步更新
补充落地备注2026-04-20
- `inventory_use` 在战斗中按战斗动作结算,而不是按非战斗库存动作直接短路返回
- 战斗态 `inventory_use` 使用后要消费物品、累计 `itemsUsed`、推进 1 回合基础冷却,再叠加物品自带的 `cooldownReduction`
- 若物品动作结算后战斗仍在继续,`storyText` 直接等于本次战斗结果文本,不触发 AI 续写
本期不做: 本期不做:
1. 新增复杂目标选择 UI 1. 新增复杂目标选择 UI

View File

@@ -1,6 +1,6 @@
# “我的”Tab 设置与账号安全 PRD # “我的”Tab 设置与账号安全 PRD
更新时间:`2026-04-19` 更新时间:`2026-04-20`
## 0. 目标 ## 0. 目标
@@ -56,7 +56,12 @@
## 3. 信息架构 ## 3. 信息架构
设置中心建议固定为段: 设置中心首层固定为段:
1. 主题外观
2. 账号信息
其中“账号信息”二级面板固定承载以下内容:
1. 账号概况 1. 账号概况
2. 当前安全状态 2. 当前安全状态
@@ -66,10 +71,14 @@
交互层级要求补充为: 交互层级要求补充为:
1. 设置首页只展示分区入口与危险操作,不在首页内联展开具体详情 1. 设置首页只展示“主题外观”“账号信息”两个分区入口与危险操作,不在首页内联展开具体详情
2. 点击任一分区入口后,必须进入独立二级面板 2. 点击任一分区入口后,必须进入独立二级面板
3. 二级面板负责单一任务,不允许把详情继续堆在入口列表下面 3. 安全状态、登录设备、操作记录不再作为首页独立入口,统一归入“账号信息”二级面板
4. 更换手机号属于独立操作面板,不允许在账号概况面板内直接展开表单 4. 更换手机号属于独立操作面板,不允许在账号信息面板内直接展开表单
5. 设置首页头部只保留一套主标题,不允许在内容区再重复放置“设置首页”“选择要管理的内容”这类二次标题块
6. 子面板导航动作必须单一明确;同一层面板内有“返回”时,不再同时展示“关闭”
7. 设置首页与各级子面板都必须定义单一滚动容器,列表内容必须可稳定滚动,禁止外层与内层同时争夺滚动
8. 二级或三级面板打开后,下层内容必须进入不可交互状态,并把焦点主动转移到当前面板内;禁止对仍保留焦点的祖先节点使用 `aria-hidden`
底部保留两个危险操作按钮: 底部保留两个危险操作按钮:
@@ -87,7 +96,6 @@
- 登录方式 - 登录方式
- 手机号脱敏值 - 手机号脱敏值
- 微信绑定状态 - 微信绑定状态
- 账号状态
这里只看信息,不做大编辑动作。 这里只看信息,不做大编辑动作。
@@ -201,11 +209,14 @@
1. 设置继续采用当前账号弹窗基础形态即可 1. 设置继续采用当前账号弹窗基础形态即可
2. 移动端优先底部弹层,桌面端可居中弹窗 2. 移动端优先底部弹层,桌面端可居中弹窗
3. 设置首页只保留分区入口,不直接承载分区详情内容 3. 设置首页只保留“主题外观”“账号信息”两个入口,不再单独展示安全状态、登录设备、操作记录入口
4. 分区详情必须通过独立子面板承载,移动端优先使用全宽底部子弹层,桌面端使用覆盖在设置首页之上的居中子面板 4. “账号信息”二级面板直接承载账号概况、安全状态、登录设备、操作记录四块内容,移动端优先纵向滚动,桌面端保持同一面板内稳定扫读
5. 更换手机号必须通过独立操作面板完成,不再使用当前面板内联展开表单 5. 更换手机号必须通过独立操作面板完成,不再使用当前面板内联展开表单
6. 危险操作按钮与普通按钮必须明显区分 6. 危险操作按钮与普通按钮必须明显区分
7. 设置首页标题处禁止展示手机号、脱敏手机号或手机号形态的 displayName 7. 设置首页标题处禁止展示手机号、脱敏手机号或手机号形态的 displayName
8. 设置首页不额外堆砌规则说明文案,标题下直接进入可操作内容
9. 子面板采用覆盖式独立面板承载详情,返回上一级时恢复首页,不在同层同时出现双导航动作
10. 面板切换必须保证键盘焦点始终停留在当前活跃面板内,返回上一级后焦点恢复到触发入口
--- ---

View File

@@ -1,6 +1,6 @@
# 平台“存档”Tab PRD # 平台“存档”Tab PRD
更新时间:`2026-04-19` 更新时间:`2026-04-20`
## 0. 目标 ## 0. 目标
@@ -84,15 +84,13 @@
## 3.1 存档 Tab 首屏结构 ## 3.1 存档 Tab 首屏结构
页面由两部分组成: 页面首屏直接展示存档列表,不再单独保留顶部“最近存档”摘要卡。
1. 顶部摘要卡 列表容器本身需要承担首屏入口作用:
2. 存档列表
顶部摘要卡用于表达: - 用户进入“存档”Tab 后第一屏就看到可恢复存档列表
- 不额外重复展示首个存档的大卡摘要
- 当前共有多少个可恢复存档 - 存档数量、排序状态如需表达,应收敛在列表标题或轻量状态信息中
- 最近一次更新的存档是谁
不要在 UI 中默认堆规则说明文案,只保留简洁的状态表达。 不要在 UI 中默认堆规则说明文案,只保留简洁的状态表达。

View File

@@ -274,6 +274,14 @@
- 当前固定动作入口收敛为 `idle / run / attack / die`,不再内置固定 `hurt` - 当前固定动作入口收敛为 `idle / run / attack / die`,不再内置固定 `hurt`
- 提示词里传给视频模型的动作名统一使用英文动作名 - 提示词里传给视频模型的动作名统一使用英文动作名
实现更新(`2026-04-20`
- `run / attack` 是当前固定动作入口里的基础必生成动作
- `idle / die` 改为可选增强动作,不再作为资产完成度硬门槛
- `idle` 缺失时运行时默认使用主图静止
- `die` 缺失时运行时默认播放一段基于主图的倒地过渡动画,并最终停在翻转倒地姿态
- 技能动作不走固定按钮,但对当前角色 `skills` 中的每个技能都属于必生成动作
## 5.3 补充路线:腾讯云相关能力 ## 5.3 补充路线:腾讯云相关能力
腾讯云相关接口里,`提交图片跳舞任务` 提供了: 腾讯云相关接口里,`提交图片跳舞任务` 提供了:
@@ -435,6 +443,11 @@
系统自动选择对应参考视频模板。 系统自动选择对应参考视频模板。
其中:
- `run / attack` 属于固定必生成动作
- `idle / die` 属于固定可选动作,未生成时走默认兜底
`jump``hurt` 这类扩展动作不再作为当前编辑器固定按钮,改为后续扩展动作槽位或手动补齐。 `jump``hurt` 这类扩展动作不再作为当前编辑器固定按钮,改为后续扩展动作槽位或手动补齐。
### B. 视频驱动 ### B. 视频驱动
@@ -941,7 +954,7 @@ draft
### 12.1 基础动作槽位必须非空 ### 12.1 基础动作槽位必须非空
第一版要求以下基础动作槽位全部有内容 第一版要求以下动作能力按“必生成 / 可选兜底”拆开
当前编辑器固定生成入口补充说明(`2026-04-19` 当前编辑器固定生成入口补充说明(`2026-04-19`
@@ -949,47 +962,33 @@ draft
- `hurt` 不再作为固定生成按钮 - `hurt` 不再作为固定生成按钮
- 如果运行时仍需 `hurt` 资源,应通过后续扩展动作槽位或手动补齐 - 如果运行时仍需 `hurt` 资源,应通过后续扩展动作槽位或手动补齐
| 动作槽位 | 是否必填 | 建议来源 | | 动作能力 | 是否必填 | 建议来源 |
| ------------- | -------- | ------------------------- | | ------------------------------- | -------- | ---------------------------------------------------------- |
| `idle` | 必填 | 模板生成 | | `run` | 必填 | 模板生成 |
| `acquire` | 必填 | 可由短持物 / 抬手动作生成 | | `attack` | 必填 | 模板生成 |
| `attack` | 必填 | 模板生成 | | `skills[*].actionPreviewConfig` | 必填 | 技能编辑面板逐个生成 |
| `run` | 必填 | 模板生成 | | `idle` | 可选 | 模板生成;缺失时默认主图静止 |
| `jump` | 必填 | 模板生成 | | `die` | 可选 | 模板生成;缺失时默认主图倒地过渡动画,最终停在翻转倒地姿态 |
| `double_jump` | 必填 | 可由跳跃二次变体生成 |
| `jump_attack` | 必填 | 跳跃攻击模板 |
| `dash` | 必填 | 冲刺模板 |
| `hurt` | 必填 | 受击模板 |
| `die` | 必填 | 倒地 / 消散模板 |
| `climb` | 必填 | 攀爬模板 |
| `wall_slide` | 必填 | 可由攀爬或停滞帧变体生成 |
这里“不能为空”指的是: 这里“必填”指的是:
- 每个基础动作槽位必须能挂到一套可播放资产 - `run / attack` 必须能挂到一套可播放资产
- 不允许在运行时出现 `null` 或空映射 - 角色当前每个技能都必须有可播放的 `actionPreviewConfig`
- 个别低优先动作允许由近似动作衍生,但槽位本身必须有有效资源 - `idle / die` 不再进入“缺失即阻塞发布”的判断
- 运行时表现仍然不能空白;`idle / die` 的缺口由默认兜底承接
### 12.2 技能动作不是第一版强制 ### 12.2 技能动作改为“按角色已配技能强制
第一版可选 第一版不再要求预留整套固定技能枚举,但要求
- `skill1` - 当前角色 `skills` 数组里的每个技能都要补出 `actionPreviewConfig`
- `skill1_jump` - 技能动作继续在技能编辑面板逐个生成,不并入固定四按钮
- `skill1_bullet`
- `skill1_bullet_fx`
- `skill2`
- `skill2_jump`
- `skill3`
- `skill3_jump`
- `skill3_bullet`
- `skill3_bullet_fx`
- `skill4`
策略建议: 策略建议:
- 基础动作先全量补齐 - 先补 `run / attack`
- 技能动作后续按角色职业差异再补 - 再逐个补当前角色已有技能动作
- `idle / die` 作为可选增强按需要补
- 投射物与特效优先继续复用当前项目已有素材与技能特效系统 - 投射物与特效优先继续复用当前项目已有素材与技能特效系统
### 12.3 第一阶段优先模板 ### 12.3 第一阶段优先模板

View File

@@ -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. 最后补创作页的幕增删改序和独立配置面板
这样可以先把“能跑”补齐,再把“编辑体验”补完整。

View File

@@ -197,6 +197,11 @@ export interface CustomWorldFoundationDraftCharacter {
relationToPlayer: string; relationToPlayer: string;
threadIds: string[]; threadIds: string[];
summary: string; summary: string;
skills?: Array<{
id: string;
name: string;
actionPreviewConfig?: Record<string, unknown> | null;
}>;
imageSrc?: string | null; imageSrc?: string | null;
generatedVisualAssetId?: string | null; generatedVisualAssetId?: string | null;
generatedAnimationSetId?: string | null; generatedAnimationSetId?: string | null;
@@ -212,6 +217,7 @@ export interface CustomWorldFoundationDraftLandmark {
importance: string; importance: string;
secret?: string; secret?: string;
dangerLevel?: string; dangerLevel?: string;
imageSrc?: string | null;
characterIds: string[]; characterIds: string[];
threadIds: string[]; threadIds: string[];
summary: string; summary: string;
@@ -246,9 +252,48 @@ export interface CustomWorldFoundationDraftCamp {
description: string; description: string;
mood: string; mood: string;
dangerLevel?: string; dangerLevel?: string;
imageSrc?: string | null;
summary: string; 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 { export interface CustomWorldFoundationDraftProfile {
name: string; name: string;
subtitle: string; subtitle: string;
@@ -266,6 +311,7 @@ export interface CustomWorldFoundationDraftProfile {
factions: CustomWorldFoundationDraftFaction[]; factions: CustomWorldFoundationDraftFaction[];
threads: CustomWorldFoundationDraftThread[]; threads: CustomWorldFoundationDraftThread[];
chapters: CustomWorldFoundationDraftChapter[]; chapters: CustomWorldFoundationDraftChapter[];
sceneChapters: CustomWorldFoundationDraftSceneChapter[];
worldHook: string; worldHook: string;
playerPremise: string; playerPremise: string;
openingSituation: string; openingSituation: string;

View File

@@ -83,6 +83,26 @@ export type PlainTextResponse = {
text: string; 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< export type CharacterChatReplyRequest<
TCharacter = unknown, TCharacter = unknown,
TStoryMoment = unknown, TStoryMoment = unknown,
@@ -162,6 +182,7 @@ export type NpcChatTurnRequest<
TNpcState = unknown, TNpcState = unknown,
TQuestOfferState = unknown, TQuestOfferState = unknown,
TQuestOfferEncounter = unknown, TQuestOfferEncounter = unknown,
TChatDirective = NpcChatTurnDirective,
> = { > = {
worldType: string; worldType: string;
character?: TCharacter; character?: TCharacter;
@@ -179,6 +200,7 @@ export type NpcChatTurnRequest<
encounter: TQuestOfferEncounter; encounter: TQuestOfferEncounter;
turnCount: number; turnCount: number;
} | null; } | null;
chatDirective?: TChatDirective | null;
}; };
export type NpcChatPendingQuestOffer<TQuest = unknown> = { export type NpcChatPendingQuestOffer<TQuest = unknown> = {
@@ -192,6 +214,7 @@ export type NpcChatTurnResult<TQuest = unknown> = {
affinityText: string; affinityText: string;
suggestions: string[]; suggestions: string[];
pendingQuestOffer?: NpcChatPendingQuestOffer<TQuest> | null; pendingQuestOffer?: NpcChatPendingQuestOffer<TQuest> | null;
chatDirective?: NpcChatTurnCompletionDirective | null;
}; };
export type NpcRecruitDialogueRequest< export type NpcRecruitDialogueRequest<

View File

@@ -3,6 +3,13 @@ import type {
RuntimeStoryChoicePayload, RuntimeStoryChoicePayload,
RuntimeStoryPatch, RuntimeStoryPatch,
} from '../../../../packages/shared/src/contracts/story.js'; } from '../../../../packages/shared/src/contracts/story.js';
import {
buildInventoryUseResultText,
incrementGameRuntimeStats,
isInventoryItemUsable,
removeInventoryItem,
resolveInventoryItemUseEffect,
} from '../../bridges/legacyInventoryRuntimeBridge.js';
import { conflict } from '../../errors.js'; import { conflict } from '../../errors.js';
import { import {
appendBuildBuffs, appendBuildBuffs,
@@ -32,6 +39,9 @@ type CombatActionConfig = {
tags: string[]; tags: string[];
durationTurns: number; durationTurns: number;
}>; }>;
consumedItemId?: string | null;
usedItem?: RuntimeCombatInventoryItem | null;
itemEffect?: NonNullable<ReturnType<typeof resolveInventoryItemUseEffect>> | null;
}; };
export type CombatResolution = { export type CombatResolution = {
@@ -50,6 +60,13 @@ const LEGACY_ATTACK_FUNCTION_IDS = new Set<string>([
'battle_finisher_window', 'battle_finisher_window',
]); ]);
type RuntimeCombatInventoryItem = Parameters<
typeof resolveInventoryItemUseEffect
>[0] & {
id: string;
quantity: number;
};
function isObject(value: unknown): value is Record<string, unknown> { function isObject(value: unknown): value is Record<string, unknown> {
return typeof value === 'object' && value !== null && !Array.isArray(value); 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() : ''; 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) { function getAliveTarget(session: RuntimeSession) {
return session.sceneHostileNpcs.find((npc) => npc.hp > 0) ?? null; 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) { function applySparAffinityReward(session: RuntimeSession) {
const npcState = getEncounterNpcState(session); const npcState = getEncounterNpcState(session);
const encounter = session.currentEncounter; const encounter = session.currentEncounter;
@@ -210,6 +274,53 @@ function resolveCombatActionConfig(params: {
} satisfies CombatActionConfig; } 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}`); throw conflict(`暂不支持的战斗动作:${functionId}`);
} }
@@ -304,6 +415,25 @@ export function resolveCombatAction(
} }
session.rawGameState.playerSkillCooldowns = nextCooldowns; session.rawGameState.playerSkillCooldowns = nextCooldowns;
if (action.consumedItemId) {
session.rawGameState.playerInventory = removeInventoryItem(
session.rawGameState.playerInventory as Parameters<typeof removeInventoryItem>[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<typeof incrementGameRuntimeStats>[0],
{ itemsUsed: 1 },
);
}
if (action.buildBuffs?.length) { if (action.buildBuffs?.length) {
session.rawGameState.activeBuildBuffs = appendBuildBuffs( session.rawGameState.activeBuildBuffs = appendBuildBuffs(
(session.rawGameState.activeBuildBuffs as Parameters<typeof appendBuildBuffs>[0]) ?? (session.rawGameState.activeBuildBuffs as Parameters<typeof appendBuildBuffs>[0]) ??
@@ -354,7 +484,10 @@ export function resolveCombatAction(
patches.push(affinityPatch); patches.push(affinityPatch);
} }
outcome = 'spar_complete'; 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) { } else if (!isSpar && session.playerHp <= 0) {
session.playerHp = 0; session.playerHp = 0;
session.inBattle = false; session.inBattle = false;
@@ -363,9 +496,21 @@ export function resolveCombatAction(
session.npcInteractionActive = false; session.npcInteractionActive = false;
session.currentEncounter = null; session.currentEncounter = null;
outcome = 'escaped'; 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') { } else if (params.functionId === 'battle_recover_breath') {
resultText = `你先把伤势和气息稳住了一轮,但${target.name}仍在持续逼近。`; 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') { } else if (params.functionId === 'battle_use_skill') {
resultText = `${action.actionText}${target.name}`; resultText = `${action.actionText}${target.name}`;
} else { } else {

View File

@@ -225,6 +225,43 @@ export interface CustomWorldSceneConnection {
summary: string; 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 { export interface CustomWorldCampScene {
name: string; name: string;
description: string; description: string;
@@ -323,6 +360,7 @@ export interface CustomWorldProfile {
storyGraph?: WorldStoryGraph | null; storyGraph?: WorldStoryGraph | null;
knowledgeFacts?: Array<Record<string, unknown>> | null; knowledgeFacts?: Array<Record<string, unknown>> | null;
threadContracts?: Array<Record<string, unknown>> | null; threadContracts?: Array<Record<string, unknown>> | null;
sceneChapterBlueprints?: SceneChapterBlueprint[] | null;
anchorContent?: Record<string, unknown> | null; anchorContent?: Record<string, unknown> | null;
creatorIntent?: CustomWorldCreatorIntent | null; creatorIntent?: CustomWorldCreatorIntent | null;
anchorPack?: CustomWorldAnchorPack | null; anchorPack?: CustomWorldAnchorPack | null;

View File

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

View File

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

View File

@@ -0,0 +1,192 @@
import { getLevelBenchmark, MAX_PLAYER_LEVEL } from './levelBenchmarks.js';
type JsonRecord = Record<string, unknown>;
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('')}`;
}

View File

@@ -3,10 +3,16 @@ import {
normalizeQuestLogEntries, normalizeQuestLogEntries,
} from '../../bridges/legacyQuestProgressBridge.js'; } from '../../bridges/legacyQuestProgressBridge.js';
export type QuestLogEntry = Parameters<typeof normalizeQuestLogEntries>[0][number]; export type QuestLogEntry = Parameters<
export type QuestProgressSignal = Parameters<typeof applyQuestProgressSignal>[1]; 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 = { export type QuestMutationFailure = {
ok: false; ok: false;
@@ -61,7 +67,9 @@ function buildSuccess(
}; };
} }
export function normalizeQuestEntries(quests: QuestLogEntry[]): QuestLogEntry[] { export function normalizeQuestEntries(
quests: QuestLogEntry[],
): QuestLogEntry[] {
return normalizeQuestLogEntries(quests); return normalizeQuestLogEntries(quests);
} }
@@ -116,10 +124,7 @@ export function getQuestForIssuer(
); );
} }
export function acceptQuest( export function acceptQuest(quests: QuestLogEntry[], quest: QuestLogEntry) {
quests: QuestLogEntry[],
quest: QuestLogEntry,
) {
const normalizedQuests = normalizeQuestEntries(quests); const normalizedQuests = normalizeQuestEntries(quests);
if (findQuestById(normalizedQuests, quest.id)) { if (findQuestById(normalizedQuests, quest.id)) {
return normalizedQuests; 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 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 const intelText = normalizedQuest.reward.intel?.rumorText
? `,并额外告诉了你一条消息:${normalizedQuest.reward.intel.rumorText}` ? `,并额外告诉了你一条消息:${normalizedQuest.reward.intel.rumorText}`
: ''; : '';
const storyHintText = normalizedQuest.reward.storyHint const storyHintText = normalizedQuest.reward.storyHint
? ` ${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) { export function isQuestReadyToClaim(quest: QuestLogEntry) {
@@ -154,10 +168,7 @@ export function isQuestReadyToClaim(quest: QuestLogEntry) {
return status === 'ready_to_turn_in' || status === 'completed'; return status === 'ready_to_turn_in' || status === 'completed';
} }
export function markQuestTurnedIn( export function markQuestTurnedIn(quests: QuestLogEntry[], questId: string) {
quests: QuestLogEntry[],
questId: string,
) {
return quests.map((quest) => return quests.map((quest) =>
quest.id === questId quest.id === questId
? normalizeQuestEntries([ ? normalizeQuestEntries([

View File

@@ -2,6 +2,10 @@ import type {
RuntimeStoryActionRequest, RuntimeStoryActionRequest,
RuntimeStoryPatch, RuntimeStoryPatch,
} from '../../../../packages/shared/src/contracts/story.js'; } from '../../../../packages/shared/src/contracts/story.js';
import {
buildExperienceGrantResultText,
grantPlayerExperience,
} from '../progression/playerProgressionService.js';
import { conflict, invalidRequest } from '../../errors.js'; import { conflict, invalidRequest } from '../../errors.js';
import { import {
appendStoryEngineCarrierMemory, appendStoryEngineCarrierMemory,
@@ -37,7 +41,9 @@ type QuestStoryResolution = {
type JsonRecord = Record<string, unknown>; type JsonRecord = Record<string, unknown>;
type RuntimeGameState = Parameters<typeof appendStoryEngineCarrierMemory>[0]; type RuntimeGameState = Parameters<typeof appendStoryEngineCarrierMemory>[0];
type RuntimeQuestLogEntry = NonNullable<ReturnType<typeof buildQuestForEncounter>>; type RuntimeQuestLogEntry = NonNullable<
ReturnType<typeof buildQuestForEncounter>
>;
type RuntimeNpcState = Parameters< type RuntimeNpcState = Parameters<
typeof markNpcFirstMeaningfulContactResolved typeof markNpcFirstMeaningfulContactResolved
>[0]; >[0];
@@ -159,7 +165,8 @@ function resolveQuestAcceptAction(
session: RuntimeSession, session: RuntimeSession,
currentStory?: unknown, currentStory?: unknown,
): QuestStoryResolution { ): QuestStoryResolution {
const { state, encounter, npcKey, npcState } = ensureEncounterQuestContext(session); const { state, encounter, npcKey, npcState } =
ensureEncounterQuestContext(session);
const quests = Array.isArray(state.quests) ? state.quests : []; const quests = Array.isArray(state.quests) ? state.quests : [];
const existingQuest = getQuestForIssuer(quests, npcKey); const existingQuest = getQuestForIssuer(quests, npcKey);
if (existingQuest) { if (existingQuest) {
@@ -174,6 +181,14 @@ function resolveQuestAcceptAction(
roleText: encounter.context, roleText: encounter.context,
scene: state.currentScenePreset, scene: state.currentScenePreset,
worldType: state.worldType, 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) => ({ currentQuests: quests.map((item) => ({
id: item.id, id: item.id,
issuerNpcId: item.issuerNpcId, issuerNpcId: item.issuerNpcId,
@@ -214,7 +229,8 @@ function resolveQuestTurnInAction(
session: RuntimeSession, session: RuntimeSession,
request: RuntimeStoryActionRequest, request: RuntimeStoryActionRequest,
): QuestStoryResolution { ): QuestStoryResolution {
const { state, encounter, npcKey, npcState } = ensureEncounterQuestContext(session); const { state, encounter, npcKey, npcState } =
ensureEncounterQuestContext(session);
const quests = Array.isArray(state.quests) ? state.quests : []; const quests = Array.isArray(state.quests) ? state.quests : [];
const questId = readQuestId(request); const questId = readQuestId(request);
const quest = const quest =
@@ -235,11 +251,22 @@ function resolveQuestTurnInAction(
} }
const nextAffinity = npcState.affinity + quest.reward.affinityBonus; const nextAffinity = npcState.affinity + quest.reward.affinityBonus;
const experienceGrant = grantPlayerExperience(
state.playerProgression,
quest.reward.experience ?? 0,
{
source: 'quest',
},
);
let nextState = { let nextState = {
...state, ...state,
quests: turnInResult.nextQuests, quests: turnInResult.nextQuests,
playerProgression: experienceGrant.state,
playerCurrency: state.playerCurrency + quest.reward.currency, playerCurrency: state.playerCurrency + quest.reward.currency,
playerInventory: addInventoryItems(state.playerInventory, quest.reward.items), playerInventory: addInventoryItems(
state.playerInventory,
quest.reward.items,
),
npcStates: { npcStates: {
...state.npcStates, ...state.npcStates,
[npcKey]: { [npcKey]: {
@@ -258,7 +285,9 @@ function resolveQuestTurnInAction(
return { return {
actionText: `${encounter.npcName}交付委托`, actionText: `${encounter.npcName}交付委托`,
resultText: buildQuestTurnInResultText(quest), resultText: buildQuestTurnInResultText(quest, {
experienceText: buildExperienceGrantResultText(experienceGrant),
}),
patches: [ patches: [
{ {
type: 'npc_affinity_changed', type: 'npc_affinity_changed',

View File

@@ -38,6 +38,7 @@ export type QuestRewardItem = {
export type QuestReward = { export type QuestReward = {
affinityBonus: number; affinityBonus: number;
currency: number; currency: number;
experience?: number;
items: QuestRewardItem[]; items: QuestRewardItem[];
intel?: { intel?: {
rumorText: string; rumorText: string;
@@ -150,7 +151,11 @@ export type QuestOpportunity = {
}; };
export type QuestProgressSignal = 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: 'treasure_inspected'; sceneId?: string | null }
| { kind: 'npc_spar_completed'; npcId: string } | { kind: 'npc_spar_completed'; npcId: string }
| { kind: 'npc_talk_completed'; npcId: string } | { kind: 'npc_talk_completed'; npcId: string }
@@ -189,6 +194,12 @@ export type QuestGenerationContext = {
name?: string; name?: string;
title?: string; title?: string;
} | null; } | null;
playerProgression?: {
level?: number;
currentLevelXp?: number;
totalXp?: number;
xpToNextLevel?: number;
} | null;
playerHp?: number; playerHp?: number;
playerMaxHp?: number; playerMaxHp?: number;
playerMana?: number; playerMana?: number;
@@ -254,6 +265,7 @@ type RuntimeStateLike = {
currentScenePreset?: RuntimeSceneLike | null; currentScenePreset?: RuntimeSceneLike | null;
storyHistory: Array<{ text: string }>; storyHistory: Array<{ text: string }>;
playerCharacter?: QuestGenerationContext['playerCharacter']; playerCharacter?: QuestGenerationContext['playerCharacter'];
playerProgression?: QuestGenerationContext['playerProgression'];
playerHp?: number; playerHp?: number;
playerMaxHp?: number; playerMaxHp?: number;
playerMana?: number; playerMana?: number;
@@ -267,7 +279,11 @@ type RuntimeStateLike = {
}; };
const REWARD_READY_STATUSES: QuestStatus[] = ['ready_to_turn_in', 'completed']; 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) { function clampProgress(progress: number | undefined, requiredCount: number) {
return Math.max(0, Math.min(requiredCount, Math.round(progress ?? 0))); return Math.max(0, Math.min(requiredCount, Math.round(progress ?? 0)));
@@ -341,7 +357,8 @@ function getScenePrimaryThreat(
} }
const hostileNpc = const hostileNpc =
scene.npcs.find((npc) => Boolean(npc.hostile || npc.monsterPresetId)) ?? null; scene.npcs.find((npc) => Boolean(npc.hostile || npc.monsterPresetId)) ??
null;
if (hostileNpc) { if (hostileNpc) {
const targetHostileNpcId = hostileNpc.monsterPresetId ?? hostileNpc.id; const targetHostileNpcId = hostileNpc.monsterPresetId ?? hostileNpc.id;
return { 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: { function buildQuestReward(params: {
issuerNpcId: string; issuerNpcId: string;
issuerNpcName: string; issuerNpcName: string;
worldType: string | null | undefined; worldType: string | null | undefined;
rewardTheme: QuestRewardTheme; rewardTheme: QuestRewardTheme;
narrativeType: QuestNarrativeType; narrativeType: QuestNarrativeType;
urgency: QuestUrgency;
stepCount: number;
scene: QuestSceneSnapshot | null; scene: QuestSceneSnapshot | null;
context?: QuestGenerationContext;
}) { }) {
const baseCurrency = const baseCurrency =
params.rewardTheme === 'intel' params.rewardTheme === 'intel'
@@ -453,10 +528,17 @@ function buildQuestReward(params: {
const reward: QuestReward = { const reward: QuestReward = {
affinityBonus: affinityBonus:
params.narrativeType === 'relationship' || params.narrativeType === 'trial' params.narrativeType === 'relationship' ||
params.narrativeType === 'trial'
? 14 ? 14
: 12, : 12,
currency: baseCurrency, currency: baseCurrency,
experience: buildQuestExperienceReward({
context: params.context,
narrativeType: params.narrativeType,
urgency: params.urgency,
stepCount: params.stepCount,
}),
items: buildRewardItems(params), items: buildRewardItems(params),
storyHint: `${params.issuerNpcName}把和眼前局势最相关的收获留给了你。`, storyHint: `${params.issuerNpcName}把和眼前局势最相关的收获留给了你。`,
}; };
@@ -479,10 +561,12 @@ function buildRewardText(
) { ) {
const itemText = const itemText =
reward.items.map((item) => item.name).join('、') || '当前局势相关的补给'; reward.items.map((item) => item.name).join('、') || '当前局势相关的补给';
const experienceText =
(reward.experience ?? 0) > 0 ? `、经验 +${reward.experience}` : '';
const intelText = reward.intel?.rumorText const intelText = reward.intel?.rumorText
? `,以及情报“${reward.intel.rumorText}` ? `,以及情报“${reward.intel.rumorText}`
: ''; : '';
return `完成后可获得好感 +${reward.affinityBonus}${formatCurrency( return `完成后可获得好感 +${reward.affinityBonus}${experienceText}${formatCurrency(
reward.currency, reward.currency,
worldType, worldType,
)}${itemText}${intelText}`; )}${itemText}${intelText}`;
@@ -522,7 +606,7 @@ function buildPrimaryQuestStep(params: {
: [threat.kind]; : [threat.kind];
const chosenKind = preferredKinds.includes(threat.kind) const chosenKind = preferredKinds.includes(threat.kind)
? threat.kind ? threat.kind
: preferredKinds[0] ?? threat.kind; : (preferredKinds[0] ?? threat.kind);
if (chosenKind === 'inspect_treasure' && scene) { if (chosenKind === 'inspect_treasure' && scene) {
return { return {
@@ -606,7 +690,9 @@ function normalizeQuestTitle(rawTitle: string, fallbackTitle: string) {
return title; 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 { function normalizeQuestLogEntry(quest: QuestLogEntry): QuestLogEntry {
@@ -618,7 +704,8 @@ function normalizeQuestLogEntry(quest: QuestLogEntry): QuestLogEntry {
Math.max(1, Math.round(step.requiredCount ?? 1)), 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 terminal = isTerminalStatus(quest.status);
const rewardReady = !terminal && !activeStep ? 'completed' : quest.status; const rewardReady = !terminal && !activeStep ? 'completed' : quest.status;
@@ -626,10 +713,21 @@ function normalizeQuestLogEntry(quest: QuestLogEntry): QuestLogEntry {
...quest, ...quest,
title: normalizeQuestTitle(quest.title, quest.title), title: normalizeQuestTitle(quest.title, quest.title),
summary: quest.summary.trim() || quest.description.trim(), summary: quest.summary.trim() || quest.description.trim(),
progress: activeStep?.progress ?? steps[steps.length - 1]?.requiredCount ?? 0, progress:
objective: deriveObjectiveFromStep(activeStep ?? steps[steps.length - 1] ?? null), activeStep?.progress ?? steps[steps.length - 1]?.requiredCount ?? 0,
objective: deriveObjectiveFromStep(
activeStep ?? steps[steps.length - 1] ?? null,
),
status: terminal ? quest.status : rewardReady, status: terminal ? quest.status : rewardReady,
completionNotified: quest.completionNotified ?? false, 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(), rewardText: quest.rewardText.trim(),
steps, steps,
activeStepId: activeStep?.id ?? null, activeStepId: activeStep?.id ?? null,
@@ -659,7 +757,9 @@ function stepMatchesSignal(step: QuestStep, signal: QuestProgressSignal) {
case 'npc_talk_completed': case 'npc_talk_completed':
return step.kind === 'talk_to_npc' && step.targetNpcId === signal.npcId; return step.kind === 'talk_to_npc' && step.targetNpcId === signal.npcId;
case 'scene_reached': case 'scene_reached':
return step.kind === 'reach_scene' && step.targetSceneId === signal.sceneId; return (
step.kind === 'reach_scene' && step.targetSceneId === signal.sceneId
);
case 'item_delivered': case 'item_delivered':
return ( return (
step.kind === 'deliver_item' && step.kind === 'deliver_item' &&
@@ -701,7 +801,8 @@ export function buildQuestGenerationContextFromState(params: {
issuerWarmthStage: getNpcWarmthStage(issuerState?.affinity ?? 0, { issuerWarmthStage: getNpcWarmthStage(issuerState?.affinity ?? 0, {
recruited: issuerState?.recruited, recruited: issuerState?.recruited,
}), }),
activeThreadIds: state.storyEngineMemory?.activeThreadIds?.slice(0, 4) ?? [], activeThreadIds:
state.storyEngineMemory?.activeThreadIds?.slice(0, 4) ?? [],
encounterKind: encounter.kind ?? 'npc', encounterKind: encounter.kind ?? 'npc',
currentSceneTreasureHintCount: currentSceneTreasureHintCount:
state.currentScenePreset?.treasureHints?.length ?? 0, state.currentScenePreset?.treasureHints?.length ?? 0,
@@ -710,6 +811,7 @@ export function buildQuestGenerationContextFromState(params: {
.map((npc) => npc.monsterPresetId ?? npc.id), .map((npc) => npc.monsterPresetId ?? npc.id),
recentStoryMoments: state.storyHistory.slice(-6), recentStoryMoments: state.storyHistory.slice(-6),
playerCharacter: state.playerCharacter ?? null, playerCharacter: state.playerCharacter ?? null,
playerProgression: state.playerProgression ?? null,
playerHp: state.playerHp, playerHp: state.playerHp,
playerMaxHp: state.playerMaxHp, playerMaxHp: state.playerMaxHp,
playerMana: state.playerMana, playerMana: state.playerMana,
@@ -731,15 +833,21 @@ 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) { export function getQuestForIssuer(
quests: QuestLogEntry[],
issuerNpcId: string,
) {
return ( return (
normalizeQuestLogEntries(quests).find( normalizeQuestLogEntries(quests).find(
(quest) => quest.issuerNpcId === issuerNpcId && quest.status !== 'turned_in', (quest) =>
quest.issuerNpcId === issuerNpcId && quest.status !== 'turned_in',
) ?? null ) ?? null
); );
} }
export function evaluateQuestOpportunity(params: QuestPreviewRequest): QuestOpportunity { export function evaluateQuestOpportunity(
params: QuestPreviewRequest,
): QuestOpportunity {
const { issuerNpcId, scene, currentQuests = [] } = params; const { issuerNpcId, scene, currentQuests = [] } = params;
if (!scene) { if (!scene) {
return { return {
@@ -750,7 +858,8 @@ export function evaluateQuestOpportunity(params: QuestPreviewRequest): QuestOppo
if ( if (
currentQuests.some( currentQuests.some(
(quest) => quest.issuerNpcId === issuerNpcId && quest.status !== 'turned_in', (quest) =>
quest.issuerNpcId === issuerNpcId && quest.status !== 'turned_in',
) )
) { ) {
return { return {
@@ -888,7 +997,10 @@ export function compileQuestIntentToQuest(
worldType: params.worldType, worldType: params.worldType,
rewardTheme: intent.rewardTheme, rewardTheme: intent.rewardTheme,
narrativeType: intent.narrativeType, narrativeType: intent.narrativeType,
urgency: intent.urgency,
stepCount: steps.length,
scene: params.scene, scene: params.scene,
context: params.context,
}); });
const rewardText = buildRewardText(reward, params.worldType); const rewardText = buildRewardText(reward, params.worldType);

View File

@@ -67,6 +67,7 @@ test('runtime snapshot hydration normalizes server snapshots for frontend restor
rewardText: '完成后可领取测试奖励。', rewardText: '完成后可领取测试奖励。',
reward: { reward: {
currency: 10, currency: 10,
experience: 0,
items: [], items: [],
}, },
steps: [ 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.playerMaxMana, 95);
assert.equal(snapshot.gameState.playerMana, 22); assert.equal(snapshot.gameState.playerMana, 22);
assert.equal(snapshot.gameState.playerCurrency, 160); 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.roster, []);
assert.deepEqual(snapshot.gameState.storyEngineMemory.activeThreadIds, []); assert.deepEqual(snapshot.gameState.storyEngineMemory.activeThreadIds, []);
assert.equal( assert.equal(
@@ -200,7 +203,16 @@ test('runtime snapshot hydration backfills starter loadout when legacy saves omi
assert.ok(snapshot); assert.ok(snapshot);
assert.equal(snapshot.gameState.playerMaxHp, 208); assert.equal(snapshot.gameState.playerMaxHp, 208);
assert.equal(snapshot.gameState.playerMaxMana, 1009); assert.equal(snapshot.gameState.playerMaxMana, 1009);
assert.equal(snapshot.gameState.playerEquipment.weapon?.id, 'starter:hero:weapon'); assert.equal(
assert.equal(snapshot.gameState.playerEquipment.armor?.id, 'starter:hero:armor'); snapshot.gameState.playerEquipment.weapon?.id,
assert.equal(snapshot.gameState.playerEquipment.relic?.id, 'starter:hero:relic'); 'starter:hero:weapon',
);
assert.equal(
snapshot.gameState.playerEquipment.armor?.id,
'starter:hero:armor',
);
assert.equal(
snapshot.gameState.playerEquipment.relic?.id,
'starter:hero:relic',
);
}); });

View File

@@ -1,5 +1,6 @@
import { jsonClone } from '../../http.js'; import { jsonClone } from '../../http.js';
import type { SavedSnapshot } from '../../repositories/runtimeRepository.js'; import type { SavedSnapshot } from '../../repositories/runtimeRepository.js';
import { normalizePlayerProgressionState } from '../progression/playerProgressionService.js';
import { normalizeQuestEntries } from '../quest/questProgressionService.js'; import { normalizeQuestEntries } from '../quest/questProgressionService.js';
import { import {
createEmptyEquipmentLoadout, createEmptyEquipmentLoadout,
@@ -61,9 +62,7 @@ function clampNonNegativeInteger(value: unknown) {
} }
function normalizeBottomTab(value: unknown) { function normalizeBottomTab(value: unknown) {
return value === 'character' || value === 'inventory' return value === 'character' || value === 'inventory' ? value : 'adventure';
? value
: 'adventure';
} }
function buildSaveMigrationManifest() { function buildSaveMigrationManifest() {
@@ -135,9 +134,7 @@ function normalizeRuntimeStats(
? Math.max(0, rawStats.playTimeMs) ? Math.max(0, rawStats.playTimeMs)
: 0, : 0,
lastPlayTickAt: options.isActiveRun ? new Date(now).toISOString() : null, lastPlayTickAt: options.isActiveRun ? new Date(now).toISOString() : null,
hostileNpcsDefeated: clampNonNegativeInteger( hostileNpcsDefeated: clampNonNegativeInteger(rawStats.hostileNpcsDefeated),
rawStats.hostileNpcsDefeated,
),
questsAccepted: clampNonNegativeInteger(rawStats.questsAccepted), questsAccepted: clampNonNegativeInteger(rawStats.questsAccepted),
itemsUsed: clampNonNegativeInteger(rawStats.itemsUsed), itemsUsed: clampNonNegativeInteger(rawStats.itemsUsed),
scenesTraveled: clampNonNegativeInteger(rawStats.scenesTraveled), scenesTraveled: clampNonNegativeInteger(rawStats.scenesTraveled),
@@ -146,28 +143,30 @@ function normalizeRuntimeStats(
function normalizeCharacterChats(value: unknown) { function normalizeCharacterChats(value: unknown) {
return Object.fromEntries( return Object.fromEntries(
Object.entries(isRecord(value) ? value : {}).map(([characterId, record]) => { Object.entries(isRecord(value) ? value : {}).map(
const rawRecord = isRecord(record) ? record : {}; ([characterId, record]) => {
const rawRecord = isRecord(record) ? record : {};
return [ return [
characterId, characterId,
{ {
history: readArray(rawRecord.history) history: readArray(rawRecord.history)
.filter( .filter(
(turn) => (turn) =>
isRecord(turn) && isRecord(turn) &&
typeof turn.text === 'string' && typeof turn.text === 'string' &&
(turn.speaker === 'player' || turn.speaker === 'character'), (turn.speaker === 'player' || turn.speaker === 'character'),
) )
.map((turn) => ({ .map((turn) => ({
speaker: turn.speaker, speaker: turn.speaker,
text: turn.text, text: turn.text,
})), })),
summary: readString(rawRecord.summary), summary: readString(rawRecord.summary),
updatedAt: readString(rawRecord.updatedAt) || null, updatedAt: readString(rawRecord.updatedAt) || null,
}, },
]; ];
}), },
),
); );
} }
@@ -194,14 +193,18 @@ function dedupeCompanions(value: unknown) {
return readArray(value) return readArray(value)
.map((entry) => normalizeCompanionState(entry)) .map((entry) => normalizeCompanionState(entry))
.filter((entry): entry is NonNullable<ReturnType<typeof normalizeCompanionState>> => { .filter(
if (!entry || seenNpcIds.has(entry.npcId)) { (
return false; entry,
} ): entry is NonNullable<ReturnType<typeof normalizeCompanionState>> => {
if (!entry || seenNpcIds.has(entry.npcId)) {
return false;
}
seenNpcIds.add(entry.npcId); seenNpcIds.add(entry.npcId);
return true; return true;
}); },
);
} }
function normalizeRoster( function normalizeRoster(
@@ -258,9 +261,8 @@ function resolveInitialPlayerCurrency(gameState: JsonRecord) {
) )
? ( ? (
( (
( (customWorldProfile.ownedSettingLayers as JsonRecord)
customWorldProfile.ownedSettingLayers as JsonRecord .ruleProfile as JsonRecord
).ruleProfile as JsonRecord
).economyProfile as JsonRecord ).economyProfile as JsonRecord
).initialCurrency ).initialCurrency
: undefined, : undefined,
@@ -270,7 +272,9 @@ function resolveInitialPlayerCurrency(gameState: JsonRecord) {
return Math.max(0, Math.round(customWorldInitialCurrency)); 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) { function normalizeEquipmentLoadout(value: unknown) {
@@ -319,7 +323,9 @@ function inferEquipmentTags(slot: RuntimeEquipmentSlotId, name: string) {
return [...tags]; return [...tags];
} }
function getLegacyCharacterEquipment(character: JsonRecord): LegacyCharacterEquipmentItem[] { function getLegacyCharacterEquipment(
character: JsonRecord,
): LegacyCharacterEquipmentItem[] {
const equipmentById: Record<string, LegacyCharacterEquipmentItem[]> = { const equipmentById: Record<string, LegacyCharacterEquipmentItem[]> = {
'sword-princess': [ 'sword-princess': [
{ slot: '武器', item: '王庭剑', rarity: '稀有' }, { slot: '武器', item: '王庭剑', rarity: '稀有' },
@@ -495,7 +501,9 @@ function normalizeGameState(gameState: unknown) {
); );
const resolvedEquipment = const resolvedEquipment =
normalizeEquipmentLoadout(rawState.playerEquipment) ?? normalizeEquipmentLoadout(rawState.playerEquipment) ??
(playerCharacter ? buildLegacyStarterEquipmentLoadout(playerCharacter) : null); (playerCharacter
? buildLegacyStarterEquipmentLoadout(playerCharacter)
: null);
const baseResourceProfile = playerCharacter const baseResourceProfile = playerCharacter
? buildCharacterResourceProfile(playerCharacter) ? buildCharacterResourceProfile(playerCharacter)
: null; : null;
@@ -512,14 +520,18 @@ function normalizeGameState(gameState: unknown) {
const normalizedCommonState = { const normalizedCommonState = {
...rawStateWithoutEquipment, ...rawStateWithoutEquipment,
customWorldProfile: customWorldProfile:
isRecord(rawState.customWorldProfile) || rawState.customWorldProfile === null isRecord(rawState.customWorldProfile) ||
? rawState.customWorldProfile ?? null rawState.customWorldProfile === null
? (rawState.customWorldProfile ?? null)
: null, : null,
runtimeStats: normalizeRuntimeStats(rawState.runtimeStats, { runtimeStats: normalizeRuntimeStats(rawState.runtimeStats, {
isActiveRun: Boolean( isActiveRun: Boolean(
rawState.playerCharacter && rawState.currentScene === 'Story', rawState.playerCharacter && rawState.currentScene === 'Story',
), ),
}), }),
playerProgression: normalizePlayerProgressionState(
rawState.playerProgression,
),
storyEngineMemory, storyEngineMemory,
chapterState: chapterState:
rawState.chapterState ?? rawState.chapterState ??
@@ -530,7 +542,7 @@ function normalizeGameState(gameState: unknown) {
rawState.campaignState ?? rawState.campaignState ??
(isRecord(storyEngineMemory.campaignState) (isRecord(storyEngineMemory.campaignState)
? storyEngineMemory.campaignState ? storyEngineMemory.campaignState
: storyEngineMemory.campaignState ?? null), : (storyEngineMemory.campaignState ?? null)),
activeScenarioPackId: activeScenarioPackId:
readString(rawState.activeScenarioPackId) || readString(rawState.activeScenarioPackId) ||
readString( readString(
@@ -623,7 +635,9 @@ function normalizeGameState(gameState: unknown) {
}; };
} }
export function normalizeSavedSnapshotPayload<T extends SnapshotShape>(snapshot: T) { export function normalizeSavedSnapshotPayload<T extends SnapshotShape>(
snapshot: T,
) {
return { return {
...snapshot, ...snapshot,
bottomTab: normalizeBottomTab(snapshot.bottomTab), bottomTab: normalizeBottomTab(snapshot.bottomTab),

View File

@@ -165,6 +165,7 @@ const COMBAT_FUNCTION_IDS = new Set<string>([
'battle_guard_break', 'battle_guard_break',
'battle_probe_pressure', 'battle_probe_pressure',
'battle_recover_breath', 'battle_recover_breath',
'inventory_use',
]); ]);
const NPC_FUNCTION_IDS = new Set<string>([ const NPC_FUNCTION_IDS = new Set<string>([

View File

@@ -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<string, number>;
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 () => { test('runtime story actions resolve inventory_use and persist updated resources', async () => {
await withTestServer('task6-inventory-use', async ({ baseUrl }) => { await withTestServer('task6-inventory-use', async ({ baseUrl }) => {
const entry = await authEntry( 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 () => { test('runtime story actions accept pending npc quest offers from saved chat state', async () => {
await withTestServer('task6-quest-accept-pending-offer', async ({ baseUrl }) => { await withTestServer(
const entry = await authEntry( 'task6-quest-accept-pending-offer',
baseUrl, async ({ baseUrl }) => {
'story_q_accept_pending', const entry = await authEntry(
'secret123', baseUrl,
); 'story_q_accept_pending',
const seededQuest = buildQuestForEncounter({ 'secret123',
issuerNpcId: 'npc_scout_01', );
issuerNpcName: '巡路人', const seededQuest = buildQuestForEncounter({
roleText: '巡路人', issuerNpcId: 'npc_scout_01',
scene: QUEST_BATTLE_SCENE, issuerNpcName: '巡路人',
worldType: 'WUXIA', roleText: '巡路人',
currentQuests: [], scene: QUEST_BATTLE_SCENE,
}); worldType: 'WUXIA',
assert.ok(seededQuest); currentQuests: [],
const pendingQuest = { });
...seededQuest, assert.ok(seededQuest);
id: 'quest-pending-offer', const pendingQuest = {
}; ...seededQuest,
id: 'quest-pending-offer',
};
await putSnapshot( await putSnapshot(
baseUrl, baseUrl,
entry.token, entry.token,
createTask6GameState({ createTask6GameState({
currentEncounter: { currentEncounter: {
kind: 'npc', kind: 'npc',
id: 'npc_scout_01', id: 'npc_scout_01',
npcName: '巡路人', npcName: '巡路人',
npcDescription: '熟悉桥口风向的探子', npcDescription: '熟悉桥口风向的探子',
context: '巡路人', context: '巡路人',
characterId: 'scout-quest', characterId: 'scout-quest',
},
currentScenePreset: QUEST_BATTLE_SCENE,
npcInteractionActive: true,
npcStates: {
npc_scout_01: {
affinity: 16,
chattedCount: 0,
helpUsed: false,
giftsGiven: 0,
inventory: [],
recruited: false,
}, },
}, currentScenePreset: QUEST_BATTLE_SCENE,
}), npcInteractionActive: true,
createPendingQuestOfferCurrentStory(pendingQuest), npcStates: {
); npc_scout_01: {
affinity: 16,
const response = await httpRequest( chattedCount: 0,
`${baseUrl}/api/runtime/story/actions/resolve`, helpUsed: false,
withBearer(entry.token, { giftsGiven: 0,
method: 'POST', inventory: [],
body: JSON.stringify({ recruited: false,
sessionId: 'runtime-main', },
clientVersion: 0,
action: {
type: 'story_choice',
functionId: 'npc_quest_accept',
}, },
}), }),
}), createPendingQuestOfferCurrentStory(pendingQuest),
); );
const payload = (await response.json()) as {
snapshot: { const response = await httpRequest(
gameState: { `${baseUrl}/api/runtime/story/actions/resolve`,
quests: Array<{ id: string; issuerNpcId: string; status: string }>; withBearer(entry.token, {
}; method: 'POST',
currentStory: { body: JSON.stringify({
displayMode?: string; sessionId: 'runtime-main',
options?: Array<{ actionText?: string }>; clientVersion: 0,
dialogue?: Array<{ speaker?: string; text?: string }>; action: {
npcChatState?: { type: 'story_choice',
pendingQuestOffer?: unknown; 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(response.status, 200);
assert.equal(payload.snapshot.gameState.quests.length, 1); assert.equal(payload.snapshot.gameState.quests.length, 1);
assert.equal( assert.equal(
payload.snapshot.gameState.quests[0]?.id, payload.snapshot.gameState.quests[0]?.id,
'quest-pending-offer', 'quest-pending-offer',
); );
assert.equal( assert.equal(
payload.snapshot.gameState.quests[0]?.issuerNpcId, payload.snapshot.gameState.quests[0]?.issuerNpcId,
'npc_scout_01', 'npc_scout_01',
); );
assert.equal(payload.snapshot.currentStory.displayMode, 'dialogue'); assert.equal(payload.snapshot.currentStory.displayMode, 'dialogue');
assert.equal( assert.equal(
payload.snapshot.currentStory.npcChatState?.pendingQuestOffer ?? null, payload.snapshot.currentStory.npcChatState?.pendingQuestOffer ?? null,
null, null,
); );
assert.deepEqual( assert.deepEqual(
payload.snapshot.currentStory.options?.map((option) => option.actionText), payload.snapshot.currentStory.options?.map(
[ (option) => option.actionText,
'这件事里你最担心哪一步', ),
'我回来时你最想先知道什么', [
'除了这份委托,你还想提醒我什么', '这件事里你最担心哪一步',
], '我回来时你最想先知道什么',
); '除了这份委托,你还想提醒我什么',
assert.equal( ],
payload.snapshot.currentStory.dialogue?.at(-2)?.text, );
'这件事我愿意接下,你把关键要点交给我。', assert.equal(
); payload.snapshot.currentStory.dialogue?.at(-2)?.text,
assert.match( '这件事我愿意接下,你把关键要点交给我。',
payload.snapshot.currentStory.dialogue?.at(-1)?.text ?? '', );
//u, assert.match(
); payload.snapshot.currentStory.dialogue?.at(-1)?.text ?? '',
}); //u,
);
},
);
}); });
test('runtime story actions progress quests from combat victories and npc turn-ins', async () => { 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: { gameState: {
quests: Array<{ status: string }>; quests: Array<{ status: string }>;
playerCurrency: number; playerCurrency: number;
playerProgression: {
level: number;
totalXp: number;
};
playerInventory: Array<{ name: string }>; playerInventory: Array<{ name: string }>;
npcStates: { npcStates: {
npc_bandit_01: { npc_bandit_01: {
@@ -1694,6 +1890,8 @@ test('runtime story actions progress quests from combat victories and npc turn-i
'turned_in', 'turned_in',
); );
assert.ok(turnInPayload.snapshot.gameState.playerCurrency > 12); 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.playerInventory.length > 0);
assert.ok( assert.ok(
turnInPayload.snapshot.gameState.npcStates.npc_bandit_01.affinity > 6, turnInPayload.snapshot.gameState.npcStates.npc_bandit_01.affinity > 6,

View File

@@ -572,13 +572,8 @@ function normalizeStatusPatch(session: RuntimeSession) {
} }
function shouldGenerateReasonedCombatStory( function shouldGenerateReasonedCombatStory(
functionId: string,
resolution: StoryResolution, resolution: StoryResolution,
) { ) {
if (!isCombatFunctionId(functionId)) {
return false;
}
const outcome = resolution.battle?.outcome; const outcome = resolution.battle?.outcome;
return ( return (
outcome === 'victory' || outcome === 'victory' ||
@@ -919,7 +914,9 @@ export async function resolveRuntimeStoryAction(params: {
const previousEncounter = session.currentEncounter const previousEncounter = session.currentEncounter
? { ...session.currentEncounter } ? { ...session.currentEncounter }
: null; : null;
if (isCombatFunctionId(functionId)) { const shouldResolveAsCombat =
functionId === 'inventory_use' ? session.inBattle : isCombatFunctionId(functionId);
if (shouldResolveAsCombat) {
resolution = resolveCombatAction(session, { resolution = resolveCombatAction(session, {
functionId, functionId,
payload: isObject(params.request.action.payload) payload: isObject(params.request.action.payload)
@@ -1003,7 +1000,7 @@ export async function resolveRuntimeStoryAction(params: {
} }
} else if ( } else if (
params.llmClient && params.llmClient &&
shouldGenerateReasonedCombatStory(functionId, resolution) shouldGenerateReasonedCombatStory(resolution)
) { ) {
try { try {
const generatedPayload = await generateReasonedStoryPayload({ const generatedPayload = await generateReasonedStoryPayload({

View File

@@ -31,6 +31,26 @@ test('npc chat turn schema normalizes player and dialogue aliases', () => {
chattedCount: 1, chattedCount: 1,
recruited: false, 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, '沈行'); assert.equal(payload.character.name, '沈行');
@@ -40,4 +60,7 @@ test('npc chat turn schema normalizes player and dialogue aliases', () => {
text: '你刚才那句话是什么意思?', text: '你刚才那句话是什么意思?',
}, },
]); ]);
assert.equal(payload.questOfferContext?.turnCount, 2);
assert.equal(payload.chatDirective?.sceneActId, 'scene-inn-act-1');
assert.equal(payload.chatDirective?.remainingTurns, 3);
}); });

View File

@@ -31,6 +31,21 @@ const baseNpcChatSchema = z.object({
context: jsonObjectSchema, 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({ export const characterChatReplyRequestSchema = baseCharacterChatSchema.extend({
conversationSummary: z.string().optional().default(''), conversationSummary: z.string().optional().default(''),
playerMessage: z.string().trim().min(1), playerMessage: z.string().trim().min(1),
@@ -59,6 +74,8 @@ export const npcChatTurnRequestSchema = baseNpcChatSchema
dialogue: z.array(jsonObjectSchema).optional(), dialogue: z.array(jsonObjectSchema).optional(),
playerMessage: z.string().trim().min(1), playerMessage: z.string().trim().min(1),
npcState: jsonObjectSchema, npcState: jsonObjectSchema,
questOfferContext: npcChatQuestOfferContextSchema.nullable().optional(),
chatDirective: npcChatDirectiveSchema.nullable().optional(),
}) })
.superRefine((value, ctx) => { .superRefine((value, ctx) => {
if (!value.character && !value.player) { if (!value.character && !value.player) {

View File

@@ -45,6 +45,7 @@ function resolveCardTitle(
draftProfile.landmarks.find((entry) => entry.id === cardId)?.name || draftProfile.landmarks.find((entry) => entry.id === cardId)?.name ||
draftProfile.threads.find((entry) => entry.id === cardId)?.title || draftProfile.threads.find((entry) => entry.id === cardId)?.title ||
draftProfile.chapters.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 : '') || (draftProfile.camp?.id === cardId ? draftProfile.camp.name : '') ||
'当前卡片' '当前卡片'
); );

View File

@@ -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 幕最后要把玩家逼到必须继续追的方向上。',
);
});

View File

@@ -10,6 +10,8 @@ import type {
CustomWorldFoundationDraftFaction, CustomWorldFoundationDraftFaction,
CustomWorldFoundationDraftLandmark, CustomWorldFoundationDraftLandmark,
CustomWorldFoundationDraftProfile, CustomWorldFoundationDraftProfile,
CustomWorldFoundationDraftSceneAct,
CustomWorldFoundationDraftSceneChapter,
CustomWorldFoundationDraftThread, CustomWorldFoundationDraftThread,
} from '../../../packages/shared/src/contracts/customWorldAgent.js'; } from '../../../packages/shared/src/contracts/customWorldAgent.js';
import { import {
@@ -74,6 +76,39 @@ const EDITABLE_CAMP_SECTION_IDS = [
'dangerLevel', 'dangerLevel',
] as const; ] 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) { function toText(value: unknown) {
return typeof value === 'string' ? value.trim() : ''; 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) { function slugify(value: string) {
const normalized = value const normalized = value
.trim() .trim()
@@ -149,9 +206,40 @@ function resolveEditableSectionIds(kind: CustomWorldDraftCardKind) {
if (kind === 'thread') return [...EDITABLE_THREAD_SECTION_IDS]; if (kind === 'thread') return [...EDITABLE_THREAD_SECTION_IDS];
if (kind === 'chapter') return [...EDITABLE_CHAPTER_SECTION_IDS]; if (kind === 'chapter') return [...EDITABLE_CHAPTER_SECTION_IDS];
if (kind === 'camp') return [...EDITABLE_CAMP_SECTION_IDS]; if (kind === 'camp') return [...EDITABLE_CAMP_SECTION_IDS];
if (kind === 'scene_chapter') return [...EDITABLE_SCENE_CHAPTER_BASE_SECTION_IDS];
return []; 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( function normalizeFaction(
value: unknown, value: unknown,
index: number, index: number,
@@ -243,6 +331,7 @@ function normalizeCharacter(
].join(''), ].join(''),
120, 120,
), ),
skills: normalizeCharacterSkills(record.skills, role || title || name || '角色'),
imageSrc: toText(record.imageSrc) || null, imageSrc: toText(record.imageSrc) || null,
generatedVisualAssetId: toText(record.generatedVisualAssetId) || null, generatedVisualAssetId: toText(record.generatedVisualAssetId) || null,
generatedAnimationSetId: toText(record.generatedAnimationSetId) || null, generatedAnimationSetId: toText(record.generatedAnimationSetId) || null,
@@ -287,6 +376,7 @@ function normalizeLandmark(
importance: secret || '玩家第一次抵达就会意识到它不只是背景', importance: secret || '玩家第一次抵达就会意识到它不只是背景',
secret: secret || '玩家第一次抵达就会意识到它不只是背景', secret: secret || '玩家第一次抵达就会意识到它不只是背景',
dangerLevel: dangerLevel || '中', dangerLevel: dangerLevel || '中',
imageSrc: toText(record.imageSrc) || null,
characterIds: toStringArray(record.characterIds, 8), characterIds: toStringArray(record.characterIds, 8),
threadIds: toStringArray(record.threadIds, 8), threadIds: toStringArray(record.threadIds, 8),
summary: summary:
@@ -410,6 +500,7 @@ function normalizeCamp(value: unknown): CustomWorldFoundationDraftCamp | null {
description: description || '玩家暂时还能整顿情报和喘口气的地方', description: description || '玩家暂时还能整顿情报和喘口气的地方',
mood: dangerLevel || '克制、紧绷,但还能暂时收拢局势', mood: dangerLevel || '克制、紧绷,但还能暂时收拢局势',
dangerLevel: dangerLevel || '克制、紧绷,但还能暂时收拢局势', dangerLevel: dangerLevel || '克制、紧绷,但还能暂时收拢局势',
imageSrc: toText(record.imageSrc) || null,
summary: summary:
summary || summary ||
clampText( 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( export function normalizeFoundationDraftProfile(
value: unknown, value: unknown,
): CustomWorldFoundationDraftProfile | null { ): CustomWorldFoundationDraftProfile | null {
@@ -474,6 +901,28 @@ export function normalizeFoundationDraftProfile(
Boolean(item), 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 camp = normalizeCamp(record.camp);
const hasStructuredFoundationContent = const hasStructuredFoundationContent =
playableNpcs.length > 0 || playableNpcs.length > 0 ||
@@ -482,13 +931,12 @@ export function normalizeFoundationDraftProfile(
factions.length > 0 || factions.length > 0 ||
threads.length > 0 || threads.length > 0 ||
chapters.length > 0 || chapters.length > 0 ||
sceneChapters.length > 0 ||
Boolean(camp); Boolean(camp);
if (!hasStructuredFoundationContent) { if (!hasStructuredFoundationContent) {
return null; return null;
} }
const mergedCharacters = dedupeById([...playableNpcs, ...storyNpcs]);
const coreConflicts = toStringArray(record.coreConflicts, 6); const coreConflicts = toStringArray(record.coreConflicts, 6);
return { return {
@@ -539,6 +987,7 @@ export function normalizeFoundationDraftProfile(
factions, factions,
threads, threads,
chapters, chapters,
sceneChapters,
worldHook: toText(record.worldHook) || name || summary, worldHook: toText(record.worldHook) || name || summary,
playerPremise: toText(record.playerPremise), playerPremise: toText(record.playerPremise),
openingSituation: toText(record.openingSituation), openingSituation: toText(record.openingSituation),
@@ -636,6 +1085,84 @@ function buildChapterWarnings(chapter: CustomWorldFoundationDraftChapter) {
return warnings; return warnings;
} }
function buildSceneChapterWarnings(params: {
sceneChapter: CustomWorldFoundationDraftSceneChapter;
characterById: Map<string, CustomWorldFoundationDraftCharacter>;
threadById: Map<string, CustomWorldFoundationDraftThread>;
landmarkById: Map<string, CustomWorldFoundationDraftLandmark>;
}) {
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() { function buildCampWarnings() {
return [] as string[]; return [] as string[];
} }
@@ -650,6 +1177,7 @@ function buildCharacterAssetHeadline(character: CustomWorldFoundationDraftCharac
generatedVisualAssetId: character.generatedVisualAssetId, generatedVisualAssetId: character.generatedVisualAssetId,
generatedAnimationSetId: character.generatedAnimationSetId, generatedAnimationSetId: character.generatedAnimationSetId,
animationMap: character.animationMap, animationMap: character.animationMap,
skills: character.skills ?? [],
}, },
roleKind: 'story', roleKind: 'story',
}); });
@@ -773,6 +1301,7 @@ export class CustomWorldAgentDraftCompiler {
...profile.landmarks.map((entry) => entry.id), ...profile.landmarks.map((entry) => entry.id),
...profile.threads.map((entry) => entry.id), ...profile.threads.map((entry) => entry.id),
...profile.chapters.map((entry) => entry.id), ...profile.chapters.map((entry) => entry.id),
...profile.sceneChapters.map((entry) => entry.id),
].slice(0, 12), ].slice(0, 12),
sections: [ sections: [
buildSection('title', '标题', profile.name), 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; return cards;
} }
} }

View File

@@ -23,6 +23,7 @@ const EDITABLE_SECTION_IDS = {
thread: new Set(['title', 'summary', 'conflictType', 'stakes']), thread: new Set(['title', 'summary', 'conflictType', 'stakes']),
chapter: new Set(['title', 'summary', 'openingEvent', 'playerGoal', 'understandingShift']), chapter: new Set(['title', 'summary', 'openingEvent', 'playerGoal', 'understandingShift']),
camp: new Set(['name', 'description', 'dangerLevel']), camp: new Set(['name', 'description', 'dangerLevel']),
sceneChapter: new Set(['title', 'summary']),
} as const; } as const;
function normalizePatches(sections: DraftSectionPatch[]) { 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))]; 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) { function resolveThreadType(value: string) {
if (value.includes('暗') || value.toLowerCase() === 'hidden') { if (value.includes('暗') || value.toLowerCase() === 'hidden') {
return 'hidden' as const; return 'hidden' as const;
@@ -60,6 +72,61 @@ function resolveThreadType(value: string) {
return 'main' as const; 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<ReturnType<typeof normalizeFoundationDraftProfile>>,
) {
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<ReturnType<typeof normalizeFoundationDraftProfile>>,
) {
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) { export function updateDraftCardSections(params: UpdateDraftCardSectionsParams) {
const draftProfile = normalizeFoundationDraftProfile(params.draftProfile); const draftProfile = normalizeFoundationDraftProfile(params.draftProfile);
if (!draftProfile) { if (!draftProfile) {
@@ -293,6 +360,70 @@ export function updateDraftCardSections(params: UpdateDraftCardSectionsParams) {
return draftProfile as unknown as Record<string, unknown>; return draftProfile as unknown as Record<string, unknown>;
} }
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<string, unknown>;
}
if (draftProfile.camp?.id === params.cardId) { if (draftProfile.camp?.id === params.cardId) {
patches.forEach(({ sectionId, value }) => { patches.forEach(({ sectionId, value }) => {
if (!EDITABLE_SECTION_IDS.camp.has(sectionId as never)) { if (!EDITABLE_SECTION_IDS.camp.has(sectionId as never)) {

View File

@@ -119,12 +119,16 @@ async function createObjectRefiningSession(
seedText: '一个被潮雾切开的列岛世界。', seedText: '一个被潮雾切开的列岛世界。',
}); });
const message1 = await orchestrator.submitMessage(userId, createdSession.sessionId, { const message1 = await orchestrator.submitMessage(
clientMessageId: 'phase5-ready-1', userId,
text: '玩家是被迫返乡的失职守灯人,开局时正站在即将熄灭的旧灯塔上。', createdSession.sessionId,
focusCardId: null, {
selectedCardIds: [], clientMessageId: 'phase5-ready-1',
}); text: '玩家是被迫返乡的失职守灯人,开局时正站在即将熄灭的旧灯塔上。',
focusCardId: null,
selectedCardIds: [],
},
);
await waitForOperation( await waitForOperation(
orchestrator, orchestrator,
userId, userId,
@@ -132,12 +136,16 @@ async function createObjectRefiningSession(
message1.operation.operationId, message1.operation.operationId,
); );
const message2 = await orchestrator.submitMessage(userId, createdSession.sessionId, { const message2 = await orchestrator.submitMessage(
clientMessageId: 'phase5-ready-2', userId,
text: '整体主题是海岛悬疑,气质冷峻克制。核心冲突是守灯会与沉船商盟争夺航道解释权。关键人物叫沈砺,是玩家的旧友兼宿敌,其实暗地里在为沉船商盟引路。标志性元素是潮雾钟声、盐火灯塔。', createdSession.sessionId,
focusCardId: null, {
selectedCardIds: [], clientMessageId: 'phase5-ready-2',
}); text: '整体主题是海岛悬疑,气质冷峻克制。核心冲突是守灯会与沉船商盟争夺航道解释权。关键人物叫沈砺,是玩家的旧友兼宿敌,其实暗地里在为沉船商盟引路。标志性元素是潮雾钟声、盐火灯塔。',
focusCardId: null,
selectedCardIds: [],
},
);
await waitForOperation( await waitForOperation(
orchestrator, orchestrator,
userId, userId,
@@ -194,7 +202,10 @@ test('phase5 generate_role_assets only allows a single role and moves session in
session.sessionId, session.sessionId,
response.operation.operationId, 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(operation?.status, 'completed');
assert.equal(snapshot?.stage, 'visual_refining'); 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 userId = 'user-phase5-sync-role-assets';
const session = await createObjectRefiningSession(orchestrator, userId); 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); assert.ok(characterCard);
@@ -255,33 +268,48 @@ test('phase5 sync_role_assets writes fields back, updates coverage and recompile
session.sessionId, session.sessionId,
response.operation.operationId, response.operation.operationId,
); );
const snapshot = await orchestrator.getSessionSnapshot(userId, session.sessionId); const snapshot = await orchestrator.getSessionSnapshot(
const profile = normalizeFoundationDraftProfile(snapshot?.draftProfile); userId,
const syncedRole = [...(profile?.playableNpcs ?? []), ...(profile?.storyNpcs ?? [])].find( session.sessionId,
(entry) => entry.id === characterCard!.id, );
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( const syncedAssetSummary = snapshot?.assetCoverage.roleAssets.find(
(entry) => entry.roleId === characterCard!.id, (entry) => entry.roleId === characterCard!.id,
); );
const latestRecord = await sessionStore.get(userId, session.sessionId); const latestRecord = await sessionStore.get(userId, session.sessionId);
assert.equal(operation?.status, 'completed'); 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?.generatedVisualAssetId, 'visual-shenli-1');
assert.equal(syncedRole?.generatedAnimationSetId, 'animation-set-shenli-1'); assert.equal(syncedRole?.generatedAnimationSetId, 'animation-set-shenli-1');
assert.equal( assert.equal(
(syncedRole?.animationMap as Record<string, { basePath?: string }> | null)?.idle (syncedRole?.animationMap as Record<string, { basePath?: string }> | null)
?.basePath, ?.idle?.basePath,
'/generated/characters/shenli/idle', '/generated/characters/shenli/idle',
); );
assert.equal(syncedAssetSummary?.status, 'complete'); const syncedSkillIds = syncedRole?.skills.map((skill) => skill.id) ?? [];
assert.equal(syncedCard?.assetStatusLabel, '动作已就绪'); assert.ok(syncedSkillIds.length > 0);
assert.ok(syncedCard?.subtitle.includes('动作已就绪')); 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( assert.ok(
snapshot?.messages.some( snapshot?.messages.some(
(message) => (message) =>
message.kind === 'action_result' && message.text.includes('动作已就绪'), message.kind === 'action_result' && message.text.includes('动作补齐中'),
), ),
); );
assert.ok((latestRecord?.checkpoints.length ?? 0) >= 2); assert.ok((latestRecord?.checkpoints.length ?? 0) >= 2);

View File

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

View File

@@ -5,13 +5,13 @@ import type {
CustomWorldRoleAssetSummary, CustomWorldRoleAssetSummary,
} from '../../../packages/shared/src/contracts/customWorldAgent.js'; } from '../../../packages/shared/src/contracts/customWorldAgent.js';
const CORE_ROLE_ANIMATION_KEYS = [ const REQUIRED_ROLE_ANIMATION_KEYS = ['run', 'attack'] as const;
'idle',
'run', type DraftRoleSkillRecord = {
'attack', id: string;
'hurt', name: string;
'die', actionPreviewConfig?: Record<string, unknown> | null;
] as const; };
type DraftRoleRecord = { type DraftRoleRecord = {
id: string; id: string;
@@ -21,6 +21,7 @@ type DraftRoleRecord = {
generatedVisualAssetId?: string | null; generatedVisualAssetId?: string | null;
generatedAnimationSetId?: string | null; generatedAnimationSetId?: string | null;
animationMap?: Record<string, unknown> | null; animationMap?: Record<string, unknown> | null;
skills: DraftRoleSkillRecord[];
}; };
type DraftRoleKind = 'playable' | 'story'; type DraftRoleKind = 'playable' | 'story';
@@ -65,11 +66,8 @@ function toAnimationMap(value: unknown) {
return toRecord(value); return toRecord(value);
} }
function hasAnimationSlot( function hasAnimationAsset(entryValue: unknown) {
animationMap: Record<string, unknown> | null | undefined, const entry = toRecord(entryValue);
slot: string,
) {
const entry = toRecord(animationMap?.[slot]);
if (!entry) { if (!entry) {
return false; return false;
} }
@@ -77,6 +75,41 @@ function hasAnimationSlot(
return Boolean(toText(entry.basePath) || toText(entry.spriteSheetPath)); return Boolean(toText(entry.basePath) || toText(entry.spriteSheetPath));
} }
function hasAnimationSlot(
animationMap: Record<string, unknown> | 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( function resolvePriorityTier(
role: DraftRoleRecord, role: DraftRoleRecord,
roleKind: DraftRoleKind, roleKind: DraftRoleKind,
@@ -127,6 +160,7 @@ function collectDraftRoles(profileInput: unknown) {
generatedVisualAssetId: toText(item.generatedVisualAssetId) || null, generatedVisualAssetId: toText(item.generatedVisualAssetId) || null,
generatedAnimationSetId: toText(item.generatedAnimationSetId) || null, generatedAnimationSetId: toText(item.generatedAnimationSetId) || null,
animationMap: toAnimationMap(item.animationMap), 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') { if (status === 'complete') {
return '动作已就绪'; return '动作已就绪';
} }
@@ -182,9 +218,12 @@ export function buildRoleAssetSummary(params: {
}): CustomWorldRoleAssetSummary { }): CustomWorldRoleAssetSummary {
const { role, roleKind } = params; const { role, roleKind } = params;
const priorityTier = resolvePriorityTier(role, roleKind); const priorityTier = resolvePriorityTier(role, roleKind);
const missingAnimations = CORE_ROLE_ANIMATION_KEYS.filter( const missingAnimations = [
(slot) => !hasAnimationSlot(role.animationMap, slot), ...REQUIRED_ROLE_ANIMATION_KEYS.filter(
); (slot) => !hasAnimationSlot(role.animationMap, slot),
),
...collectMissingSkillActions(role),
];
const hasPortrait = const hasPortrait =
Boolean(role.imageSrc) && Boolean(role.generatedVisualAssetId); Boolean(role.imageSrc) && Boolean(role.generatedVisualAssetId);
const hasAnimationSet = Boolean(role.generatedAnimationSetId); const hasAnimationSet = Boolean(role.generatedAnimationSetId);
@@ -210,10 +249,7 @@ export function buildRoleAssetSummary(params: {
}; };
} }
export function getRoleAssetSummaryById( export function getRoleAssetSummaryById(draftProfile: unknown, roleId: string) {
draftProfile: unknown,
roleId: string,
) {
const roleEntry = collectDraftRoles(draftProfile).find( const roleEntry = collectDraftRoles(draftProfile).find(
(entry) => entry.role.id === roleId, (entry) => entry.role.id === roleId,
); );
@@ -281,8 +317,7 @@ export function mergeRoleAssetIntoDraftProfile(
return touched; return touched;
}; };
const touched = const touched = updateRoleList('playableNpcs') || updateRoleList('storyNpcs');
updateRoleList('playableNpcs') || updateRoleList('storyNpcs');
if (!touched || !updatedRole) { if (!touched || !updatedRole) {
throw new Error('目标角色不存在,无法同步角色资产。'); throw new Error('目标角色不存在,无法同步角色资产。');

View File

@@ -96,6 +96,10 @@ interface AdventurePanelProps {
scenesTraveled: number; scenesTraveled: number;
currentSceneName: string; currentSceneName: string;
playerCurrency: number; playerCurrency: number;
playerLevel?: number;
playerCurrentLevelXp?: number;
playerXpToNextLevel?: number;
playerTotalXp?: number;
inventoryItemCount: number; inventoryItemCount: number;
inventoryStackCount: number; inventoryStackCount: number;
activeCompanionCount: number; activeCompanionCount: number;
@@ -276,6 +280,19 @@ function formatPlayTime(playTimeMs: number) {
return `${minutes}${String(seconds).padStart(2, '0')}`; 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) { function getOptionGoalAffordanceClass(option: StoryOption) {
switch (option.goalAffordance?.relation) { switch (option.goalAffordance?.relation) {
case 'advance': case 'advance':
@@ -467,6 +484,17 @@ function QuestRewardGrid({
</div> </div>
</div> </div>
<div className="rounded-xl border border-sky-300/18 bg-sky-500/10 px-3 py-2.5 text-sky-50">
<div className="flex items-center gap-2">
<ScrollText className="h-4 w-4" />
<span className="text-sm font-semibold">
+{quest.reward.experience ?? 0}
</span>
</div>
<div className="mt-1 text-[10px] uppercase tracking-[0.2em] text-sky-100/70">
</div>
</div>
</div> </div>
<RewardItemIconGrid <RewardItemIconGrid
@@ -661,11 +689,12 @@ export function AdventurePanel({
currentStory.deferredOptions?.length, currentStory.deferredOptions?.length,
); );
const saveAndExitDisabled = isLoading || isStoryStreaming; const saveAndExitDisabled = isLoading || isStoryStreaming;
const primaryQuestGoal = goalStack.activeGoal?.sourceKind === 'quest' const primaryQuestGoal =
? goalStack.activeGoal goalStack.activeGoal?.sourceKind === 'quest'
: goalStack.immediateStepGoal?.sourceKind === 'quest' ? goalStack.activeGoal
? goalStack.immediateStepGoal : goalStack.immediateStepGoal?.sourceKind === 'quest'
: null; ? goalStack.immediateStepGoal
: null;
const [isGoalPanelOpen, setIsGoalPanelOpen] = useState(false); const [isGoalPanelOpen, setIsGoalPanelOpen] = useState(false);
const [isQuestPanelOpen, setIsQuestPanelOpen] = useState(false); const [isQuestPanelOpen, setIsQuestPanelOpen] = useState(false);
const [isSettingsPanelOpen, setIsSettingsPanelOpen] = useState(false); const [isSettingsPanelOpen, setIsSettingsPanelOpen] = useState(false);
@@ -678,7 +707,8 @@ export function AdventurePanel({
string | null string | null
>(null); >(null);
const [rewardQuestId, setRewardQuestId] = useState<string | null>(null); const [rewardQuestId, setRewardQuestId] = useState<string | null>(null);
const [rewardQuestHandoff, setRewardQuestHandoff] = useState<GoalHandoff | null>(null); const [rewardQuestHandoff, setRewardQuestHandoff] =
useState<GoalHandoff | null>(null);
const [selectedRewardItemQuestId, setSelectedRewardItemQuestId] = useState< const [selectedRewardItemQuestId, setSelectedRewardItemQuestId] = useState<
string | null string | null
>(null); >(null);
@@ -699,7 +729,9 @@ export function AdventurePanel({
const selectedQuest = useMemo( const selectedQuest = useMemo(
() => () =>
quests.find((quest) => quest.id === selectedQuestId) ?? quests.find((quest) => quest.id === selectedQuestId) ??
(pendingNpcQuestOffer?.id === selectedQuestId ? pendingNpcQuestOffer : null), (pendingNpcQuestOffer?.id === selectedQuestId
? pendingNpcQuestOffer
: null),
[pendingNpcQuestOffer, quests, selectedQuestId], [pendingNpcQuestOffer, quests, selectedQuestId],
); );
const rewardQuest = useMemo( const rewardQuest = useMemo(
@@ -899,6 +931,13 @@ export function AdventurePanel({
], ],
[statistics], [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 = const shouldMountAdventureOverlays =
isGoalPanelOpen || isGoalPanelOpen ||
isSettingsPanelOpen || isSettingsPanelOpen ||
@@ -1060,6 +1099,27 @@ export function AdventurePanel({
</div> </div>
<div className="mt-auto shrink-0 pb-2"> <div className="mt-auto shrink-0 pb-2">
<div className="mb-2 rounded-xl border border-amber-300/15 bg-[radial-gradient(circle_at_top,rgba(251,191,36,0.14),transparent_65%),rgba(0,0,0,0.24)] px-3 py-2.5">
<div className="flex items-center justify-between gap-3 text-[11px]">
<div className="font-semibold text-amber-50">Lv.{playerLevel}</div>
<div className="text-zinc-400">
{playerXpToNextLevel > 0
? `${playerCurrentLevelXp}/${playerXpToNextLevel}`
: 'MAX'}
</div>
</div>
<div className="mt-2 h-1.5 overflow-hidden rounded-full border border-white/10 bg-black/30">
<div
className="h-full rounded-full bg-[linear-gradient(90deg,rgba(251,191,36,0.78),rgba(253,224,71,0.96))]"
style={{
width:
playerProgressionRatio <= 0
? '0%'
: `${Math.max(6, playerProgressionRatio * 100)}%`,
}}
/>
</div>
</div>
<div className="mb-2 flex flex-wrap items-center justify-between gap-2"> <div className="mb-2 flex flex-wrap items-center justify-between gap-2">
<div className="flex min-w-0 flex-wrap items-center gap-2"> <div className="flex min-w-0 flex-wrap items-center gap-2">
<button <button
@@ -1123,40 +1183,67 @@ export function AdventurePanel({
<div className="p-4" aria-hidden="true" /> <div className="p-4" aria-hidden="true" />
) : ( ) : (
<> <>
{displayedOptions.map((option, index) => { {displayedOptions.map((option, index) => {
const optionImpactSummary = getOptionImpactSummary( const optionImpactSummary = getOptionImpactSummary(
option, option,
playerCharacter, playerCharacter,
playerHp, playerHp,
playerMaxHp, playerMaxHp,
playerMana, playerMana,
playerMaxMana, playerMaxMana,
playerSkillCooldowns, playerSkillCooldowns,
currentNpcBattleMode, currentNpcBattleMode,
); );
const isDeferredContinueOption = const isDeferredContinueOption =
hasDeferredAdventureOptions && hasDeferredAdventureOptions &&
isContinueAdventureOption(option); isContinueAdventureOption(option);
const optionDisabled = option.disabled === true; const optionDisabled = option.disabled === true;
const compactOptionDetailText = option.disabledReason const compactOptionDetailText = option.disabledReason
? option.disabledReason ? option.disabledReason
: getCompactOptionDetailText(option); : getCompactOptionDetailText(option);
if (isDeferredContinueOption) {
return (
<motion.button
key={`${option.functionId}-${option.actionText}-${index}`}
type="button"
initial={{ opacity: 0, y: 22 }}
animate={{ opacity: 1, y: 0 }}
transition={{ duration: 0.24, ease: 'easeOut' }}
onClick={() => handleOptionChoice(option)}
className="pixel-nine-slice pixel-pressable pixel-choice-button group w-full text-left"
style={getNineSliceStyle(UI_CHROME.choiceButton)}
>
<div className="flex items-center justify-between">
<span
className={`text-xs ${getOptionActionTextClass(option)}`}
>
{option.actionText}
</span>
<PixelIcon
src={CHROME_ICONS.optionArrow}
className="h-3 w-3 opacity-70 transition-opacity group-hover:opacity-100"
/>
</div>
<div className="mt-1 text-[10px] leading-relaxed text-zinc-500">
</div>
</motion.button>
);
}
if (isDeferredContinueOption) {
return ( return (
<motion.button <button
key={`${option.functionId}-${option.actionText}-${index}`} key={`${option.functionId}-${option.actionText}-${index}`}
type="button" type="button"
initial={{ opacity: 0, y: 22 }}
animate={{ opacity: 1, y: 0 }}
transition={{ duration: 0.24, ease: 'easeOut' }}
onClick={() => handleOptionChoice(option)} onClick={() => handleOptionChoice(option)}
className="pixel-nine-slice pixel-pressable pixel-choice-button group w-full text-left" disabled={optionDisabled}
className={`pixel-nine-slice pixel-choice-button group w-full text-left ${optionDisabled ? 'cursor-not-allowed opacity-55' : 'pixel-pressable'}`}
style={getNineSliceStyle(UI_CHROME.choiceButton)} style={getNineSliceStyle(UI_CHROME.choiceButton)}
> >
<div className="flex items-center justify-between"> <div className="flex items-center justify-between">
<span <span
className={`text-xs ${getOptionActionTextClass(option)}`} className={`${isNpcChatMode ? 'text-sm sm:text-[15px]' : 'text-xs'} ${getOptionActionTextClass(option)}`}
> >
{option.actionText} {option.actionText}
</span> </span>
@@ -1165,85 +1252,61 @@ export function AdventurePanel({
className="h-3 w-3 opacity-70 transition-opacity group-hover:opacity-100" className="h-3 w-3 opacity-70 transition-opacity group-hover:opacity-100"
/> />
</div> </div>
<div className="mt-1 text-[10px] leading-relaxed text-zinc-500"> {!isNpcChatMode && compactOptionDetailText && (
<div className="mt-1 text-[10px] leading-relaxed text-zinc-500">
</div> {compactOptionDetailText}
</motion.button> </div>
); )}
} {!isNpcChatMode && option.goalAffordance?.label && (
<div
return ( className={`mt-1 text-[10px] ${getOptionGoalAffordanceClass(option)}`}
<button >
key={`${option.functionId}-${option.actionText}-${index}`} {option.goalAffordance.label}
type="button" </div>
onClick={() => handleOptionChoice(option)} )}
disabled={optionDisabled} {!isNpcChatMode &&
className={`pixel-nine-slice pixel-choice-button group w-full text-left ${optionDisabled ? 'cursor-not-allowed opacity-55' : 'pixel-pressable'}`} optionImpactSummary &&
style={getNineSliceStyle(UI_CHROME.choiceButton)} !optionDisabled && (
> <div className="mt-1 text-[10px] text-zinc-500">
<div className="flex items-center justify-between"> {optionImpactSummary}
<span </div>
className={`${isNpcChatMode ? 'text-sm sm:text-[15px]' : 'text-xs'} ${getOptionActionTextClass(option)}`} )}
>
{option.actionText}
</span>
<PixelIcon
src={CHROME_ICONS.optionArrow}
className="h-3 w-3 opacity-70 transition-opacity group-hover:opacity-100"
/>
</div>
{!isNpcChatMode && compactOptionDetailText && (
<div className="mt-1 text-[10px] leading-relaxed text-zinc-500">
{compactOptionDetailText}
</div>
)}
{!isNpcChatMode && option.goalAffordance?.label && (
<div className={`mt-1 text-[10px] ${getOptionGoalAffordanceClass(option)}`}>
{option.goalAffordance.label}
</div>
)}
{!isNpcChatMode && optionImpactSummary && !optionDisabled && (
<div className="mt-1 text-[10px] text-zinc-500">
{optionImpactSummary}
</div>
)}
</button>
);
})}
{isNpcChatMode && !isNpcQuestOfferMode ? (
<div className="pixel-nine-slice pixel-panel mt-0.5 border border-white/10 bg-black/25 p-1.5">
<div className="flex min-w-0 items-center gap-2">
<input
value={npcChatDraft}
onChange={(event) => 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}
/>
<button
type="button"
onClick={submitNpcChatDraft}
disabled={isLoading || !npcChatDraft.trim()}
className="inline-flex h-9 shrink-0 items-center rounded-md border border-amber-300/20 bg-amber-500/10 px-2.5 text-[11px] text-amber-100 transition-colors disabled:cursor-not-allowed disabled:opacity-40 sm:px-3 sm:text-xs"
>
</button> </button>
);
})}
{isNpcChatMode && !isNpcQuestOfferMode ? (
<div className="pixel-nine-slice pixel-panel mt-0.5 border border-white/10 bg-black/25 p-1.5">
<div className="flex min-w-0 items-center gap-2">
<input
value={npcChatDraft}
onChange={(event) => 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}
/>
<button
type="button"
onClick={submitNpcChatDraft}
disabled={isLoading || !npcChatDraft.trim()}
className="inline-flex h-9 shrink-0 items-center rounded-md border border-amber-300/20 bg-amber-500/10 px-2.5 text-[11px] text-amber-100 transition-colors disabled:cursor-not-allowed disabled:opacity-40 sm:px-3 sm:text-xs"
>
</button>
</div>
</div> </div>
</div> ) : null}
) : null}
</> </>
)} )}
</div> </div>
@@ -1307,4 +1370,3 @@ export function AdventurePanel({
</div> </div>
); );
} }

View File

@@ -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> = {}): 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(
<CharacterAnimator
state={AnimationState.IDLE}
character={buildCharacter()}
/>,
);
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(
<CharacterAnimator
state={AnimationState.DIE}
character={buildCharacter()}
/>,
);
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)');
});
});

View File

@@ -15,28 +15,88 @@ const DEFAULT_ANIMATIONS: Record<AnimationState, CharacterAnimationConfig> = {
[AnimationState.ACQUIRE]: { frames: 1, prefix: 'acquire', folder: 'acquire' }, [AnimationState.ACQUIRE]: { frames: 1, prefix: 'acquire', folder: 'acquire' },
[AnimationState.ATTACK]: { frames: 1, prefix: 'Attack', folder: 'attack' }, [AnimationState.ATTACK]: { frames: 1, prefix: 'Attack', folder: 'attack' },
[AnimationState.RUN]: { frames: 1, prefix: 'Run', folder: 'run' }, [AnimationState.RUN]: { frames: 1, prefix: 'Run', folder: 'run' },
[AnimationState.DOUBLE_JUMP]: { frames: 1, prefix: 'double jump', folder: 'double jump' }, [AnimationState.DOUBLE_JUMP]: {
[AnimationState.JUMP_ATTACK]: { frames: 1, prefix: 'jump attack', folder: 'jump attack' }, 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.DASH]: { frames: 1, prefix: 'dash', folder: 'dash' },
[AnimationState.HURT]: { frames: 1, prefix: 'hurt', folder: 'hurt' }, [AnimationState.HURT]: { frames: 1, prefix: 'hurt', folder: 'hurt' },
[AnimationState.DIE]: { frames: 1, prefix: 'die', folder: 'die' }, [AnimationState.DIE]: { frames: 1, prefix: 'die', folder: 'die' },
[AnimationState.CLIMB]: { frames: 1, prefix: 'Climb', folder: 'climb' }, [AnimationState.CLIMB]: { frames: 1, prefix: 'Climb', folder: 'climb' },
[AnimationState.SKILL1]: { frames: 1, prefix: 'skill1', folder: 'skill1' }, [AnimationState.SKILL1]: { frames: 1, prefix: 'skill1', folder: 'skill1' },
[AnimationState.SKILL1_JUMP]: { frames: 1, prefix: 'skill1 jump', folder: 'skill1 jump' }, [AnimationState.SKILL1_JUMP]: {
[AnimationState.SKILL1_BULLET]: { frames: 1, prefix: 'skill1 bullet', folder: 'skill1 bullet' }, frames: 1,
[AnimationState.SKILL1_BULLET_FX]: { frames: 1, prefix: 'skill1 bullet FX', folder: 'skill1 bullet FX' }, 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]: { 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]: { frames: 1, prefix: 'skill3', folder: 'skill3' },
[AnimationState.SKILL3_JUMP]: { frames: 1, prefix: 'skill3 jump', folder: 'skill3 jump' }, [AnimationState.SKILL3_JUMP]: {
[AnimationState.SKILL3_BULLET]: { frames: 1, prefix: 'skill3 bullet', folder: 'skill3 bullet' }, frames: 1,
[AnimationState.SKILL3_BULLET_FX]: { frames: 1, prefix: 'skill3 bullet FX', folder: 'skill3 bullet FX' }, 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.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.IDLE]: { frames: 1, prefix: 'Idle', folder: 'idle' },
[AnimationState.JUMP]: { frames: 1, prefix: 'Jump', folder: 'jump' }, [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<CharacterAnimatorProps> = ({ export const CharacterAnimator: React.FC<CharacterAnimatorProps> = ({
state, state,
character, character,
@@ -45,11 +105,20 @@ export const CharacterAnimator: React.FC<CharacterAnimatorProps> = ({
imageClassName, imageClassName,
playbackRate = 1, playbackRate = 1,
}) => { }) => {
const config = const explicitConfig = character.animationMap?.[state];
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] ?? DEFAULT_ANIMATIONS[state] ??
character.animationMap?.[AnimationState.IDLE] ?? character.animationMap?.[AnimationState.IDLE] ??
DEFAULT_ANIMATIONS[AnimationState.IDLE]; DEFAULT_ANIMATIONS[AnimationState.IDLE];
const fallbackToPortrait =
usePortraitIdleFallback || usePortraitDeathFallback || hasRenderError;
const config = fallbackToPortrait ? PORTRAIT_FALLBACK_ANIMATION : baseConfig;
const startFrame = const startFrame =
typeof config.startFrame === 'number' && Number.isFinite(config.startFrame) typeof config.startFrame === 'number' && Number.isFinite(config.startFrame)
? Math.max(1, Math.floor(config.startFrame)) ? Math.max(1, Math.floor(config.startFrame))
@@ -66,6 +135,20 @@ export const CharacterAnimator: React.FC<CharacterAnimatorProps> = ({
const effectivePlaybackRate = Number.isFinite(playbackRate) const effectivePlaybackRate = Number.isFinite(playbackRate)
? Math.max(0.1, playbackRate) ? Math.max(0.1, playbackRate)
: 1; : 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 = [ const animationSignature = [
state, state,
config.basePath ?? '', config.basePath ?? '',
@@ -78,6 +161,11 @@ export const CharacterAnimator: React.FC<CharacterAnimatorProps> = ({
fps, fps,
effectivePlaybackRate, effectivePlaybackRate,
].join('::'); ].join('::');
useEffect(() => {
setHasRenderError(false);
}, [requestedAnimationSignature]);
const endFrame = startFrame + frameCount - 1; const endFrame = startFrame + frameCount - 1;
const intervalDelay = Math.max( const intervalDelay = Math.max(
40, 40,
@@ -101,16 +189,11 @@ export const CharacterAnimator: React.FC<CharacterAnimatorProps> = ({
}, intervalDelay); }, intervalDelay);
return () => window.clearInterval(interval); return () => window.clearInterval(interval);
}, [ }, [endFrame, frameCount, intervalDelay, startFrame]);
endFrame,
frameCount,
intervalDelay,
startFrame,
]);
const frameNumber = frameIndex.toString().padStart(2, '0'); const frameNumber = frameIndex.toString().padStart(2, '0');
const normalizedBasePath = config.basePath?.replace(/\/+$/u, ''); const normalizedBasePath = config.basePath?.replace(/\/+$/u, '');
const imagePath = normalizedBasePath const generatedImagePath = normalizedBasePath
? config.file ? config.file
? `${normalizedBasePath}/${encodeURIComponent(config.file)}` ? `${normalizedBasePath}/${encodeURIComponent(config.file)}`
: `${normalizedBasePath}/${config.prefix}${frameNumber}.${config.extension ?? 'png'}` : `${normalizedBasePath}/${config.prefix}${frameNumber}.${config.extension ?? 'png'}`
@@ -122,7 +205,15 @@ export const CharacterAnimator: React.FC<CharacterAnimatorProps> = ({
? `/character/${folder}/${variant}/Hero/${animationFolder}/${encodeURIComponent(config.file)}` ? `/character/${folder}/${variant}/Hero/${animationFolder}/${encodeURIComponent(config.file)}`
: `/character/${folder}/${variant}/Hero/${animationFolder}/${config.prefix}${frameNumber}.${config.extension ?? 'png'}`; : `/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 ( return (
<div className={`relative ${className ?? ''}`} style={style}> <div className={`relative ${className ?? ''}`} style={style}>
@@ -130,11 +221,11 @@ export const CharacterAnimator: React.FC<CharacterAnimatorProps> = ({
src={imagePath} src={imagePath}
alt={`${character.name} ${state} animation`} alt={`${character.name} ${state} animation`}
className={resolvedImageClassName} className={resolvedImageClassName}
style={{ imageRendering: 'pixelated' }} style={imageStyle}
onError={(e) => { onError={(e) => {
const target = e.target as HTMLImageElement; if (!hasRenderError) {
target.src = character.portrait; setHasRenderError(true);
target.className = resolvedImageClassName; }
}} }}
/> />
</div> </div>

View File

@@ -1038,7 +1038,7 @@ export function CustomWorldEntityCatalog({
</div> </div>
</div> </div>
<div className="sticky top-0 z-10 -mx-1 space-y-3 bg-[linear-gradient(180deg,rgba(255,252,253,0.98)_0%,rgba(255,244,248,0.94)_76%,rgba(255,244,248,0)_100%)] px-1 pb-3 pt-1 backdrop-blur-sm"> <div className="platform-sticky-fade sticky top-0 z-10 -mx-1 space-y-3 px-1 pb-3 pt-1 backdrop-blur-sm">
<div className="flex gap-2 overflow-x-auto pb-1 scrollbar-hide"> <div className="flex gap-2 overflow-x-auto pb-1 scrollbar-hide">
{RESULT_TABS.map((tab) => ( {RESULT_TABS.map((tab) => (
<div key={tab.id}> <div key={tab.id}>

View File

@@ -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))]" 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' }} style={{ WebkitOverflowScrolling: 'touch' }}
> >
<div className="sticky top-0 z-20 -mx-3 mb-4 flex items-center justify-between gap-3 bg-[linear-gradient(180deg,rgba(255,247,250,0.96),rgba(255,244,248,0.86),rgba(255,244,248,0))] px-3 pb-3 pt-1 sm:static sm:mx-0 sm:bg-none sm:px-0 sm:pb-0 sm:pt-0"> <div className="platform-sticky-fade sticky top-0 z-20 -mx-3 mb-4 flex items-center justify-between gap-3 px-3 pb-3 pt-1 backdrop-blur-sm sm:static sm:mx-0 sm:bg-none sm:px-0 sm:pb-0 sm:pt-0 sm:backdrop-blur-none">
<button <button
type="button" type="button"
onClick={onBack} onClick={onBack}

View File

@@ -64,18 +64,11 @@ type CustomWorldAiActionConfig = {
frameCount: number; frameCount: number;
durationSeconds: number; durationSeconds: number;
loop: boolean; loop: boolean;
required: boolean;
fallbackStatusLabel?: string;
}; };
const CORE_ACTIONS: CustomWorldAiActionConfig[] = [ const CORE_ACTIONS: CustomWorldAiActionConfig[] = [
{
animation: AnimationState.IDLE,
label: '待机',
templateId: 'idle',
fps: 8,
frameCount: 8,
durationSeconds: 4,
loop: true,
},
{ {
animation: AnimationState.RUN, animation: AnimationState.RUN,
label: '奔跑', label: '奔跑',
@@ -84,6 +77,7 @@ const CORE_ACTIONS: CustomWorldAiActionConfig[] = [
frameCount: 8, frameCount: 8,
durationSeconds: 4, durationSeconds: 4,
loop: true, loop: true,
required: true,
}, },
{ {
animation: AnimationState.ATTACK, animation: AnimationState.ATTACK,
@@ -93,6 +87,18 @@ const CORE_ACTIONS: CustomWorldAiActionConfig[] = [
frameCount: 8, frameCount: 8,
durationSeconds: 4, durationSeconds: 4,
loop: false, loop: false,
required: true,
},
{
animation: AnimationState.IDLE,
label: '待机',
templateId: 'idle',
fps: 8,
frameCount: 8,
durationSeconds: 4,
loop: true,
required: false,
fallbackStatusLabel: '默认静止',
}, },
{ {
animation: AnimationState.DIE, animation: AnimationState.DIE,
@@ -102,6 +108,8 @@ const CORE_ACTIONS: CustomWorldAiActionConfig[] = [
frameCount: 8, frameCount: 8,
durationSeconds: 4, durationSeconds: 4,
loop: false, loop: false,
required: false,
fallbackStatusLabel: '默认倒地动画',
}, },
]; ];
@@ -698,6 +706,15 @@ export function CustomWorldRoleAssetStudioModal({
const hasAnyGeneratingAnimations = Object.values(generatingAnimationMap).some( const hasAnyGeneratingAnimations = Object.values(generatingAnimationMap).some(
(value) => value === true, (value) => value === true,
); );
const isSelectedAnimationGenerated = hasGeneratedAnimation(
workingRole,
selectedAnimation,
);
const shouldUseSelectedAnimationPreview =
Boolean(previewCharacter) &&
(isSelectedAnimationGenerated ||
selectedAnimation === AnimationState.IDLE ||
selectedAnimation === AnimationState.DIE);
const animationPreviewFrameStyle = useMemo( const animationPreviewFrameStyle = useMemo(
() => getAnimationPreviewFrameStyle(selectedAnimationConfig, 440), () => getAnimationPreviewFrameStyle(selectedAnimationConfig, 440),
[selectedAnimationConfig], [selectedAnimationConfig],
@@ -1258,8 +1275,7 @@ export function CustomWorldRoleAssetStudioModal({
<div className="space-y-4"> <div className="space-y-4">
<div className="platform-role-studio__preview rounded-3xl p-4"> <div className="platform-role-studio__preview rounded-3xl p-4">
<div className="platform-role-studio__stage flex min-h-[28rem] items-center justify-center rounded-2xl p-4"> <div className="platform-role-studio__stage flex min-h-[28rem] items-center justify-center rounded-2xl p-4">
{previewCharacter && {shouldUseSelectedAnimationPreview && previewCharacter ? (
hasGeneratedAnimation(workingRole, selectedAnimation) ? (
<div <div
className="flex items-center justify-center" className="flex items-center justify-center"
style={animationPreviewViewportStyle} style={animationPreviewViewportStyle}
@@ -1276,7 +1292,7 @@ export function CustomWorldRoleAssetStudioModal({
<img <img
src={previewImageSrc} src={previewImageSrc}
alt={workingRole.name} alt={workingRole.name}
className="max-h-[28rem] w-full object-contain" className="max-h-[28rem] w-full object-contain pixelated"
/> />
) : ( ) : (
<div className="px-4 text-sm text-zinc-500"></div> <div className="px-4 text-sm text-zinc-500"></div>
@@ -1351,12 +1367,13 @@ export function CustomWorldRoleAssetStudioModal({
<div className="text-sm font-semibold text-white"> <div className="text-sm font-semibold text-white">
{item.label} {item.label}
</div> </div>
<div className="mt-1 text-[11px] text-zinc-400"> <div className="mt-1 flex flex-wrap items-center gap-2 text-[11px] text-zinc-400">
{isGenerating {isGenerating
? '后台生成中' ? '后台生成中'
: isSelected : isSelected
? '当前预览' ? '当前预览'
: '点击切换'} : '点击切换'}
<span>{item.required ? '必需动作' : '可选动作'}</span>
</div> </div>
</div> </div>
<StatusBadge <StatusBadge
@@ -1368,7 +1385,9 @@ export function CustomWorldRoleAssetStudioModal({
? '生成中' ? '生成中'
: isReady : isReady
? '已生成' ? '已生成'
: '待生成'} : item.required
? '待生成'
: (item.fallbackStatusLabel ?? '可选')}
</StatusBadge> </StatusBadge>
</div> </div>
</button> </button>

View File

@@ -247,6 +247,8 @@ export function SkillEffectPreview({
encounter={null} encounter={null}
currentScenePreset={scenePreset} currentScenePreset={scenePreset}
worldType={worldType} worldType={worldType}
customWorldProfile={null}
storyEngineMemory={null}
sceneHostileNpcs={sceneHostileNpcs} sceneHostileNpcs={sceneHostileNpcs}
playerX={PLAYER_X} playerX={PLAYER_X}
playerOffsetY={0} playerOffsetY={0}

File diff suppressed because it is too large Load Diff

View File

@@ -11,18 +11,13 @@ export const GENERATED_FRAME_WIDTH = 192;
export const GENERATED_FRAME_HEIGHT = 256; export const GENERATED_FRAME_HEIGHT = 256;
export const REQUIRED_BASE_ANIMATIONS: AnimationState[] = [ export const REQUIRED_BASE_ANIMATIONS: AnimationState[] = [
AnimationState.IDLE,
AnimationState.ACQUIRE,
AnimationState.ATTACK, AnimationState.ATTACK,
AnimationState.RUN, AnimationState.RUN,
AnimationState.JUMP, ];
AnimationState.DOUBLE_JUMP,
AnimationState.JUMP_ATTACK, export const OPTIONAL_BASE_ANIMATIONS: AnimationState[] = [
AnimationState.DASH, AnimationState.IDLE,
AnimationState.HURT,
AnimationState.DIE, AnimationState.DIE,
AnimationState.CLIMB,
AnimationState.WALL_SLIDE,
]; ];
export type DraftVisualCandidate = { export type DraftVisualCandidate = {
@@ -1094,9 +1089,7 @@ export async function buildReferenceVideoFromCharacterAnimation(
const stopPromise = new Promise<Blob>((resolve) => { const stopPromise = new Promise<Blob>((resolve) => {
recorder.onstop = () => { recorder.onstop = () => {
resolve( resolve(new Blob(chunks, { type: recorder.mimeType || 'video/webm' }));
new Blob(chunks, { type: recorder.mimeType || 'video/webm' }),
);
}; };
}); });

View File

@@ -1,6 +1,6 @@
/* @vitest-environment jsdom */ /* @vitest-environment jsdom */
import { render, screen, within } from '@testing-library/react'; import { render, screen, waitFor, within } from '@testing-library/react';
import userEvent from '@testing-library/user-event'; import userEvent from '@testing-library/user-event';
import { expect, test, vi } from 'vitest'; import { expect, test, vi } from 'vitest';
@@ -27,7 +27,13 @@ function renderAccountModal(overrides?: {
riskBlocks?: AuthRiskBlockSummary[]; riskBlocks?: AuthRiskBlockSummary[];
sessions?: AuthSessionSummary[]; sessions?: AuthSessionSummary[];
auditLogs?: AuthAuditLogEntry[]; auditLogs?: AuthAuditLogEntry[];
initialSection?: 'appearance' | 'account' | 'security' | 'devices' | 'logs' | null; initialSection?:
| 'appearance'
| 'account'
| 'security'
| 'devices'
| 'logs'
| null;
}) { }) {
return render( return render(
<AccountModal <AccountModal
@@ -69,6 +75,14 @@ test('settings header uses a generic title instead of the phone number', () => {
expect(screen.getByRole('dialog', { name: '设置与账号安全' })).toBeTruthy(); expect(screen.getByRole('dialog', { name: '设置与账号安全' })).toBeTruthy();
expect(screen.getByText('设置与账号安全')).toBeTruthy(); expect(screen.getByText('设置与账号安全')).toBeTruthy();
expect(screen.queryByText('138****8000')).toBeNull(); expect(screen.queryByText('138****8000')).toBeNull();
expect(screen.queryByText('选择要管理的内容')).toBeNull();
expect(
screen.queryByText('主题、账号与设备能力统一在独立面板中管理'),
).toBeNull();
expect(screen.queryByText(/^安全状态$/)).toBeNull();
expect(screen.queryByText(/^登录设备$/)).toBeNull();
expect(screen.queryByText(/^操作记录$/)).toBeNull();
expect(screen.queryByText('当前账号状态')).toBeNull();
}); });
test('account actions open in independent panels instead of inline expansion', async () => { test('account actions open in independent panels instead of inline expansion', async () => {
@@ -80,13 +94,154 @@ test('account actions open in independent panels instead of inline expansion', a
const accountDialog = screen.getByRole('dialog', { name: '账号信息' }); const accountDialog = screen.getByRole('dialog', { name: '账号信息' });
expect(accountDialog).toBeTruthy(); expect(accountDialog).toBeTruthy();
expect(within(accountDialog).getByRole('button', { name: '返回' })).toBeTruthy(); expect(
expect(within(accountDialog).getByRole('button', { name: '更换手机号' })).toBeTruthy(); within(accountDialog).getByRole('button', { name: '返回' }),
).toBeTruthy();
expect(
within(accountDialog).getByRole('button', { name: '更换手机号' }),
).toBeTruthy();
expect(screen.queryByLabelText('新手机号')).toBeNull(); expect(screen.queryByLabelText('新手机号')).toBeNull();
await user.click(within(accountDialog).getByRole('button', { name: '更换手机号' })); await user.click(
within(accountDialog).getByRole('button', { name: '更换手机号' }),
);
const changePhoneDialog = screen.getByRole('dialog', { name: '绑定新手机号' }); const changePhoneDialog = screen.getByRole('dialog', {
name: '绑定新手机号',
});
expect(within(changePhoneDialog).getByLabelText('新手机号')).toBeTruthy(); expect(within(changePhoneDialog).getByLabelText('新手机号')).toBeTruthy();
expect(within(changePhoneDialog).getByLabelText('验证码')).toBeTruthy(); expect(within(changePhoneDialog).getByLabelText('验证码')).toBeTruthy();
}); });
test('nested settings panels keep back navigation without an extra close action', async () => {
const user = userEvent.setup();
renderAccountModal();
await user.click(screen.getByRole('button', { name: /账号信息/ }));
const accountDialog = screen.getByRole('dialog', { name: '账号信息' });
expect(
within(accountDialog).getByRole('button', { name: '返回' }),
).toBeTruthy();
expect(
within(accountDialog).queryByRole('button', { name: '关闭' }),
).toBeNull();
await user.click(
within(accountDialog).getByRole('button', { name: '更换手机号' }),
);
const changePhoneDialog = screen.getByRole('dialog', {
name: '绑定新手机号',
});
expect(
within(changePhoneDialog).getByRole('button', { name: '返回' }),
).toBeTruthy();
expect(
within(changePhoneDialog).queryByRole('button', { name: '关闭' }),
).toBeNull();
});
test('settings overlays move focus away from inert triggers and restore it on back', async () => {
const user = userEvent.setup();
renderAccountModal();
const accountTrigger = screen.getByRole('button', { name: /账号信息/ });
expect(document.activeElement).not.toBe(accountTrigger);
await user.click(accountTrigger);
const accountDialog = screen.getByRole('dialog', { name: '账号信息' });
const accountBackButton = within(accountDialog).getByRole('button', {
name: '返回',
});
await waitFor(() => {
expect(document.activeElement).toBe(accountBackButton);
});
const changePhoneTrigger = within(accountDialog).getByRole('button', {
name: '更换手机号',
});
await user.click(changePhoneTrigger);
const changePhoneDialog = screen.getByRole('dialog', {
name: '绑定新手机号',
});
const changePhoneBackButton = within(changePhoneDialog).getByRole('button', {
name: '返回',
});
await waitFor(() => {
expect(document.activeElement).toBe(changePhoneBackButton);
});
await user.click(changePhoneBackButton);
await waitFor(() => {
expect(document.activeElement).toBe(changePhoneTrigger);
});
await user.click(accountBackButton);
await waitFor(() => {
expect(document.activeElement).toBe(accountTrigger);
});
});
test('account panel includes merged security devices and audit sections', async () => {
const user = userEvent.setup();
renderAccountModal({
riskBlocks: [
{
scopeType: 'phone',
title: '手机号保护',
detail: '检测到异常验证行为,已开启保护。',
remainingSeconds: 600,
expiresAt: '2026-04-20T10:00:00.000Z',
},
],
sessions: [
{
sessionId: 'session-1',
clientLabel: 'iPhone 15 Pro',
isCurrent: true,
lastSeenAt: '2026-04-20T09:00:00.000Z',
expiresAt: '2026-04-27T09:00:00.000Z',
ipMasked: '10.0.*.*',
},
],
auditLogs: [
{
id: 'log-1',
title: '登录成功',
detail: '通过手机号验证码完成登录。',
createdAt: '2026-04-20T08:00:00.000Z',
ipMasked: '10.0.*.*',
},
],
});
await user.click(screen.getByRole('button', { name: /账号信息/ }));
const accountDialog = screen.getByRole('dialog', { name: '账号信息' });
expect(within(accountDialog).getByText('安全状态')).toBeTruthy();
expect(within(accountDialog).getByText('登录设备')).toBeTruthy();
expect(within(accountDialog).getByText('操作记录')).toBeTruthy();
expect(within(accountDialog).getByText('手机号保护')).toBeTruthy();
expect(within(accountDialog).getByText('iPhone 15 Pro')).toBeTruthy();
expect(within(accountDialog).getByText('登录成功')).toBeTruthy();
});
test('legacy nested section requests now open the merged account panel', () => {
renderAccountModal({ initialSection: 'security' });
const accountDialog = screen.getByRole('dialog', { name: '账号信息' });
expect(accountDialog).toBeTruthy();
expect(within(accountDialog).getByText('安全状态')).toBeTruthy();
expect(within(accountDialog).getByText('登录设备')).toBeTruthy();
expect(within(accountDialog).getByText('操作记录')).toBeTruthy();
});

View File

@@ -1,8 +1,12 @@
import { type ReactNode, useCallback, useEffect, useState } from 'react'; import {
type ReactNode,
useCallback,
useEffect,
useRef,
useState,
} from 'react';
import type { import type { PlatformTheme } from '../../../packages/shared/src/contracts/runtime';
PlatformTheme,
} from '../../../packages/shared/src/contracts/runtime';
import type { import type {
AuthAuditLogEntry, AuthAuditLogEntry,
AuthCaptchaChallenge, AuthCaptchaChallenge,
@@ -51,17 +55,38 @@ type AccountModalProps = {
}; };
const SETTINGS_SECTIONS: Array<{ const SETTINGS_SECTIONS: Array<{
id: PlatformSettingsSection; id: 'appearance' | 'account';
label: string; label: string;
detail: string; detail: string;
}> = [ }> = [
{ id: 'appearance', label: '主题外观', detail: '亮暗主题' }, { id: 'appearance', label: '主题外观', detail: '亮暗主题' },
{ id: 'account', label: '账号信息', detail: '身份与换绑' }, { id: 'account', label: '账号信息', detail: '身份与安全' },
{ id: 'security', label: '安全状态', detail: '保护与限制' },
{ id: 'devices', label: '登录设备', detail: '会话管理' },
{ id: 'logs', label: '操作记录', detail: '最近动作' },
]; ];
const ACCOUNT_MODAL_MAX_HEIGHT =
'calc(100vh - env(safe-area-inset-top, 0px) - env(safe-area-inset-bottom, 0px) - 2rem)';
type PrimarySettingsSection = (typeof SETTINGS_SECTIONS)[number]['id'];
function normalizeSettingsSection(
section: PlatformSettingsSection | null | undefined,
): PrimarySettingsSection | null {
if (section === 'appearance') {
return 'appearance';
}
if (
section === 'account' ||
section === 'security' ||
section === 'devices' ||
section === 'logs'
) {
return 'account';
}
return null;
}
function resolveLoginMethodLabel(loginMethod: AuthUser['loginMethod']) { function resolveLoginMethodLabel(loginMethod: AuthUser['loginMethod']) {
switch (loginMethod) { switch (loginMethod) {
case 'wechat': case 'wechat':
@@ -88,37 +113,6 @@ function formatSessionTime(value: string) {
}); });
} }
function SectionHeader({
eyebrow,
title,
description,
action,
}: {
eyebrow: string;
title: string;
description?: string;
action?: ReactNode;
}) {
return (
<div className="flex items-start justify-between gap-3">
<div>
<div className="text-[11px] font-semibold tracking-[0.24em] text-[var(--platform-cool-text)]">
{eyebrow}
</div>
<div className="mt-2 text-2xl font-semibold text-[var(--platform-text-strong)]">
{title}
</div>
{description ? (
<div className="mt-2 text-sm text-[var(--platform-text-base)]">
{description}
</div>
) : null}
</div>
{action}
</div>
);
}
function SettingsEntryCard({ function SettingsEntryCard({
label, label,
detail, detail,
@@ -128,12 +122,12 @@ function SettingsEntryCard({
label: string; label: string;
detail: string; detail: string;
summary: string; summary: string;
onClick: () => void; onClick: (trigger: HTMLButtonElement) => void;
}) { }) {
return ( return (
<button <button
type="button" type="button"
onClick={onClick} onClick={(event) => onClick(event.currentTarget)}
className="platform-subpanel w-full rounded-[1.5rem] px-4 py-4 text-left transition hover:border-[var(--platform-surface-hover-border)]" className="platform-subpanel w-full rounded-[1.5rem] px-4 py-4 text-left transition hover:border-[var(--platform-surface-hover-border)]"
> >
<div className="flex items-start justify-between gap-3"> <div className="flex items-start justify-between gap-3">
@@ -179,10 +173,11 @@ function OverlayPanel({
onClick={onBack ?? onClose} onClick={onBack ?? onClose}
> >
<div <div
className="platform-auth-card flex max-h-full w-full flex-col overflow-hidden rounded-[28px] p-5 sm:max-w-3xl sm:p-6" className="platform-auth-card flex w-full flex-col overflow-hidden rounded-[28px] p-5 sm:max-w-3xl sm:p-6"
role="dialog" role="dialog"
aria-modal="true" aria-modal="true"
aria-label={title} aria-label={title}
style={{ maxHeight: ACCOUNT_MODAL_MAX_HEIGHT }}
onClick={(event) => event.stopPropagation()} onClick={(event) => event.stopPropagation()}
> >
<div className="flex items-start justify-between gap-4"> <div className="flex items-start justify-between gap-4">
@@ -191,6 +186,7 @@ function OverlayPanel({
{onBack ? ( {onBack ? (
<button <button
type="button" type="button"
autoFocus
className="platform-button platform-button--ghost min-h-0 rounded-full px-3 py-1.5 text-xs" className="platform-button platform-button--ghost min-h-0 rounded-full px-3 py-1.5 text-xs"
onClick={onBack} onClick={onBack}
> >
@@ -212,17 +208,21 @@ function OverlayPanel({
</div> </div>
<div className="flex items-center gap-2"> <div className="flex items-center gap-2">
{action} {action}
<button {onBack ? null : (
type="button" <button
className="platform-button platform-button--ghost min-h-0 rounded-full px-3 py-1.5 text-xs" type="button"
onClick={onClose} className="platform-button platform-button--ghost min-h-0 rounded-full px-3 py-1.5 text-xs"
> onClick={onClose}
>
</button>
</button>
)}
</div> </div>
</div> </div>
<div className="mt-5 min-h-0 flex-1 overflow-y-auto pr-1">{children}</div> <div className="mt-5 min-h-0 flex-1 overflow-y-auto overscroll-y-contain pr-1">
{children}
</div>
</div> </div>
</div> </div>
); );
@@ -290,7 +290,9 @@ export function AccountModal({
onChangePhone, onChangePhone,
}: AccountModalProps) { }: AccountModalProps) {
const [activeSection, setActiveSection] = const [activeSection, setActiveSection] =
useState<PlatformSettingsSection | null>(initialSection); useState<PrimarySettingsSection | null>(
normalizeSettingsSection(initialSection),
);
const [isChangePhonePanelOpen, setIsChangePhonePanelOpen] = useState(false); const [isChangePhonePanelOpen, setIsChangePhonePanelOpen] = useState(false);
const [phone, setPhone] = useState(''); const [phone, setPhone] = useState('');
const [code, setCode] = useState(''); const [code, setCode] = useState('');
@@ -301,6 +303,21 @@ export function AccountModal({
const [sendingCode, setSendingCode] = useState(false); const [sendingCode, setSendingCode] = useState(false);
const [changingPhone, setChangingPhone] = useState(false); const [changingPhone, setChangingPhone] = useState(false);
const [cooldownSeconds, setCooldownSeconds] = useState(0); const [cooldownSeconds, setCooldownSeconds] = useState(0);
const settingsHomeRef = useRef<HTMLDivElement | null>(null);
const sectionTriggerRef = useRef<HTMLButtonElement | null>(null);
const changePhoneTriggerRef = useRef<HTMLButtonElement | null>(null);
const focusAfterNextPaint = useCallback((element: HTMLElement | null) => {
if (!element) {
return;
}
window.requestAnimationFrame(() => {
if (element.isConnected) {
element.focus();
}
});
}, []);
const resetChangePhoneDraft = useCallback(() => { const resetChangePhoneDraft = useCallback(() => {
setPhone(''); setPhone('');
@@ -316,12 +333,27 @@ export function AccountModal({
return; return;
} }
setActiveSection(initialSection); setActiveSection(normalizeSettingsSection(initialSection));
setIsChangePhonePanelOpen(false); setIsChangePhonePanelOpen(false);
setAccountNotice(''); setAccountNotice('');
sectionTriggerRef.current = null;
changePhoneTriggerRef.current = null;
resetChangePhoneDraft(); resetChangePhoneDraft();
}, [initialSection, isOpen, resetChangePhoneDraft]); }, [initialSection, isOpen, resetChangePhoneDraft]);
useEffect(() => {
const settingsHome = settingsHomeRef.current;
if (!settingsHome) {
return;
}
settingsHome.toggleAttribute('inert', activeSection !== null);
return () => {
settingsHome.removeAttribute('inert');
};
}, [activeSection]);
useEffect(() => { useEffect(() => {
if (cooldownSeconds <= 0) { if (cooldownSeconds <= 0) {
return; return;
@@ -337,15 +369,19 @@ export function AccountModal({
}, [cooldownSeconds]); }, [cooldownSeconds]);
const closeSectionPanel = useCallback(() => { const closeSectionPanel = useCallback(() => {
const sectionTrigger = sectionTriggerRef.current;
setIsChangePhonePanelOpen(false); setIsChangePhonePanelOpen(false);
setActiveSection(null); setActiveSection(null);
resetChangePhoneDraft(); resetChangePhoneDraft();
}, [resetChangePhoneDraft]); focusAfterNextPaint(sectionTrigger);
}, [focusAfterNextPaint, resetChangePhoneDraft]);
const closeChangePhonePanel = useCallback(() => { const closeChangePhonePanel = useCallback(() => {
const changePhoneTrigger = changePhoneTriggerRef.current;
setIsChangePhonePanelOpen(false); setIsChangePhonePanelOpen(false);
resetChangePhoneDraft(); resetChangePhoneDraft();
}, [resetChangePhoneDraft]); focusAfterNextPaint(changePhoneTrigger);
}, [focusAfterNextPaint, resetChangePhoneDraft]);
if (!isOpen) { if (!isOpen) {
return null; return null;
@@ -358,46 +394,25 @@ export function AccountModal({
: isPersistingSettings : isPersistingSettings
? '正在同步平台设置...' ? '正在同步平台设置...'
: '平台设置已同步'; : '平台设置已同步';
const latestAuditLog = auditLogs[0];
const accountSummaryCards = [ const accountSummaryCards = [
['登录方式', resolveLoginMethodLabel(user.loginMethod)], ['登录方式', resolveLoginMethodLabel(user.loginMethod)],
['手机号', user.phoneNumberMasked || '未绑定'], ['手机号', user.phoneNumberMasked || '未绑定'],
['微信绑定', user.wechatBound ? '已绑定' : '未绑定'], ['微信绑定', user.wechatBound ? '已绑定' : '未绑定'],
[
'账号状态',
user.bindingStatus === 'pending_bind_phone' ? '待绑定手机号' : '已激活',
],
] as const; ] as const;
const sectionSummaries: Record<PlatformSettingsSection, string> = { const sectionSummaries: Record<PrimarySettingsSection, string> = {
appearance: appearance:
platformTheme === 'dark' platformTheme === 'dark' ? '当前使用暗色主题。' : '当前使用亮色主题。',
? '当前使用暗色主题。' account:
: '当前使用亮色主题。', user.phoneNumberMasked || user.wechatBound
account: user.phoneNumberMasked ? '查看身份、安全状态、登录设备与操作记录。'
? '查看账号身份与换绑入口。' : '查看账号绑定状态与安全记录。',
: '查看账号身份与绑定状态。',
security: loadingRiskBlocks
? '正在读取安全状态。'
: riskBlocks.length > 0
? `当前有 ${riskBlocks.length} 项保护生效。`
: '当前没有生效中的安全限制。',
devices: loadingSessions
? '正在读取设备会话。'
: sessions.length > 0
? `当前共有 ${sessions.length} 台设备会话。`
: '暂无可展示的登录设备。',
logs: loadingAuditLogs
? '正在读取账号动态。'
: latestAuditLog
? `最近一条记录:${formatSessionTime(latestAuditLog.createdAt)}`
: '暂无账号操作记录。',
}; };
return ( return (
<div <div
className={`platform-theme platform-theme--${platformTheme} platform-overlay fixed inset-0 z-[70] flex items-end justify-center overflow-y-auto px-4 sm:items-center`} className={`platform-theme platform-theme--${platformTheme} platform-overlay fixed inset-0 z-[70] flex items-end justify-center overflow-hidden px-4 sm:items-center`}
style={{ style={{
paddingTop: 'calc(env(safe-area-inset-top, 0px) + 1rem)', paddingTop: 'calc(env(safe-area-inset-top, 0px) + 1rem)',
paddingBottom: 'calc(env(safe-area-inset-bottom, 0px) + 1rem)', paddingBottom: 'calc(env(safe-area-inset-bottom, 0px) + 1rem)',
@@ -405,23 +420,18 @@ export function AccountModal({
onClick={onClose} onClick={onClose}
> >
<div <div
className="platform-auth-card relative flex max-h-full w-full max-w-5xl flex-col overflow-hidden rounded-[28px] p-5 sm:p-6" className="platform-auth-card relative flex w-full max-w-5xl flex-col overflow-hidden rounded-[28px] p-5 sm:p-6"
role="dialog" role="dialog"
aria-modal="true" aria-modal="true"
aria-label="设置与账号安全" aria-label="设置与账号安全"
style={{ maxHeight: ACCOUNT_MODAL_MAX_HEIGHT }}
onClick={(event) => event.stopPropagation()} onClick={(event) => event.stopPropagation()}
> >
<div className="flex items-start justify-between gap-4"> <div className="flex items-start justify-between gap-4">
<div> <div>
<div className="text-xs uppercase tracking-[0.28em] text-[var(--platform-cool-text)]"> <div className="text-2xl font-semibold text-[var(--platform-text-strong)]">
</div>
<div className="mt-2 text-2xl font-semibold text-[var(--platform-text-strong)]">
</div> </div>
<div className="mt-2 text-sm text-[var(--platform-text-base)]">
</div>
</div> </div>
<button <button
type="button" type="button"
@@ -432,81 +442,55 @@ export function AccountModal({
</button> </button>
</div> </div>
<div className="mt-5 min-h-0 flex-1 overflow-hidden"> <div className="mt-5 min-h-0 flex-1 overflow-y-auto overscroll-y-contain pr-1">
<div <div ref={settingsHomeRef} className="space-y-4">
className="min-h-0 h-full overflow-y-auto pr-1" <div className="grid gap-3 sm:grid-cols-2">
aria-hidden={activeSection !== null} {SETTINGS_SECTIONS.map((section) => (
> <SettingsEntryCard
<div className="space-y-4"> key={section.id}
<SectionHeader label={section.label}
eyebrow="设置首页" detail={section.detail}
title="选择要管理的内容" summary={sectionSummaries[section.id]}
description="每项内容都会进入独立面板,不在当前层级堆叠详情。" onClick={(trigger) => {
/> sectionTriggerRef.current = trigger;
setAccountNotice('');
<div className="grid gap-3 sm:grid-cols-2"> setActiveSection(section.id);
{SETTINGS_SECTIONS.map((section) => (
<SettingsEntryCard
key={section.id}
label={section.label}
detail={section.detail}
summary={sectionSummaries[section.id]}
onClick={() => {
setAccountNotice('');
setActiveSection(section.id);
}}
/>
))}
</div>
<div className="grid gap-3 lg:grid-cols-2">
<div className="platform-subpanel rounded-2xl px-4 py-4">
<div className="text-sm font-semibold text-[var(--platform-text-strong)]">
</div>
<div className="mt-1 text-sm text-[var(--platform-text-base)]">
{platformTheme === 'dark' ? '暗色主题' : '亮色主题'}
</div>
<span className="platform-pill platform-pill--neutral mt-3 inline-flex px-3 py-1 text-[11px]">
{themeStatusText}
</span>
</div>
<div className="platform-subpanel rounded-2xl px-4 py-4">
<div className="text-sm font-semibold text-[var(--platform-text-strong)]">
</div>
<div className="mt-1 text-sm text-[var(--platform-text-base)]">
{user.bindingStatus === 'pending_bind_phone'
? '待绑定手机号'
: '账号已激活'}
</div>
<div className="mt-3 text-xs tracking-[0.18em] text-[var(--platform-text-soft)]">
{resolveLoginMethodLabel(user.loginMethod)}
</div>
</div>
</div>
<div className="grid gap-3 sm:grid-cols-2">
<button
type="button"
className="platform-button platform-button--ghost h-11 w-full text-sm"
onClick={() => {
void onLogout();
}} }}
> />
退 ))}
</button> </div>
<button
type="button" <div className="platform-subpanel rounded-2xl px-4 py-4">
className="platform-button platform-button--danger h-11 w-full text-sm" <div className="text-sm font-semibold text-[var(--platform-text-strong)]">
onClick={() => {
void onLogoutAll();
}}
>
退
</button>
</div> </div>
<div className="mt-1 text-sm text-[var(--platform-text-base)]">
{platformTheme === 'dark' ? '暗色主题' : '亮色主题'}
</div>
<span className="platform-pill platform-pill--neutral mt-3 inline-flex px-3 py-1 text-[11px]">
{themeStatusText}
</span>
</div>
<div className="grid gap-3 sm:grid-cols-2">
<button
type="button"
className="platform-button platform-button--ghost h-11 w-full text-sm"
onClick={() => {
void onLogout();
}}
>
退
</button>
<button
type="button"
className="platform-button platform-button--danger h-11 w-full text-sm"
onClick={() => {
void onLogoutAll();
}}
>
退
</button>
</div> </div>
</div> </div>
</div> </div>
@@ -515,7 +499,7 @@ export function AccountModal({
<OverlayPanel <OverlayPanel
eyebrow="平台偏好" eyebrow="平台偏好"
title="主题外观" title="主题外观"
description="切换平台层的亮色暗色展示。" description="切换平台亮色暗色主题。"
onBack={closeSectionPanel} onBack={closeSectionPanel}
onClose={onClose} onClose={onClose}
> >
@@ -560,7 +544,7 @@ export function AccountModal({
<OverlayPanel <OverlayPanel
eyebrow="身份信息" eyebrow="身份信息"
title="账号信息" title="账号信息"
description="查看当前登录身份,并通过独立面板处理手机号换绑。" description="统一查看身份、安全状态、登录设备与最近操作。"
onBack={closeSectionPanel} onBack={closeSectionPanel}
onClose={onClose} onClose={onClose}
> >
@@ -600,7 +584,8 @@ export function AccountModal({
<button <button
type="button" type="button"
className="platform-button platform-button--ghost min-h-0 rounded-full px-3 py-1.5 text-[11px]" className="platform-button platform-button--ghost min-h-0 rounded-full px-3 py-1.5 text-[11px]"
onClick={() => { onClick={(event) => {
changePhoneTriggerRef.current = event.currentTarget;
setAccountNotice(''); setAccountNotice('');
resetChangePhoneDraft(); resetChangePhoneDraft();
setIsChangePhonePanelOpen(true); setIsChangePhonePanelOpen(true);
@@ -610,13 +595,201 @@ export function AccountModal({
</button> </button>
</div> </div>
</div> </div>
<div className="platform-subpanel rounded-2xl px-4 py-4">
<div className="flex flex-wrap items-start justify-between gap-3">
<div>
<div className="text-base font-semibold text-[var(--platform-text-strong)]">
</div>
<div className="mt-1 text-sm text-[var(--platform-text-base)]">
</div>
</div>
<button
type="button"
className="platform-button platform-button--ghost min-h-0 rounded-full px-3 py-1.5 text-[11px]"
onClick={() => {
void onRefreshRiskBlocks();
}}
>
</button>
</div>
<div className="mt-4 grid gap-3">
{loadingRiskBlocks ? (
<div className="rounded-2xl border border-[var(--platform-subpanel-border)] px-4 py-3 text-sm text-[var(--platform-text-soft)]">
...
</div>
) : riskBlocks.length > 0 ? (
riskBlocks.map((block) => (
<div
key={`${block.scopeType}:${block.expiresAt}`}
className="platform-banner platform-banner--warning text-sm"
>
<div className="flex items-center justify-between gap-3">
<span>{block.title}</span>
<span className="text-xs">
{' '}
{Math.max(1, Math.ceil(block.remainingSeconds / 60))}{' '}
</span>
</div>
<div className="mt-2 text-xs leading-5">
{block.detail}
</div>
<button
type="button"
className="platform-button platform-button--secondary mt-3 min-h-0 h-9 px-3 text-xs"
onClick={() => {
void onLiftRiskBlock(block.scopeType);
}}
>
</button>
</div>
))
) : (
<div className="rounded-2xl border border-[var(--platform-subpanel-border)] px-4 py-3 text-sm text-[var(--platform-text-soft)]">
</div>
)}
</div>
</div>
<div className="platform-subpanel rounded-2xl px-4 py-4">
<div className="flex flex-wrap items-start justify-between gap-3">
<div>
<div className="text-base font-semibold text-[var(--platform-text-strong)]">
</div>
<div className="mt-1 text-sm text-[var(--platform-text-base)]">
</div>
</div>
<button
type="button"
className="platform-button platform-button--ghost min-h-0 rounded-full px-3 py-1.5 text-[11px]"
onClick={() => {
void onRefreshSessions();
}}
>
</button>
</div>
<div className="mt-4 grid gap-3">
{loadingSessions ? (
<div className="rounded-2xl border border-[var(--platform-subpanel-border)] px-4 py-3 text-sm text-[var(--platform-text-soft)]">
...
</div>
) : sessions.length > 0 ? (
sessions.map((session) => (
<div
key={session.sessionId}
className="rounded-2xl border border-[var(--platform-subpanel-border)] px-4 py-3 text-sm text-[var(--platform-text-base)]"
>
<div className="flex items-center justify-between gap-3">
<span>{session.clientLabel}</span>
<span className="platform-pill platform-pill--success px-2.5 py-1 text-[10px]">
{session.isCurrent ? '当前设备' : '已登录'}
</span>
</div>
<div className="mt-2 text-xs leading-5 text-[var(--platform-text-soft)]">
{formatSessionTime(session.lastSeenAt)}
</div>
<div className="text-xs leading-5 text-[var(--platform-text-soft)]">
{formatSessionTime(session.expiresAt)}
</div>
{session.ipMasked ? (
<div className="text-xs leading-5 text-[var(--platform-text-soft)]">
IP{session.ipMasked}
</div>
) : null}
{!session.isCurrent ? (
<button
type="button"
className="platform-button platform-button--danger mt-3 min-h-0 h-9 px-3 text-xs"
onClick={() => {
void onRevokeSession(session.sessionId);
}}
>
线
</button>
) : null}
</div>
))
) : (
<div className="rounded-2xl border border-[var(--platform-subpanel-border)] px-4 py-3 text-sm text-[var(--platform-text-soft)]">
</div>
)}
</div>
</div>
<div className="platform-subpanel rounded-2xl px-4 py-4">
<div className="flex flex-wrap items-start justify-between gap-3">
<div>
<div className="text-base font-semibold text-[var(--platform-text-strong)]">
</div>
<div className="mt-1 text-sm text-[var(--platform-text-base)]">
</div>
</div>
<button
type="button"
className="platform-button platform-button--ghost min-h-0 rounded-full px-3 py-1.5 text-[11px]"
onClick={() => {
void onRefreshAuditLogs();
}}
>
</button>
</div>
<div className="mt-4 grid gap-3">
{loadingAuditLogs ? (
<div className="rounded-2xl border border-[var(--platform-subpanel-border)] px-4 py-3 text-sm text-[var(--platform-text-soft)]">
...
</div>
) : auditLogs.length > 0 ? (
auditLogs.map((log) => (
<div
key={log.id}
className="rounded-2xl border border-[var(--platform-subpanel-border)] px-4 py-3 text-sm text-[var(--platform-text-base)]"
>
<div className="flex items-center justify-between gap-3">
<span>{log.title}</span>
<span className="text-xs text-[var(--platform-text-soft)]">
{formatSessionTime(log.createdAt)}
</span>
</div>
<div className="mt-2 text-xs leading-5 text-[var(--platform-text-soft)]">
{log.detail}
</div>
{log.ipMasked ? (
<div className="text-xs leading-5 text-[var(--platform-text-soft)]">
IP{log.ipMasked}
</div>
) : null}
</div>
))
) : (
<div className="rounded-2xl border border-[var(--platform-subpanel-border)] px-4 py-3 text-sm text-[var(--platform-text-soft)]">
</div>
)}
</div>
</div>
</div> </div>
{isChangePhonePanelOpen ? ( {isChangePhonePanelOpen ? (
<OverlayPanel <OverlayPanel
eyebrow="手机号换绑" eyebrow="手机号换绑"
title="绑定新手机号" title="绑定新手机号"
description="验证码与校验流程继续由后端决定,前端只负责收集输入与展示结果。" description="输入新手机号并完成验证码验证。"
onBack={closeChangePhonePanel} onBack={closeChangePhonePanel}
onClose={onClose} onClose={onClose}
> >
@@ -644,18 +817,23 @@ export function AccountModal({
/> />
<button <button
type="button" type="button"
disabled={sendingCode || cooldownSeconds > 0 || !phone.trim()} disabled={
sendingCode || cooldownSeconds > 0 || !phone.trim()
}
className="platform-button platform-button--secondary h-11 shrink-0 px-4 text-sm disabled:cursor-not-allowed disabled:opacity-55" className="platform-button platform-button--secondary h-11 shrink-0 px-4 text-sm disabled:cursor-not-allowed disabled:opacity-55"
onClick={() => { onClick={() => {
void (async () => { void (async () => {
setSendingCode(true); setSendingCode(true);
setChangePhoneError(''); setChangePhoneError('');
try { try {
const result = await onSendChangePhoneCode(phone, { const result = await onSendChangePhoneCode(
challengeId: phone,
changePhoneCaptchaChallenge?.challengeId, {
answer: captchaAnswer, challengeId:
}); changePhoneCaptchaChallenge?.challengeId,
answer: captchaAnswer,
},
);
setCooldownSeconds(result.cooldownSeconds); setCooldownSeconds(result.cooldownSeconds);
setChangePhoneHint( setChangePhoneHint(
`验证码已发送,有效期约 ${Math.max(1, Math.round(result.expiresInSeconds / 60))} 分钟。`, `验证码已发送,有效期约 ${Math.max(1, Math.round(result.expiresInSeconds / 60))} 分钟。`,
@@ -732,200 +910,6 @@ export function AccountModal({
) : null} ) : null}
</OverlayPanel> </OverlayPanel>
) : null} ) : null}
{activeSection === 'security' ? (
<OverlayPanel
eyebrow="安全状态"
title="保护与限制"
description="查看当前生效中的账号保护状态。"
action={(
<button
type="button"
className="platform-button platform-button--ghost min-h-0 rounded-full px-3 py-1.5 text-[11px]"
onClick={() => {
void onRefreshRiskBlocks();
}}
>
</button>
)}
onBack={closeSectionPanel}
onClose={onClose}
>
<div className="grid gap-3">
{loadingRiskBlocks ? (
<div className="platform-subpanel rounded-2xl px-4 py-3 text-sm text-[var(--platform-text-soft)]">
...
</div>
) : riskBlocks.length > 0 ? (
riskBlocks.map((block) => (
<div
key={`${block.scopeType}:${block.expiresAt}`}
className="platform-banner platform-banner--warning text-sm"
>
<div className="flex items-center justify-between gap-3">
<span>{block.title}</span>
<span className="text-xs">
{Math.max(1, Math.ceil(block.remainingSeconds / 60))}{' '}
</span>
</div>
<div className="mt-2 text-xs leading-5">{block.detail}</div>
<button
type="button"
className="platform-button platform-button--secondary mt-3 min-h-0 h-9 px-3 text-xs"
onClick={() => {
void onLiftRiskBlock(block.scopeType);
}}
>
</button>
</div>
))
) : (
<div className="platform-subpanel rounded-2xl px-4 py-3 text-sm text-[var(--platform-text-soft)]">
</div>
)}
</div>
</OverlayPanel>
) : null}
{activeSection === 'devices' ? (
<OverlayPanel
eyebrow="会话管理"
title="登录设备"
description="查看当前账号的设备会话状态。"
action={(
<button
type="button"
className="platform-button platform-button--ghost min-h-0 rounded-full px-3 py-1.5 text-[11px]"
onClick={() => {
void onRefreshSessions();
}}
>
</button>
)}
onBack={closeSectionPanel}
onClose={onClose}
>
<div className="space-y-4">
<div className="grid gap-3">
{loadingSessions ? (
<div className="platform-subpanel rounded-2xl px-4 py-3 text-sm text-[var(--platform-text-soft)]">
...
</div>
) : sessions.length > 0 ? (
sessions.map((session) => (
<div
key={session.sessionId}
className="platform-subpanel rounded-2xl px-4 py-3 text-sm text-[var(--platform-text-base)]"
>
<div className="flex items-center justify-between gap-3">
<span>{session.clientLabel}</span>
<span className="platform-pill platform-pill--success px-2.5 py-1 text-[10px]">
{session.isCurrent ? '当前设备' : '已登录'}
</span>
</div>
<div className="mt-2 text-xs leading-5 text-[var(--platform-text-soft)]">
{formatSessionTime(session.lastSeenAt)}
</div>
<div className="text-xs leading-5 text-[var(--platform-text-soft)]">
{formatSessionTime(session.expiresAt)}
</div>
{session.ipMasked ? (
<div className="text-xs leading-5 text-[var(--platform-text-soft)]">
IP{session.ipMasked}
</div>
) : null}
{!session.isCurrent ? (
<button
type="button"
className="platform-button platform-button--danger mt-3 min-h-0 h-9 px-3 text-xs"
onClick={() => {
void onRevokeSession(session.sessionId);
}}
>
线
</button>
) : null}
</div>
))
) : (
<div className="platform-subpanel rounded-2xl px-4 py-3 text-sm text-[var(--platform-text-soft)]">
</div>
)}
</div>
<button
type="button"
className="platform-button platform-button--danger h-11 w-full text-sm"
onClick={() => {
void onLogoutAll();
}}
>
退
</button>
</div>
</OverlayPanel>
) : null}
{activeSection === 'logs' ? (
<OverlayPanel
eyebrow="账号动态"
title="最近操作"
description="查看最近的账号登录与安全动作。"
action={(
<button
type="button"
className="platform-button platform-button--ghost min-h-0 rounded-full px-3 py-1.5 text-[11px]"
onClick={() => {
void onRefreshAuditLogs();
}}
>
</button>
)}
onBack={closeSectionPanel}
onClose={onClose}
>
<div className="grid gap-3">
{loadingAuditLogs ? (
<div className="platform-subpanel rounded-2xl px-4 py-3 text-sm text-[var(--platform-text-soft)]">
...
</div>
) : auditLogs.length > 0 ? (
auditLogs.map((log) => (
<div
key={log.id}
className="platform-subpanel rounded-2xl px-4 py-3 text-sm text-[var(--platform-text-base)]"
>
<div className="flex items-center justify-between gap-3">
<span>{log.title}</span>
<span className="text-xs text-[var(--platform-text-soft)]">
{formatSessionTime(log.createdAt)}
</span>
</div>
<div className="mt-2 text-xs leading-5 text-[var(--platform-text-soft)]">
{log.detail}
</div>
{log.ipMasked ? (
<div className="text-xs leading-5 text-[var(--platform-text-soft)]">
IP{log.ipMasked}
</div>
) : null}
</div>
))
) : (
<div className="platform-subpanel rounded-2xl px-4 py-3 text-sm text-[var(--platform-text-soft)]">
</div>
)}
</div>
</OverlayPanel>
) : null}
</div> </div>
</div> </div>
); );

View File

@@ -43,3 +43,39 @@ test('draft detail panel renders sections and warnings', () => {
expect(html).toContain('编辑设定'); expect(html).toContain('编辑设定');
expect(html).toContain('新增角色'); expect(html).toContain('新增角色');
}); });
test('draft detail panel renders scene chapter label and background preview', () => {
const html = renderToStaticMarkup(
<CustomWorldAgentDraftDetailPanel
detail={{
id: 'scene-chapter-docks',
kind: 'scene_chapter',
title: '潮汐码头章节',
sections: [
{
id: 'sceneName',
label: '所属场景',
value: '潮汐码头',
},
{
id: 'act:act-docks-1:backgroundImageSrc',
label: '第 1 幕背景图',
value: '/images/scene/docks-act-1.webp',
},
],
linkedIds: ['landmark-docks', 'thread-smuggling'],
locked: false,
editable: true,
editableSectionIds: ['title', 'summary', 'act:act-docks-1:title'],
warningMessages: [],
}}
loading={false}
onClose={() => {}}
onStartEdit={() => {}}
/>,
);
expect(html).toContain('场景章节');
expect(html).toContain('第 1 幕背景图');
expect(html).toContain('img');
});

View File

@@ -28,6 +28,7 @@ function resolveKindLabel(kind: CustomWorldDraftCardDetail['kind']) {
if (kind === 'landmark') return '地点'; if (kind === 'landmark') return '地点';
if (kind === 'thread') return '线程'; if (kind === 'thread') return '线程';
if (kind === 'chapter') return '第一幕'; if (kind === 'chapter') return '第一幕';
if (kind === 'scene_chapter') return '场景章节';
return '草稿卡'; return '草稿卡';
} }
@@ -72,6 +73,15 @@ export function CustomWorldAgentDraftDetailPanel({
onGenerateLandmark, onGenerateLandmark,
onOpenRoleAssetStudio, onOpenRoleAssetStudio,
}: CustomWorldAgentDraftDetailPanelProps) { }: CustomWorldAgentDraftDetailPanelProps) {
const shouldRenderImagePreview = (
detailKind: CustomWorldDraftCardDetail['kind'],
sectionId: string,
value: string,
) =>
detailKind === 'scene_chapter' &&
sectionId.endsWith(':backgroundImageSrc') &&
value !== '待继续精修';
return ( return (
<section className="platform-remap-surface rounded-[1.5rem] border border-white/10 bg-black/18 px-4 py-4"> <section className="platform-remap-surface rounded-[1.5rem] border border-white/10 bg-black/18 px-4 py-4">
<div className="flex items-start justify-between gap-3"> <div className="flex items-start justify-between gap-3">
@@ -168,6 +178,13 @@ export function CustomWorldAgentDraftDetailPanel({
<div className="text-[11px] tracking-[0.16em] text-zinc-400"> <div className="text-[11px] tracking-[0.16em] text-zinc-400">
{section.label} {section.label}
</div> </div>
{shouldRenderImagePreview(detail.kind, section.id, section.value) ? (
<img
src={section.value}
alt={section.label}
className="mt-3 h-40 w-full rounded-[1rem] border border-white/10 object-cover"
/>
) : null}
<div className="mt-2 whitespace-pre-wrap text-sm leading-7 text-zinc-100"> <div className="mt-2 whitespace-pre-wrap text-sm leading-7 text-zinc-100">
{section.value} {section.value}
</div> </div>

View File

@@ -9,6 +9,7 @@ type CustomWorldAgentDraftDrawerProps = {
const DRAWER_KIND_ORDER: CustomWorldDraftCardSummary['kind'][] = [ const DRAWER_KIND_ORDER: CustomWorldDraftCardSummary['kind'][] = [
'world', 'world',
'chapter', 'chapter',
'scene_chapter',
'thread', 'thread',
'faction', 'faction',
'character', 'character',
@@ -19,6 +20,7 @@ const DRAWER_KIND_ORDER: CustomWorldDraftCardSummary['kind'][] = [
function resolveGroupLabel(kind: CustomWorldDraftCardSummary['kind']) { function resolveGroupLabel(kind: CustomWorldDraftCardSummary['kind']) {
if (kind === 'world') return '世界总卡'; if (kind === 'world') return '世界总卡';
if (kind === 'chapter') return '第一幕'; if (kind === 'chapter') return '第一幕';
if (kind === 'scene_chapter') return '场景章节';
if (kind === 'thread') return '世界线程'; if (kind === 'thread') return '世界线程';
if (kind === 'faction') return '势力'; if (kind === 'faction') return '势力';
if (kind === 'character') return '关键角色'; if (kind === 'character') return '关键角色';

View File

@@ -46,3 +46,57 @@ test('draft detail panel renders editable form in edit mode', () => {
expect(html).toContain('角色名'); expect(html).toContain('角色名');
expect(html).toContain('textarea'); expect(html).toContain('textarea');
}); });
test('draft detail panel uses textarea for scene chapter act narrative fields', () => {
const html = renderToStaticMarkup(
<CustomWorldAgentDraftDetailPanel
detail={{
id: 'scene-chapter-docks',
kind: 'scene_chapter',
title: '潮汐码头章节',
sections: [
{
id: 'title',
label: '场景章节标题',
value: '潮汐码头章节',
},
{
id: 'act:act-docks-1:summary',
label: '第 1 幕摘要',
value: '玩家刚抵达时,林潮先决定要不要放行。',
},
{
id: 'act:act-docks-1:encounterNpcIds',
label: '第 1 幕相遇 NPC',
value: '林潮\n晏九',
},
{
id: 'act:act-docks-1:transitionHook',
label: '第 1 幕过渡钩子',
value: '确认站位后,真正的封锁者会压上来。',
},
],
linkedIds: ['thread-smuggling'],
locked: false,
editable: true,
editableSectionIds: [
'title',
'act:act-docks-1:summary',
'act:act-docks-1:encounterNpcIds',
'act:act-docks-1:transitionHook',
],
warningMessages: [],
}}
loading={false}
editMode
onClose={() => {}}
onCancelEdit={() => {}}
onSave={() => {}}
/>,
);
expect(html).toContain('第 1 幕摘要');
expect(html).toContain('第 1 幕相遇 NPC');
expect(html).toContain('第 1 幕过渡钩子');
expect(html).toContain('textarea');
});

View File

@@ -15,6 +15,7 @@ type CustomWorldDraftEditPanelProps = {
}; };
function shouldUseTextarea(sectionId: string, value: string) { function shouldUseTextarea(sectionId: string, value: string) {
const sceneActField = sectionId.match(/^act:[^:]+:(.+)$/u)?.[1] ?? null;
return ( return (
value.length > 28 || value.length > 28 ||
value.includes('\n') || value.includes('\n') ||
@@ -26,7 +27,11 @@ function shouldUseTextarea(sectionId: string, value: string) {
sectionId === 'stakes' || sectionId === 'stakes' ||
sectionId === 'openingEvent' || sectionId === 'openingEvent' ||
sectionId === 'understandingShift' || sectionId === 'understandingShift' ||
sectionId === 'description' sectionId === 'description' ||
sceneActField === 'summary' ||
sceneActField === 'encounterNpcIds' ||
sceneActField === 'actGoal' ||
sceneActField === 'transitionHook'
); );
} }

View File

@@ -2,6 +2,7 @@ import {useEffect, useLayoutEffect, useRef, useState} from 'react';
import {resolveCompatibilityTemplateWorldType} from '../../data/customWorldRuntime'; import {resolveCompatibilityTemplateWorldType} from '../../data/customWorldRuntime';
import {MONSTERS_BY_WORLD, PLAYER_BASE_X_METERS} from '../../data/hostileNpcs'; import {MONSTERS_BY_WORLD, PLAYER_BASE_X_METERS} from '../../data/hostileNpcs';
import {resolveActiveSceneActBackgroundImage} from '../../services/customWorldSceneActRuntime';
import {AnimationState, WorldType} from '../../types'; import {AnimationState, WorldType} from '../../types';
import {GameCanvasEffectLayer} from './GameCanvasEffectLayer'; import {GameCanvasEffectLayer} from './GameCanvasEffectLayer';
import {GameCanvasEntityLayer} from './GameCanvasEntityLayer'; import {GameCanvasEntityLayer} from './GameCanvasEntityLayer';
@@ -25,6 +26,8 @@ export function GameCanvasRuntime({
encounter, encounter,
currentScenePreset, currentScenePreset,
worldType, worldType,
customWorldProfile = null,
storyEngineMemory = null,
sceneHostileNpcs, sceneHostileNpcs,
playerX, playerX,
playerOffsetY, playerOffsetY,
@@ -50,7 +53,16 @@ export function GameCanvasRuntime({
const resolvedWorldType = worldType const resolvedWorldType = worldType
? resolveCompatibilityTemplateWorldType(worldType) ?? WorldType.WUXIA ? resolveCompatibilityTemplateWorldType(worldType) ?? WorldType.WUXIA
: null; : 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'); || (resolvedWorldType === WorldType.WUXIA ? '/scene_bg/45_PixelSky.png' : '/scene_bg/47_PixelSky.png');
const monsters = resolvedWorldType ? MONSTERS_BY_WORLD[resolvedWorldType] : []; const monsters = resolvedWorldType ? MONSTERS_BY_WORLD[resolvedWorldType] : [];
const groundBottom = '18%'; const groundBottom = '18%';

View File

@@ -9,9 +9,11 @@ import {
CombatActionMode, CombatActionMode,
CombatVisualEffect, CombatVisualEffect,
CompanionRenderState, CompanionRenderState,
CustomWorldProfile,
Encounter, Encounter,
SceneHostileNpc, SceneHostileNpc,
ScenePresetInfo, ScenePresetInfo,
StoryEngineMemoryState,
WorldType, WorldType,
} from '../../types'; } from '../../types';
import {CharacterAnimator} from '../CharacterAnimator'; import {CharacterAnimator} from '../CharacterAnimator';
@@ -29,6 +31,8 @@ export interface GameCanvasProps {
encounter: Encounter | null; encounter: Encounter | null;
currentScenePreset: ScenePresetInfo | null; currentScenePreset: ScenePresetInfo | null;
worldType: WorldType | null; worldType: WorldType | null;
customWorldProfile?: CustomWorldProfile | null;
storyEngineMemory?: StoryEngineMemoryState | null;
sceneHostileNpcs: SceneHostileNpc[]; sceneHostileNpcs: SceneHostileNpc[];
playerX: number; playerX: number;
playerOffsetY: number; playerOffsetY: number;

View File

@@ -39,6 +39,8 @@ export function GameShellCanvasStage({
encounter={visibleGameState.currentEncounter} encounter={visibleGameState.currentEncounter}
currentScenePreset={visibleGameState.currentScenePreset} currentScenePreset={visibleGameState.currentScenePreset}
worldType={visibleGameState.worldType} worldType={visibleGameState.worldType}
customWorldProfile={visibleGameState.customWorldProfile}
storyEngineMemory={visibleGameState.storyEngineMemory}
sceneHostileNpcs={visibleGameState.sceneHostileNpcs} sceneHostileNpcs={visibleGameState.sceneHostileNpcs}
playerX={visibleGameState.playerX} playerX={visibleGameState.playerX}
playerOffsetY={visibleGameState.playerOffsetY} playerOffsetY={visibleGameState.playerOffsetY}

View File

@@ -703,7 +703,6 @@ export function PlatformHomeView({
saves: Archive, saves: Archive,
profile: UserRound, profile: UserRound,
} as const; } as const;
const latestSaveEntry = saveEntries[0] ?? null;
const openUserSurface = () => { const openUserSurface = () => {
if (authUi?.user) { if (authUi?.user) {
authUi.openAccountModal(); authUi.openAccountModal();
@@ -876,41 +875,6 @@ export function PlatformHomeView({
<div className={MOBILE_PAGE_STAGE_CLASS}> <div className={MOBILE_PAGE_STAGE_CLASS}>
{authUi?.user ? ( {authUi?.user ? (
<> <>
<section
className={`${HERO_SURFACE_CLASS} relative overflow-hidden px-[18px] py-4 text-left`}
>
<div className="absolute inset-0 bg-[radial-gradient(circle_at_top_left,rgba(255,255,255,0.18),transparent_30%),radial-gradient(circle_at_right,rgba(255,205,178,0.18),transparent_28%),linear-gradient(135deg,rgba(255,92,120,0.92),rgba(255,139,98,0.9))]" />
<div className="relative z-10 flex min-h-[10.5rem] flex-col gap-4">
<div className="flex flex-wrap items-center justify-between gap-2">
<span className="platform-pill platform-pill--cool">
SAVE ARCHIVE
</span>
<div className="platform-pill platform-pill--neutral px-3 text-[11px] tracking-[0.08em]">
{saveEntries.length > 0 ? `${saveEntries.length} 个存档` : '暂无存档'}
</div>
</div>
<div className="flex min-w-0 items-stretch gap-3 sm:gap-4">
<div className="min-w-0 flex-1">
<div className="line-clamp-2 break-words text-[1.95rem] font-black leading-[1.02] text-white sm:text-3xl">
{latestSaveEntry ? latestSaveEntry.worldName : '存档'}
</div>
<div className="mt-2 text-sm leading-6 text-zinc-200/88">
{latestSaveEntry
? `最近更新于 ${formatSnapshotTime(latestSaveEntry.lastPlayedAt)},点开后可直接继续游玩。`
: '你在平台里留下的最近可恢复存档会显示在这里。'}
</div>
</div>
{latestSaveEntry ? (
<SaveArchivePreview
entry={latestSaveEntry}
label="最近更新"
className="h-[8.8rem] w-[6.1rem] sm:h-[9.4rem] sm:w-[7rem]"
/>
) : null}
</div>
</div>
</section>
{saveError ? ( {saveError ? (
<div className="rounded-2xl border border-rose-400/20 bg-rose-500/10 px-4 py-3 text-sm leading-6 text-rose-100"> <div className="rounded-2xl border border-rose-400/20 bg-rose-500/10 px-4 py-3 text-sm leading-6 text-rose-100">
{saveError} {saveError}

View File

@@ -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((await screen.findAllByText('潮雾列岛')).length).toBeGreaterThan(0); expect((await screen.findAllByText('潮雾列岛')).length).toBeGreaterThan(0);
expect(screen.getAllByText('最近更新时间排序').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 () => { test('save tab can resume a selected archive directly into the game', async () => {

View File

@@ -85,6 +85,10 @@ export interface GameShellAdventureStatistics {
scenesTraveled: number; scenesTraveled: number;
currentSceneName: string; currentSceneName: string;
playerCurrency: number; playerCurrency: number;
playerLevel?: number;
playerCurrentLevelXp?: number;
playerXpToNextLevel?: number;
playerTotalXp?: number;
inventoryItemCount: number; inventoryItemCount: number;
inventoryStackCount: number; inventoryStackCount: number;
activeCompanionCount: number; activeCompanionCount: number;

View File

@@ -1,5 +1,6 @@
import { useCallback, useEffect, useMemo, useState } from 'react'; import { useCallback, useEffect, useMemo, useState } from 'react';
import { normalizePlayerProgressionState } from '../../data/playerProgression';
import { getLiveGamePlayTimeMs } from '../../data/runtimeStats'; import { getLiveGamePlayTimeMs } from '../../data/runtimeStats';
import { getWorldCampScenePreset } from '../../data/scenePresets'; import { getWorldCampScenePreset } from '../../data/scenePresets';
import type { import type {
@@ -41,7 +42,8 @@ export function buildGameShellDialogueIndicator(params: {
return { return {
showPlayer: true, showPlayer: true,
showEncounter: 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 = const activeEncounterNpcId =
params.visibleGameState.currentEncounter?.kind === 'npc' params.visibleGameState.currentEncounter?.kind === 'npc'
? params.visibleGameState.currentEncounter.id ?? null ? (params.visibleGameState.currentEncounter.id ?? null)
: null; : null;
if (!activeEncounterNpcId) { if (!activeEncounterNpcId) {
return params.visibleCompanionRenderStates; return params.visibleCompanionRenderStates;
@@ -79,6 +81,9 @@ export function buildAdventureStatistics(params: {
livePlayTimeMs: number; livePlayTimeMs: number;
}): GameShellAdventureStatistics { }): GameShellAdventureStatistics {
const { gameState, visibleGameState, livePlayTimeMs } = params; const { gameState, visibleGameState, livePlayTimeMs } = params;
const playerProgression = normalizePlayerProgressionState(
visibleGameState.playerProgression ?? null,
);
return { return {
playTimeMs: livePlayTimeMs, playTimeMs: livePlayTimeMs,
@@ -94,6 +99,10 @@ export function buildAdventureStatistics(params: {
scenesTraveled: gameState.runtimeStats.scenesTraveled, scenesTraveled: gameState.runtimeStats.scenesTraveled,
currentSceneName: visibleGameState.currentScenePreset?.name ?? '当前区域', currentSceneName: visibleGameState.currentScenePreset?.name ?? '当前区域',
playerCurrency: visibleGameState.playerCurrency, playerCurrency: visibleGameState.playerCurrency,
playerLevel: playerProgression.level,
playerCurrentLevelXp: playerProgression.currentLevelXp,
playerXpToNextLevel: playerProgression.xpToNextLevel,
playerTotalXp: playerProgression.totalXp,
inventoryItemCount: visibleGameState.playerInventory.reduce( inventoryItemCount: visibleGameState.playerInventory.reduce(
(sum, item) => sum + item.quantity, (sum, item) => sum + item.quantity,
0, 0,
@@ -104,17 +113,11 @@ export function buildAdventureStatistics(params: {
}; };
} }
export function useGameShellRuntimeViewModel(params: Pick< export function useGameShellRuntimeViewModel(
GameShellProps, params: Pick<GameShellProps, 'session' | 'story' | 'companions'>,
'session' | 'story' | 'companions' ) {
>) {
const { session, story, companions } = params; const { session, story, companions } = params;
const { const { gameState, currentStory, isLoading, isMapOpen } = session;
gameState,
currentStory,
isLoading,
isMapOpen,
} = session;
const { npcUi, characterChatUi, handleChoice } = story; const { npcUi, characterChatUi, handleChoice } = story;
const { buildCompanionRenderStates } = companions; const { buildCompanionRenderStates } = companions;
@@ -122,7 +125,7 @@ export function useGameShellRuntimeViewModel(params: Pick<
const openingCampSceneId = useMemo( const openingCampSceneId = useMemo(
() => () =>
gameState.worldType gameState.worldType
? getWorldCampScenePreset(gameState.worldType)?.id ?? null ? (getWorldCampScenePreset(gameState.worldType)?.id ?? null)
: null, : null,
[gameState.worldType], [gameState.worldType],
); );

View File

@@ -39,6 +39,8 @@ import {
KnowledgeFact, KnowledgeFact,
RoleAttributeProfile, RoleAttributeProfile,
SceneNarrativeResidue, SceneNarrativeResidue,
SceneActBlueprint,
SceneChapterBlueprint,
ThemePack, ThemePack,
ThreadContract, ThreadContract,
WorldStoryGraph, WorldStoryGraph,
@@ -85,6 +87,18 @@ const CUSTOM_WORLD_NPC_VISUAL_GEAR_TYPES =
'magic', 'magic',
'ranged', '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([ 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 { function normalizeProfile(value: unknown): CustomWorldProfile | null {
if (!isRecord(value)) return null; if (!isRecord(value)) return null;
@@ -979,15 +1084,18 @@ function normalizeProfile(value: unknown): CustomWorldProfile | null {
landmarks: landmarkDrafts, landmarks: landmarkDrafts,
storyNpcs, storyNpcs,
}), }),
themePack: preserveStructuredRecord<ThemePack>(value.themePack), themePack: preserveStructuredRecord<ThemePack>(value.themePack),
storyGraph: preserveStructuredRecord<WorldStoryGraph>(value.storyGraph), storyGraph: preserveStructuredRecord<WorldStoryGraph>(value.storyGraph),
knowledgeFacts: knowledgeFacts:
preserveStructuredRecordArray<KnowledgeFact>(value.knowledgeFacts), preserveStructuredRecordArray<KnowledgeFact>(value.knowledgeFacts),
threadContracts: threadContracts:
preserveStructuredRecordArray<ThreadContract>(value.threadContracts), preserveStructuredRecordArray<ThreadContract>(value.threadContracts),
anchorContent: preserveStructuredRecord<EightAnchorContent>( sceneChapterBlueprints: normalizeSceneChapterBlueprints(
value.anchorContent, value.sceneChapterBlueprints,
), ),
anchorContent: preserveStructuredRecord<EightAnchorContent>(
value.anchorContent,
),
creatorIntent: normalizeCustomWorldCreatorIntent(value.creatorIntent), creatorIntent: normalizeCustomWorldCreatorIntent(value.creatorIntent),
anchorPack: anchorPack:
value.anchorPack && typeof value.anchorPack === 'object' value.anchorPack && typeof value.anchorPack === 'object'

View File

@@ -283,6 +283,8 @@ export function createSceneHostileNpcsFromEncounters(
name: encounter.npcName, name: encounter.npcName,
description: encounter.npcDescription, description: encounter.npcDescription,
renderKind: 'npc' as const, renderKind: 'npc' as const,
levelProfile: encounter.levelProfile,
experienceReward: encounter.experienceReward,
encounter: { encounter: {
...encounter, ...encounter,
xMeters: monster.xMeters, xMeters: monster.xMeters,

View File

@@ -1935,6 +1935,8 @@ export function createNpcBattleMonster(
combatTags: monsterPreset.combatTags, combatTags: monsterPreset.combatTags,
attributeProfile: monsterPreset.attributeProfile, attributeProfile: monsterPreset.attributeProfile,
behaviorVectors: monsterPreset.behaviorVectors, behaviorVectors: monsterPreset.behaviorVectors,
levelProfile: encounter.levelProfile,
experienceReward: encounter.experienceReward ?? 0,
encounter: { encounter: {
...encounter, ...encounter,
hostile: true, hostile: true,
@@ -1987,6 +1989,8 @@ export function createNpcBattleMonster(
hp: maxHp, hp: maxHp,
maxHp, maxHp,
renderKind: 'npc' as const, renderKind: 'npc' as const,
levelProfile: encounter.levelProfile,
experienceReward: 0,
encounter: { encounter: {
...encounter, ...encounter,
xMeters: 3.2, xMeters: 3.2,
@@ -2008,6 +2012,8 @@ export function createNpcBattleMonster(
hp: Math.max(baseHp, 80 + npcState.affinity), hp: Math.max(baseHp, 80 + npcState.affinity),
maxHp: Math.max(baseHp, 80 + npcState.affinity), maxHp: Math.max(baseHp, 80 + npcState.affinity),
renderKind: 'npc' as const, renderKind: 'npc' as const,
levelProfile: encounter.levelProfile,
experienceReward: encounter.experienceReward ?? 0,
encounter: { encounter: {
...encounter, ...encounter,
xMeters: 3.2, xMeters: 3.2,

View File

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

View File

@@ -1,7 +1,7 @@
import {describe, expect, it} from 'vitest'; import { describe, expect, it } from 'vitest';
import type {QuestLogEntry, QuestStep, ScenePresetInfo} from '../types'; import type { QuestLogEntry, QuestStep, ScenePresetInfo } from '../types';
import {WorldType} from '../types'; import { WorldType } from '../types';
import { import {
applyQuestProgressFromHostileNpcDefeat, applyQuestProgressFromHostileNpcDefeat,
applyQuestProgressFromNpcTalk, applyQuestProgressFromNpcTalk,
@@ -28,7 +28,10 @@ const TEST_SCENE = {
}, },
], ],
treasureHints: [], treasureHints: [],
} satisfies Pick<ScenePresetInfo, 'id' | 'name' | 'description' | 'npcs' | 'treasureHints'>; } satisfies Pick<
ScenePresetInfo,
'id' | 'name' | 'description' | 'npcs' | 'treasureHints'
>;
const CHAPTER_SCENE = { const CHAPTER_SCENE = {
id: 'palace_court', id: 'palace_court',
@@ -56,7 +59,10 @@ const CHAPTER_SCENE = {
}, },
], ],
treasureHints: ['回廊暗格里的香囊', '花圃石座下的旧金牌'], treasureHints: ['回廊暗格里的香囊', '花圃石座下的旧金牌'],
} satisfies Pick<ScenePresetInfo, 'id' | 'name' | 'description' | 'npcs' | 'treasureHints'>; } satisfies Pick<
ScenePresetInfo,
'id' | 'name' | 'description' | 'npcs' | 'treasureHints'
>;
const OVERRIDDEN_SCENE = { const OVERRIDDEN_SCENE = {
id: 'wuxia-palace-court', id: 'wuxia-palace-court',
@@ -84,10 +90,13 @@ const OVERRIDDEN_SCENE = {
}, },
], ],
treasureHints: ['回廊暗格里的香囊', '花圃石座下的旧金牌'], treasureHints: ['回廊暗格里的香囊', '花圃石座下的旧金牌'],
} satisfies Pick<ScenePresetInfo, 'id' | 'name' | 'description' | 'npcs' | 'treasureHints'>; } satisfies Pick<
ScenePresetInfo,
'id' | 'name' | 'description' | 'npcs' | 'treasureHints'
>;
function requireStep(quest: QuestLogEntry, stepId: string): QuestStep { 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(); expect(step).toBeTruthy();
return step!; return step!;
} }
@@ -109,7 +118,11 @@ describe('questFlow', () => {
expect(requireStep(quest!, 'step_primary').kind).toBe('defeat_hostile_npc'); expect(requireStep(quest!, 'step_primary').kind).toBe('defeat_hostile_npc');
expect(requireStep(quest!, 'step_report_back').kind).toBe('talk_to_npc'); expect(requireStep(quest!, 'step_report_back').kind).toBe('talk_to_npc');
expect(quest?.status).toBe('active'); 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', () => { 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?.objective.kind).toBe('talk_to_npc');
expect(afterBattle?.status).toBe('active'); 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(afterReport?.status).toBe('ready_to_turn_in');
expect(isQuestReadyToClaim(afterReport!)).toBe(true); expect(isQuestReadyToClaim(afterReport!)).toBe(true);
}); });
@@ -157,6 +173,7 @@ describe('questFlow', () => {
reward: { reward: {
affinityBonus: 10, affinityBonus: 10,
currency: 20, currency: 20,
experience: 0,
items: [], items: [],
}, },
rewardText: 'Legacy reward text', rewardText: 'Legacy reward text',
@@ -178,6 +195,7 @@ describe('questFlow', () => {
expect(quest).toBeTruthy(); expect(quest).toBeTruthy();
expect(quest?.chapterId).toBe('chapter:scene:palace_court'); expect(quest?.chapterId).toBe('chapter:scene:palace_court');
expect(quest?.sceneId).toBe('palace_court'); expect(quest?.sceneId).toBe('palace_court');
expect(quest?.reward.experience).toBeGreaterThan(0);
expect(quest?.steps?.map((step) => step.kind)).toEqual([ expect(quest?.steps?.map((step) => step.kind)).toEqual([
'talk_to_npc', 'talk_to_npc',
'defeat_hostile_npc', 'defeat_hostile_npc',
@@ -192,7 +210,10 @@ describe('questFlow', () => {
}); });
expect(quest).toBeTruthy(); 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'); expect(afterOpeningTalk?.objective.kind).toBe('defeat_hostile_npc');
const afterPressure = applyQuestProgressFromHostileNpcDefeat( const afterPressure = applyQuestProgressFromHostileNpcDefeat(
@@ -202,7 +223,10 @@ describe('questFlow', () => {
)[0]; )[0];
expect(afterPressure?.objective.kind).toBe('talk_to_npc'); 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(afterTurningTalk?.status).toBe('ready_to_turn_in');
expect(isQuestReadyToClaim(afterTurningTalk!)).toBe(true); expect(isQuestReadyToClaim(afterTurningTalk!)).toBe(true);
}); });
@@ -215,8 +239,14 @@ describe('questFlow', () => {
expect(quest).toBeTruthy(); expect(quest).toBeTruthy();
expect(quest?.title).toBe('查清内庭旧痕'); expect(quest?.title).toBe('查清内庭旧痕');
expect(requireStep(quest!, 'step_scene_pressure').kind).toBe('inspect_treasure'); expect(requireStep(quest!, 'step_scene_pressure').kind).toBe(
expect(requireStep(quest!, 'step_scene_pressure').title).toBe('调查回廊暗格'); 'inspect_treasure',
expect(requireStep(quest!, 'step_scene_turning').title).toBe('拿旧金牌去对问侍女'); );
expect(requireStep(quest!, 'step_scene_pressure').title).toBe(
'调查回廊暗格',
);
expect(requireStep(quest!, 'step_scene_turning').title).toBe(
'拿旧金牌去对问侍女',
);
}); });
}); });

File diff suppressed because it is too large Load Diff

View File

@@ -15,6 +15,10 @@ import {
getSceneHostileNpcs, getSceneHostileNpcs,
getWorldCampScenePreset, getWorldCampScenePreset,
} from './scenePresets'; } from './scenePresets';
import {
canUseLimitedPrimaryNpcChat,
resolveActiveSceneActEncounterNpcIds,
} from '../services/customWorldSceneActRuntime';
export const EXPLORE_APPROACH_DURATION_MS = 4000; export const EXPLORE_APPROACH_DURATION_MS = 4000;
export const PREVIEW_ENTITY_X_METERS = 12; export const PREVIEW_ENTITY_X_METERS = 12;
@@ -33,6 +37,18 @@ function getResolvedNpcState(state: GameState, encounter: Encounter) {
function shouldAutoStartBattleForEncounter(state: GameState, encounter: Encounter) { function shouldAutoStartBattleForEncounter(state: GameState, encounter: Encounter) {
if (encounter.kind !== 'npc') return false; if (encounter.kind !== 'npc') return false;
const npcState = getResolvedNpcState(state, encounter); 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; return npcState.affinity < 0 || (npcState.relationState?.affinity ?? npcState.affinity) < 0;
} }
@@ -91,11 +107,23 @@ function getAvailableFriendlySceneNpcs(state: GameState) {
&& state.currentScenePreset?.id && state.currentScenePreset?.id
&& getWorldCampScenePreset(state.worldType)?.id === 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) return getSceneFriendlyNpcs(state.currentScenePreset)
.filter(candidate => !isCampScene || Boolean(candidate.characterId)) .filter(candidate => !isCampScene || Boolean(candidate.characterId))
.filter(candidate => candidate.characterId !== state.playerCharacter?.id) .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) { function getAvailableHostileSceneNpcs(state: GameState) {

View File

@@ -296,6 +296,7 @@ export function buildEncounterFromSceneNpc(
imageSrc: npc.imageSrc, imageSrc: npc.imageSrc,
visual: npc.visual, visual: npc.visual,
narrativeProfile: npc.narrativeProfile, narrativeProfile: npc.narrativeProfile,
levelProfile: npc.levelProfile,
}; };
} }

View File

@@ -1,7 +1,4 @@
import type { import type { Dispatch, SetStateAction } from 'react';
Dispatch,
SetStateAction,
} from 'react';
import { import {
acceptQuest, acceptQuest,
@@ -42,9 +39,7 @@ import {
buildCompanionReactionBatch, buildCompanionReactionBatch,
} from '../../services/storyEngine/companionReactionDirector'; } from '../../services/storyEngine/companionReactionDirector';
import { resolveAllCompanionResolutions } from '../../services/storyEngine/companionResolutionDirector'; import { resolveAllCompanionResolutions } from '../../services/storyEngine/companionResolutionDirector';
import { import { appendConsequenceRecord } from '../../services/storyEngine/consequenceLedger';
appendConsequenceRecord,
} from '../../services/storyEngine/consequenceLedger';
import { buildContentDiffReport } from '../../services/storyEngine/contentDiffReport'; import { buildContentDiffReport } from '../../services/storyEngine/contentDiffReport';
import { resolveEndingState } from '../../services/storyEngine/endingResolver'; import { resolveEndingState } from '../../services/storyEngine/endingResolver';
import { buildEpilogueSummary } from '../../services/storyEngine/epilogueComposer'; import { buildEpilogueSummary } from '../../services/storyEngine/epilogueComposer';
@@ -97,8 +92,9 @@ const ENCOUNTER_ENTRY_DURATION_MS = 1800;
const ENCOUNTER_ENTRY_TICK_MS = 180; const ENCOUNTER_ENTRY_TICK_MS = 180;
function dedupeStrings(values: Array<string | null | undefined>, limit = 10) { function dedupeStrings(values: Array<string | null | undefined>, limit = 10) {
return [...new Set(values.map((value) => value?.trim() ?? '').filter(Boolean))] return [
.slice(0, limit); ...new Set(values.map((value) => value?.trim() ?? '').filter(Boolean)),
].slice(0, limit);
} }
function hydrateStoryEngineMemory(state: GameState): GameState { function hydrateStoryEngineMemory(state: GameState): GameState {
@@ -112,11 +108,15 @@ function hydrateStoryEngineMemory(state: GameState): GameState {
} }
const role = const role =
state.customWorldProfile.storyNpcs.find((npc) => state.customWorldProfile.storyNpcs.find(
npc.id === state.currentEncounter?.id || npc.name === state.currentEncounter?.npcName, (npc) =>
) npc.id === state.currentEncounter?.id ||
?? state.customWorldProfile.playableNpcs.find((npc) => npc.name === state.currentEncounter?.npcName,
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) { if (!role) {
return { return {
@@ -126,17 +126,19 @@ function hydrateStoryEngineMemory(state: GameState): GameState {
} }
const themePack = const themePack =
state.customWorldProfile.themePack state.customWorldProfile.themePack ??
?? buildThemePackFromWorldProfile(state.customWorldProfile); buildThemePackFromWorldProfile(state.customWorldProfile);
const storyGraph = const storyGraph =
state.customWorldProfile.storyGraph state.customWorldProfile.storyGraph ??
?? buildFallbackWorldStoryGraph(state.customWorldProfile, themePack); buildFallbackWorldStoryGraph(state.customWorldProfile, themePack);
const narrativeProfile = normalizeActorNarrativeProfile( const narrativeProfile = normalizeActorNarrativeProfile(
role.narrativeProfile, role.narrativeProfile,
buildFallbackActorNarrativeProfile(role, storyGraph, themePack), buildFallbackActorNarrativeProfile(role, storyGraph, themePack),
); );
const npcState = const npcState =
state.npcStates[state.currentEncounter.id ?? state.currentEncounter.npcName]; state.npcStates[
state.currentEncounter.id ?? state.currentEncounter.npcName
];
const activeThreadIds = const activeThreadIds =
storyEngineMemory.activeThreadIds.length > 0 storyEngineMemory.activeThreadIds.length > 0
? storyEngineMemory.activeThreadIds ? storyEngineMemory.activeThreadIds
@@ -164,20 +166,25 @@ function hydrateStoryEngineMemory(state: GameState): GameState {
...state, ...state,
storyEngineMemory: { storyEngineMemory: {
...storyEngineMemory, ...storyEngineMemory,
discoveredFactIds: dedupeStrings([ discoveredFactIds: dedupeStrings(
...storyEngineMemory.discoveredFactIds, [
...visibilitySlice.sayableFactIds, ...storyEngineMemory.discoveredFactIds,
], 16), ...visibilitySlice.sayableFactIds,
activeThreadIds: dedupeStrings([ ],
...storyEngineMemory.activeThreadIds, 16,
...activeThreadIds, ),
], 6), activeThreadIds: dedupeStrings(
[...storyEngineMemory.activeThreadIds, ...activeThreadIds],
6,
),
}, },
}; };
} }
function findNewInventoryItems(previousState: GameState, nextState: GameState) { 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)); return nextState.playerInventory.filter((item) => !previousIds.has(item.id));
} }
@@ -189,9 +196,9 @@ function ensureSceneChapterQuestState(params: {
params.nextState.storyEngineMemory ?? createEmptyStoryEngineMemoryState(); params.nextState.storyEngineMemory ?? createEmptyStoryEngineMemoryState();
const scene = params.nextState.currentScenePreset; const scene = params.nextState.currentScenePreset;
if ( if (
params.nextState.currentScene !== 'Story' params.nextState.currentScene !== 'Story' ||
|| !params.nextState.worldType !params.nextState.worldType ||
|| !scene?.id !scene?.id
) { ) {
return { return {
...params.nextState, ...params.nextState,
@@ -199,9 +206,10 @@ function ensureSceneChapterQuestState(params: {
}; };
} }
const openedSceneChapterIds = dedupeStrings([ const openedSceneChapterIds = dedupeStrings(
...(storyEngineMemory.openedSceneChapterIds ?? []), [...(storyEngineMemory.openedSceneChapterIds ?? [])],
], 64); 64,
);
if (openedSceneChapterIds.includes(scene.id)) { if (openedSceneChapterIds.includes(scene.id)) {
return { return {
...params.nextState, ...params.nextState,
@@ -216,7 +224,10 @@ function ensureSceneChapterQuestState(params: {
...storyEngineMemory, ...storyEngineMemory,
openedSceneChapterIds: [...openedSceneChapterIds, scene.id], openedSceneChapterIds: [...openedSceneChapterIds, scene.id],
}; };
const existingChapterQuest = getChapterQuestForScene(params.nextState.quests, scene.id); const existingChapterQuest = getChapterQuestForScene(
params.nextState.quests,
scene.id,
);
if (existingChapterQuest) { if (existingChapterQuest) {
return { return {
...params.nextState, ...params.nextState,
@@ -227,6 +238,13 @@ function ensureSceneChapterQuestState(params: {
const chapterQuest = buildChapterQuestForScene({ const chapterQuest = buildChapterQuestForScene({
scene, scene,
worldType: params.nextState.worldType, 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) { if (!chapterQuest) {
return { return {
@@ -250,8 +268,8 @@ function applyStoryEngineEchoes(params: {
}) { }) {
const hydratedState = hydrateStoryEngineMemory(params.nextState); const hydratedState = hydrateStoryEngineMemory(params.nextState);
const contracts = hydratedState.customWorldProfile const contracts = hydratedState.customWorldProfile
? hydratedState.customWorldProfile.threadContracts ? (hydratedState.customWorldProfile.threadContracts ??
?? buildThreadContractsFromProfile(hydratedState.customWorldProfile) buildThreadContractsFromProfile(hydratedState.customWorldProfile))
: []; : [];
const newItems = findNewInventoryItems(params.previousState, hydratedState); const newItems = findNewInventoryItems(params.previousState, hydratedState);
const signals = collectStorySignals({ const signals = collectStorySignals({
@@ -279,12 +297,13 @@ function applyStoryEngineEchoes(params: {
state: stateWithSceneChapter, state: stateWithSceneChapter,
reactions, reactions,
}); });
const storyEngineMemory = stateWithReactions.storyEngineMemory ?? createEmptyStoryEngineMemoryState(); const storyEngineMemory =
stateWithReactions.storyEngineMemory ?? createEmptyStoryEngineMemoryState();
const chapterState = advanceChapterState({ const chapterState = advanceChapterState({
previousChapter: previousChapter:
stateWithReactions.chapterState stateWithReactions.chapterState ??
?? storyEngineMemory.currentChapter storyEngineMemory.currentChapter ??
?? null, null,
nextChapter: resolveCurrentChapterState({ nextChapter: resolveCurrentChapterState({
state: stateWithReactions, state: stateWithReactions,
}), }),
@@ -358,7 +377,10 @@ function applyStoryEngineEchoes(params: {
chapterState, chapterState,
}); });
const campaignState = advanceCampaignState({ const campaignState = advanceCampaignState({
previous: storyEngineMemory.campaignState ?? stateWithMutations.campaignState ?? null, previous:
storyEngineMemory.campaignState ??
stateWithMutations.campaignState ??
null,
next: resolveCampaignState({ next: resolveCampaignState({
state: stateWithMutations, state: stateWithMutations,
actState, actState,
@@ -380,9 +402,9 @@ function applyStoryEngineEchoes(params: {
}) })
: null; : null;
const activeScenarioPack = const activeScenarioPack =
resolveScenarioPack(stateWithMutations.activeScenarioPackId) resolveScenarioPack(stateWithMutations.activeScenarioPackId) ??
?? compiledPacks?.scenarioPack compiledPacks?.scenarioPack ??
?? null; null;
const activeCampaignPack = compiledPacks?.campaignPack ?? null; const activeCampaignPack = compiledPacks?.campaignPack ?? null;
const playerStyleProfile = updatePlayerStyleProfileFromAction({ const playerStyleProfile = updatePlayerStyleProfileFromAction({
current: storyEngineMemory.playerStyleProfile, current: storyEngineMemory.playerStyleProfile,
@@ -401,15 +423,15 @@ function applyStoryEngineEchoes(params: {
companionResolutions, companionResolutions,
factionTensionStates, factionTensionStates,
}) })
: storyEngineMemory.endingState ?? null; : (storyEngineMemory.endingState ?? null);
const epilogueSummary = const epilogueSummary = endingState
endingState ? buildEpilogueSummary({
? buildEpilogueSummary({ endingState,
endingState, companionResolutions,
companionResolutions, })
}) : null;
: null; const currentJourneyBeatId =
const currentJourneyBeatId = journeyBeat?.id ?? storyEngineMemory.currentJourneyBeatId ?? null; journeyBeat?.id ?? storyEngineMemory.currentJourneyBeatId ?? null;
const branchBudgetStatus = evaluateBranchBudget({ const branchBudgetStatus = evaluateBranchBudget({
consequenceLedger, consequenceLedger,
authorialConstraintPack, authorialConstraintPack,
@@ -455,20 +477,20 @@ function applyStoryEngineEchoes(params: {
seeds: ['baseline', 'companion', 'explore'], seeds: ['baseline', 'companion', 'explore'],
}) })
: []; : [];
const replaySummary = const replaySummary = simulationRunResults[0]
simulationRunResults[0] ? replayNarrativeRun({
? replayNarrativeRun({ recordedSeed: recordReplaySeed({
recordedSeed: recordReplaySeed({ seed: simulationRunResults[0].seed,
seed: simulationRunResults[0].seed, label: `${activeCampaignPack?.title ?? 'campaign'} 基线回放`,
label: `${activeCampaignPack?.title ?? 'campaign'} 基线回放`, }),
}), result: simulationRunResults[0],
result: simulationRunResults[0], }).summary
}).summary : null;
: null;
const releaseGateReport = buildReleaseGateReport({ const releaseGateReport = buildReleaseGateReport({
qaReport: narrativeQaReport, qaReport: narrativeQaReport,
simulationResults: simulationRunResults, simulationResults: simulationRunResults,
unresolvedThreadCount: stateWithMutations.storyEngineMemory?.activeThreadIds.length ?? 0, unresolvedThreadCount:
stateWithMutations.storyEngineMemory?.activeThreadIds.length ?? 0,
}); });
const saveMigrationManifest = buildSaveMigrationManifest({ const saveMigrationManifest = buildSaveMigrationManifest({
version: 'story-engine-v5', version: 'story-engine-v5',
@@ -497,37 +519,41 @@ function applyStoryEngineEchoes(params: {
simulationRunResults, simulationRunResults,
}, },
}); });
const continueDigest = buildContinueGameDigest({ const continueDigest =
state: { buildContinueGameDigest({
...stateWithMutations, state: {
chapterState, ...stateWithMutations,
campaignState, chapterState,
storyEngineMemory: { campaignState,
...baseMemoryForQa, storyEngineMemory: {
currentJourneyBeatId, ...baseMemoryForQa,
narrativeQaReport, currentJourneyBeatId,
releaseGateReport, narrativeQaReport,
simulationRunResults, releaseGateReport,
narrativeCodex, simulationRunResults,
saveMigrationManifest, narrativeCodex,
saveMigrationManifest,
},
}, },
}, }) +
}) + [ [
epilogueSummary, epilogueSummary,
replaySummary, replaySummary,
telemetrySnapshot.summary, telemetrySnapshot.summary,
contentDiffReport.summary, contentDiffReport.summary,
`发布门禁:${releaseGateReport.status} / ${releaseGateReport.summary}`, `发布门禁:${releaseGateReport.status} / ${releaseGateReport.summary}`,
] ]
.filter(Boolean) .filter(Boolean)
.join('\n'); .join('\n');
return { return {
...stateWithMutations, ...stateWithMutations,
chapterState, chapterState,
campaignState, campaignState,
activeScenarioPackId: activeScenarioPack?.id ?? stateWithMutations.activeScenarioPackId ?? null, activeScenarioPackId:
activeCampaignPackId: activeCampaignPack?.id ?? stateWithMutations.activeCampaignPackId ?? null, activeScenarioPack?.id ?? stateWithMutations.activeScenarioPackId ?? null,
activeCampaignPackId:
activeCampaignPack?.id ?? stateWithMutations.activeCampaignPackId ?? null,
storyEngineMemory: { storyEngineMemory: {
...baseMemoryForQa, ...baseMemoryForQa,
currentJourneyBeatId, currentJourneyBeatId,
@@ -604,14 +630,14 @@ export function createStoryProgressionActions({
actionText, actionText,
resultText, resultText,
lastFunctionId, lastFunctionId,
) => { ) => {
const nextHistory = appendStoryHistory(gameState, actionText, resultText); const nextHistory = appendStoryHistory(gameState, actionText, resultText);
const stateWithHistory = applyStoryEngineEchoes({ const stateWithHistory = applyStoryEngineEchoes({
previousState: gameState, previousState: gameState,
nextState: { nextState: {
...nextState, ...nextState,
storyHistory: nextHistory, storyHistory: nextHistory,
} as GameState, } as GameState,
actionText, actionText,
lastFunctionId, lastFunctionId,
}); });
@@ -620,14 +646,14 @@ export function createStoryProgressionActions({
setAiError(null); setAiError(null);
setIsLoading(true); setIsLoading(true);
try { try {
const nextStory = await generateStoryForState({ const nextStory = await generateStoryForState({
state: stateWithHistory, state: stateWithHistory,
character, character,
history: nextHistory, history: nextHistory,
choice: actionText, choice: actionText,
lastFunctionId, lastFunctionId,
}); });
const recoveredState = applyStoryEngineEchoes({ const recoveredState = applyStoryEngineEchoes({
previousState: gameState, previousState: gameState,
nextState: applyStoryReasoningRecovery(stateWithHistory), nextState: applyStoryReasoningRecovery(stateWithHistory),
@@ -639,72 +665,91 @@ export function createStoryProgressionActions({
} catch (error) { } catch (error) {
console.error('Failed to continue scripted story:', error); console.error('Failed to continue scripted story:', error);
setAiError(error instanceof Error ? error.message : '未知智能生成错误'); setAiError(error instanceof Error ? error.message : '未知智能生成错误');
setCurrentStory(buildFallbackStoryForState(stateWithHistory, character, resultText)); setCurrentStory(
buildFallbackStoryForState(stateWithHistory, character, resultText),
);
} finally { } finally {
setIsLoading(false); setIsLoading(false);
} }
}; };
const commitGeneratedStateWithEncounterEntry: CommitGeneratedStateWithEncounterEntry = async ( const commitGeneratedStateWithEncounterEntry: CommitGeneratedStateWithEncounterEntry =
entryState, async (
resolvedState, entryState,
character, resolvedState,
actionText, character,
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,
actionText, actionText,
resultText,
lastFunctionId, 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 { for (let tick = 1; tick <= runTicks; tick += 1) {
const nextStory = await generateStoryForState({ const progress = tick / runTicks;
state: stateWithHistory, setGameState(
character, interpolateEncounterTransitionState(
history: nextHistory, entryState,
choice: actionText, resolvedState,
lastFunctionId, progress,
}); ),
const recoveredState = applyStoryEngineEchoes({ );
await new Promise((resolve) =>
window.setTimeout(resolve, tickDurationMs),
);
}
}
const nextHistory = appendStoryHistory(gameState, actionText, resultText);
const stateWithHistory = applyStoryEngineEchoes({
previousState: gameState, previousState: gameState,
nextState: applyStoryReasoningRecovery(stateWithHistory), nextState: {
...resolvedState,
storyHistory: nextHistory,
} as GameState,
actionText, actionText,
lastFunctionId, lastFunctionId,
}); });
setGameState(recoveredState);
setCurrentStory(nextStory); setGameState(stateWithHistory);
} catch (error) {
console.error('Failed to continue encounter-entry story:', error); try {
setAiError(error instanceof Error ? error.message : '未知智能生成错误'); const nextStory = await generateStoryForState({
setCurrentStory(buildFallbackStoryForState(stateWithHistory, character, resultText)); state: stateWithHistory,
} finally { character,
setIsLoading(false); 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 { return {
commitGeneratedState, commitGeneratedState,

View File

@@ -9,23 +9,43 @@ import {
} from '../data/characterPresets'; } from '../data/characterPresets';
import { setRuntimeCustomWorldProfile } from '../data/customWorldRuntime'; import { setRuntimeCustomWorldProfile } from '../data/customWorldRuntime';
import { getInitialPlayerCurrency } from '../data/economy'; import { getInitialPlayerCurrency } from '../data/economy';
import { applyEquipmentLoadoutToState, buildInitialEquipmentLoadout, createEmptyEquipmentLoadout } from '../data/equipmentEffects'; import {
import { buildInitialNpcState, buildInitialPlayerInventory } from '../data/npcInteractions'; applyEquipmentLoadoutToState,
buildInitialEquipmentLoadout,
createEmptyEquipmentLoadout,
} from '../data/equipmentEffects';
import {
buildInitialNpcState,
buildInitialPlayerInventory,
} from '../data/npcInteractions';
import { createInitialPlayerProgressionState } from '../data/playerProgression';
import { createInitialGameRuntimeStats } from '../data/runtimeStats'; import { createInitialGameRuntimeStats } from '../data/runtimeStats';
import { ensureSceneEncounterPreview,RESOLVED_ENTITY_X_METERS } from '../data/sceneEncounterPreviews'; import {
import { getScenePreset,getWorldCampScenePreset } from '../data/scenePresets'; ensureSceneEncounterPreview,
RESOLVED_ENTITY_X_METERS,
} from '../data/sceneEncounterPreviews';
import { getScenePreset, getWorldCampScenePreset } from '../data/scenePresets';
import { createEmptyStoryEngineMemoryState } from '../services/storyEngine/visibilityEngine'; 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'; import type { BottomTab } from '../types/navigation';
const PLAYER_BASE_MAX_HP = 180; const PLAYER_BASE_MAX_HP = 180;
export type {BottomTab} from '../types/navigation'; export type { BottomTab } from '../types/navigation';
function mergeStarterInventoryItems<T extends { category: string; name: string }>( function mergeStarterInventoryItems<
explicitItems: T[], T extends { category: string; name: string },
fallbackItems: T[], >(explicitItems: T[], fallbackItems: T[]) {
) {
const merged = new Map<string, T>(); const merged = new Map<string, T>();
[...explicitItems, ...fallbackItems].forEach((item) => { [...explicitItems, ...fallbackItems].forEach((item) => {
@@ -117,13 +137,15 @@ function createInitialCampEncounter(
): Encounter | null { ): Encounter | null {
if (!worldType) return null; if (!worldType) return null;
const campScenePreset = getWorldCampScenePreset(worldType) ?? getScenePreset(worldType, 0); const campScenePreset =
getWorldCampScenePreset(worldType) ?? getScenePreset(worldType, 0);
const npcCandidates = (campScenePreset?.npcs ?? []) const npcCandidates = (campScenePreset?.npcs ?? [])
.filter((npc: SceneNpc) => Boolean(npc.characterId)) .filter((npc: SceneNpc) => Boolean(npc.characterId))
.filter((npc: SceneNpc) => npc.characterId !== playerCharacter.id); .filter((npc: SceneNpc) => npc.characterId !== playerCharacter.id);
if (npcCandidates.length === 0) return null; 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; if (!npc) return null;
return { return {
@@ -145,6 +167,7 @@ function createInitialGameState(): GameState {
customWorldProfile: null, customWorldProfile: null,
playerCharacter: null, playerCharacter: null,
runtimeStats: createInitialGameRuntimeStats(), runtimeStats: createInitialGameRuntimeStats(),
playerProgression: createInitialPlayerProgressionState(),
currentScene: 'Selection', currentScene: 'Selection',
storyHistory: [], storyHistory: [],
storyEngineMemory: createEmptyStoryEngineMemoryState(), storyEngineMemory: createEmptyStoryEngineMemoryState(),
@@ -191,14 +214,18 @@ function createInitialGameState(): GameState {
} }
export function useGameFlow() { export function useGameFlow() {
const [gameState, setGameState] = useState<GameState>(() => createInitialGameState()); const [gameState, setGameState] = useState<GameState>(() =>
createInitialGameState(),
);
const [bottomTab, setBottomTab] = useState<BottomTab>('adventure'); const [bottomTab, setBottomTab] = useState<BottomTab>('adventure');
const [isMapOpen, setIsMapOpen] = useState(false); const [isMapOpen, setIsMapOpen] = useState(false);
useEffect(() => { useEffect(() => {
setRuntimeCustomWorldProfile(gameState.customWorldProfile); setRuntimeCustomWorldProfile(gameState.customWorldProfile);
setRuntimeCharacterOverrides( setRuntimeCharacterOverrides(
gameState.customWorldProfile ? buildCustomWorldRuntimeCharacters(gameState.customWorldProfile) : null, gameState.customWorldProfile
? buildCustomWorldRuntimeCharacters(gameState.customWorldProfile)
: null,
); );
}, [gameState.customWorldProfile]); }, [gameState.customWorldProfile]);
@@ -216,7 +243,7 @@ export function useGameFlow() {
); );
const initialScenePreset = getScenePreset(resolvedWorldType, 0) ?? null; const initialScenePreset = getScenePreset(resolvedWorldType, 0) ?? null;
setIsMapOpen(false); setIsMapOpen(false);
setGameState(prev => setGameState((prev) =>
ensureSceneEncounterPreview({ ensureSceneEncounterPreview({
...prev, ...prev,
worldType: resolvedWorldType, worldType: resolvedWorldType,
@@ -225,6 +252,7 @@ export function useGameFlow() {
sceneHostileNpcs: [], sceneHostileNpcs: [],
currentEncounter: null, currentEncounter: null,
npcInteractionActive: false, npcInteractionActive: false,
playerProgression: createInitialPlayerProgressionState(),
storyEngineMemory: createEmptyStoryEngineMemoryState(), storyEngineMemory: createEmptyStoryEngineMemoryState(),
chapterState: null, chapterState: null,
campaignState: null, campaignState: null,
@@ -257,110 +285,114 @@ export function useGameFlow() {
setBottomTab('adventure'); setBottomTab('adventure');
setIsMapOpen(false); setIsMapOpen(false);
setGameState(prev => setGameState((prev) => {
{ const resolvedWorldType = prev.worldType;
const resolvedWorldType = prev.worldType; const resolvedCustomWorldProfile = prev.customWorldProfile;
const resolvedCustomWorldProfile = prev.customWorldProfile; const initialScenePreset = resolvedWorldType
const initialScenePreset = resolvedWorldType ? (getWorldCampScenePreset(resolvedWorldType) ??
? getWorldCampScenePreset(resolvedWorldType) ?? getScenePreset(resolvedWorldType, 0) getScenePreset(resolvedWorldType, 0))
: null; : null;
const initialEncounter = createInitialCampEncounter( const initialEncounter = createInitialCampEncounter(
resolvedWorldType, resolvedWorldType,
character, character,
); );
const initialNpcState = initialEncounter const initialNpcState = initialEncounter
? buildInitialNpcState(initialEncounter, resolvedWorldType, prev) ? buildInitialNpcState(initialEncounter, resolvedWorldType, prev)
: null; : null;
const initialEquipment = buildInitialEquipmentLoadout( const initialEquipment = buildInitialEquipmentLoadout(
character, character,
resolvedCustomWorldProfile, resolvedCustomWorldProfile,
); );
const explicitStarterItems = const explicitStarterItems =
resolvedWorldType === WorldType.CUSTOM resolvedWorldType === WorldType.CUSTOM
? buildExplicitCustomWorldRoleStarterState( ? buildExplicitCustomWorldRoleStarterState(
resolvedCustomWorldProfile!, 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(
character, 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, resolvedWorldType,
resolvedCustomWorldProfile, resolvedCustomWorldProfile,
), ),
), playerInventory: mergeStarterInventoryItems<InventoryItem>(
playerEquipment: createEmptyEquipmentLoadout(), explicitStarterItems?.inventory ?? [],
npcStates: initialEncounter && initialNpcState buildInitialPlayerInventory(
? { character,
[initialEncounter.id!]: initialNpcState, resolvedWorldType,
} resolvedCustomWorldProfile,
: {}, ),
quests: [], ),
roster: [], playerEquipment: createEmptyEquipmentLoadout(),
companions: [], npcStates:
currentBattleNpcId: null, initialEncounter && initialNpcState
currentNpcBattleMode: null, ? {
currentNpcBattleOutcome: null, [initialEncounter.id!]: initialNpcState,
sparReturnEncounter: null, }
sparPlayerHpBefore: null, : {},
sparPlayerMaxHpBefore: null, quests: [],
sparStoryHistoryBefore: null, roster: [],
}, mergedStarterEquipment), companions: [],
); currentBattleNpcId: null,
}, currentNpcBattleMode: null,
); currentNpcBattleOutcome: null,
sparReturnEncounter: null,
sparPlayerHpBefore: null,
sparPlayerMaxHpBefore: null,
sparStoryHistoryBefore: null,
},
mergedStarterEquipment,
),
);
});
}; };
return { return {

View File

@@ -28,6 +28,24 @@ body {
-webkit-font-smoothing: antialiased; -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,
.fusion-pixel-app * { .fusion-pixel-app * {
font-family: "Fusion Pixel", "Inter", ui-sans-serif, system-ui, sans-serif !important; font-family: "Fusion Pixel", "Inter", ui-sans-serif, system-ui, sans-serif !important;

View File

@@ -7,10 +7,7 @@ import {
resolveHydratedSnapshotState, resolveHydratedSnapshotState,
} from './runtimeSnapshot'; } from './runtimeSnapshot';
function createStory( function createStory(text: string, streaming = false): StoryMoment {
text: string,
streaming = false,
): StoryMoment {
return { return {
text, text,
options: [], options: [],
@@ -63,6 +60,13 @@ function createHydratedBattleSnapshot(
hp: 18, hp: 18,
maxHp: 32, maxHp: 32,
description: '拦路的刀客', description: '拦路的刀客',
levelProfile: {
level: 4,
referenceStrength: 202,
progressionRole: 'rival',
source: 'manual',
},
experienceReward: 20,
}, },
], ],
playerX: 0, playerX: 0,
@@ -160,6 +164,14 @@ describe('runtimeSnapshot', () => {
armor: null, armor: null,
relic: 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.playerMaxHp).toBe(12);
expect(hydrated.gameState.playerHp).toBe(12); expect(hydrated.gameState.playerHp).toBe(12);
expect(hydrated.gameState.playerMaxMana).toBe(12); expect(hydrated.gameState.playerMaxMana).toBe(12);
@@ -180,6 +192,13 @@ describe('runtimeSnapshot', () => {
description: '拦路的刀客', description: '拦路的刀客',
hp: 18, hp: 18,
maxHp: 32, maxHp: 32,
levelProfile: {
level: 4,
referenceStrength: 202,
progressionRole: 'rival',
source: 'manual',
},
experienceReward: 20,
attackRange: expect.any(Number), attackRange: expect.any(Number),
speed: expect.any(Number), speed: expect.any(Number),
animation: 'idle', animation: 'idle',
@@ -210,6 +229,13 @@ describe('runtimeSnapshot', () => {
speed: 7, speed: 7,
hp: 18, hp: 18,
maxHp: 32, maxHp: 32,
levelProfile: {
level: 4,
referenceStrength: 202,
progressionRole: 'rival',
source: 'manual',
},
experienceReward: 20,
renderKind: 'npc', renderKind: 'npc',
encounter: { encounter: {
kind: 'npc', kind: 'npc',

View File

@@ -2,6 +2,7 @@ import {
buildInitialNpcState, buildInitialNpcState,
createNpcBattleMonster, createNpcBattleMonster,
} from '../data/npcInteractions'; } from '../data/npcInteractions';
import { normalizePlayerProgressionState } from '../data/playerProgression';
import type { import type {
Encounter, Encounter,
GameState, GameState,
@@ -18,9 +19,7 @@ import type {
SnapshotState, SnapshotState,
} from './runtimeSnapshotTypes'; } from './runtimeSnapshotTypes';
function normalizeBottomTab( function normalizeBottomTab(bottomTab: string | null | undefined): BottomTab {
bottomTab: string | null | undefined,
): BottomTab {
return bottomTab === 'character' || bottomTab === 'inventory' return bottomTab === 'character' || bottomTab === 'inventory'
? bottomTab ? bottomTab
: 'adventure'; : 'adventure';
@@ -106,6 +105,8 @@ function normalizeRuntimeBattleEncounter(
typeof encounter.npcAvatar === 'string' ? encounter.npcAvatar : '', typeof encounter.npcAvatar === 'string' ? encounter.npcAvatar : '',
context: typeof encounter.context === 'string' ? encounter.context : '', context: typeof encounter.context === 'string' ? encounter.context : '',
hostile: true, hostile: true,
levelProfile: encounter.levelProfile,
experienceReward: encounter.experienceReward,
} satisfies Encounter; } satisfies Encounter;
} }
@@ -126,9 +127,7 @@ function resolveRuntimeNpcBattleState(
} }
const npcStateKey = const npcStateKey =
gameState.currentBattleNpcId ?? gameState.currentBattleNpcId ?? encounter.id ?? encounter.npcName;
encounter.id ??
encounter.npcName;
const npcState = const npcState =
gameState.npcStates[npcStateKey] ?? gameState.npcStates[npcStateKey] ??
buildInitialNpcState( buildInitialNpcState(
@@ -161,9 +160,13 @@ function hydrateRuntimeNpcBattleMonster(params: {
); );
const candidate = params.hostileNpc as Partial<SceneHostileNpc>; const candidate = params.hostileNpc as Partial<SceneHostileNpc>;
const xMeters = const xMeters =
typeof candidate.xMeters === 'number' ? candidate.xMeters : template.xMeters; typeof candidate.xMeters === 'number'
? candidate.xMeters
: template.xMeters;
const yOffset = const yOffset =
typeof candidate.yOffset === 'number' ? candidate.yOffset : template.yOffset; typeof candidate.yOffset === 'number'
? candidate.yOffset
: template.yOffset;
return { return {
...template, ...template,
@@ -198,6 +201,11 @@ function hydrateRuntimeNpcBattleMonster(params: {
: template.attackRange, : template.attackRange,
speed: speed:
typeof candidate.speed === 'number' ? candidate.speed : template.speed, typeof candidate.speed === 'number' ? candidate.speed : template.speed,
levelProfile: candidate.levelProfile ?? template.levelProfile,
experienceReward:
typeof candidate.experienceReward === 'number'
? candidate.experienceReward
: template.experienceReward,
encounter: { encounter: {
...template.encounter, ...template.encounter,
xMeters, xMeters,
@@ -263,6 +271,9 @@ export function normalizeSavedGameState(gameState: GameState) {
return hydrateRuntimeNpcBattleGameState({ return hydrateRuntimeNpcBattleGameState({
...hydratableState, ...hydratableState,
playerProgression: normalizePlayerProgressionState(
hydratableState.playerProgression ?? null,
),
playerMaxHp, playerMaxHp,
playerHp: Math.min(hydratableState.playerHp, playerMaxHp), playerHp: Math.min(hydratableState.playerHp, playerMaxHp),
playerMaxMana, playerMaxMana,
@@ -305,14 +316,19 @@ export function isHydratedSnapshotState(
(gameState.runtimeSessionId === null || (gameState.runtimeSessionId === null ||
typeof gameState.runtimeSessionId === 'string') && typeof gameState.runtimeSessionId === 'string') &&
(!gameState.playerCharacter || (!gameState.playerCharacter ||
Boolean(gameState.playerEquipment && typeof gameState.playerEquipment === 'object')), Boolean(
gameState.playerEquipment &&
typeof gameState.playerEquipment === 'object',
)),
); );
} }
export function rehydrateSavedSnapshot<T extends HydratedSnapshotState>( export function rehydrateSavedSnapshot<T extends HydratedSnapshotState>(
snapshot: T, snapshot: T,
): T { ): T {
const hydratedGameState = hydrateRuntimeNpcBattleGameState(snapshot.gameState); const hydratedGameState = hydrateRuntimeNpcBattleGameState(
snapshot.gameState,
);
if (hydratedGameState === snapshot.gameState) { if (hydratedGameState === snapshot.gameState) {
return snapshot; return snapshot;

View File

@@ -23,6 +23,7 @@ import type {
CharacterChatSuggestionsRequest, CharacterChatSuggestionsRequest,
CharacterChatSummaryRequest, CharacterChatSummaryRequest,
NpcChatDialogueRequest, NpcChatDialogueRequest,
NpcChatTurnDirective,
NpcChatTurnRequest, NpcChatTurnRequest,
NpcChatTurnResult, NpcChatTurnResult,
NpcRecruitDialogueRequest, NpcRecruitDialogueRequest,
@@ -977,6 +978,7 @@ export async function streamNpcChatTurn(
state: GameState; state: GameState;
turnCount: number; turnCount: number;
} | null; } | null;
chatDirective?: NpcChatTurnDirective | null;
} = {}, } = {},
) { ) {
const payload = { const payload = {
@@ -998,6 +1000,7 @@ export async function streamNpcChatTurn(
turnCount: options.questOfferContext.turnCount, turnCount: options.questOfferContext.turnCount,
} }
: null, : null,
chatDirective: options.chatDirective ?? null,
} satisfies NpcChatTurnRequest; } satisfies NpcChatTurnRequest;
const response = await fetchWithApiAuth( const response = await fetchWithApiAuth(

View File

@@ -30,6 +30,7 @@ import type {
NpcDisclosureStage, NpcDisclosureStage,
NpcWarmthStage, NpcWarmthStage,
PlayerStyleProfile, PlayerStyleProfile,
PlayerProgressionState,
QuestStatus, QuestStatus,
ReleaseGateReport, ReleaseGateReport,
ScenarioPack, ScenarioPack,
@@ -212,6 +213,7 @@ export interface QuestGenerationContext {
currentSceneTreasureHintCount?: number; currentSceneTreasureHintCount?: number;
recentStoryMoments: StoryMoment[]; recentStoryMoments: StoryMoment[];
playerCharacter?: Character | null; playerCharacter?: Character | null;
playerProgression?: PlayerProgressionState | null;
playerHp?: number; playerHp?: number;
playerMaxHp?: number; playerMaxHp?: number;
playerMana?: number; playerMana?: number;

View File

@@ -3,8 +3,8 @@ import { normalizeCustomWorldProfileRecord } from '../data/customWorldLibrary';
import { type CustomWorldProfile, WorldType } from '../types'; import { type CustomWorldProfile, WorldType } from '../types';
import { buildAgentDraftFoundationSettingText } from './customWorldAgentGenerationProgress'; import { buildAgentDraftFoundationSettingText } from './customWorldAgentGenerationProgress';
function toText(value: unknown) { function toText(value: unknown, fallback = '') {
return typeof value === 'string' ? value.trim() : ''; return typeof value === 'string' ? value.trim() : fallback;
} }
function isRecord(value: unknown): value is Record<string, unknown> { function isRecord(value: unknown): value is Record<string, unknown> {
@@ -178,6 +178,88 @@ function adaptDraftLandmarks(value: unknown, storyNpcIdSet: Set<string>) {
.filter(Boolean) as AdaptedDraftLandmark[]; .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<string>,
landmarkIdSet: Set<string>,
) {
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( export function buildCustomWorldProfileFromAgentDraft(
session: CustomWorldAgentSessionSnapshot | null | undefined, session: CustomWorldAgentSessionSnapshot | null | undefined,
): CustomWorldProfile | null { ): CustomWorldProfile | null {
@@ -203,6 +285,13 @@ export function buildCustomWorldProfileFromAgentDraft(
const storyNpcIdSet = new Set( const storyNpcIdSet = new Set(
storyNpcs.map((entry) => toText(entry.id)).filter(Boolean), 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({ const normalized = normalizeCustomWorldProfileRecord({
id: `agent-draft-${session.sessionId}`, id: `agent-draft-${session.sessionId}`,
settingText, settingText,
@@ -220,7 +309,7 @@ export function buildCustomWorldProfileFromAgentDraft(
coreConflicts: toStringArray(draftProfile.coreConflicts, 6), coreConflicts: toStringArray(draftProfile.coreConflicts, 6),
playableNpcs, playableNpcs,
storyNpcs, storyNpcs,
landmarks: adaptDraftLandmarks(draftProfile.landmarks, storyNpcIdSet), landmarks: adaptedLandmarks,
camp: isRecord(draftProfile.camp) camp: isRecord(draftProfile.camp)
? { ? {
name: toText(draftProfile.camp.name), name: toText(draftProfile.camp.name),
@@ -231,6 +320,11 @@ export function buildCustomWorldProfileFromAgentDraft(
imageSrc: toText(draftProfile.camp.imageSrc) || undefined, imageSrc: toText(draftProfile.camp.imageSrc) || undefined,
} }
: undefined, : undefined,
sceneChapterBlueprints: adaptDraftSceneChapters(
draftProfile.sceneChapters,
storyNpcIdSet,
landmarkIdSet,
),
anchorContent: session.anchorContent, anchorContent: session.anchorContent,
creatorIntent: session.creatorIntent, creatorIntent: session.creatorIntent,
anchorPack: session.anchorPack, anchorPack: session.anchorPack,

View File

@@ -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<GameState, 'customWorldProfile' | 'currentScenePreset' | 'storyEngineMemory'>;
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,
};
}

View File

@@ -1,20 +1,22 @@
import {getNpcDisclosureStage, getNpcWarmthStage} from '../data/npcInteractions'; import {
getNpcDisclosureStage,
getNpcWarmthStage,
} from '../data/npcInteractions';
import { import {
buildFallbackQuestIntent, buildFallbackQuestIntent,
compileQuestIntentToQuest, compileQuestIntentToQuest,
evaluateQuestOpportunity, evaluateQuestOpportunity,
} from '../data/questFlow'; } from '../data/questFlow';
import type { import type { Encounter, GameState, QuestLogEntry } from '../types';
Encounter, import type { QuestGenerationContext } from './aiTypes';
GameState,
QuestLogEntry,
} from '../types';
import type {QuestGenerationContext} from './aiTypes';
import { requestJson } from './apiClient'; import { requestJson } from './apiClient';
import {requestChatMessageContent} from './llmClient'; import { requestChatMessageContent } from './llmClient';
import {parseJsonResponseText} from './llmParsers'; import { parseJsonResponseText } from './llmParsers';
import {buildQuestIntentPrompt, QUEST_INTENT_SYSTEM_PROMPT} from './questPrompt'; import {
import type {QuestIntent, QuestPreviewRequest} from './questTypes'; buildQuestIntentPrompt,
QUEST_INTENT_SYSTEM_PROMPT,
} from './questPrompt';
import type { QuestIntent, QuestPreviewRequest } from './questTypes';
import { import {
buildFallbackActorNarrativeProfile, buildFallbackActorNarrativeProfile,
normalizeActorNarrativeProfile, normalizeActorNarrativeProfile,
@@ -47,16 +49,13 @@ function coerceStringArray(value: unknown, fallback: string[]) {
} }
const items = value const items = value
.map(item => (typeof item === 'string' ? item.trim() : '')) .map((item) => (typeof item === 'string' ? item.trim() : ''))
.filter(Boolean); .filter(Boolean);
return items.length > 0 ? items : fallback; return items.length > 0 ? items : fallback;
} }
function resolveIssuerNarrativeProfile( function resolveIssuerNarrativeProfile(state: GameState, encounter: Encounter) {
state: GameState,
encounter: Encounter,
) {
if (encounter.narrativeProfile) { if (encounter.narrativeProfile) {
return encounter.narrativeProfile; return encounter.narrativeProfile;
} }
@@ -65,22 +64,22 @@ function resolveIssuerNarrativeProfile(
} }
const role = const role =
state.customWorldProfile.storyNpcs.find((npc) => state.customWorldProfile.storyNpcs.find(
npc.id === encounter.id || npc.name === encounter.npcName, (npc) => npc.id === encounter.id || npc.name === encounter.npcName,
) ) ??
?? state.customWorldProfile.playableNpcs.find((npc) => state.customWorldProfile.playableNpcs.find(
npc.id === encounter.id || npc.name === encounter.npcName, (npc) => npc.id === encounter.id || npc.name === encounter.npcName,
); );
if (!role) { if (!role) {
return null; return null;
} }
const themePack = const themePack =
state.customWorldProfile.themePack state.customWorldProfile.themePack ??
?? buildThemePackFromWorldProfile(state.customWorldProfile); buildThemePackFromWorldProfile(state.customWorldProfile);
const storyGraph = const storyGraph =
state.customWorldProfile.storyGraph state.customWorldProfile.storyGraph ??
?? buildFallbackWorldStoryGraph(state.customWorldProfile, themePack); buildFallbackWorldStoryGraph(state.customWorldProfile, themePack);
return normalizeActorNarrativeProfile( return normalizeActorNarrativeProfile(
role.narrativeProfile, 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') { if (!rawIntent || typeof rawIntent !== 'object') {
return fallback; return fallback;
} }
@@ -99,44 +101,56 @@ function sanitizeQuestIntent(rawIntent: unknown, fallback: QuestIntent): QuestIn
title: coerceQuestTitle(intent.title, fallback.title), title: coerceQuestTitle(intent.title, fallback.title),
description: coerceString(intent.description, fallback.description), description: coerceString(intent.description, fallback.description),
summary: coerceString(intent.summary, fallback.summary), summary: coerceString(intent.summary, fallback.summary),
narrativeType: ( narrativeType:
typeof intent.narrativeType === 'string' typeof intent.narrativeType === 'string' &&
&& ['bounty', 'escort', 'investigation', 'retrieval', 'relationship', 'trial'].includes(intent.narrativeType) [
) 'bounty',
? intent.narrativeType as QuestIntent['narrativeType'] 'escort',
: fallback.narrativeType, 'investigation',
'retrieval',
'relationship',
'trial',
].includes(intent.narrativeType)
? (intent.narrativeType as QuestIntent['narrativeType'])
: fallback.narrativeType,
dramaticNeed: coerceString(intent.dramaticNeed, fallback.dramaticNeed), dramaticNeed: coerceString(intent.dramaticNeed, fallback.dramaticNeed),
issuerGoal: coerceString(intent.issuerGoal, fallback.issuerGoal), issuerGoal: coerceString(intent.issuerGoal, fallback.issuerGoal),
playerHook: coerceString(intent.playerHook, fallback.playerHook), playerHook: coerceString(intent.playerHook, fallback.playerHook),
worldReason: coerceString(intent.worldReason, fallback.worldReason), worldReason: coerceString(intent.worldReason, fallback.worldReason),
recommendedObjectiveKinds: coerceStringArray(intent.recommendedObjectiveKinds, fallback.recommendedObjectiveKinds) recommendedObjectiveKinds: coerceStringArray(
.filter(kind => [ intent.recommendedObjectiveKinds,
fallback.recommendedObjectiveKinds,
).filter((kind) =>
[
'defeat_hostile_npc', 'defeat_hostile_npc',
'inspect_treasure', 'inspect_treasure',
'spar_with_npc', 'spar_with_npc',
'talk_to_npc', 'talk_to_npc',
'reach_scene', 'reach_scene',
'deliver_item', 'deliver_item',
].includes(kind)) as QuestIntent['recommendedObjectiveKinds'], ].includes(kind),
urgency: ( ) as QuestIntent['recommendedObjectiveKinds'],
typeof intent.urgency === 'string' urgency:
&& ['low', 'medium', 'high'].includes(intent.urgency) typeof intent.urgency === 'string' &&
) ['low', 'medium', 'high'].includes(intent.urgency)
? intent.urgency as QuestIntent['urgency'] ? (intent.urgency as QuestIntent['urgency'])
: fallback.urgency, : fallback.urgency,
intimacy: ( intimacy:
typeof intent.intimacy === 'string' typeof intent.intimacy === 'string' &&
&& ['transactional', 'cooperative', 'trust_based'].includes(intent.intimacy) ['transactional', 'cooperative', 'trust_based'].includes(intent.intimacy)
) ? (intent.intimacy as QuestIntent['intimacy'])
? intent.intimacy as QuestIntent['intimacy'] : fallback.intimacy,
: fallback.intimacy, rewardTheme:
rewardTheme: ( typeof intent.rewardTheme === 'string' &&
typeof intent.rewardTheme === 'string' ['currency', 'resource', 'relationship', 'intel', 'rare_item'].includes(
&& ['currency', 'resource', 'relationship', 'intel', 'rare_item'].includes(intent.rewardTheme) intent.rewardTheme,
) )
? intent.rewardTheme as QuestIntent['rewardTheme'] ? (intent.rewardTheme as QuestIntent['rewardTheme'])
: fallback.rewardTheme, : fallback.rewardTheme,
followupHooks: coerceStringArray(intent.followupHooks, fallback.followupHooks), followupHooks: coerceStringArray(
intent.followupHooks,
fallback.followupHooks,
),
}; };
} }
@@ -144,10 +158,13 @@ export function buildQuestGenerationContextFromState(params: {
state: GameState; state: GameState;
encounter: Encounter; encounter: Encounter;
}): QuestGenerationContext { }): QuestGenerationContext {
const {state, encounter} = params; const { state, encounter } = params;
const issuerNpcId = encounter.id ?? encounter.npcName; const issuerNpcId = encounter.id ?? encounter.npcName;
const issuerState = state.npcStates[issuerNpcId]; const issuerState = state.npcStates[issuerNpcId];
const issuerNarrativeProfile = resolveIssuerNarrativeProfile(state, encounter); const issuerNarrativeProfile = resolveIssuerNarrativeProfile(
state,
encounter,
);
return { return {
worldType: state.worldType, worldType: state.worldType,
@@ -164,16 +181,18 @@ export function buildQuestGenerationContextFromState(params: {
issuerDisclosureStage: getNpcDisclosureStage(issuerState?.affinity ?? 0), issuerDisclosureStage: getNpcDisclosureStage(issuerState?.affinity ?? 0),
issuerWarmthStage: getNpcWarmthStage(issuerState?.affinity ?? 0), issuerWarmthStage: getNpcWarmthStage(issuerState?.affinity ?? 0),
activeThreadIds: activeThreadIds:
state.storyEngineMemory?.activeThreadIds?.slice(0, 4) state.storyEngineMemory?.activeThreadIds?.slice(0, 4) ??
?? issuerNarrativeProfile?.relatedThreadIds?.slice(0, 4) issuerNarrativeProfile?.relatedThreadIds?.slice(0, 4) ??
?? [], [],
encounterKind: encounter.kind ?? 'npc', encounterKind: encounter.kind ?? 'npc',
currentSceneTreasureHintCount: state.currentScenePreset?.treasureHints?.length ?? 0, currentSceneTreasureHintCount:
state.currentScenePreset?.treasureHints?.length ?? 0,
currentSceneHostileNpcIds: (state.currentScenePreset?.npcs ?? []) currentSceneHostileNpcIds: (state.currentScenePreset?.npcs ?? [])
.filter(npc => Boolean(npc.hostile || npc.monsterPresetId)) .filter((npc) => Boolean(npc.hostile || npc.monsterPresetId))
.map(npc => npc.id), .map((npc) => npc.id),
recentStoryMoments: state.storyHistory.slice(-6), recentStoryMoments: state.storyHistory.slice(-6),
playerCharacter: state.playerCharacter, playerCharacter: state.playerCharacter,
playerProgression: state.playerProgression ?? null,
playerHp: state.playerHp, playerHp: state.playerHp,
playerMaxHp: state.playerMaxHp, playerMaxHp: state.playerMaxHp,
playerMana: state.playerMana, playerMana: state.playerMana,
@@ -182,7 +201,7 @@ export function buildQuestGenerationContextFromState(params: {
playerEquipment: state.playerEquipment, playerEquipment: state.playerEquipment,
activeCompanions: state.companions, activeCompanions: state.companions,
rosterCompanions: state.roster, rosterCompanions: state.roster,
currentQuestSummary: state.quests.map(quest => ({ currentQuestSummary: state.quests.map((quest) => ({
id: quest.id, id: quest.id,
title: quest.title, title: quest.title,
status: quest.status, status: quest.status,
@@ -195,7 +214,7 @@ export async function generateQuestForNpcEncounter(params: {
state: GameState; state: GameState;
encounter: Encounter; encounter: Encounter;
}): Promise<QuestLogEntry | null> { }): Promise<QuestLogEntry | null> {
const {state, encounter} = params; const { state, encounter } = params;
const issuerNpcId = encounter.id ?? encounter.npcName; const issuerNpcId = encounter.id ?? encounter.npcName;
const request: QuestPreviewRequest = { const request: QuestPreviewRequest = {
issuerNpcId, issuerNpcId,
@@ -203,12 +222,12 @@ export async function generateQuestForNpcEncounter(params: {
roleText: encounter.context, roleText: encounter.context,
scene: state.currentScenePreset, scene: state.currentScenePreset,
worldType: state.worldType, worldType: state.worldType,
currentQuests: state.quests.map(quest => ({ currentQuests: state.quests.map((quest) => ({
id: quest.id, id: quest.id,
issuerNpcId: quest.issuerNpcId, issuerNpcId: quest.issuerNpcId,
status: quest.status, status: quest.status,
})), })),
context: buildQuestGenerationContextFromState({state, encounter}), context: buildQuestGenerationContextFromState({ state, encounter }),
origin: 'ai_compiled', origin: 'ai_compiled',
}; };
const opportunity = evaluateQuestOpportunity(request); const opportunity = evaluateQuestOpportunity(request);
@@ -257,7 +276,7 @@ export async function generateQuestForNpcEncounter(params: {
debugLabel: 'quest-intent', debugLabel: 'quest-intent',
}, },
); );
const parsed = parseJsonResponseText(content) as {intent?: unknown}; const parsed = parseJsonResponseText(content) as { intent?: unknown };
const intent = sanitizeQuestIntent(parsed.intent, fallbackIntent); const intent = sanitizeQuestIntent(parsed.intent, fallbackIntent);
return compileQuestIntentToQuest( return compileQuestIntentToQuest(
{ {
@@ -267,7 +286,10 @@ export async function generateQuestForNpcEncounter(params: {
intent, intent,
); );
} catch (error) { } catch (error) {
console.warn('[QuestDirector] falling back to deterministic quest intent', error); console.warn(
'[QuestDirector] falling back to deterministic quest intent',
error,
);
return compileQuestIntentToQuest( return compileQuestIntentToQuest(
{ {
...request, ...request,

View File

@@ -46,6 +46,7 @@ export function createEmptyStoryEngineMemoryState(): StoryEngineMemoryState {
resolvedScarIds: [], resolvedScarIds: [],
recentCarrierIds: [], recentCarrierIds: [],
openedSceneChapterIds: [], openedSceneChapterIds: [],
currentSceneActState: null,
recentSignalIds: [], recentSignalIds: [],
recentCompanionReactions: [], recentCompanionReactions: [],
currentChapter: null, currentChapter: null,

View File

@@ -318,6 +318,43 @@ export interface CustomWorldSceneConnection {
summary: string; 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 { export interface CustomWorldCampScene {
name: string; name: string;
description: string; description: string;
@@ -360,6 +397,7 @@ export interface CustomWorldProfile {
storyGraph?: WorldStoryGraph | null; storyGraph?: WorldStoryGraph | null;
knowledgeFacts?: KnowledgeFact[] | null; knowledgeFacts?: KnowledgeFact[] | null;
threadContracts?: ThreadContract[] | null; threadContracts?: ThreadContract[] | null;
sceneChapterBlueprints?: SceneChapterBlueprint[] | null;
anchorContent?: EightAnchorContent | null; anchorContent?: EightAnchorContent | null;
creatorIntent?: CustomWorldCreatorIntent | null; creatorIntent?: CustomWorldCreatorIntent | null;
anchorPack?: CustomWorldAnchorPack | null; anchorPack?: CustomWorldAnchorPack | null;

View File

@@ -1,5 +1,5 @@
import type {TimedBuildBuff} from './build'; import type { TimedBuildBuff } from './build';
import type {Character} from './characters'; import type { Character } from './characters';
import { import {
AnimationState, AnimationState,
type CombatActionMode, type CombatActionMode,
@@ -7,8 +7,8 @@ import {
type NpcBattleOutcome, type NpcBattleOutcome,
WorldType, WorldType,
} from './core'; } from './core';
import type {CustomWorldProfile} from './customWorld'; import type { CustomWorldProfile } from './customWorld';
import type {EquipmentLoadout, InventoryItem} from './items'; import type { EquipmentLoadout, InventoryItem } from './items';
import type { import type {
CombatVisualEffect, CombatVisualEffect,
CompanionState, CompanionState,
@@ -18,8 +18,12 @@ import type {
SceneHostileNpc, SceneHostileNpc,
ScenePresetInfo, ScenePresetInfo,
} from './scene'; } from './scene';
import type {CharacterChatRecord, QuestLogEntry, StoryMoment} from './story'; import type { CharacterChatRecord, QuestLogEntry, StoryMoment } from './story';
import type {CampaignState, ChapterState, StoryEngineMemoryState} from './storyEngine'; import type {
CampaignState,
ChapterState,
StoryEngineMemoryState,
} from './storyEngine';
export interface GameRuntimeStats { export interface GameRuntimeStats {
playTimeMs: number; playTimeMs: number;
@@ -30,6 +34,17 @@ export interface GameRuntimeStats {
scenesTraveled: number; 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 { export interface GameState {
worldType: WorldType | null; worldType: WorldType | null;
customWorldProfile: CustomWorldProfile | null; customWorldProfile: CustomWorldProfile | null;
@@ -37,6 +52,7 @@ export interface GameState {
runtimeSessionId?: string | null; runtimeSessionId?: string | null;
runtimeActionVersion?: number; runtimeActionVersion?: number;
runtimeStats: GameRuntimeStats; runtimeStats: GameRuntimeStats;
playerProgression?: PlayerProgressionState | null;
currentScene: string; currentScene: string;
storyHistory: StoryMoment[]; storyHistory: StoryMoment[];
storyEngineMemory?: StoryEngineMemoryState; storyEngineMemory?: StoryEngineMemoryState;

View File

@@ -107,6 +107,26 @@ export interface Encounter {
imageSrc?: string; imageSrc?: string;
visual?: CustomWorldNpcVisual; visual?: CustomWorldNpcVisual;
narrativeProfile?: ActorNarrativeProfile | null; 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 { export interface SceneHostileNpc {
@@ -129,6 +149,8 @@ export interface SceneHostileNpc {
combatTags?: string[]; combatTags?: string[];
attributeProfile?: RoleAttributeProfile; attributeProfile?: RoleAttributeProfile;
behaviorVectors?: RoleActionDefinition[]; behaviorVectors?: RoleActionDefinition[];
levelProfile?: EntityLevelProfile;
experienceReward?: number;
} }
export interface SceneNpc { export interface SceneNpc {
@@ -158,6 +180,7 @@ export interface SceneNpc {
imageSrc?: string; imageSrc?: string;
visual?: CustomWorldNpcVisual; visual?: CustomWorldNpcVisual;
narrativeProfile?: ActorNarrativeProfile | null; narrativeProfile?: ActorNarrativeProfile | null;
levelProfile?: EntityLevelProfile;
} }
export type SceneEncounterKind = 'npc' | 'treasure' | 'none'; export type SceneEncounterKind = 'npc' | 'treasure' | 'none';

View File

@@ -4,8 +4,8 @@ import {
type QuestStatus, type QuestStatus,
type TreasureInteractionAction, type TreasureInteractionAction,
} from './core'; } from './core';
import type {InventoryItem} from './items'; import type { InventoryItem } from './items';
import type {SceneDirective} from './scene'; import type { SceneDirective } from './scene';
export interface StoryOptionGoalAffordance { export interface StoryOptionGoalAffordance {
goalId: string; goalId: string;
@@ -31,6 +31,7 @@ export interface StoryOption {
export interface QuestReward { export interface QuestReward {
affinityBonus: number; affinityBonus: number;
currency: number; currency: number;
experience?: number;
items: InventoryItem[]; items: InventoryItem[];
storyHint?: string; storyHint?: string;
intel?: { intel?: {
@@ -115,6 +116,11 @@ export interface StoryNpcChatState {
npcName: string; npcName: string;
turnCount: number; turnCount: number;
customInputPlaceholder?: string; customInputPlaceholder?: string;
sceneActId?: string | null;
turnLimit?: number | null;
remainingTurns?: number | null;
limitReason?: 'negative_affinity' | null;
forceExitAfterTurn?: boolean;
pendingQuestOffer?: { pendingQuestOffer?: {
quest: QuestLogEntry; quest: QuestLogEntry;
} | null; } | null;

View File

@@ -266,6 +266,15 @@ export interface ChapterState {
chapterQuestId?: string | null; chapterQuestId?: string | null;
} }
export interface SceneActRuntimeState {
sceneId: string;
chapterId: string;
currentActId: string;
currentActIndex: number;
completedActIds: string[];
visitedActIds: string[];
}
export interface JourneyBeat { export interface JourneyBeat {
id: string; id: string;
beatType: beatType:
@@ -522,6 +531,7 @@ export interface StoryEngineMemoryState {
resolvedScarIds: string[]; resolvedScarIds: string[];
recentCarrierIds: string[]; recentCarrierIds: string[];
openedSceneChapterIds?: string[]; openedSceneChapterIds?: string[];
currentSceneActState?: SceneActRuntimeState | null;
recentSignalIds?: string[]; recentSignalIds?: string[];
recentCompanionReactions?: CompanionReactionRecord[]; recentCompanionReactions?: CompanionReactionRecord[];
currentChapter?: ChapterState | null; currentChapter?: ChapterState | null;