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

This commit is contained in:
2026-04-20 15:45:14 +08:00
parent 8a7bd90458
commit 1c72066bab
73 changed files with 7814 additions and 1018 deletions

View File

@@ -1,7 +1,7 @@
# AGENTS.md
## 项目约束
- 前端工程node版本使用22.22.2
- 代码需要有完善的中文注释
- 在落地工程修改前检查是否有详细指导本次落地的文档,若没有文档或文档的完善程度仍有落地过程中编码级别的歧义优先优化文档后落地工程迭代。
- 对工程的修改不仅要落地到代码更面还要更改对应文档若没有生成新的文档文档统一存在doc目录中
- 不要擅自把现有中文文案、注释、剧情、文档改写成英文,除非用户明确要求翻译。

View File

@@ -42,6 +42,7 @@ This project should treat `public/UI` and `public/Icons` as the single source of
- Pick icons by UI meaning, not by whichever file "looks close enough" in one screen.
- If a state exists in art, wire both active and inactive assets instead of tinting one image in CSS.
- Major UI chrome should use authored textures from `public/UI`, not only plain Tailwind borders.
- Do not mix `background` shorthand with `backgroundImage` / `backgroundRepeat` / `backgroundPosition` / `backgroundSize` in the same inline `style` object; use longhand fields consistently to avoid React rerender warnings and stale paint bugs.
## Layout Rules For Icon UI

View File

@@ -0,0 +1,379 @@
# 角色资产 Prompt 链路审计2026-04-20
更新时间:`2026-04-20`
## 0. 本次审计回答什么问题
本次只回答角色资产相关的 4 个问题:
1. `characterAssetPrompts.ts` 里的 `visualPromptText``animationPromptText`,是不是“生成角色形象 / 动作形象的默认描述”。
2. 生成角色形象的系统提示词在哪个文件,生成默认角色形象描述文本的提示词在哪个文件。
3. 生成角色动作的系统提示词在哪个文件,生成默认角色动作描述文本的提示词在哪个文件。
4. 当前链路里是否存在冗余流程、保留接口或无效代码。
---
## 1. 先说结论
结论不是“只有一套 prompt”而是
**当前角色资产链路至少有两层 prompt且这两层在仓库里被不同文件承担。**
### 1.1 默认描述文本层
这层的目标是:
**先给资产工坊里的输入框一个默认可编辑文本。**
这层不直接拿去生成图片或动作视频。
当前实际主链来源:
- `src/prompts/customWorldRolePromptDefaults.ts`
它会把角色已有字段映射成:
- `visualPromptText`
- `animationPromptText`
- `scenePromptText`
其中:
- `visualPromptText` 优先取 `visualDescription`
- `animationPromptText` 优先取 `actionDescription`
- `scenePromptText` 优先取 `sceneVisualDescription`
这层是**默认描述文本**,不是正式图像模型 prompt。
### 1.2 正式模型 prompt 层
这层的目标是:
**把“默认描述文本”进一步编译成正式给图像模型 / 动作模型的完整 prompt。**
当前主链来源:
- `server-node/src/prompts/characterAssetPrompts.ts`
- `packages/shared/src/prompts/qwenSprite.ts`
也就是说:
1. 前端先有一段短文本
2. 后端再用正式 prompt builder 把它扩成模型真正使用的完整 prompt
---
## 2. 角色形象生成链路
## 2.1 生成角色形象的系统提示词在哪
如果这里问的是“正式生成角色主图时,真正控制模型输出方向的 prompt 主源在哪”,答案是:
- `server-node/src/prompts/characterAssetPrompts.ts`
- `packages/shared/src/prompts/qwenSprite.ts`
更准确说:
1. `buildNpcVisualPrompt`
- 文件:`server-node/src/prompts/characterAssetPrompts.ts`
- 作用:把短描述文本和角色摘要合并
2. `buildMasterPrompt`
- 文件:`packages/shared/src/prompts/qwenSprite.ts`
- 作用:提供正式的角色主图 prompt 骨架
最终角色形象正式生成请求使用的是:
- `buildNpcVisualPrompt(...)`
调用位置:
- `server-node/src/modules/assets/characterAssetRoutes.ts`
即:
**角色主图正式生成的系统提示词主链,不在前端默认值文件,而在后端 `characterAssetPrompts.ts` + 共享 `qwenSprite.ts`。**
## 2.2 生成默认角色形象描述文本的提示词在哪
当前仓库需要分两种情况:
### 情况 A当前自定义世界资产工坊真实主链
当前资产工坊默认输入框实际使用:
- `src/prompts/customWorldRolePromptDefaults.ts`
这不是 LLM system prompt而是本地字段映射规则。
换句话说,当前页面上的默认“形象描述”主要来自:
- `role.visualDescription`
- 或回退到 `role.description`
### 情况 B仓库里保留的“默认 bundle 编译接口”
仓库里仍保留一条后端接口:
- `/api/assets/character-prompts/generate`
对应文件:
- `server-node/src/prompts/characterAssetPrompts.ts`
这条链使用:
- `CHARACTER_PROMPT_BUNDLE_SYSTEM_PROMPT`
- `buildCharacterPromptBundleUserPrompt`
它的职责是:
**让 LLM 从角色卡摘要里编译出一组默认文本 bundle。**
但当前实际问题是:
**自定义世界角色资产工坊初始化默认值,并没有走这条接口。**
因此当前状态更准确地说是:
- 仓库里有一条“LLM 编译默认文本 bundle”的保留链
- 但当前资产工坊真实初始默认值主链,走的是前端本地映射
---
## 3. 角色动作生成链路
## 3.1 生成角色动作的系统提示词在哪
当前正式动作生成主链在:
- `server-node/src/prompts/characterAssetPrompts.ts`
- `packages/shared/src/prompts/qwenSprite.ts`
其中分两类:
1. `buildArkCharacterAnimationPrompt`
- 当前图生视频动作链路主入口
2. `buildNpcAnimationPrompt`
- 通用动作视频 prompt builder
3. `buildImageSequencePrompt`
- 连续帧方案动作 prompt builder
4. `buildVideoActionPrompt`
- 共享动作模板骨架,在 `packages/shared/src/prompts/qwenSprite.ts`
当前主动作链路更偏向:
- `buildArkCharacterAnimationPrompt`
调用位置:
- `server-node/src/modules/assets/characterAssetRoutes.ts`
## 3.2 生成默认角色动作描述文本的提示词在哪
当前资产工坊真实默认“动作描述”来源:
- `src/prompts/customWorldRolePromptDefaults.ts`
规则是:
- 优先 `actionDescription`
- 回退 `combatStyle`
这仍然是**默认描述文本层**,不是最终动作模型 prompt。
仓库里也保留了 LLM 编译 bundle 的接口链:
- `CHARACTER_PROMPT_BUNDLE_SYSTEM_PROMPT`
- `buildCharacterPromptBundleUserPrompt`
这条链也会生成:
- `animationPromptText`
但当前资产工坊真实初始默认值并没有实际调用它。
---
## 4. `characterAssetPrompts.ts` 里的 `visualPromptText` / `animationPromptText` 到底是什么
这两个字段容易混淆,因为它们名字里带 `Prompt`
但当前工程里它们更准确的定位是:
**“默认描述文本 bundle 字段名”,不是最终图像模型请求体里的最终 prompt 名称。**
也就是:
- `visualPromptText`
- 在 UI 里更像“角色形象描述默认文本”
- 之后会再被编译进正式图像 prompt
- `animationPromptText`
- 在 UI 里更像“角色动作描述默认文本”
- 之后会再被编译进正式动作 prompt
所以对你的问题可以直接回答为:
**是,它们在当前语义上确实可以看作“默认角色形象 / 动作描述文本”。**
但需要补一句:
**它们不是最终一步的正式模型系统提示词,而是正式模型 prompt 的上游输入。**
---
## 5. 当前真实调用链
## 5.1 当前资产工坊页面初始默认值主链
当前真实主链:
1. 角色对象已有字段进入前端
2. `src/prompts/customWorldRolePromptDefaults.ts`
3. `CustomWorldRoleAssetStudioModal.tsx`
4. 输入框初始值:
- `visualPromptText`
- `animationPromptText`
这条链:
-
- 本地可控
- 不依赖额外一次 LLM 调用
## 5.2 当前正式角色主图生成主链
1. 前端把输入框里的 `visualPromptText` 提交到后端
2. `server-node/src/prompts/characterAssetPrompts.ts`
- `buildNpcVisualPrompt`
3. `packages/shared/src/prompts/qwenSprite.ts`
- `buildMasterPrompt`
4. 图像模型正式生成
## 5.3 当前正式角色动作生成主链
1. 前端把输入框里的 `animationPromptText` 提交到后端
2. `server-node/src/prompts/characterAssetPrompts.ts`
- `buildArkCharacterAnimationPrompt`
-`buildNpcAnimationPrompt`
-`buildImageSequencePrompt`
3. `packages/shared/src/prompts/qwenSprite.ts`
- `buildVideoActionPrompt`
4. 动作模型正式生成
---
## 6. 冗余流程与当前问题
## 6.1 明确存在的冗余点:默认 bundle 双链并存
当前仓库里“默认描述文本”其实有两套来源:
### 第一套:前端本地字段映射
- `src/prompts/customWorldRolePromptDefaults.ts`
### 第二套:后端 LLM bundle 编译接口
- `server-node/src/prompts/characterAssetPrompts.ts`
- `/api/assets/character-prompts/generate`
问题不在于“两套都存在”,而在于:
**当前自定义世界资产工坊真实默认值只走第一套,第二套保留但没有进入当前主 UI 链。**
这意味着:
1. 从业务视角看,默认描述文本存在双份真相。
2. 从维护视角看,两个地方都在描述 `visualPromptText / animationPromptText / scenePromptText` 的生成语义。
3. 从测试视角看,后端 bundle 接口仍有测试,但 UI 主链没有使用它。
判断:
**这是当前最明显的冗余流程。**
## 6.2 `scenePromptText` 结构存在,但当前资产工坊没有完整承接
当前这套链路里:
- `customWorldRolePromptDefaults.ts` 会返回 `scenePromptText`
- `characterAssetPrompts.ts` 也会返回 `scenePromptText`
但当前资产工坊 UI 里并没有完整对应输入框链路。
这说明:
**场景描述文本在结构层存在,但在当前角色资产工坊里没有形成完整的用户可编辑闭环。**
## 6.3 共享模板与工具模板存在相似实现,但职责不同
仓库里同时有:
- `packages/shared/src/prompts/qwenSprite.ts`
- `src/prompts/qwenSpriteSheetToolPrompts.ts`
它们都提供类似的主图 / 动作模板能力。
但当前定位不同:
- `packages/shared/src/prompts/qwenSprite.ts`
- 正式角色资产主链共享模板
- `src/prompts/qwenSpriteSheetToolPrompts.ts`
- Qwen 工具链 prompt
它们不是同一条业务主链里的重复实现,但确实容易让人误读为“双份正式模板”。
判断:
**这是“职责上可解释,但认知上高混淆”的并行模板,不建议现在直接删,但需要文档明确边界。**
## 6.4 当前没有证据说明正式主图 / 动作 prompt builder 是无效代码
以下 builder 当前都有正式调用点:
- `buildNpcVisualPrompt`
- `buildNpcVisualNegativePrompt`
- `buildArkCharacterAnimationPrompt`
- `buildNpcAnimationPrompt`
- `buildImageSequencePrompt`
因此它们不能算“无效代码”。
真正更接近“保留接口但未进入当前 UI 主链”的,是:
- `CHARACTER_PROMPT_BUNDLE_SYSTEM_PROMPT`
- `buildCharacterPromptBundleUserPrompt`
- `/api/assets/character-prompts/generate`
这套链路仍有测试、仍可工作,但当前不属于自定义世界资产工坊的真实默认值主链。
---
## 7. 本次建议
如果后续要继续收口,建议按顺序处理:
1. 先明确“资产工坊默认值唯一主源”到底选前端本地映射还是后端 LLM bundle 接口。
2. 如果继续保留前端本地映射为主链,则把后端 bundle 接口标注为备用 / 实验 / 非主链能力。
3. 如果准备切回后端 bundle 接口为主链,则要把当前 UI 初始化逻辑真正接上,并补场景描述输入框闭环。
4.`scenePromptText` 做完整承接,不要继续停留在结构存在但 UI 不消费的状态。
5. 继续保留 `packages/shared/src/prompts/qwenSprite.ts` 与工具链 prompt 分层,但在文档里强制写清“正式主链 / 工具链”边界。
---
## 8. 本次审计覆盖文件
- `server-node/src/prompts/characterAssetPrompts.ts`
- `packages/shared/src/prompts/qwenSprite.ts`
- `server-node/src/modules/assets/characterAssetRoutes.ts`
- `src/prompts/customWorldRolePromptDefaults.ts`
- `src/components/CustomWorldRoleAssetStudioModal.tsx`
- `src/components/asset-studio/characterAssetWorkflowPersistence.ts`
- `src/prompts/qwenSpriteSheetToolPrompts.ts`
---
## 9. 一句话版结论
一句话总结就是:
**当前角色资产系统把“默认描述文本”和“正式模型 prompt”拆成了两层这是合理的真正的问题不是有两层而是“默认描述文本层”现在同时保留了前端本地映射和后端 LLM 编译两条链,而当前 UI 主链只用了前者,导致出现明显的冗余和认知混乱。**

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 运行时完整测试、服务端承接验证与当前门禁缺口。
- [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):自定义世界创作工具当前问题、体验断层和优化优先级审计。
- [CHARACTER_ASSET_PROMPT_CHAIN_AUDIT_2026-04-20.md](./CHARACTER_ASSET_PROMPT_CHAIN_AUDIT_2026-04-20.md):角色资产默认描述文本、正式图像/动作 prompt、共享模板与保留接口的分层与冗余审计。
- [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):未引用垃圾、旧入口残留、前后端双份真相与后端迁移项的专项审计。

View File

@@ -233,6 +233,13 @@ function buildNpcFirstContactOptionCatalog(
- `npc_quest_accept`
- `npc_recruit`
补一条实现约束:
- 首次进入 `npc_chat` 时,前端聊天状态里不允许直接塞预设对白充当首句。
- 角色第一次真正对玩家开口时说什么,必须由 `npc_chat` 对应的 prompt 约束来生成,并要求首句是自然招呼或开场判断。
- 不能再用“某人看着你,像是在等你把话接下去”这类第三人称占位旁白充当可见对话历史首句,也不能在聊天 state 里本地硬编码一条替代台词。
- 当玩家在场景中第一次真正撞上角色型 NPC 并进入聊天时,应直接触发一轮由 NPC 主动开口的模型回复;这一轮只生成 NPC 自己的首句与后续可选回应,不得代替玩家补写未说过的话。
4. 首遇状态下,不允许前两项直接变成:
- 深背景追问
- 直接招募
@@ -329,6 +336,7 @@ firstContactRelationStance?: 'guarded' | 'neutral' | 'cooperative' | 'bonded' |
- 它们只能作为“某个具体场景下调用通用首遇规则”的薄包装
- 不应继续承担独立的开场规则系统
- 更不能把本地预设对白直接写进 `npc_chat` 的可见对话历史里,`npc_chat` 首个角色台词必须由 prompt 生成
也就是说:

View File

@@ -13,11 +13,29 @@
5. 已在冒险主面板补充最小等级展示:`Lv.` 与细经验条;任务奖励面板可看到经验数值。
6. 已收回任务日志里的直接领奖入口,任务奖励结算当前以 NPC 交付链路为准。
## 实现进度2026-04-20 第二批)
当前仓库已继续落地第二批成长能力:
1. 已给运行时敌对 NPC / 战斗遭遇补上 `levelProfile``experienceReward`,前后端快照、战斗态和恢复链路会保留这组元数据。
2. 已新增敌对成长解析服务,当前先以玩家当前等级为 fallback`npc_fight` / 敌对战斗入口自动生成等级、参考强度、战斗生命值与击杀经验。
3. 已将 Express 后端战斗胜利结算接入 `hostile_npc` 经验发放,击败敌对 NPC 后会直接更新 `playerProgression`,并写回 `hostileNpcsDefeated` 统计。
4. 已在战斗画布中补上敌对 NPC 的最小 `Lv.` 徽标展示,保持 UI 极简表达。
## 实现进度2026-04-20 第三批)
当前仓库已继续落地第三批“章节预算 / 自动定级”能力:
1. 已新增服务端 `chapterProgressionPlanner`,会基于 `sceneChapterBlueprints` 编译每章的 `entry / exit pseudo level`、总经验预算、任务经验份额、敌对经验份额与预计击杀数。
2. 已新增 `npcLevelResolver`,会根据当前章节阶段和当前 act 的 `primaryNpcId` 自动区分 `hostile_standard / hostile_elite / hostile_boss / rival`,并输出 `source = chapter_auto` 的等级档案。
3. 已将 `npc_fight` / `npc_spar` 开战入口接入章节上下文解析;当运行时存在章节蓝图、当前章和当前 act 信息时,敌对 NPC 不再只跟随玩家当前等级,而会按章节自动定级并生成更贴合本章预算的经验奖励。
4. 已补上规划器、定级器与路由级验证,确认同一玩家在不同章节和不同阶段触发敌对战斗时,会得到不同的等级与经验结果。
本轮仍未落地的部分:
1. 击败敌对 NPC 经验
2. 章节经验预算 / ledger 统计
3. 按章节自动定级 NPC 与运行时敌对经验掉落
1. `ChapterExperienceLedger` 的正式持久化、按章实际经验记账与偏差回看还未接入
2. 同章重复刷敌的 `repeatPenalty` 与超预算衰减还未落地,当前仍是“预算规划 + 单次掉落”版本
3. 当前自动定级已优先接入敌对战斗入口,友方 / 环境 NPC 的更广泛等级消费链路仍待继续铺开
## 0. 目标

View File

@@ -127,6 +127,12 @@
- 未登录:弹出登录弹窗,并缓存 `action`
- 登录成功:自动执行缓存的 `action`
账号入口补充约束:
- 不再提供 `AuthGate` 层右上角固定悬浮的全局登录 / 账号信息入口
- 登录触发统一来自页面内受保护动作、个人页、存档页等明确入口
- 账号信息面板只通过页面内按钮打开,不在平台右上角常驻悬浮
## 4.2 平台首页数据加载
`PreGameSelectionFlow` 在未登录时只读取:

View File

@@ -6,16 +6,16 @@
## 1. 相关文件一览
| 路径 | 作用 |
|------|------|
| `UI_CODING_STANDARD.md` | 资源目录约定、9-slice 规则、图标语义、`Icons`/`UI` 命名解读、已知问题(含世界按钮切片) |
| `src/uiAssets.ts` | **唯一推荐** 的 UI 资源映射:`UI_CHROME`9-slice 配置)、`TAB_ICONS``WORLD_SELECT_ICONS``getNineSliceStyle()` |
| `src/components/PixelIcon.tsx` | 小图标 `<img>``image-rendering: pixelated` |
| `src/index.css` | `.pixel-nine-slice``.pixel-root-shell` / `.pixel-app-shell`、tab/按钮布局类、`--ui-scale` |
| `src/App.tsx` | 世界选择、角色卡、底部 tab、剧情/背包面板、地图弹窗、`MudMapRoom` |
| `src/components/GameCanvas.tsx` | 场景名按钮9-slice `Title_frame_m` |
| `vite.config.ts` | `root` / `envDir` 指向 `__dirname`,保证 `.env.local` 从项目根加载 |
| `public/UI/``public/Icons/` | 静态资源(路径以 `/UI/...``/Icons/...` 引用) |
| 路径 | 作用 |
| ------------------------------- | ------------------------------------------------------------------------------------------------------------------ |
| `UI_CODING_STANDARD.md` | 资源目录约定、9-slice 规则、图标语义、`Icons`/`UI` 命名解读、已知问题(含世界按钮切片) |
| `src/uiAssets.ts` | **唯一推荐** 的 UI 资源映射:`UI_CHROME`9-slice 配置)、`TAB_ICONS``WORLD_SELECT_ICONS``getNineSliceStyle()` |
| `src/components/PixelIcon.tsx` | 小图标 `<img>``image-rendering: pixelated` |
| `src/index.css` | `.pixel-nine-slice``.pixel-root-shell` / `.pixel-app-shell`、tab/按钮布局类、`--ui-scale` |
| `src/App.tsx` | 世界选择、角色卡、底部 tab、剧情/背包面板、地图弹窗、`MudMapRoom` |
| `src/components/GameCanvas.tsx` | 场景名按钮9-slice `Title_frame_m` |
| `vite.config.ts` | `root` / `envDir` 指向 `__dirname`,保证 `.env.local` 从项目根加载 |
| `public/UI/``public/Icons/` | 静态资源(路径以 `/UI/...``/Icons/...` 引用) |
---
@@ -48,22 +48,22 @@
以下为 `src/uiAssets.ts` 中主要键与界面位置的对应关系(切片数值以文件内为准):
| Key | 资源(示例) | 用途 |
|-----|----------------|------|
| `appBackground` | `Background_fill.png` | 根壳 + 下半屏平铺底 |
| Key | 资源(示例) | 用途 |
| ----------------------------------------- | ------------------------------------- | ------------------------------------------ |
| `appBackground` | `Background_fill.png` | 根壳 + 下半屏平铺底 |
| `worldButtonWuxia` / `worldButtonXianxia` | `1_orange_button` / `1_violet_button` | 开局武侠/仙侠(**条高 28px**,切片见下文) |
| `characterCardFrame` | `pick_hero_frame` | 选角卡片 |
| `tabActive` / `tabInactive` | `Shop_tab_picked` / `Shop_tab` | 底部「角色 / 冒险 / 背包」 |
| `panel` | `Frame_bg_big_2` | 装备区等通用面板 |
| `storyPanel` | `Dialogue_frame` | 剧情正文区 |
| `inventoryPanel` | `Inventory_bg` | 背包条目 |
| `statsPanel` | `Stats_bar` | 角色数值面板 |
| `choiceButton` | `Options_bar` | 剧情选项按钮 |
| `modalPanel` | `Popup_window` | 地图弹窗外壳 |
| `infoPanel` | `Dialogue_frame` | 地图弹窗内「当前地点 / 可前往」信息块 |
| `sceneTitle` | `Title_frame_m` | 战斗画布顶部场景名按钮 |
| `mapRoomCell` | `Map_frame` | 地图节点卡片(`MudMapRoom` |
| `mapDiagramPanel` | `Frame_bg_big_2` | 地图关系图整体衬底 |
| `characterCardFrame` | `pick_hero_frame` | 选角卡片 |
| `tabActive` / `tabInactive` | `Shop_tab_picked` / `Shop_tab` | 底部「角色 / 冒险 / 背包」 |
| `panel` | `Frame_bg_big_2` | 装备区等通用面板 |
| `storyPanel` | `Dialogue_frame` | 剧情正文区 |
| `inventoryPanel` | `Inventory_bg` | 背包条目 |
| `statsPanel` | `Stats_bar` | 角色数值面板 |
| `choiceButton` | `Options_bar` | 剧情选项按钮 |
| `modalPanel` | `Popup_window` | 地图弹窗外壳 |
| `infoPanel` | `Dialogue_frame` | 地图弹窗内「当前地点 / 可前往」信息块 |
| `sceneTitle` | `Title_frame_m` | 战斗画布顶部场景名按钮 |
| `mapRoomCell` | `Map_frame` | 地图节点卡片(`MudMapRoom` |
| `mapDiagramPanel` | `Frame_bg_big_2` | 地图关系图整体衬底 |
图标路径:`TAB_ICONS``WORLD_SELECT_ICONS``CHROME_ICONS`;装备槽与背包分类见 `getEquipmentSlotIcon` / `getInventoryCategoryIcon`
@@ -113,12 +113,35 @@
---
## 8. 2026-04-18 补充记录
## 8. 2026-04-18 / 2026-04-20 账号入口补充记录
- `GameShellRuntime` 进入游戏壳时,会主动隐藏认证层提供的右上角全局账号信息条
- 原因不是账号功能下线,而是这个悬浮条会遮挡冒险主场景内容,移动端更明显
- 账号相关入口保留在平台首页 / 个人页内部按钮与账号弹窗,不再占用游戏 HUD 区域
- 早期方案曾在 `AuthGate` 层提供右上角全局账号信息条,并在 `GameShellRuntime` 中临时隐藏
- 2026-04-20 起,这个全局悬浮入口已整体下线,不再区分“平台显示 / 冒险隐藏”
- 原因是右上角高频观察区不适合承载账号入口,且平台内已经有更明确的页面内入口
- 当前账号相关入口统一保留在平台首页受保护动作、个人页、存档页与账号弹窗,不再占用全局悬浮层。
---
*文档目的:交接给下一个 Agent 时,优先读本文件 + `UI_CODING_STANDARD.md`,再改 `uiAssets.ts` / `App.tsx` / `index.css`。*
## 9. 2026-04-20 等级 HUD / 冒险布局补充
- 当前运行中的等级 UI 已从 `AdventurePanel` 底部移出,改为放在 `GameShellRuntime` 左上角固定 HUD避免把主对话区挤短。
- 左上角 HUD 复用 `CharacterInfoShared.tsx` 里的 `PlayerLevelProgress`,角色面板、实体详情、游戏 HUD 使用同一套等级进度表现。
- `AdventurePanel` 不再承担等级条展示,底部交互区只保留队伍 / 背包 / 刷新 / 退出聊天 / 选项 / 自定义输入,并压缩了底部留白与面板间距。
- 角色信息不只在总 HUD 里显示:`CharacterPanel` 的队伍成员卡、角色详情面板,以及 `AdventureEntityModal` 的实体详情头部都会展示角色身份与等级信息。
- 队长展示正式 `Lv.`;同行角色展示“参考 Lv.”NPC 优先展示运行时 `levelProfile.level`,这样 UI 只负责表现,不在前端虚构额外成长逻辑。
- 左上角等级 HUD 不使用背景框体,仅保留 `Lv`、等级数字与极细经验线,避免遮挡场景背景与移动端视野。
---
## 10. 2026-04-20 平台亮色主题主 Tab 修正
- `PlatformHomeView.tsx` 的四个主 Tab首页 / 创作 / 存档 / 我的)现在统一挂在 `platform-remap-surface` 下,让亮色主题能接管历史遗留的 `text-zinc-*``bg-black/*``border-white/*` 组合。
- 平台首页卡片覆层不要在组件里继续写死深色 `rgba(8,10,14,...)` 渐变;这次已收口为 `--platform-card-overlay-soft``--platform-card-overlay-strong``--platform-card-overlay-deep`,明暗主题都从 token 走。
- 平台桌面顶栏里的账号头像、移动端底部主 Tab 分隔线,也不要保留暗色主题时留下的固定蓝色渐变和深色边线,应直接使用平台主题变量(如 `--platform-profile-avatar-fill``--platform-line-soft`)。
- 后续如果继续调整平台主 Tab 视觉,优先改 `src/index.css` 的平台主题 token 和 remap 规则;只有 token 无法表达时,再做局部组件样式补丁,避免亮色主题再次出现“页面整体是亮的,但局部卡片仍是暗的”。
- 参考图方向已明确:平台亮色主题应以白色为主底色,粉红只承担背景气氛和重点 CTA不应让整页主壳继续像深粉底板。
- 移动端底部 `platform-bottom-nav` 的 Tab 激活态必须与默认态使用同一套盒模型;边框要预占位,不能在 onPress / active 时临时增加边框导致按钮尺寸和留白跳变。
---
_文档目的交接给下一个 Agent 时,优先读本文件 + `UI_CODING_STANDARD.md`,再改 `uiAssets.ts` / `App.tsx` / `index.css`。_

View File

@@ -94,6 +94,8 @@
- 全局账号信息条挂在这里,会直接压住场景、敌人血条或顶部提示,手机端尤其明显。
- 结论:
账号入口应收回平台首页、个人页或设置面板,不要在实际冒险主场景常驻悬浮显示。
- 当前仓库已进一步收口为:
不再提供右上角全局账号悬浮条,统一只保留页面内入口与独立账号面板。
## 5. 队伍面板经验

View File

@@ -260,7 +260,7 @@ MVP 必须与当前项目可扮演角色动作槽位对齐。
- `run / attack` 是固定基础必生成动作
- `idle / die` 改为固定可选动作,不再作为发布硬门槛
- `idle` 未生成时默认直接使用主图静止显示
- `die` 未生成时默认播放一段基于主图的倒地过渡动画,并最终停在翻转倒地姿态
- `die` 未生成时默认播放一段基于主图的向后倒地过渡动画,并最终停在翻转倒地姿态
- 角色已配置的每个技能,都必须在技能编辑面板里补出对应动作预览
- 图生视频默认走火山方舟 `Seedance` 首尾帧方案
- 接口请求体中的两张参考图分别固定为 `first_frame / last_frame`
@@ -275,7 +275,7 @@ MVP 必须与当前项目可扮演角色动作槽位对齐。
| 基础动作 | `attack` | 必填 | 角色普通攻击主动作 |
| 技能动作 | `skills[*].actionPreviewConfig` | 必填 | 当前角色每个已配置技能都要有独立动作资源 |
| 可选动作 | `idle` | 可选 | 缺失时默认走主图静止待机 |
| 可选动作 | `die` | 可选 | 缺失时默认走主图倒地过渡动画,最终停在翻转倒地姿态 |
| 可选动作 | `die` | 可选 | 缺失时默认走主图向后倒地过渡动画,最终停在翻转倒地姿态 |
这里“必生成”指的是:

View File

@@ -251,7 +251,7 @@ kind === 'character';
1. `run / attack` 为固定必生成动作
2. 角色已配置技能时,对应技能动作也属于必生成动作
3. `idle / die` 只作为可选增强,缺失时分别走主图静止 / 主图倒地过渡动画兜底,死亡动画最终停在翻转倒地姿态
3. `idle / die` 只作为可选增强,缺失时分别走主图静止 / 主图向后倒地过渡动画兜底,死亡动画带轻微过冲回落,最终停在翻转倒地姿态
### 阶段 D动作发布

View File

@@ -583,7 +583,7 @@ type CustomWorldScenePriorityTier = 'key' | 'supporting';
默认兜底:
1. `idle` 缺失时使用主图静止
2. `die` 缺失时使用主图倒地过渡动画,最终停在翻转倒地姿态
2. `die` 缺失时使用主图向后倒地过渡动画,最终停在翻转倒地姿态
### 场景图抽卡策略
@@ -663,7 +663,7 @@ type CustomWorldScenePriorityTier = 'key' | 'supporting';
1. `idle / die` 不再是发布硬门槛
2. `idle` 缺失时运行时默认使用主图静止
3. `die` 缺失时运行时默认播放主图倒地过渡动画,最终停在翻转倒地姿态
3. `die` 缺失时运行时默认播放主图向后倒地过渡动画,并通过轻微过冲回落让动作更自然,最终停在翻转倒地姿态
说明:

View File

@@ -24,6 +24,17 @@
**每个场景由创作者在工具中配置为 `2~5` 幕;每一幕都绑定独立背景图和相遇 NPC 顺序;每一幕的第一个 NPC 视为主角色;运行时按幕切换背景和可遇对象,并根据主角色当前好感度裁决聊天轮数与第 5 轮收束方式。**
补充口径修正:
1. `scene_chapter` 在本期继续保留为数据层 / 编译层 / 运行时层概念。
2. `scene_chapter` 不作为创作者可见的独立 Tab、独立卡片或独立导航入口。
3. 创作者配置多幕的唯一入口,是现有“场景”列表里的场景编辑弹层。
4. 每一幕的 NPC 配置区必须直接叠在当前幕背景预览上,以“对面角色站位”的方式呈现;三个站位既是预览,也是编辑入口。
5. 幕编辑站位中每个角色只显示角色形象与名称,不展示额外信息块、规则说明或说明性标签。
6. 幕内小预览的构图固定为左侧玩家、右侧当前幕角色;右侧三个站位采用一前两后。
7. 新建幕默认仅预置 1 个主角色槽位内容,其余槽位留空,等待创作者补充。
8. 角色名称显示在角色形象上方,角色渲染不附带方形 UI 底板。
这份文档必须能直接指导后续创作工具和游戏流程改造,避免需求落地漂移。
---
@@ -72,14 +83,14 @@
当前仓库已经具备下面这些基础:
1. `packages/shared/src/contracts/customWorldAgent.ts`
-存在 `scene_chapter` 草稿卡 kind
1. `src/types/customWorld.ts`
- `SceneChapterBlueprint / SceneActBlueprint / sceneChapterBlueprints` 数据结构
2. `server-node/src/services/customWorldAgentDraftCompiler.ts`
- 已经能编译世界、第一幕、线程、势力、角色、地点等草稿卡
- 已经能把草稿阶段生成的场景章节数据编译成正式多幕蓝图
3. `src/components/custom-world-agent/CustomWorldAgentDraftDrawer.tsx`
- 已有草稿抽屉,但还没有把 `scene_chapter` 正式纳入抽屉分组
3. `src/components/CustomWorldEntityEditorModal.tsx`
- 已有现成的 `LandmarkEditor`,这是本期多幕配置的正确承载位置
4. 现有场景背景图生成与发布链已存在。
@@ -324,62 +335,61 @@ type NpcChatTurnResult = {
本次必须继续复用现有:
1. `src/components/custom-world-agent/CustomWorldAgentDraftDrawer.tsx`
2. `src/components/custom-world-agent/CustomWorldDraftCardDetailModal.tsx`
3. `src/components/custom-world-agent/CustomWorldDraftEditPanel.tsx`
1. `src/components/CustomWorldResultView.tsx`
2. `src/components/CustomWorldEntityCatalog.tsx`
3. `src/components/CustomWorldEntityEditorModal.tsx` 内的 `LandmarkEditor`
不新建独立页面。
不新建独立页面,也不新增独立 `scene_chapter` Tab
新增规则:
1. 草稿抽屉必须正式支持 `scene_chapter` 分组
2. `scene_chapter` 分组应位于 `chapter` 后、`thread`
3. 点开 `scene_chapter` 草稿卡后,进入现有详情弹层和编辑面板体系
4. 创作页面卡片摘要后续可增加 `sceneChapterCount`,但第一版不是阻塞项。
1. 创作者从现有“场景”列表点击任一场景卡,进入对应场景编辑弹层
2. 多幕配置必须作为场景编辑弹层内的一个区块出现,归属于该场景
3. `scene_chapter` 仅作为保存层和运行时蓝图存在,不单独暴露在创作者导航里
4. 场景卡片可增加“幕数量”轻量摘要,但第一版不是阻塞项。
## 7.2 场景章节卡展示要求
## 7.2 场景编辑弹层展示要求
每张 `scene_chapter` 草稿卡至少展示:
场景编辑弹层至少展示:
1. 场景名称
2. 章节标题
3. 幕数量
4. 已就绪背景图数量
5. 关联 NPC 数量
6. 关联线程数量
7. 当前风险数
1. 场景名称与描述
2. 场景主图
3. 场景内 NPC
4. 多幕配置区块
5. 场景连接关系
详情页必须至少展示:
多幕区块至少展示:
1. 场景摘要
2. 幕结构总
3. 每幕的背景缩略图
4. 每幕主角色
5. 每幕的辅助 NPC
6. 每幕目标
7. 每幕过渡钩子
1. 幕列表
2. 每幕与场景主图同规格的背景预
3. 每幕对面角色的 `3` 个固定槽位
4. 每幕主角色标记
5. 每幕背景配置入口
6. 每幕预览入口
## 7.3 幕编辑交互
每个场景章节卡的编辑区必须支持下面这些操作:
每个场景编辑弹层里的多幕区块必须支持下面这些操作:
1. 新增幕
2. 删除幕
3. 调整幕顺序
4. 编辑幕标题
5. 编辑幕摘要
6. 绑定幕背景图
7. 配置幕相遇 NPC 顺序
8. 编辑幕目标
9. 编辑幕过渡钩子
4. 绑定幕背景图
5. 在幕背景预览上点击角色槽位,为该槽位配置角色
6. 移除某个已配置槽位的角色
7. 开始当前幕预览
交互要求:
1. 幕列表在桌面端纵向堆叠,在移动端同样保持纵向,不做复杂双列。
2. 每幕是独立卡片,不把所有字段一次性铺满。
3. 点击“配置背景图”时必须打开独立面板或独立弹层,不允许在当前卡片下方内联展开
4. 点击“配置相遇 NPC”时必须打开独立面板或独立弹层不允许在当前卡片下方内联展开
5. 默认不展示大段规则说明文字
3. 三个角色槽位必须直接叠在幕背景图上,作为当前幕预览的一部分
4. 每个槽位只显示角色形象与名称,不展开为信息块
5. 空槽位以虚线站位展示,点击后进入角色选择弹层
6. 点击“配置背景图”时必须打开独立面板或独立弹层,不允许在当前卡片下方内联展开。
7. 点击角色槽位时必须打开独立面板或独立弹层,不允许在当前卡片下方内联展开。
8. 单幕手工编辑区不再暴露“幕标题 / 幕摘要 / 幕目标 / 过渡铺垫”字段,这些内容继续留在 Agent 草稿生成与编译层维护。
9. 默认不展示大段规则说明文字。
## 7.4 幕背景图配置
@@ -392,6 +402,7 @@ type NpcChatTurnResult = {
3. 幕背景图和场景总背景图不是同一个概念,允许不同幕使用不同图。
4. 发布前如果存在未绑定背景图的幕,必须阻止发布。
5. 幕切换时运行时优先使用幕背景图,而不是地点默认图。
6. 幕背景预览窗口长宽比与场景主图预览保持一致。
## 7.5 幕相遇 NPC 配置
@@ -399,18 +410,32 @@ NPC 配置面板必须支持:
1. 从当前世界的 `playableNpcs + storyNpcs` 中选择角色
2. 只展示与当前场景相关的优先推荐角色
3. 支持排序
4. 第一位角色明确标记为“主角色”
3. `3` 个固定槽位进行配置,而不是长列表表单
4. 第一位明确标记为“主角色”
5. 允许同一角色出现在多个不同幕
6. 同一幕内不允许同一角色重复占用多个槽位
硬约束:
1. 每幕至少 `1` 名 NPC。
2. 第一位 NPC 不能为空
2. 第一槽位不能为空,后续槽位才能继续配置
3. 不允许把不存在于当前世界角色池中的 id 写入幕配置。
4. 若主角色未与当前场景或线程建立任何关联,给出发布警告。
5. 存储时继续落到 `encounterNpcIds` 有序数组,槽位从左到右按顺序压缩写入。
## 7.6 创作校验
## 7.6 幕预览
创作者在场景编辑弹层里点击“幕预览”后,必须直接进入当前幕的运行时预览。
要求如下:
1. 预览必须复用正常游戏运行时,而不是单独写一个静态演示页。
2. 预览启动时要把当前幕设为活跃幕,并带上当前幕背景与当前幕主角色。
3. 若当前幕主角色好感度小于 `0`,预览中必须直接进入最多 `5` 轮的有限聊天态。
4. 若当前幕主角色好感度大于 `0`,预览中必须沿用无限轮聊天规则。
5. 预览面板使用独立全屏层,不挤压原场景编辑弹层布局。
## 7.7 创作校验
`CustomWorldQualityFinding` 至少新增下面这些检查项:
@@ -589,10 +614,11 @@ interface SceneActRuntimeState {
必须做到:
1. `scene_chapter` 卡片可见
1. 在“场景”列表点击场景卡后,可以看到多幕配置区块
2. 幕列表可编辑
3. 背景图选择和 NPC 选择都走独立面板
4. 移动端仍能完成幕排序、背景选择、NPC 排序
3. 每幕以大图预览 + 角色槽位的方式编辑
4. 背景选择、角色槽位选择、幕预览都走独立面板
5. 移动端仍能完成幕排序、背景选择、槽位换角与幕预览
## 10.2 游戏主面板
@@ -617,11 +643,12 @@ Adventure 主面板在本次迭代中至少增加下面这些表现:
前端只负责:
1. 渲染 `scene_chapter` 草稿卡与幕编辑 UI
1. 在现有场景编辑弹层中渲染多幕编辑 UI
2. 发起背景图配置和 NPC 配置请求
3. 渲染当前幕背景和幕标题
4. 渲染负好感聊天剩余轮数
5. 根据后端返回切换幕、退出聊天、展示后续 options
5. 启动当前幕预览并承载正常游戏运行时
6. 根据后端返回切换幕、退出聊天、展示后续 options
前端不负责:
@@ -657,7 +684,7 @@ Adventure 主面板在本次迭代中至少增加下面这些表现:
- 新增发布态 `sceneChapterBlueprints`
3. `server-node/src/services/customWorldAgentDraftCompiler.ts`
- 编译 `scene_chapter` 草稿
- 编译 `scene_chapter` 草稿数据
4. `server-node/src/services/customWorldAgentDraftEditService.ts`
- 支持场景幕的增删改排序
@@ -665,31 +692,28 @@ Adventure 主面板在本次迭代中至少增加下面这些表现:
5. `server-node/src/services/customWorldAgentQualityService.ts`
- 增加幕背景和幕 NPC 校验
6. `src/components/custom-world-agent/CustomWorldAgentDraftDrawer.tsx`
- 展示 `scene_chapter` 分组
6. `src/components/CustomWorldEntityCatalog.tsx`
- 继续承载场景列表入口
7. `src/components/custom-world-agent/CustomWorldDraftCardDetailModal.tsx`
- 展示幕详情
7. `src/components/CustomWorldEntityEditorModal.tsx`
- `LandmarkEditor` 中新增幕编辑 UI
8. `src/components/custom-world-agent/CustomWorldDraftEditPanel.tsx`
- 新增幕编辑 UI
9. `src/data/questFlow.ts`
8. `src/data/questFlow.ts`
- 让 scene chapter quest 感知当前幕
10. `src/services/storyEngine/chapterDirector.ts`
9. `src/services/storyEngine/chapterDirector.ts`
- 用当前幕映射章节阶段和摘要
11. `src/hooks/story/npcEncounterActions.ts`
10. `src/hooks/story/npcEncounterActions.ts`
- 新增主角色有限聊天与第 5 轮收束逻辑
12. `packages/shared/src/contracts/story.ts`
11. `packages/shared/src/contracts/story.ts`
- 扩展 `NpcChatTurnResult`
13. `src/services/aiService.ts`
12. `src/services/aiService.ts`
- 透传有限聊天新字段
14. `server-node/src/modules/ai/chatOrchestrator.ts`
13. `server-node/src/modules/ai/chatOrchestrator.ts`
- 生成第 `5` 轮铺垫式收束结果
---
@@ -698,7 +722,7 @@ Adventure 主面板在本次迭代中至少增加下面这些表现:
当下面这些结果都成立时,视为本次 PRD 已被正确落地:
1. 创作者可以在现有创作工作区中创建并编辑 `scene_chapter`
1. 创作者可以在现有场景编辑弹层中配置每个场景的多幕
2. 每个场景章节都可以配置 `2~5` 幕。
3. 每一幕都可以绑定独立背景图。
4. 每一幕都可以配置有序 NPC 列表,第一位自动成为主角色。

View File

@@ -963,6 +963,11 @@ behaviorVectors: Array<{
3. 一句解释文本
4. 怪物的“敌意关系状态”
补一条 UI 落地约束:
- 包括选角流、角色面板、详情弹窗在内,所有属性展示入口都必须直接读取当前世界的 `WorldAttributeSchema.slots`
- 禁止回退显示 `力量 / 敏捷 / 智力 / 精神` 这类旧四维占位文案,除非该入口明确处于旧数据迁移调试模式。
## 11.3 对玩家的信息揭示分层
不是所有 NPC 初见时都展示完整属性。

View File

@@ -68,22 +68,21 @@
3. 登录设备
4. 更换手机号
5. 账号操作记录
6. 退出登录
7. 退出全部设备
交互层级要求补充为:
1. 设置首页只展示“主题外观”“账号信息”两个分区入口与危险操作,不在首页内联展开具体详情
1. 设置首页只展示“主题外观”“账号信息”两个分区入口,不在首页内联展开具体详情
2. 点击任一分区入口后,必须进入独立二级面板
3. 安全状态、登录设备、操作记录不再作为首页独立入口,统一归入“账号信息”二级面板
4. 更换手机号属于独立操作面板,不允许在账号信息面板内直接展开表单
5. 设置首页头部只保留一套主标题,不允许在内容区再重复放置“设置首页”“选择要管理的内容”这类二次标题块
6. 子面板导航动作必须单一明确;同一层面板内有“返回”时,不再同时展示“关闭”
7. 设置首页与各级子面板都必须定义单一滚动容器,列表内容必须可稳定滚动,禁止外层与内层同时争夺滚动
8. 二级或三级面板打开后,下层内容必须进入不可交互状态,并把焦点主动转移到当前面板内;禁止对仍保留焦点的祖先节点使用 `aria-hidden`
底部保留两个危险操作按钮:
1. 退出登录
2. 退出全部设备
5. 退出登录与退出全部设备统一归入“账号信息”二级面板,不再在设置首页单独占位
6. 设置首页头部只保留一套主标题,不允许在内容区再重复放置“设置首页”“选择要管理的内容”这类二次标题块
7. 子面板导航动作必须单一明确;同一层面板内有“返回”时,不再同时展示“关闭”
8. 子面板返回按钮固定摆在面板右上角
9. 设置首页与各级子面板都必须定义单一滚动容器,列表内容必须可稳定滚动,禁止外层与内层同时争夺滚动
10. 二级或三级面板打开后,下层内容必须进入不可交互状态,并把焦点主动转移到当前面板内;禁止对仍保留焦点的祖先节点使用 `aria-hidden`
---
@@ -210,7 +209,7 @@
1. 设置继续采用当前账号弹窗基础形态即可
2. 移动端优先底部弹层,桌面端可居中弹窗
3. 设置首页只保留“主题外观”“账号信息”两个入口,不再单独展示安全状态、登录设备、操作记录入口
4. “账号信息”二级面板直接承载账号概况、安全状态、登录设备、操作记录四块内容,移动端优先纵向滚动,桌面端保持同一面板内稳定扫读
4. “账号信息”二级面板直接承载账号概况、安全状态、登录设备、操作记录与退出动作,移动端优先纵向滚动,桌面端保持同一面板内稳定扫读
5. 更换手机号必须通过独立操作面板完成,不再使用当前面板内联展开表单
6. 危险操作按钮与普通按钮必须明显区分
7. 设置首页标题处禁止展示手机号、脱敏手机号或手机号形态的 displayName

View File

@@ -279,7 +279,7 @@
- `run / attack` 是当前固定动作入口里的基础必生成动作
- `idle / die` 改为可选增强动作,不再作为资产完成度硬门槛
- `idle` 缺失时运行时默认使用主图静止
- `die` 缺失时运行时默认播放一段基于主图的倒地过渡动画,并最终停在翻转倒地姿态
- `die` 缺失时运行时默认播放一段基于主图的向后倒地过渡动画,并通过轻微过冲回落让动作更自然,最终停在翻转倒地姿态
- 技能动作不走固定按钮,但对当前角色 `skills` 中的每个技能都属于必生成动作
## 5.3 补充路线:腾讯云相关能力
@@ -968,7 +968,7 @@ draft
| `attack` | 必填 | 模板生成 |
| `skills[*].actionPreviewConfig` | 必填 | 技能编辑面板逐个生成 |
| `idle` | 可选 | 模板生成;缺失时默认主图静止 |
| `die` | 可选 | 模板生成;缺失时默认主图倒地过渡动画,最终停在翻转倒地姿态 |
| `die` | 可选 | 模板生成;缺失时默认主图向后倒地过渡动画,带轻微过冲回落,最终停在翻转倒地姿态 |
这里“必填”指的是:

View File

@@ -4,15 +4,16 @@
## 1. 本轮落地范围
本轮先完成 `scene_chapter` 的第一批基础链路,让“场景章节 -> 多幕 -> 主角色 -> 幕背景/相遇 NPC”真正进入现有创作工具和草稿系统
本轮先完成场景多幕的第一批基础链路,让“场景章节 -> 多幕 -> 主角色 -> 幕背景/相遇 NPC”真正进入现有创作工具与运行时
本轮目标不是一次性做完 PRD 全量能力,而是先把下面条主干打通:
本轮目标不是一次性做完 PRD 全量能力,而是先把下面条主干打通:
1. 草稿层可以承载 `scene chapter / scene act`
2. 草稿编译器可以把 `scene_chapter` 编译成正式卡片
3. 创作可以看到、打开、编辑 `scene_chapter`
4. 编辑后的幕信息可以正确写回草稿
2. 后端可以把 `scene_chapter` 编译成正式蓝图
3. 创作可以在现有场景编辑弹层里看到并编辑多幕配置
4. 编辑后的幕信息可以正确写回 `sceneChapterBlueprints`
5. 运行时共享层先具备读取幕背景、主角色、相遇 NPC 池的基础能力
6. 当前幕主角色的负好感 `5` 轮聊天限制先形成首个可运行闭环
## 2. 本轮已落地
@@ -55,43 +56,62 @@
`server-node/src/services/customWorldAgentChangeSummaryService.ts` 也已支持解析 `scene_chapter` 标题。
## 2.4 创作页展示
## 2.4 场景编辑器接入
前端已完成第一批接入:
1. 草稿抽屉正式加入 `scene_chapter` 分组
2. `scene_chapter` 分组顺序位于 `chapter` 后、`thread`
3. 详情面板已支持 `场景章节` 类型标签
4. 幕背景 section 在详情面板里会直接渲染图片预览
5. 编辑面板已支持幕摘要 / 相遇 NPC / 幕目标 / 过渡钩子等动态多行字段
1. `scene_chapter` 不再作为独立 Tab / 独立卡片暴露给创作者
2. 多幕配置已内嵌到 `CustomWorldEntityEditorModal.tsx``LandmarkEditor`
3. 单幕编辑已从文本表单切成“背景大图预览 + 3 个角色槽位”的轻量交互
4. “幕标题 / 幕摘要 / 幕目标 / 过渡钩子”已从场景手工编辑区移除,继续留在草稿生成与编译层
5. 角色槽位已改成直接叠在幕背景图上的站位式预览,每个角色只显示形象与名称
6. 每幕背景图与角色槽位都走独立弹窗,不做卡片内联展开
7. 角色槽位会把第一槽位写回 `primaryNpcId`,其余槽位顺序压缩写回 `encounterNpcIds`
8. 每幕已补上“幕预览”入口,点击后会以独立全屏层启动当前幕运行时预览
9. 保存场景时会把幕配置同步写回 `CustomWorldProfile.sceneChapterBlueprints`
## 2.5 运行时基础层
本轮同步补齐了幕运行的基础读取能力,便于下一轮继续接游戏流程
本轮同步补齐了幕运行的基础读取能力:
1. 当前幕背景图优先覆盖场景默认背景
2. 当前幕相遇 NPC 池可参与场景相遇过滤
3. 当前幕主角色与负好感有限聊天的判定 helper 已建立
4. 场景预览层已能识别“负好感主角色不直接自动开战”的基础分支
5. 编辑器内幕预览会把当前幕直接装配进真实游戏壳,而不是走静态假页面
6. 幕编辑中的 3 个角色槽位已进一步收敛成贴在背景图上的站位式角色预览,交互与幕预览保持同一位置语义,只显示角色形象与名称
7. 幕预览运行时已补 custom world NPC 的视觉兜底链路,优先使用 `visual / imageSrc` 渲染,避免角色形象或动画空白
8. 当前幕小预览已调整为左侧玩家、右侧敌对/相遇角色的构图NPC 站位采用一前两后
9. 新增幕默认只带 1 个主角色,后续槽位由创作者按需补充
10. 小预览里的名字已移动到角色头顶,角色渲染不再带方形底板,避免遮挡场景背景
## 2.6 负好感主角色有限聊天闭环
本轮已把 PRD 里的第一版运行时闭环接到现有游戏流程:
1. `StoryEngineMemoryState.currentSceneActState` 会在进入场景章节时初始化到首幕
2. 当前幕主角色若好感度小于 `0`,相遇后不再直接进入敌对宣言,而是进入有限聊天态
3. 有限聊天态会把 `turnLimit / remainingTurns / limitReason` 透传到前后端聊天链路
4.`5` 轮会由后端 prompt 强约束生成“铺垫式收束”回复,不再继续生成下一轮聊天建议
5.`5` 轮返回后,前端会自动清掉 `npcChatState`,隐藏输入框,并给出 `继续` 的后续推进入口
6. Adventure 面板会显示当前幕标题与有限聊天剩余轮数
## 3. 当前仍未完成
下面这些仍属于 PRD 未完项,需要下一轮继续:
1. 创作页里的“新增幕 / 删除幕 / 调整幕顺序”交互
2. 背景图配置与 NPC 配置的独立面板化交互
3. 发布期 `qualityFindings` / blocker 的正式接入
4. `SceneActRuntimeState` 的完整推进与持久化
5. 当前幕主角色负好感 `5` 轮聊天限制的前后端完整闭环
6.`5` 轮“铺垫式收束”提示与强制退出聊天态
7. 幕切换后的系统提示与 Adventure 面板状态展示
1. 发布期 `qualityFindings` / blocker 的正式接入
2. `SceneActRuntimeState` 的完整推进、跨幕推进规则与持久化
3. 幕切换后的系统提示、切幕触发条件与背景/相遇对象的完整联动
4. 高好感主角色“无限轮聊天”与更多委托触发细则的专项验证
5. Agent 聊八锚点 -> 生成草稿 -> 场景内多幕配置的整条创作闭环仍需继续打磨
## 4. 下一轮建议顺序
建议下一轮按下面顺序继续:
1. 先补 `SceneActRuntimeState` 初始化与幕推进
2.`npcEncounterActions / aiService / chatOrchestrator` 的负好感有限聊天闭环
3. 最后补创作页的幕增删改序和独立配置面板
1. 先补 `SceneActRuntimeState` 的跨幕推进规则与持久化
2.补发布期 blocker / quality findings
3. 最后补高好感委托验证与 Agent 创作闭环
这样可以先把“能跑”补齐,再把“编辑体验”补完整。
这样可以先把“能跑”继续扩成“能切幕”,再把“发布质量门槛”和“完整创作闭环”补完整。

View File

@@ -170,6 +170,7 @@ export type NpcChatDialogueRequest<
context: TContext;
topic: string;
resultSummary: string;
npcInitiatesConversation?: boolean;
};
export type NpcChatTurnRequest<
@@ -195,6 +196,7 @@ export type NpcChatTurnRequest<
dialogue?: TConversationTurn[];
playerMessage: string;
npcState: TNpcState;
npcInitiatesConversation?: boolean;
questOfferContext?: {
state: TQuestOfferState;
encounter: TQuestOfferEncounter;

View File

@@ -1,3 +1,20 @@
/**
* 共享 sprite / 角色资产正式 prompt 模板。
*
* 这份脚本属于“正式模型 prompt 模板层”,不负责从角色卡里挑默认文本。
* 它的定位是:
* - 给后端角色主图生成链路提供标准主图 prompt 骨架
* - 给后端角色动作视频生成链路提供标准动作 prompt 骨架
*
* 当前角色资产主链中的关系是:
* 1. 前端或后端先拿到一段较短的描述文本
* 2. server-node/src/prompts/characterAssetPrompts.ts
* 再调用本文件 buildMasterPrompt / buildVideoActionPrompt
* 把短描述扩成正式给模型吃的 prompt
*
* 因此本文件不要承载“角色卡字段挑选”或“UI 默认值”职责,
* 只维护共享的正式 prompt 骨架与动作模板。
*/
export type QwenSpriteActionTemplateId =
| 'idle'
| 'run'
@@ -106,6 +123,13 @@ export function getActionTemplateById(id: QwenSpriteActionTemplateId) {
);
}
/**
* 正式角色主图 prompt 骨架。
*
* 输入应该是一段已经整理好的角色摘要或视觉描述,
* 这里会把它嵌进统一的 sprite 资产约束中,
* 输出真正发给图像模型的完整 prompt。
*/
export function buildMasterPrompt(characterBrief: string) {
return [
'单人2D 横版游戏角色标准设定图,主体完整可见,底部轮廓完整,身体比例稳定,轮廓清楚,适合后续制作 sprite sheet 动画。',
@@ -122,6 +146,13 @@ export function buildMasterPrompt(characterBrief: string) {
.join('\n');
}
/**
* 正式动作视频 prompt 骨架。
*
* 输入应该是已经整理好的动作细节与角色摘要,
* 这里负责统一拼装成 sprite 动作生成所需的正式 prompt
* 包括视角、像素风格、动作模板、绿幕约束等。
*/
export function buildVideoActionPrompt(options: {
actionTemplate: QwenSpriteActionTemplate;
actionDetailText: string;

View File

@@ -6,6 +6,7 @@ import type {
CharacterChatSummaryRequest,
NpcChatDialogueRequest,
type NpcChatPendingQuestOffer,
type NpcChatTurnCompletionDirective,
NpcChatTurnRequest,
NpcRecruitDialogueRequest,
} from '../../../../packages/shared/src/contracts/story.js';
@@ -53,6 +54,10 @@ function readNumber(value: unknown, fallback = 0) {
return typeof value === 'number' && Number.isFinite(value) ? value : fallback;
}
function readBoolean(value: unknown, fallback = false) {
return typeof value === 'boolean' ? value : fallback;
}
function readString(value: unknown) {
return typeof value === 'string' && value.trim() ? value.trim() : '';
}
@@ -168,6 +173,14 @@ async function maybeBuildPendingNpcQuestOffer(
payload: NpcChatTurnRequest,
affinityDelta: number,
): Promise<NpcChatPendingQuestOffer | null> {
const chatDirective = readRecord(payload.chatDirective);
if (
readString(chatDirective?.limitReason) === 'negative_affinity' ||
readBoolean(chatDirective?.forceExitAfterTurn, false)
) {
return null;
}
const questOfferContext = readRecord(payload.questOfferContext);
const state = readRecord(questOfferContext?.state);
const encounter = readRecord(questOfferContext?.encounter);
@@ -305,6 +318,19 @@ export async function streamNpcChatTurnFromOrchestrator(
try {
let streamedReply = '';
const chatDirective = readRecord(params.payload.chatDirective);
const closingMode =
readString(chatDirective?.closingMode) === 'foreshadow_close'
? 'foreshadow_close'
: 'free';
const turnLimit = Math.max(0, readNumber(chatDirective?.turnLimit, 0));
const remainingTurns = Math.max(
0,
readNumber(chatDirective?.remainingTurns, 0),
);
const forceExit =
closingMode === 'foreshadow_close' ||
readBoolean(chatDirective?.forceExitAfterTurn, false);
const npcReply = (
await llmClient.streamMessageContent({
@@ -318,16 +344,19 @@ export async function streamNpcChatTurnFromOrchestrator(
})
).trim();
const suggestionText = await llmClient.requestMessageContent({
systemPrompt: NPC_CHAT_TURN_SUGGESTION_SYSTEM_PROMPT,
userPrompt: buildNpcChatTurnSuggestionPrompt(
params.payload,
npcReply || streamedReply,
),
debugLabel: 'runtime.npc_chat.turn.suggestions',
});
let suggestions: string[] = [];
if (!forceExit) {
const suggestionText = await llmClient.requestMessageContent({
systemPrompt: NPC_CHAT_TURN_SUGGESTION_SYSTEM_PROMPT,
userPrompt: buildNpcChatTurnSuggestionPrompt(
params.payload,
npcReply || streamedReply,
),
debugLabel: 'runtime.npc_chat.turn.suggestions',
});
const suggestions = parseLineListContent(suggestionText, 3);
suggestions = parseLineListContent(suggestionText, 3);
}
const npcState = readRecord(params.payload.npcState);
const chattedCount = readNumber(npcState?.chattedCount, 0);
const affinityDelta = computeNpcChatAffinityDelta({
@@ -335,21 +364,34 @@ export async function streamNpcChatTurnFromOrchestrator(
npcReply: npcReply || streamedReply,
chattedCount,
});
const pendingQuestOffer = await maybeBuildPendingNpcQuestOffer(
llmClient,
params.payload,
affinityDelta,
);
const pendingQuestOffer = forceExit
? null
: await maybeBuildPendingNpcQuestOffer(
llmClient,
params.payload,
affinityDelta,
);
const completionDirective: NpcChatTurnCompletionDirective | null =
chatDirective
? {
turnLimit: turnLimit > 0 ? turnLimit : null,
remainingTurns,
forceExit,
closingMode,
}
: null;
writeSseEvent(params.response, 'complete', {
npcReply: npcReply || streamedReply,
affinityDelta,
affinityText: describeAffinityShift(affinityDelta),
suggestions:
suggestions.length === 3
suggestions: forceExit
? []
: suggestions.length === 3
? suggestions
: buildFallbackNpcChatSuggestions(params.payload.playerMessage),
pendingQuestOffer,
chatDirective: completionDirective,
});
params.response.write('data: [DONE]\n\n');
params.response.end();

View File

@@ -370,6 +370,319 @@ test('chat orchestrator returns pending npc quest offers from the server side',
assert.match(payload.pendingQuestOffer?.introText ?? '', //u);
});
test('chat orchestrator adds first-contact greeting constraints to the first npc turn prompt', async () => {
const encounter = {
kind: 'npc',
id: 'npc_first_contact_01',
npcName: '林晓峰',
npcDescription: '初次照面的试探者',
context: '雨夜桥口',
characterId: 'first-contact-npc',
} as const;
const requestPayload = {
worldType: TEST_WORLD,
character: createTestCharacter(),
player: createTestCharacter(),
encounter,
monsters: [],
history: [],
context: {
...createStoryContext(),
isFirstMeaningfulContact: true,
firstContactRelationStance: 'guarded',
encounterAllowedTopics: ['眼前动静', '来意试探'],
encounterBlockedTopics: ['完整来历', '真正目标'],
},
conversationHistory: [],
dialogue: [],
playerMessage: '你刚才一直在看哪边?',
npcState: {
affinity: 8,
chattedCount: 0,
},
} satisfies NpcChatTurnRequest;
const responseChunks: string[] = [];
const capturedReplyPrompts: string[] = [];
let requestMessageCount = 0;
const llmClient = {
streamMessageContent: async ({
userPrompt,
onUpdate,
}: {
userPrompt: string;
onUpdate?: (text: string) => void;
}) => {
capturedReplyPrompts.push(userPrompt);
const reply = '先打个招呼。你先别急着往前,再看一眼桥那边的风。';
onUpdate?.(reply);
return reply;
},
requestMessageContent: async () => {
requestMessageCount += 1;
return '你是在提醒我还是拦我\n桥那边到底出了什么事\n你刚才看见谁了';
},
} as const;
const request = {
method: 'POST',
originalUrl: '/api/runtime/chat/npc/turn/stream',
requestId: 'test-request',
requestStartedAt: Date.now(),
header: () => '',
on: () => request,
} as never;
const response = {
locals: {},
statusCode: 200,
setHeader: () => undefined,
status(code: number) {
this.statusCode = code;
return this;
},
write(chunk: string) {
responseChunks.push(chunk);
return true;
},
end(chunk?: string) {
if (chunk) {
responseChunks.push(chunk);
}
return this;
},
} as never;
await streamNpcChatTurnFromOrchestrator(llmClient as never, {
request,
response,
payload: requestPayload,
});
assert.equal(requestMessageCount, 1);
assert.match(capturedReplyPrompts[0] ?? '', //u);
assert.match(
capturedReplyPrompts[0] ?? '',
//u,
);
assert.match(
capturedReplyPrompts[0] ?? '',
//u,
);
});
test('chat orchestrator marks npc-initiated first contact openings in the first npc turn prompt', async () => {
const encounter = {
kind: 'npc',
id: 'npc_first_contact_opening',
npcName: '沈雁回',
npcDescription: '在风口先观察来意的人',
context: '桥头对峙',
characterId: 'npc-first-open',
} as const;
const requestPayload = {
worldType: TEST_WORLD,
character: createTestCharacter(),
player: createTestCharacter(),
encounter,
monsters: [],
history: [],
context: {
...createStoryContext(),
isFirstMeaningfulContact: true,
firstContactRelationStance: 'neutral',
encounterAllowedTopics: ['眼前局势', '来意试探'],
encounterBlockedTopics: ['完整旧事'],
},
conversationHistory: [],
dialogue: [],
playerMessage: '【NPC 主动开场】',
npcState: {
affinity: 18,
chattedCount: 0,
},
npcInitiatesConversation: true,
} satisfies NpcChatTurnRequest;
const capturedReplyPrompts: string[] = [];
const llmClient = {
streamMessageContent: async ({
userPrompt,
onUpdate,
}: {
userPrompt: string;
onUpdate?: (text: string) => void;
}) => {
capturedReplyPrompts.push(userPrompt);
const reply = '先站住。你带着这身风尘过来,总不会只是为了看看桥景。';
onUpdate?.(reply);
return reply;
},
requestMessageContent: async () =>
'我先听你说桥上出了什么事\n你先说你在防谁\n我不是来翻旧账的',
} as const;
const request = {
method: 'POST',
originalUrl: '/api/runtime/chat/npc/turn/stream',
requestId: 'test-request',
requestStartedAt: Date.now(),
header: () => '',
on: () => request,
} as never;
const response = {
locals: {},
statusCode: 200,
setHeader: () => undefined,
status(code: number) {
this.statusCode = code;
return this;
},
write() {
return true;
},
end() {
return this;
},
} as never;
await streamNpcChatTurnFromOrchestrator(llmClient as never, {
request,
response,
payload: requestPayload,
});
assert.match(
capturedReplyPrompts[0] ?? '',
//u,
);
assert.match(
capturedReplyPrompts[0] ?? '',
//u,
);
assert.match(
capturedReplyPrompts[0] ?? '',
//u,
);
});
test('chat orchestrator force closes the fifth hostile primary-npc turn with foreshadowing', async () => {
const encounter = {
kind: 'npc',
id: 'npc_bridge_rival',
npcName: '断桥客',
npcDescription: '守着旧桥的冷面旧敌',
context: '断桥旧案',
characterId: 'bridge-rival',
} as const;
const requestPayload = {
worldType: TEST_WORLD,
character: createTestCharacter(),
player: createTestCharacter(),
encounter,
monsters: [],
history: [],
context: createStoryContext(),
conversationHistory: [
{ speaker: 'player', text: '你一直躲着不说完。' },
{ speaker: 'npc', text: '有些话说完了,人也就该死了。' },
],
dialogue: [
{ speaker: 'player', text: '你一直躲着不说完。' },
{ speaker: 'npc', text: '有些话说完了,人也就该死了。' },
],
playerMessage: '那你至少告诉我,接下来该去哪里找答案。',
npcState: {
affinity: -12,
chattedCount: 4,
},
chatDirective: {
sceneActId: 'scene-bridge-act-1',
turnLimit: 5,
remainingTurns: 0,
limitReason: 'negative_affinity',
closingMode: 'foreshadow_close',
forceExitAfterTurn: true,
},
} satisfies NpcChatTurnRequest;
const responseChunks: string[] = [];
const capturedReplyPrompts: string[] = [];
let requestMessageCount = 0;
const llmClient = {
streamMessageContent: async ({
userPrompt,
onUpdate,
}: {
userPrompt: string;
onUpdate?: (text: string) => void;
}) => {
capturedReplyPrompts.push(userPrompt);
const reply = '去城西废桥下找那盏没灭的灯。等你看见它,再来问我剩下那半句。';
onUpdate?.(reply);
return reply;
},
requestMessageContent: async () => {
requestMessageCount += 1;
return '这条回复不该被调用';
},
} as const;
const request = {
method: 'POST',
originalUrl: '/api/runtime/chat/npc/turn/stream',
requestId: 'test-request',
requestStartedAt: Date.now(),
header: () => '',
on: () => request,
} as never;
const response = {
locals: {},
statusCode: 200,
setHeader: () => undefined,
status(code: number) {
this.statusCode = code;
return this;
},
write(chunk: string) {
responseChunks.push(chunk);
return true;
},
end(chunk?: string) {
if (chunk) {
responseChunks.push(chunk);
}
return this;
},
} as never;
await streamNpcChatTurnFromOrchestrator(llmClient as never, {
request,
response,
payload: requestPayload,
});
assert.equal(requestMessageCount, 0);
assert.match(capturedReplyPrompts[0] ?? '', //u);
assert.match(capturedReplyPrompts[0] ?? '', //u);
const eventText = responseChunks.join('');
const completeBlock = eventText
.split('\n\n')
.find((block) => block.includes('event: complete'));
assert.ok(completeBlock);
const completeLine = completeBlock
?.split('\n')
.find((line) => line.startsWith('data:'));
assert.ok(completeLine);
const payload = JSON.parse(completeLine!.slice(5).trim()) as {
suggestions?: string[];
chatDirective?: {
forceExit?: boolean;
remainingTurns?: number | null;
closingMode?: string;
} | null;
};
assert.deepEqual(payload.suggestions, []);
assert.equal(payload.chatDirective?.forceExit, true);
assert.equal(payload.chatDirective?.remainingTurns, 0);
assert.equal(payload.chatDirective?.closingMode, 'foreshadow_close');
});
test('custom world orchestrator requests LLM content before compiling the profile', async () => {
const capturedPrompts: Array<{ systemPrompt: string; userPrompt: string }> = [];
const storyNpcNames = Array.from(

View File

@@ -11,6 +11,11 @@ import {
resolveInventoryItemUseEffect,
} from '../../bridges/legacyInventoryRuntimeBridge.js';
import { conflict } from '../../errors.js';
import {
buildExperienceGrantResultText,
grantPlayerExperience,
} from '../progression/playerProgressionService.js';
import { resolveHostileBattleProfile } from '../progression/hostileProgressionService.js';
import {
appendBuildBuffs,
resolvePlayerOutgoingDamageResult,
@@ -41,7 +46,9 @@ type CombatActionConfig = {
}>;
consumedItemId?: string | null;
usedItem?: RuntimeCombatInventoryItem | null;
itemEffect?: NonNullable<ReturnType<typeof resolveInventoryItemUseEffect>> | null;
itemEffect?: NonNullable<
ReturnType<typeof resolveInventoryItemUseEffect>
> | null;
};
export type CombatResolution = {
@@ -87,6 +94,15 @@ function getAliveTarget(session: RuntimeSession) {
return session.sceneHostileNpcs.find((npc) => npc.hp > 0) ?? null;
}
function getVictoryResolvedTargets(
session: RuntimeSession,
primaryTargetId: string,
) {
return session.sceneHostileNpcs.filter(
(npc) => npc.id === primaryTargetId || npc.hp > 0,
);
}
function getCombatInventoryItem(
session: RuntimeSession,
itemId: string,
@@ -147,13 +163,64 @@ function applySparAffinityReward(session: RuntimeSession) {
}
function clampPlayerVitals(session: RuntimeSession) {
session.playerHp = Math.max(0, Math.min(session.playerHp, session.playerMaxHp));
session.playerHp = Math.max(
0,
Math.min(session.playerHp, session.playerMaxHp),
);
session.playerMana = Math.max(
0,
Math.min(session.playerMana, session.playerMaxMana),
);
}
function applyHostileVictoryRewards(
session: RuntimeSession,
resolvedTargets: RuntimeSession['sceneHostileNpcs'],
) {
if (resolvedTargets.length <= 0) {
return '';
}
const grantedXp = resolvedTargets.reduce((sum, hostileNpc) => {
const battleProfile = resolveHostileBattleProfile({
playerProgression: session.rawGameState.playerProgression,
encounter: {
hostile: true,
monsterPresetId: hostileNpc.id,
levelProfile: hostileNpc.levelProfile,
experienceReward: hostileNpc.experienceReward,
},
battleMode: 'fight',
});
return sum + battleProfile.experienceReward;
}, 0);
const experienceGrant = grantPlayerExperience(
session.rawGameState.playerProgression,
grantedXp,
{
source: 'hostile_npc',
},
);
session.rawGameState.playerProgression = experienceGrant.state;
session.rawGameState.runtimeStats = incrementGameRuntimeStats(
(isObject(session.rawGameState.runtimeStats)
? session.rawGameState.runtimeStats
: {
hostileNpcsDefeated: 0,
questsAccepted: 0,
itemsUsed: 0,
scenesTraveled: 0,
}) as Parameters<typeof incrementGameRuntimeStats>[0],
{
hostileNpcsDefeated: resolvedTargets.length,
},
);
return buildExperienceGrantResultText(experienceGrant);
}
function finishBattle(
session: RuntimeSession,
outcome: RuntimeBattlePresentation['outcome'],
@@ -194,10 +261,7 @@ function buildBasicAttackBaseDamage(session: RuntimeSession) {
);
}
function tickCooldownMap(
cooldowns: Record<string, number>,
turns: number,
) {
function tickCooldownMap(cooldowns: Record<string, number>, turns: number) {
let nextCooldowns = cooldowns;
for (let index = 0; index < Math.max(0, Math.floor(turns)); index += 1) {
@@ -232,7 +296,10 @@ function resolveCombatActionConfig(params: {
} satisfies CombatActionConfig;
}
if (functionId === 'battle_attack_basic' || LEGACY_ATTACK_FUNCTION_IDS.has(functionId)) {
if (
functionId === 'battle_attack_basic' ||
LEGACY_ATTACK_FUNCTION_IDS.has(functionId)
) {
return {
actionText: '普通攻击',
manaCost: 0,
@@ -253,7 +320,9 @@ function resolveCombatActionConfig(params: {
throw conflict('battle_use_skill 缺少 skillId');
}
const skill = character.skills.find((candidate) => candidate.id === skillId);
const skill = character.skills.find(
(candidate) => candidate.id === skillId,
);
if (!skill) {
throw conflict(`未找到技能:${skillId}`);
}
@@ -386,7 +455,9 @@ export function resolveCombatAction(
const damageResult =
action.baseDamage > 0
? resolvePlayerOutgoingDamageResult(
session.rawGameState as Parameters<typeof resolvePlayerOutgoingDamageResult>[0],
session.rawGameState as Parameters<
typeof resolvePlayerOutgoingDamageResult
>[0],
character,
action.baseDamage,
1,
@@ -397,7 +468,7 @@ export function resolveCombatAction(
? action.baseDamage > 0
? 1
: 0
: damageResult?.damage ?? 0;
: (damageResult?.damage ?? 0);
session.playerMana -= action.manaCost;
session.playerHp += action.heal ?? 0;
@@ -417,7 +488,9 @@ export function resolveCombatAction(
if (action.consumedItemId) {
session.rawGameState.playerInventory = removeInventoryItem(
session.rawGameState.playerInventory as Parameters<typeof removeInventoryItem>[0],
session.rawGameState.playerInventory as Parameters<
typeof removeInventoryItem
>[0],
action.consumedItemId,
1,
);
@@ -436,8 +509,9 @@ export function resolveCombatAction(
if (action.buildBuffs?.length) {
session.rawGameState.activeBuildBuffs = appendBuildBuffs(
(session.rawGameState.activeBuildBuffs as Parameters<typeof appendBuildBuffs>[0]) ??
[],
(session.rawGameState.activeBuildBuffs as Parameters<
typeof appendBuildBuffs
>[0]) ?? [],
action.buildBuffs as Parameters<typeof appendBuildBuffs>[1],
);
}
@@ -463,17 +537,21 @@ export function resolveCombatAction(
outcome = 'spar_complete';
resultText = `你和${target.name}这轮过招已经分出高下,对方也承认了你的身手。`;
} else {
const resolvedTargets = getVictoryResolvedTargets(session, target.id);
const experienceText = applyHostileVictoryRewards(
session,
resolvedTargets,
);
finishBattle(session, 'victory');
outcome = 'victory';
resultText = `你这一手彻底压垮了${target.name},眼前战斗已经正式结束。`;
resultText = experienceText
? `你这一手彻底压垮了${target.name},眼前战斗已经正式结束。 ${experienceText}`
: `你这一手彻底压垮了${target.name},眼前战斗已经正式结束。`;
}
} else {
const baseCounter = isSpar
? 1
: Math.max(
4,
Math.round(target.maxHp * 0.14 * action.counterMultiplier),
);
: Math.max(4, Math.round(target.maxHp * 0.14 * action.counterMultiplier));
damageTaken = baseCounter;
session.playerHp = Math.max(isSpar ? 1 : 0, session.playerHp - damageTaken);

View File

@@ -1,5 +1,6 @@
import type { RuntimeStoryPatch } from '../../../../packages/shared/src/contracts/story.js';
import { conflict } from '../../errors.js';
import { resolveHostileBattleProfile } from '../progression/hostileProgressionService.js';
import {
MAX_TASK5_COMPANIONS,
getEncounterNpcState,
@@ -9,6 +10,8 @@ import {
type RuntimeSession,
} from '../story/runtimeSession.js';
type JsonRecord = Record<string, unknown>;
export type NpcInteractionResolution = {
actionText: string;
resultText: string;
@@ -50,22 +53,39 @@ function buildAffinityPatch(
} satisfies RuntimeStoryPatch;
}
function isRecord(value: unknown): value is JsonRecord {
return typeof value === 'object' && value !== null && !Array.isArray(value);
}
function buildBattleTarget(
encounter: RuntimeEncounter,
npcState: RuntimeNpcState,
rawGameState: JsonRecord,
playerProgression: unknown,
mode: 'fight' | 'spar',
) {
const maxHp =
mode === 'spar'
? 8
: Math.max(32, 24 + Math.max(0, Math.round(npcState.affinity * 0.35)));
const currentScenePreset = isRecord(rawGameState.currentScenePreset)
? rawGameState.currentScenePreset
: null;
const battleProfile = resolveHostileBattleProfile({
playerProgression,
encounter,
battleMode: mode,
customWorldProfile: rawGameState.customWorldProfile,
sceneId:
(typeof currentScenePreset?.id === 'string' && currentScenePreset.id) ||
null,
chapterState: rawGameState.chapterState,
storyEngineMemory: rawGameState.storyEngineMemory,
});
return {
id: encounter.id,
name: encounter.npcName,
hp: maxHp,
maxHp,
hp: battleProfile.battleMaxHp,
maxHp: battleProfile.battleMaxHp,
description: encounter.npcDescription,
levelProfile: battleProfile.levelProfile,
experienceReward: battleProfile.experienceReward,
};
}
@@ -127,7 +147,10 @@ export function resolveNpcInteraction(
const previousAffinity = npcState.affinity;
const nextAffinity = previousAffinity + 4;
session.playerHp = Math.min(session.playerMaxHp, session.playerHp + 10);
session.playerMana = Math.min(session.playerMaxMana, session.playerMana + 8);
session.playerMana = Math.min(
session.playerMaxMana,
session.playerMana + 8,
);
setEncounterNpcState(session, {
...npcState,
affinity: nextAffinity,
@@ -197,17 +220,23 @@ export function resolveNpcInteraction(
}
case 'npc_fight':
case 'npc_spar': {
const battleTarget = buildBattleTarget(
encounter,
session.rawGameState,
session.rawGameState.playerProgression,
functionId === 'npc_spar' ? 'spar' : 'fight',
);
session.npcInteractionActive = false;
session.inBattle = true;
session.currentNpcBattleMode = functionId === 'npc_spar' ? 'spar' : 'fight';
session.currentNpcBattleMode =
functionId === 'npc_spar' ? 'spar' : 'fight';
session.currentNpcBattleOutcome = null;
session.sceneHostileNpcs = [
buildBattleTarget(
encounter,
npcState,
functionId === 'npc_spar' ? 'spar' : 'fight',
),
];
session.currentEncounter = {
...encounter,
levelProfile: battleTarget.levelProfile,
experienceReward: battleTarget.experienceReward,
};
session.sceneHostileNpcs = [battleTarget];
return {
actionText:

View File

@@ -0,0 +1,225 @@
import assert from 'node:assert/strict';
import test from 'node:test';
import type { CustomWorldProfile } from '../custom-world/runtimeTypes.js';
import {
buildChapterProgressionPlans,
resolveCurrentChapterProgressionContext,
} from './chapterProgressionPlanner.js';
function createProgressionProfile() {
return {
id: 'custom-world-progression',
settingText: '测试世界',
name: '测试世界',
subtitle: '章节成长测试',
summary: '用于章节成长规划测试。',
tone: '紧张',
playerGoal: '推进章节',
templateWorldType: 'CUSTOM',
majorFactions: [],
coreConflicts: [],
attributeSchema: {
id: 'schema-1',
worldId: 'custom-world-progression',
schemaVersion: 1,
schemaName: '测试属性',
generatedFrom: {
worldType: 'CUSTOM',
worldName: '测试世界',
settingSummary: '测试',
tone: '紧张',
conflictCore: '推进',
},
slots: [],
},
playableNpcs: [],
storyNpcs: [
{
id: 'npc_chapter_1_raider',
name: '谷口匪徒',
title: '匪徒',
role: '敌对角色',
description: '盘踞谷口的劫匪',
backstory: '',
personality: '',
motivation: '',
combatStyle: '近战',
initialAffinity: -30,
relationshipHooks: [],
tags: ['hostile'],
backstoryReveal: {
publicSummary: '',
privateChatUnlockAffinity: 0,
chapters: [],
},
skills: [],
initialItems: [],
},
{
id: 'npc_chapter_2_hunter',
name: '林地猎手',
title: '追猎者',
role: '敌对角色',
description: '在林间追猎闯入者',
backstory: '',
personality: '',
motivation: '',
combatStyle: '远程',
initialAffinity: -24,
relationshipHooks: [],
tags: ['hostile'],
backstoryReveal: {
publicSummary: '',
privateChatUnlockAffinity: 0,
chapters: [],
},
skills: [],
initialItems: [],
},
{
id: 'npc_chapter_3_lord',
name: '祭坛领主',
title: '镇守者',
role: '敌对首领',
description: '守在祭坛深处的最终敌人',
backstory: '',
personality: '',
motivation: '',
combatStyle: '重压',
initialAffinity: -40,
relationshipHooks: [],
tags: ['hostile', 'boss'],
backstoryReveal: {
publicSummary: '',
privateChatUnlockAffinity: 0,
chapters: [],
},
skills: [],
initialItems: [],
},
],
items: [],
landmarks: [],
sceneChapterBlueprints: [
{
id: 'chapter-1',
sceneId: 'scene-1',
title: '第一章',
summary: '谷口起势',
linkedThreadIds: [],
linkedLandmarkIds: [],
acts: [
{
id: 'act-1-open',
sceneId: 'scene-1',
title: '谷口相撞',
summary: '初次冲突',
stageCoverage: ['opening'],
encounterNpcIds: ['npc_chapter_1_raider'],
primaryNpcId: 'npc_chapter_1_raider',
linkedThreadIds: [],
advanceRule: 'after_primary_contact',
actGoal: '打开局面',
transitionHook: '继续深入',
},
],
},
{
id: 'chapter-2',
sceneId: 'scene-2',
title: '第二章',
summary: '林地围猎',
linkedThreadIds: [],
linkedLandmarkIds: [],
acts: [
{
id: 'act-2-mid',
sceneId: 'scene-2',
title: '林地追击',
summary: '压力上升',
stageCoverage: ['expansion', 'turning_point'],
encounterNpcIds: ['npc_chapter_2_hunter'],
primaryNpcId: 'npc_chapter_2_hunter',
linkedThreadIds: [],
advanceRule: 'after_active_step_complete',
actGoal: '逼近真相',
transitionHook: '抵达深处',
},
],
},
{
id: 'chapter-3',
sceneId: 'scene-3',
title: '第三章',
summary: '祭坛对决',
linkedThreadIds: [],
linkedLandmarkIds: [],
acts: [
{
id: 'act-3-final',
sceneId: 'scene-3',
title: '祭坛收束',
summary: '正面收口',
stageCoverage: ['climax'],
encounterNpcIds: ['npc_chapter_3_lord'],
primaryNpcId: 'npc_chapter_3_lord',
linkedThreadIds: [],
advanceRule: 'after_chapter_resolution',
actGoal: '击败首领',
transitionHook: '收束余波',
},
],
},
],
} as unknown as CustomWorldProfile;
}
test('buildChapterProgressionPlans builds increasing chapter budgets from blueprints', () => {
const plans = buildChapterProgressionPlans(createProgressionProfile());
assert.equal(plans.length, 3);
assert.deepEqual(
plans.map((plan) => plan.chapterIndex),
[1, 2, 3],
);
assert.ok(plans[1]!.entryPseudoLevel > plans[0]!.entryPseudoLevel);
assert.ok(plans[2]!.exitPseudoLevel > plans[1]!.exitPseudoLevel);
assert.equal(
plans[0]!.questXpBudget + plans[0]!.hostileXpBudget,
plans[0]!.totalXpBudget,
);
assert.ok(plans[2]!.totalXpBudget >= plans[0]!.totalXpBudget);
assert.ok(plans[2]!.hostileXpBudget >= plans[0]!.hostileXpBudget);
});
test('resolveCurrentChapterProgressionContext follows the current act and explicit stage', () => {
const context = resolveCurrentChapterProgressionContext({
customWorldProfile: createProgressionProfile(),
sceneId: 'scene-2',
chapterState: {
id: 'chapter-2',
stage: 'turning_point',
sceneId: 'scene-2',
},
storyEngineMemory: {
currentChapter: {
id: 'chapter-2',
stage: 'turning_point',
sceneId: 'scene-2',
},
currentSceneActState: {
sceneId: 'scene-2',
chapterId: 'chapter-2',
currentActId: 'act-2-mid',
currentActIndex: 0,
},
},
});
assert.ok(context);
assert.equal(context?.plan.chapterId, 'chapter-2');
assert.equal(context?.plan.chapterIndex, 2);
assert.equal(context?.activeAct?.id, 'act-2-mid');
assert.equal(context?.stage, 'turning_point');
});

View File

@@ -0,0 +1,480 @@
import type {
CustomWorldProfile,
SceneActBlueprint,
SceneActStage,
SceneChapterBlueprint,
} from '../custom-world/runtimeTypes.js';
type JsonRecord = Record<string, unknown>;
type ChapterStateLike = {
id: string;
stage: SceneActStage;
sceneId: string | null;
};
type SceneActRuntimeStateLike = {
sceneId: string;
chapterId: string;
currentActId: string;
currentActIndex: number;
};
export type ChapterPaceBand =
| 'opening_fast'
| 'steady'
| 'pressure'
| 'finale_dense';
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: ChapterPaceBand;
}
export interface ChapterProgressionContext {
plan: ChapterProgressionPlan;
activeChapter: SceneChapterBlueprint;
activeAct: SceneActBlueprint | null;
stage: SceneActStage;
}
const DEFAULT_STAGE: SceneActStage = 'opening';
const DEFAULT_TERMINAL_STORY_LEVEL = 15;
const MIN_TERMINAL_STORY_LEVEL = 5;
const PSEUDO_LEVEL_CURVE_EXPONENT = 0.92;
function isRecord(value: unknown): value is JsonRecord {
return typeof value === 'object' && value !== null && !Array.isArray(value);
}
function readString(value: unknown) {
return typeof value === 'string' && value.trim() ? value.trim() : '';
}
function readNumber(value: unknown) {
return typeof value === 'number' && Number.isFinite(value) ? value : null;
}
function roundToNearestFive(value: number) {
return Math.round(value / 5) * 5;
}
function normalizeStage(value: unknown): SceneActStage | null {
return value === 'opening' ||
value === 'expansion' ||
value === 'turning_point' ||
value === 'climax' ||
value === 'aftermath'
? value
: null;
}
function readChapterState(value: unknown): ChapterStateLike | null {
if (!isRecord(value)) {
return null;
}
const id = readString(value.id);
const stage = normalizeStage(value.stage);
if (!id || !stage) {
return null;
}
return {
id,
stage,
sceneId: readString(value.sceneId) || null,
};
}
function readSceneActRuntimeState(value: unknown): SceneActRuntimeStateLike | null {
if (!isRecord(value)) {
return null;
}
const sceneId = readString(value.sceneId);
const chapterId = readString(value.chapterId);
const currentActId = readString(value.currentActId);
const currentActIndex = readNumber(value.currentActIndex);
if (!sceneId || !chapterId || !currentActId || currentActIndex === null) {
return null;
}
return {
sceneId,
chapterId,
currentActId,
currentActIndex: Math.max(0, Math.round(currentActIndex)),
};
}
function readStoryEngineMemoryChapter(value: unknown) {
return readChapterState(isRecord(value) ? value.currentChapter : null);
}
function readStoryEngineMemoryActState(value: unknown) {
return readSceneActRuntimeState(
isRecord(value) ? value.currentSceneActState : null,
);
}
function getChapterBlueprints(
profile: CustomWorldProfile | null | undefined,
) {
return (profile?.sceneChapterBlueprints ?? []).filter(
(entry): entry is SceneChapterBlueprint =>
Boolean(entry?.id && entry.sceneId && Array.isArray(entry.acts)),
);
}
function resolveExplicitStage(params: {
chapterState?: unknown;
storyEngineMemory?: unknown;
}) {
return (
readChapterState(params.chapterState)?.stage ??
readStoryEngineMemoryChapter(params.storyEngineMemory)?.stage ??
null
);
}
function pickActStage(act: SceneActBlueprint | null) {
if (!act) {
return null;
}
return act.stageCoverage
.map((stage) => normalizeStage(stage))
.find((stage): stage is SceneActStage => Boolean(stage)) ?? null;
}
function resolveActiveChapterBlueprint(params: {
customWorldProfile?: CustomWorldProfile | null;
sceneId?: string | null;
chapterState?: unknown;
storyEngineMemory?: unknown;
}) {
const chapters = getChapterBlueprints(params.customWorldProfile);
if (chapters.length <= 0) {
return null;
}
const runtimeActState = readStoryEngineMemoryActState(params.storyEngineMemory);
if (runtimeActState) {
const matchedByActState = chapters.find(
(chapter) =>
chapter.id === runtimeActState.chapterId &&
chapter.sceneId === runtimeActState.sceneId,
);
if (matchedByActState) {
return matchedByActState;
}
}
const requestedSceneId =
readString(params.sceneId) ||
readChapterState(params.chapterState)?.sceneId ||
readStoryEngineMemoryChapter(params.storyEngineMemory)?.sceneId ||
'';
if (requestedSceneId) {
const matchedByScene = chapters.find(
(chapter) =>
chapter.sceneId === requestedSceneId ||
chapter.linkedLandmarkIds.includes(requestedSceneId),
);
if (matchedByScene) {
return matchedByScene;
}
}
const explicitChapterId =
readChapterState(params.chapterState)?.id ||
readStoryEngineMemoryChapter(params.storyEngineMemory)?.id ||
'';
if (explicitChapterId) {
const matchedById = chapters.find((chapter) => chapter.id === explicitChapterId);
if (matchedById) {
return matchedById;
}
}
return chapters[0] ?? null;
}
function resolveActiveActBlueprint(params: {
activeChapter: SceneChapterBlueprint;
explicitStage?: SceneActStage | null;
storyEngineMemory?: unknown;
}) {
const runtimeActState = readStoryEngineMemoryActState(params.storyEngineMemory);
if (
runtimeActState &&
runtimeActState.chapterId === params.activeChapter.id &&
runtimeActState.sceneId === params.activeChapter.sceneId
) {
const matchedById = params.activeChapter.acts.find(
(act) => act.id === runtimeActState.currentActId,
);
if (matchedById) {
return matchedById;
}
const matchedByIndex = params.activeChapter.acts[runtimeActState.currentActIndex];
if (matchedByIndex) {
return matchedByIndex;
}
}
if (params.explicitStage) {
const matchedByStage = params.activeChapter.acts.find((act) =>
act.stageCoverage.includes(params.explicitStage!),
);
if (matchedByStage) {
return matchedByStage;
}
}
return params.activeChapter.acts[0] ?? null;
}
function resolveTerminalStoryLevel(totalChapters: number) {
return Math.max(
MIN_TERMINAL_STORY_LEVEL,
Math.min(
DEFAULT_TERMINAL_STORY_LEVEL,
Math.round(3 + Math.max(1, totalChapters) * 2.4),
),
);
}
function computeXpToNextLevel(level: number) {
const scale = Math.max(0, level - 1);
return 60 + 20 * scale + 8 * scale * scale;
}
function resolvePseudoLevelXp(pseudoLevel: number) {
const normalizedLevel = Math.max(1, pseudoLevel);
const lowerLevel = Math.floor(normalizedLevel);
let lowerLevelXp = 0;
for (let level = 1; level < lowerLevel; level += 1) {
lowerLevelXp += computeXpToNextLevel(level);
}
return (
lowerLevelXp +
computeXpToNextLevel(lowerLevel) * (normalizedLevel - lowerLevel)
);
}
function resolveChapterBoundaryPseudoLevel(params: {
boundaryIndex: number;
totalChapters: number;
}) {
if (params.boundaryIndex <= 0 || params.totalChapters <= 0) {
return 1;
}
const progress = Math.min(
1,
Math.max(0, params.boundaryIndex / params.totalChapters),
);
const terminalStoryLevel = resolveTerminalStoryLevel(params.totalChapters);
return (
1 +
Math.pow(progress, PSEUDO_LEVEL_CURVE_EXPONENT) *
Math.max(0, terminalStoryLevel - 1)
);
}
function resolveEncounterNpcIds(chapter: SceneChapterBlueprint) {
return [...new Set(chapter.acts.flatMap((act) => act.encounterNpcIds))];
}
function isLikelyHostileNpc(
profile: CustomWorldProfile,
npcId: string,
) {
const matchedNpc = profile.storyNpcs.find((npc) => npc.id === npcId);
if (!matchedNpc) {
return /hostile|enemy|monster|bandit|boss|elite||||/u.test(npcId);
}
if (matchedNpc.initialAffinity < 0) {
return true;
}
const fingerprint = [
matchedNpc.role,
matchedNpc.name,
matchedNpc.title,
matchedNpc.description,
...matchedNpc.tags,
].join(' ');
return /hostile|enemy|monster|bandit|boss|elite||||||/u.test(
fingerprint,
);
}
function resolveHostileShare(params: {
totalEncounterCount: number;
hostileEncounterCount: number;
}) {
if (params.hostileEncounterCount <= 0) {
return 0;
}
const hostileRatio =
params.hostileEncounterCount / Math.max(1, params.totalEncounterCount);
if (hostileRatio >= 0.55) {
return 0.45;
}
if (hostileRatio <= 0.2) {
return 0.25;
}
return 0.35;
}
function resolveChapterPaceBand(params: {
chapterIndex: number;
totalChapters: number;
hostileShare: number;
}) {
if (params.chapterIndex <= 1) {
return 'opening_fast' as const;
}
if (params.chapterIndex >= params.totalChapters) {
return 'finale_dense' as const;
}
if (params.hostileShare >= 0.45) {
return 'pressure' as const;
}
return 'steady' as const;
}
function buildChapterPlan(params: {
profile: CustomWorldProfile;
chapter: SceneChapterBlueprint;
chapterIndex: number;
totalChapters: number;
}) {
const entryPseudoLevel = resolveChapterBoundaryPseudoLevel({
boundaryIndex: params.chapterIndex - 1,
totalChapters: params.totalChapters,
});
const exitPseudoLevel = resolveChapterBoundaryPseudoLevel({
boundaryIndex: params.chapterIndex,
totalChapters: params.totalChapters,
});
const totalXpBudget = Math.max(
40,
roundToNearestFive(
resolvePseudoLevelXp(exitPseudoLevel) -
resolvePseudoLevelXp(entryPseudoLevel),
),
);
const encounterNpcIds = resolveEncounterNpcIds(params.chapter);
const hostileEncounterCount = encounterNpcIds.filter((npcId) =>
isLikelyHostileNpc(params.profile, npcId),
).length;
const hostileShare = resolveHostileShare({
totalEncounterCount: encounterNpcIds.length,
hostileEncounterCount,
});
const expectedHostileDefeatCount =
hostileEncounterCount > 0
? Math.max(hostileEncounterCount, Math.min(encounterNpcIds.length, 3))
: 0;
const hostileXpBudget =
expectedHostileDefeatCount > 0
? Math.max(5, roundToNearestFive(totalXpBudget * hostileShare))
: 0;
const questXpBudget = Math.max(0, totalXpBudget - hostileXpBudget);
return {
chapterId: params.chapter.id,
chapterIndex: params.chapterIndex,
totalChapters: params.totalChapters,
entryPseudoLevel: Number(entryPseudoLevel.toFixed(3)),
exitPseudoLevel: Number(exitPseudoLevel.toFixed(3)),
entryLevel: Math.max(1, Math.floor(entryPseudoLevel)),
exitLevel: Math.max(1, Math.round(exitPseudoLevel)),
totalXpBudget,
questXpBudget,
hostileXpBudget,
expectedHostileDefeatCount,
paceBand: resolveChapterPaceBand({
chapterIndex: params.chapterIndex,
totalChapters: params.totalChapters,
hostileShare,
}),
} satisfies ChapterProgressionPlan;
}
export function buildChapterProgressionPlans(
customWorldProfile: CustomWorldProfile | null | undefined,
) {
const chapters = getChapterBlueprints(customWorldProfile);
if (!customWorldProfile || chapters.length <= 0) {
return [];
}
return chapters.map((chapter, index) =>
buildChapterPlan({
profile: customWorldProfile,
chapter,
chapterIndex: index + 1,
totalChapters: chapters.length,
}),
);
}
export function resolveCurrentChapterProgressionContext(params: {
customWorldProfile?: CustomWorldProfile | null;
sceneId?: string | null;
chapterState?: unknown;
storyEngineMemory?: unknown;
}) {
const activeChapter = resolveActiveChapterBlueprint(params);
if (!activeChapter || !params.customWorldProfile) {
return null;
}
const plans = buildChapterProgressionPlans(params.customWorldProfile);
const plan = plans.find((entry) => entry.chapterId === activeChapter.id);
if (!plan) {
return null;
}
const explicitStage = resolveExplicitStage(params);
const activeAct = resolveActiveActBlueprint({
activeChapter,
explicitStage,
storyEngineMemory: params.storyEngineMemory,
});
return {
plan,
activeChapter,
activeAct,
stage: explicitStage ?? pickActStage(activeAct) ?? DEFAULT_STAGE,
} satisfies ChapterProgressionContext;
}

View File

@@ -0,0 +1,182 @@
import assert from 'node:assert/strict';
import test from 'node:test';
import type { CustomWorldProfile } from '../custom-world/runtimeTypes.js';
import { resolveHostileBattleProfile } from './hostileProgressionService.js';
function createAutoScaledProfile() {
return {
id: 'custom-world-auto-level',
settingText: '测试世界',
name: '测试世界',
subtitle: '自动定级',
summary: '用于 hostile 自动定级测试。',
tone: '压迫',
playerGoal: '推进终章',
templateWorldType: 'CUSTOM',
majorFactions: [],
coreConflicts: [],
attributeSchema: {
id: 'schema-1',
worldId: 'custom-world-auto-level',
schemaVersion: 1,
schemaName: '测试属性',
generatedFrom: {
worldType: 'CUSTOM',
worldName: '测试世界',
settingSummary: '测试',
tone: '压迫',
conflictCore: '推进',
},
slots: [],
},
playableNpcs: [],
storyNpcs: [
{
id: 'npc_chapter_final',
name: '祭坛领主',
title: '镇守者',
role: '敌对首领',
description: '最终守关者',
backstory: '',
personality: '',
motivation: '',
combatStyle: '重压',
initialAffinity: -40,
relationshipHooks: [],
tags: ['hostile', 'boss'],
backstoryReveal: {
publicSummary: '',
privateChatUnlockAffinity: 0,
chapters: [],
},
skills: [],
initialItems: [],
},
],
items: [],
landmarks: [],
sceneChapterBlueprints: [
{
id: 'chapter-final',
sceneId: 'scene-final',
title: '终章',
summary: '最终对决',
linkedThreadIds: [],
linkedLandmarkIds: [],
acts: [
{
id: 'act-final',
sceneId: 'scene-final',
title: '祭坛收束',
summary: '最终收口',
stageCoverage: ['climax'],
encounterNpcIds: ['npc_chapter_final'],
primaryNpcId: 'npc_chapter_final',
linkedThreadIds: [],
advanceRule: 'after_chapter_resolution',
actGoal: '击败首领',
transitionHook: '进入余波',
},
],
},
],
} as unknown as CustomWorldProfile;
}
test('resolveHostileBattleProfile falls back to the current player level for standard hostiles', () => {
const profile = resolveHostileBattleProfile({
playerProgression: {
level: 5,
currentLevelXp: 0,
totalXp: 472,
xpToNextLevel: 268,
},
encounter: {
hostile: true,
monsterPresetId: 'monster-01',
},
battleMode: 'fight',
});
assert.equal(profile.levelProfile.level, 5);
assert.equal(profile.levelProfile.progressionRole, 'hostile_standard');
assert.equal(profile.levelProfile.referenceStrength, 260);
assert.equal(profile.experienceReward, 20);
assert.equal(profile.battleMaxHp, 48);
});
test('resolveHostileBattleProfile preserves explicit level metadata and rewards', () => {
const profile = resolveHostileBattleProfile({
playerProgression: {
level: 4,
currentLevelXp: 0,
totalXp: 280,
xpToNextLevel: 192,
},
encounter: {
hostile: true,
levelProfile: {
level: 7,
referenceStrength: 412,
progressionRole: 'hostile_elite',
source: 'chapter_auto',
chapterId: 'chapter-03',
},
experienceReward: 55,
},
battleMode: 'fight',
});
assert.equal(profile.levelProfile.level, 7);
assert.equal(profile.levelProfile.referenceStrength, 412);
assert.equal(profile.levelProfile.progressionRole, 'hostile_elite');
assert.equal(profile.levelProfile.source, 'chapter_auto');
assert.equal(profile.levelProfile.chapterId, 'chapter-03');
assert.equal(profile.experienceReward, 55);
assert.equal(profile.battleMaxHp, 86);
});
test('resolveHostileBattleProfile prefers chapter auto scaling over player fallback when chapter context exists', () => {
const profile = resolveHostileBattleProfile({
playerProgression: {
level: 2,
currentLevelXp: 0,
totalXp: 80,
xpToNextLevel: 88,
},
encounter: {
id: 'npc_chapter_final',
hostile: true,
monsterPresetId: 'final-lord',
},
battleMode: 'fight',
customWorldProfile: createAutoScaledProfile(),
sceneId: 'scene-final',
chapterState: {
id: 'chapter-final',
stage: 'climax',
sceneId: 'scene-final',
},
storyEngineMemory: {
currentChapter: {
id: 'chapter-final',
stage: 'climax',
sceneId: 'scene-final',
},
currentSceneActState: {
sceneId: 'scene-final',
chapterId: 'chapter-final',
currentActId: 'act-final',
currentActIndex: 0,
},
},
});
assert.equal(profile.levelProfile.source, 'chapter_auto');
assert.equal(profile.levelProfile.chapterId, 'chapter-final');
assert.equal(profile.levelProfile.chapterIndex, 1);
assert.equal(profile.levelProfile.progressionRole, 'hostile_boss');
assert.ok(profile.levelProfile.level > 2);
assert.ok(profile.experienceReward > 0);
});

View File

@@ -0,0 +1,353 @@
import { getLevelBenchmark, MAX_PLAYER_LEVEL } from './levelBenchmarks.js';
import { normalizePlayerProgressionState } from './playerProgressionService.js';
import type { CustomWorldProfile, SceneActStage } from '../custom-world/runtimeTypes.js';
import {
resolveCurrentChapterProgressionContext,
type ChapterProgressionContext,
} from './chapterProgressionPlanner.js';
import { resolveChapterAutoLevelProfile } from './npcLevelResolver.js';
type JsonRecord = Record<string, unknown>;
export type ProgressionRole =
| 'guide'
| 'ambient'
| 'support'
| 'hostile_standard'
| 'hostile_elite'
| 'hostile_boss'
| 'rival';
export interface RuntimeEntityLevelProfile {
level: number;
referenceStrength: number;
chapterId?: string | null;
chapterIndex?: number | null;
progressionRole: ProgressionRole;
source: 'chapter_auto' | 'preset_override' | 'manual';
}
export interface RuntimeHostileEncounterSeed {
id?: string | null;
hostile?: boolean;
monsterPresetId?: string | null;
levelProfile?: unknown;
experienceReward?: unknown;
}
export interface ResolvedHostileBattleProfile {
levelProfile: RuntimeEntityLevelProfile;
experienceReward: number;
battleMaxHp: number;
}
const ROLE_HP_BONUS: Record<ProgressionRole, number> = {
guide: 0,
ambient: 0,
support: 0,
hostile_standard: 0,
hostile_elite: 10,
hostile_boss: 24,
rival: 6,
};
const ROLE_XP_MULTIPLIER: Record<ProgressionRole, number> = {
guide: 0,
ambient: 0,
support: 0,
hostile_standard: 1,
hostile_elite: 1.15,
hostile_boss: 1.3,
rival: 1,
};
function isRecord(value: unknown): value is JsonRecord {
return typeof value === 'object' && value !== null && !Array.isArray(value);
}
function readNumber(value: unknown) {
return typeof value === 'number' && Number.isFinite(value) ? value : null;
}
function readString(value: unknown) {
return typeof value === 'string' && value.trim() ? value.trim() : '';
}
function clampLevel(value: unknown) {
const parsed =
typeof value === 'number' && Number.isFinite(value) ? Math.floor(value) : 1;
return Math.min(MAX_PLAYER_LEVEL, Math.max(1, parsed));
}
function clampNonNegativeInteger(value: unknown) {
const parsed =
typeof value === 'number' && Number.isFinite(value) ? Math.floor(value) : 0;
return Math.max(0, parsed);
}
function roundToNearestFive(value: number) {
return Math.round(value / 5) * 5;
}
function normalizeProgressionRole(
value: unknown,
fallback: ProgressionRole,
): ProgressionRole {
return value === 'guide' ||
value === 'ambient' ||
value === 'support' ||
value === 'hostile_standard' ||
value === 'hostile_elite' ||
value === 'hostile_boss' ||
value === 'rival'
? value
: fallback;
}
function normalizeLevelProfileSource(
value: unknown,
fallback: RuntimeEntityLevelProfile['source'],
) {
return value === 'chapter_auto' ||
value === 'preset_override' ||
value === 'manual'
? value
: fallback;
}
function resolveDefaultRole(params: {
encounter?: RuntimeHostileEncounterSeed | null;
battleMode: 'fight' | 'spar';
}): ProgressionRole {
if (params.battleMode === 'spar') {
return 'rival';
}
if (
params.encounter?.hostile === true ||
readString(params.encounter?.monsterPresetId).length > 0
) {
return 'hostile_standard';
}
return 'rival';
}
function resolveLevelDeltaMultiplier(playerLevel: number, targetLevel: number) {
const delta = targetLevel - playerLevel;
if (delta <= -4) {
return 0.3;
}
if (delta <= -2) {
return 0.7;
}
if (delta >= 2) {
return 1.15;
}
return 1;
}
function resolveChapterStageMultiplier(stage: SceneActStage | null | undefined) {
switch (stage) {
case 'opening':
return 0.9;
case 'turning_point':
return 1.05;
case 'climax':
return 1.15;
case 'aftermath':
return 0.8;
case 'expansion':
default:
return 1;
}
}
function resolveCustomWorldProfile(value: unknown) {
return isRecord(value) ? (value as CustomWorldProfile) : null;
}
function resolveChapterBudgetedBaseXp(
context: ChapterProgressionContext | null,
) {
if (!context || context.plan.expectedHostileDefeatCount <= 0) {
return null;
}
return (
context.plan.hostileXpBudget / context.plan.expectedHostileDefeatCount
);
}
export function normalizeRuntimeEntityLevelProfile(
value: unknown,
fallbackRole: ProgressionRole = 'hostile_standard',
): RuntimeEntityLevelProfile | null {
if (!isRecord(value)) {
return null;
}
const levelMetric = readNumber(value.level);
if (levelMetric === null) {
return null;
}
const level = clampLevel(levelMetric);
const benchmark = getLevelBenchmark(level);
const referenceStrength = readNumber(value.referenceStrength);
return {
level,
referenceStrength:
referenceStrength !== null && referenceStrength > 0
? Math.round(referenceStrength)
: benchmark.referenceStrength,
chapterId: readString(value.chapterId) || null,
chapterIndex:
typeof value.chapterIndex === 'number' &&
Number.isFinite(value.chapterIndex)
? Math.max(0, Math.round(value.chapterIndex))
: null,
progressionRole: normalizeProgressionRole(
value.progressionRole,
fallbackRole,
),
source: normalizeLevelProfileSource(value.source, 'manual'),
};
}
export function buildHostileExperienceReward(params: {
explicitExperienceReward?: unknown;
levelProfile: RuntimeEntityLevelProfile;
playerProgression?: unknown;
battleMode: 'fight' | 'spar';
chapterStage?: SceneActStage | null;
budgetedBaseXp?: number | null;
}) {
if (params.battleMode === 'spar') {
return 0;
}
const explicitReward = clampNonNegativeInteger(
params.explicitExperienceReward,
);
if (explicitReward > 0) {
return explicitReward;
}
const playerLevel = normalizePlayerProgressionState(
params.playerProgression,
).level;
const benchmark = getLevelBenchmark(params.levelProfile.level);
const baseKillXp =
typeof params.budgetedBaseXp === 'number' &&
Number.isFinite(params.budgetedBaseXp) &&
params.budgetedBaseXp > 0
? params.budgetedBaseXp
: benchmark.xpToNextLevel * 0.08;
const scaledReward =
baseKillXp *
resolveChapterStageMultiplier(params.chapterStage) *
resolveLevelDeltaMultiplier(playerLevel, params.levelProfile.level) *
ROLE_XP_MULTIPLIER[params.levelProfile.progressionRole];
return Math.max(5, roundToNearestFive(scaledReward));
}
export function buildHostileBattleMaxHp(params: {
levelProfile: RuntimeEntityLevelProfile;
battleMode: 'fight' | 'spar';
}) {
if (params.battleMode === 'spar') {
return Math.max(
8,
Math.min(16, 8 + Math.floor((params.levelProfile.level - 1) / 2)),
);
}
const benchmark = getLevelBenchmark(params.levelProfile.level);
return Math.max(
32,
Math.round(benchmark.baseHp / 9) +
ROLE_HP_BONUS[params.levelProfile.progressionRole],
);
}
export function resolveHostileBattleProfile(params: {
playerProgression?: unknown;
encounter?: RuntimeHostileEncounterSeed | null;
battleMode: 'fight' | 'spar';
customWorldProfile?: unknown;
sceneId?: string | null;
chapterState?: unknown;
storyEngineMemory?: unknown;
}): ResolvedHostileBattleProfile {
const fallbackRole = resolveDefaultRole({
encounter: params.encounter,
battleMode: params.battleMode,
});
const normalizedPlayerProgression = normalizePlayerProgressionState(
params.playerProgression,
);
const explicitLevelProfile = normalizeRuntimeEntityLevelProfile(
params.encounter?.levelProfile,
fallbackRole,
);
const chapterContext =
explicitLevelProfile?.source === 'chapter_auto'
? null
: resolveCurrentChapterProgressionContext({
customWorldProfile: resolveCustomWorldProfile(
params.customWorldProfile,
),
sceneId: params.sceneId,
chapterState: params.chapterState,
storyEngineMemory: params.storyEngineMemory,
});
const chapterAutoLevelProfile =
explicitLevelProfile || !chapterContext
? null
: resolveChapterAutoLevelProfile({
plan: chapterContext.plan,
stage: chapterContext.stage,
encounter: params.encounter,
battleMode: params.battleMode,
primaryNpcId: chapterContext.activeAct?.primaryNpcId ?? null,
});
const level =
explicitLevelProfile?.level ??
chapterAutoLevelProfile?.level ??
clampLevel(normalizedPlayerProgression.level);
const benchmark = getLevelBenchmark(level);
const levelProfile =
explicitLevelProfile ??
chapterAutoLevelProfile ??
({
level,
referenceStrength: benchmark.referenceStrength,
chapterId: null,
chapterIndex: null,
progressionRole: fallbackRole,
source: 'manual',
} satisfies RuntimeEntityLevelProfile);
return {
levelProfile,
experienceReward: buildHostileExperienceReward({
explicitExperienceReward: params.encounter?.experienceReward,
levelProfile,
playerProgression: normalizedPlayerProgression,
battleMode: params.battleMode,
chapterStage: chapterContext?.stage ?? null,
budgetedBaseXp: resolveChapterBudgetedBaseXp(chapterContext),
}),
battleMaxHp: buildHostileBattleMaxHp({
levelProfile,
battleMode: params.battleMode,
}),
};
}

View File

@@ -0,0 +1,82 @@
import assert from 'node:assert/strict';
import test from 'node:test';
import type { ChapterProgressionPlan } from './chapterProgressionPlanner.js';
import {
resolveAutoProgressionRole,
resolveChapterAutoLevelProfile,
} from './npcLevelResolver.js';
const TEST_PLAN: ChapterProgressionPlan = {
chapterId: 'chapter-3',
chapterIndex: 3,
totalChapters: 4,
entryPseudoLevel: 6.2,
exitPseudoLevel: 8.8,
entryLevel: 6,
exitLevel: 9,
totalXpBudget: 560,
questXpBudget: 360,
hostileXpBudget: 200,
expectedHostileDefeatCount: 3,
paceBand: 'pressure',
};
test('resolveAutoProgressionRole upgrades current act hostile primary npc to boss in climax', () => {
assert.equal(
resolveAutoProgressionRole({
encounter: {
id: 'npc_final_lord',
hostile: true,
monsterPresetId: 'final-lord',
},
battleMode: 'fight',
stage: 'climax',
primaryNpcId: 'npc_final_lord',
}),
'hostile_boss',
);
assert.equal(
resolveAutoProgressionRole({
encounter: {
id: 'npc_final_lord',
},
battleMode: 'spar',
stage: 'climax',
primaryNpcId: 'npc_final_lord',
}),
'rival',
);
});
test('resolveChapterAutoLevelProfile applies role offsets on top of chapter stage anchor', () => {
const standard = resolveChapterAutoLevelProfile({
plan: TEST_PLAN,
stage: 'climax',
encounter: {
id: 'npc_guard_01',
hostile: true,
monsterPresetId: 'guard',
},
battleMode: 'fight',
primaryNpcId: 'npc_final_lord',
});
const boss = resolveChapterAutoLevelProfile({
plan: TEST_PLAN,
stage: 'climax',
encounter: {
id: 'npc_final_lord',
hostile: true,
monsterPresetId: 'final-lord',
},
battleMode: 'fight',
primaryNpcId: 'npc_final_lord',
});
assert.equal(standard.progressionRole, 'hostile_standard');
assert.equal(boss.progressionRole, 'hostile_boss');
assert.ok(boss.level >= standard.level + 2);
assert.equal(boss.chapterId, 'chapter-3');
assert.equal(boss.chapterIndex, 3);
assert.equal(boss.source, 'chapter_auto');
});

View File

@@ -0,0 +1,106 @@
import type { SceneActStage } from '../custom-world/runtimeTypes.js';
import { getLevelBenchmark, MAX_PLAYER_LEVEL } from './levelBenchmarks.js';
import type { ChapterProgressionPlan } from './chapterProgressionPlanner.js';
import type {
ProgressionRole,
RuntimeEntityLevelProfile,
RuntimeHostileEncounterSeed,
} from './hostileProgressionService.js';
const ROLE_LEVEL_OFFSETS: Record<ProgressionRole, number> = {
guide: 0,
ambient: -1,
support: 0,
hostile_standard: 0,
hostile_elite: 1,
hostile_boss: 2,
rival: 0,
};
function clampLevel(value: number) {
return Math.min(MAX_PLAYER_LEVEL, Math.max(1, Math.round(value)));
}
function interpolate(min: number, max: number, progress: number) {
return min + (max - min) * progress;
}
function resolveStageProgress(stage: SceneActStage) {
switch (stage) {
case 'opening':
return 0;
case 'expansion':
return 0.4;
case 'turning_point':
return 0.72;
case 'climax':
return 1;
case 'aftermath':
return 0.82;
default:
return 0;
}
}
function readString(value: unknown) {
return typeof value === 'string' && value.trim() ? value.trim() : '';
}
export function resolveAutoProgressionRole(params: {
encounter?: RuntimeHostileEncounterSeed | null;
battleMode: 'fight' | 'spar';
stage: SceneActStage;
primaryNpcId?: string | null;
}): ProgressionRole {
if (params.battleMode === 'spar') {
return 'rival';
}
const encounterId = readString(params.encounter?.id);
const primaryNpcId = readString(params.primaryNpcId);
const isHostile =
params.encounter?.hostile === true ||
readString(params.encounter?.monsterPresetId).length > 0;
if (!isHostile) {
return primaryNpcId && encounterId === primaryNpcId ? 'rival' : 'support';
}
if (primaryNpcId && encounterId === primaryNpcId) {
return params.stage === 'climax' ? 'hostile_boss' : 'hostile_elite';
}
return 'hostile_standard';
}
export function resolveChapterAutoLevelProfile(params: {
plan: ChapterProgressionPlan;
stage: SceneActStage;
encounter?: RuntimeHostileEncounterSeed | null;
battleMode: 'fight' | 'spar';
primaryNpcId?: string | null;
}): RuntimeEntityLevelProfile {
const progressionRole = resolveAutoProgressionRole({
encounter: params.encounter,
battleMode: params.battleMode,
stage: params.stage,
primaryNpcId: params.primaryNpcId,
});
const baseStageLevel = interpolate(
params.plan.entryPseudoLevel,
params.plan.exitPseudoLevel,
resolveStageProgress(params.stage),
);
const level = clampLevel(
baseStageLevel + ROLE_LEVEL_OFFSETS[progressionRole],
);
return {
level,
referenceStrength: getLevelBenchmark(level).referenceStrength,
chapterId: params.plan.chapterId,
chapterIndex: params.plan.chapterIndex,
progressionRole,
source: 'chapter_auto',
};
}

View File

@@ -8,6 +8,10 @@ import type {
} from '../../../../packages/shared/src/contracts/story.js';
import { TASK6_RUNTIME_FUNCTION_IDS } from '../../../../packages/shared/src/contracts/story.js';
import type { SavedSnapshot } from '../../repositories/runtimeRepository.js';
import {
normalizeRuntimeEntityLevelProfile,
type RuntimeEntityLevelProfile,
} from '../progression/hostileProgressionService.js';
import { resolvePlayerOutgoingDamageResult } from '../runtime/runtimeBuildModule.js';
import {
isInventoryItemUsable,
@@ -53,6 +57,8 @@ export type RuntimeEncounter = {
hostile: boolean;
characterId: string | null;
monsterPresetId: string | null;
levelProfile?: RuntimeEntityLevelProfile;
experienceReward?: number;
};
export type RuntimeHostileNpc = {
@@ -61,6 +67,8 @@ export type RuntimeHostileNpc = {
hp: number;
maxHp: number;
description: string;
levelProfile?: RuntimeEntityLevelProfile;
experienceReward?: number;
};
export type RuntimeCompanion = {
@@ -371,6 +379,10 @@ function readArray(value: unknown) {
return Array.isArray(value) ? value : [];
}
function clampNonNegativeInteger(value: unknown) {
return Math.max(0, Math.round(readNumber(value, 0)));
}
function normalizeStoryHistory(value: unknown) {
return readArray(value)
.map((entry) => {
@@ -444,6 +456,10 @@ function normalizeEncounter(value: unknown): RuntimeEncounter | null {
Boolean(readString(rawEncounter.monsterPresetId)),
characterId: readString(rawEncounter.characterId) || null,
monsterPresetId: readString(rawEncounter.monsterPresetId) || null,
levelProfile:
normalizeRuntimeEntityLevelProfile(rawEncounter.levelProfile, 'rival') ??
undefined,
experienceReward: clampNonNegativeInteger(rawEncounter.experienceReward),
};
}
@@ -471,6 +487,12 @@ function normalizeHostileNpc(value: unknown): RuntimeHostileNpc | null {
hp,
maxHp,
description: readString(rawNpc.description),
levelProfile:
normalizeRuntimeEntityLevelProfile(
rawNpc.levelProfile,
'hostile_standard',
) ?? undefined,
experienceReward: clampNonNegativeInteger(rawNpc.experienceReward),
};
}

View File

@@ -339,6 +339,153 @@ const QUEST_TREASURE_SCENE = {
treasureHints: ['残匣', '旧印'],
};
function createChapterAutoScalingProfile() {
return {
id: 'custom-world-auto-scaling',
settingText: '测试世界',
name: '测试世界',
subtitle: '章节自动定级',
summary: '用于 runtime 章节定级测试。',
tone: '压迫',
playerGoal: '推进章节',
templateWorldType: 'CUSTOM',
majorFactions: [],
coreConflicts: [],
attributeSchema: {
id: 'schema-1',
worldId: 'custom-world-auto-scaling',
schemaVersion: 1,
schemaName: '测试属性',
generatedFrom: {
worldType: 'CUSTOM',
worldName: '测试世界',
settingSummary: '测试',
tone: '压迫',
conflictCore: '推进',
},
slots: [],
},
playableNpcs: [],
storyNpcs: [
{
id: 'npc_outskirts_raider',
name: '谷口匪徒',
title: '匪徒',
role: '敌对角色',
description: '卡在谷口要道上的拦路人',
backstory: '',
personality: '',
motivation: '',
combatStyle: '近战',
initialAffinity: -20,
relationshipHooks: [],
tags: ['hostile'],
backstoryReveal: {
publicSummary: '',
privateChatUnlockAffinity: 0,
chapters: [],
},
skills: [],
initialItems: [],
},
{
id: 'npc_sanctum_lord',
name: '祭坛领主',
title: '镇守者',
role: '敌对首领',
description: '守在终章祭坛里的重压敌人',
backstory: '',
personality: '',
motivation: '',
combatStyle: '重击',
initialAffinity: -40,
relationshipHooks: [],
tags: ['hostile', 'boss'],
backstoryReveal: {
publicSummary: '',
privateChatUnlockAffinity: 0,
chapters: [],
},
skills: [],
initialItems: [],
},
],
items: [],
landmarks: [],
sceneChapterBlueprints: [
{
id: 'chapter-outskirts',
sceneId: 'scene-outskirts',
title: '谷口起势',
summary: '初段冲突',
linkedThreadIds: [],
linkedLandmarkIds: [],
acts: [
{
id: 'act-outskirts-open',
sceneId: 'scene-outskirts',
title: '谷口相撞',
summary: '第一轮冲突',
stageCoverage: ['opening'],
encounterNpcIds: ['npc_outskirts_raider'],
primaryNpcId: 'npc_outskirts_raider',
linkedThreadIds: [],
advanceRule: 'after_primary_contact',
actGoal: '稳住开局',
transitionHook: '继续深入',
},
],
},
{
id: 'chapter-forest',
sceneId: 'scene-forest',
title: '林地紧逼',
summary: '中段过渡',
linkedThreadIds: [],
linkedLandmarkIds: [],
acts: [
{
id: 'act-forest-mid',
sceneId: 'scene-forest',
title: '林地追逼',
summary: '第二轮压迫',
stageCoverage: ['expansion', 'turning_point'],
encounterNpcIds: ['npc_outskirts_raider'],
primaryNpcId: 'npc_outskirts_raider',
linkedThreadIds: [],
advanceRule: 'after_active_step_complete',
actGoal: '逼近深处',
transitionHook: '抵达祭坛',
},
],
},
{
id: 'chapter-sanctum',
sceneId: 'scene-sanctum',
title: '祭坛收束',
summary: '终章对决',
linkedThreadIds: [],
linkedLandmarkIds: [],
acts: [
{
id: 'act-sanctum-final',
sceneId: 'scene-sanctum',
title: '终章收口',
summary: '最终对决',
stageCoverage: ['climax'],
encounterNpcIds: ['npc_sanctum_lord'],
primaryNpcId: 'npc_sanctum_lord',
linkedThreadIds: [],
advanceRule: 'after_chapter_resolution',
actGoal: '击败首领',
transitionHook: '余波展开',
},
],
},
],
};
}
test('runtime story actions resolve npc chat on the server and persist updated affinity', async () => {
await withTestServer('npc-chat', async ({ baseUrl }) => {
const entry = await authEntry(baseUrl, 'story_npc_chat', 'secret123');
@@ -541,6 +688,369 @@ test('runtime story state exposes npc interaction metadata directly from the ser
});
});
test('runtime story actions attach hostile level metadata when npc fights start on the server', async () => {
await withTestServer('npc-fight-level-profile', async ({ baseUrl }) => {
const entry = await authEntry(
baseUrl,
'story_npc_fight_level',
'secret123',
);
await putSnapshot(
baseUrl,
entry.token,
createTask6GameState({
currentEncounter: {
kind: 'npc',
id: 'npc_duelist_01',
npcName: '拦路刀客',
npcDescription: '持刀拦路的江湖客',
context: '渡口挑衅',
hostile: true,
},
npcInteractionActive: true,
playerProgression: {
level: 4,
currentLevelXp: 0,
totalXp: 280,
xpToNextLevel: 192,
},
npcStates: {
npc_duelist_01: {
affinity: -18,
chattedCount: 0,
helpUsed: false,
giftsGiven: 0,
inventory: [],
recruited: false,
},
},
}),
);
const response = await httpRequest(
`${baseUrl}/api/runtime/story/actions/resolve`,
withBearer(entry.token, {
method: 'POST',
body: JSON.stringify({
sessionId: 'runtime-main',
clientVersion: 0,
action: {
type: 'story_choice',
functionId: 'npc_fight',
},
}),
}),
);
const payload = (await response.json()) as {
snapshot: {
gameState: {
currentEncounter: {
levelProfile?: {
level: number;
progressionRole: string;
};
experienceReward?: number;
} | null;
sceneHostileNpcs: Array<{
maxHp: number;
levelProfile?: {
level: number;
referenceStrength: number;
progressionRole: string;
};
experienceReward?: number;
}>;
currentNpcBattleMode: string | null;
inBattle: boolean;
};
};
};
assert.equal(response.status, 200);
assert.equal(payload.snapshot.gameState.inBattle, true);
assert.equal(payload.snapshot.gameState.currentNpcBattleMode, 'fight');
assert.equal(
payload.snapshot.gameState.currentEncounter?.levelProfile?.level,
4,
);
assert.equal(
payload.snapshot.gameState.currentEncounter?.experienceReward,
15,
);
assert.equal(
payload.snapshot.gameState.sceneHostileNpcs[0]?.levelProfile?.level,
4,
);
assert.equal(
payload.snapshot.gameState.sceneHostileNpcs[0]?.levelProfile
?.progressionRole,
'hostile_standard',
);
assert.ok(
(payload.snapshot.gameState.sceneHostileNpcs[0]?.levelProfile
?.referenceStrength ?? 0) > 0,
);
assert.equal(
payload.snapshot.gameState.sceneHostileNpcs[0]?.experienceReward,
15,
);
assert.ok(
(payload.snapshot.gameState.sceneHostileNpcs[0]?.maxHp ?? 0) >= 32,
);
});
});
test('runtime story actions auto-scale hostile levels across chapters instead of only following player level', async () => {
await withTestServer('npc-fight-chapter-auto-scaling', async ({ baseUrl }) => {
const entry = await authEntry(
baseUrl,
'story_nf_auto',
'secret123',
);
const profile = createChapterAutoScalingProfile();
await putSnapshot(
baseUrl,
entry.token,
createTask6GameState({
customWorldProfile: profile,
currentScenePreset: {
id: 'scene-outskirts',
name: '谷口',
description: '路口被劫匪堵住。',
npcs: [],
treasureHints: [],
},
currentEncounter: {
kind: 'npc',
id: 'npc_outskirts_raider',
npcName: '谷口匪徒',
npcDescription: '盘踞谷口的拦路人',
context: '谷口拦截',
hostile: true,
monsterPresetId: 'outskirts-raider',
},
npcInteractionActive: true,
playerProgression: {
level: 2,
currentLevelXp: 0,
totalXp: 80,
xpToNextLevel: 88,
},
chapterState: {
id: 'chapter-outskirts',
title: '谷口起势',
theme: '开局冲突',
primaryThreadIds: [],
stage: 'opening',
chapterSummary: '第一章刚刚开始。',
sceneId: 'scene-outskirts',
chapterQuestId: null,
},
storyEngineMemory: {
currentChapter: {
id: 'chapter-outskirts',
title: '谷口起势',
theme: '开局冲突',
primaryThreadIds: [],
stage: 'opening',
chapterSummary: '第一章刚刚开始。',
sceneId: 'scene-outskirts',
chapterQuestId: null,
},
currentSceneActState: {
sceneId: 'scene-outskirts',
chapterId: 'chapter-outskirts',
currentActId: 'act-outskirts-open',
currentActIndex: 0,
completedActIds: [],
visitedActIds: ['act-outskirts-open'],
},
},
npcStates: {
npc_outskirts_raider: {
affinity: -20,
chattedCount: 0,
helpUsed: false,
giftsGiven: 0,
inventory: [],
recruited: false,
},
},
}),
);
const openingResponse = await httpRequest(
`${baseUrl}/api/runtime/story/actions/resolve`,
withBearer(entry.token, {
method: 'POST',
body: JSON.stringify({
sessionId: 'runtime-main',
clientVersion: 0,
action: {
type: 'story_choice',
functionId: 'npc_fight',
},
}),
}),
);
const openingPayload = (await openingResponse.json()) as {
snapshot: {
gameState: {
currentEncounter: {
levelProfile?: {
level: number;
source: string;
progressionRole: string;
};
experienceReward?: number;
} | null;
};
};
};
assert.equal(openingResponse.status, 200);
const openingLevel =
openingPayload.snapshot.gameState.currentEncounter?.levelProfile?.level ?? 0;
const openingXp =
openingPayload.snapshot.gameState.currentEncounter?.experienceReward ?? 0;
assert.equal(
openingPayload.snapshot.gameState.currentEncounter?.levelProfile?.source,
'chapter_auto',
);
assert.equal(
openingPayload.snapshot.gameState.currentEncounter?.levelProfile
?.progressionRole,
'hostile_elite',
);
await putSnapshot(
baseUrl,
entry.token,
createTask6GameState({
customWorldProfile: profile,
currentScenePreset: {
id: 'scene-sanctum',
name: '祭坛',
description: '终章祭坛已经压到面前。',
npcs: [],
treasureHints: [],
},
currentEncounter: {
kind: 'npc',
id: 'npc_sanctum_lord',
npcName: '祭坛领主',
npcDescription: '镇守终章祭坛的首领',
context: '祭坛正面压制',
hostile: true,
monsterPresetId: 'sanctum-lord',
},
npcInteractionActive: true,
playerProgression: {
level: 2,
currentLevelXp: 0,
totalXp: 80,
xpToNextLevel: 88,
},
chapterState: {
id: 'chapter-sanctum',
title: '祭坛收束',
theme: '最终对决',
primaryThreadIds: [],
stage: 'climax',
chapterSummary: '终章已经进入最后收口。',
sceneId: 'scene-sanctum',
chapterQuestId: null,
},
storyEngineMemory: {
currentChapter: {
id: 'chapter-sanctum',
title: '祭坛收束',
theme: '最终对决',
primaryThreadIds: [],
stage: 'climax',
chapterSummary: '终章已经进入最后收口。',
sceneId: 'scene-sanctum',
chapterQuestId: null,
},
currentSceneActState: {
sceneId: 'scene-sanctum',
chapterId: 'chapter-sanctum',
currentActId: 'act-sanctum-final',
currentActIndex: 0,
completedActIds: [],
visitedActIds: ['act-sanctum-final'],
},
},
npcStates: {
npc_sanctum_lord: {
affinity: -32,
chattedCount: 0,
helpUsed: false,
giftsGiven: 0,
inventory: [],
recruited: false,
},
},
}),
);
const climaxResponse = await httpRequest(
`${baseUrl}/api/runtime/story/actions/resolve`,
withBearer(entry.token, {
method: 'POST',
body: JSON.stringify({
sessionId: 'runtime-main',
clientVersion: 0,
action: {
type: 'story_choice',
functionId: 'npc_fight',
},
}),
}),
);
const climaxPayload = (await climaxResponse.json()) as {
snapshot: {
gameState: {
currentEncounter: {
levelProfile?: {
level: number;
source: string;
progressionRole: string;
chapterIndex?: number;
};
experienceReward?: number;
} | null;
};
};
};
assert.equal(climaxResponse.status, 200);
const climaxLevel =
climaxPayload.snapshot.gameState.currentEncounter?.levelProfile?.level ?? 0;
const climaxXp =
climaxPayload.snapshot.gameState.currentEncounter?.experienceReward ?? 0;
assert.equal(
climaxPayload.snapshot.gameState.currentEncounter?.levelProfile?.source,
'chapter_auto',
);
assert.equal(
climaxPayload.snapshot.gameState.currentEncounter?.levelProfile
?.progressionRole,
'hostile_boss',
);
assert.equal(
climaxPayload.snapshot.gameState.currentEncounter?.levelProfile
?.chapterIndex,
3,
);
assert.ok(climaxLevel > openingLevel);
assert.ok(climaxLevel > 2);
assert.ok(climaxXp > openingXp);
});
});
test('runtime story actions resolve combat finishers on the server and collapse the battle state', async () => {
await withTestServer('combat-finisher', async ({ baseUrl }) => {
const entry = await authEntry(
@@ -623,6 +1133,19 @@ test('runtime story actions resolve combat finishers on the server and collapse
damageDealt: number;
} | null;
};
snapshot: {
gameState: {
currentNpcBattleOutcome: string | null;
playerProgression: {
level: number;
totalXp: number;
lastGrantedSource: string | null;
};
runtimeStats: {
hostileNpcsDefeated: number;
};
};
};
};
assert.equal(response.status, 200);
@@ -634,6 +1157,15 @@ test('runtime story actions resolve combat finishers on the server and collapse
);
assert.equal(payload.presentation.battle?.outcome, 'victory');
assert.ok((payload.presentation.battle?.damageDealt ?? 0) >= 12);
assert.ok(payload.snapshot.gameState.playerProgression.totalXp > 0);
assert.equal(
payload.snapshot.gameState.playerProgression.lastGrantedSource,
'hostile_npc',
);
assert.equal(
payload.snapshot.gameState.runtimeStats.hostileNpcsDefeated,
1,
);
assert.ok(
payload.viewModel.availableOptions.some(
(option) => option.functionId === 'idle_observe_signs',

View File

@@ -4,6 +4,25 @@ import {
getActionTemplateById,
} from '../../../packages/shared/src/prompts/qwenSprite.js';
/**
* 角色资产正式 prompt 主源。
*
* 这份脚本同时承担两层职责:
* 1. 角色卡 -> 默认资产描述文本
* - 产出 visualPromptText / animationPromptText / scenePromptText
* - 这层本质上是在“编译默认描述文本”,不是最终直接发给图像模型的完整 prompt
* 2. 默认描述文本 -> 正式模型 prompt
* - buildNpcVisualPrompt / buildNpcAnimationPrompt / buildArkCharacterAnimationPrompt
* - 这层才是正式发给图像 / 动作模型的 prompt 组装入口
*
* 当前仓库状态需要特别区分:
* - 当前自定义世界角色资产工坊默认输入框,实际直接使用前端
* src/prompts/customWorldRolePromptDefaults.ts
* - 本文件里的 CHARACTER_PROMPT_BUNDLE_SYSTEM_PROMPT 及其生成接口
* /api/assets/character-prompts/generate 目前仍保留、可用、且有测试覆盖,
* 但不是当前资产工坊初始默认值的主链来源
* - 当前正式角色主图与动作生成,仍然走本文件里的正式 prompt builder
*/
function clampPromptSeedText(value: unknown, maxLength: number) {
if (typeof value !== 'string') {
return '';
@@ -39,6 +58,17 @@ export type CharacterPromptBundle = {
model: string | null;
};
/**
* 当默认描述文本编译接口不可用,或当前环境不走 LLM 编译时,
* 用角色卡字段本地拼出一份可直接使用的默认文本 bundle。
*
* 这份返回值属于“默认描述文本层”:
* - visualPromptText: 给角色主图用的默认描述
* - animationPromptText: 给动作试片用的默认描述
* - scenePromptText: 给角色关联场景用的默认描述
*
* 它不是最终发给正式图像 / 动作模型的完整 prompt。
*/
export function buildFallbackCharacterPromptBundle(params: {
characterName: string;
roleKind: string;
@@ -108,6 +138,12 @@ function sanitizePromptBundleValue(
return normalized || fallback;
}
/**
* 将 LLM 返回的默认文本 bundle 规整成稳定结构。
*
* 这里只负责兜底、限长和字段补齐,不负责把 bundle 进一步编译成
* 正式图像 / 动作生成 prompt。
*/
export function sanitizeCharacterPromptBundle(
value: unknown,
fallback: CharacterPromptBundle,
@@ -161,6 +197,13 @@ function buildCompactAnimationCharacterBrief(value: string) {
.join('');
}
/**
* 默认文本 bundle 的 user prompt。
*
* 这段文本只用于让 LLM 从角色卡摘要里提炼出
* visualPromptText / animationPromptText / scenePromptText 三段默认描述,
* 不是正式图像模型或动作模型的 system prompt。
*/
export function buildCharacterPromptBundleUserPrompt(params: {
roleKind: string;
characterBriefText: string;
@@ -197,6 +240,17 @@ export function buildCharacterPromptBundleUserPrompt(params: {
.join('\n');
}
/**
* 正式角色主图 prompt 编译入口。
*
* 输入的 promptText 通常是资产工坊输入框里的“形象描述”文本;
* 这里会把它和角色摘要合并,再交给共享层 buildMasterPrompt
* 产出真正发给图像模型的正式 prompt。
*
* 因此:
* - promptText = 默认描述文本层
* - buildNpcVisualPrompt 返回值 = 正式图像生成 prompt 层
*/
export function buildNpcVisualPrompt(promptText: string, characterBriefText = '') {
const mergedBrief = [characterBriefText.trim(), promptText.trim()]
.filter(Boolean)
@@ -207,6 +261,11 @@ export function buildNpcVisualPrompt(promptText: string, characterBriefText = ''
);
}
/**
* 正式角色主图生成的负向提示词。
*
* 只服务于图像生成请求,不参与默认描述文本生成。
*/
export function buildNpcVisualNegativePrompt() {
return [
'正面视角',
@@ -239,6 +298,12 @@ export function buildNpcVisualNegativePrompt() {
].join('');
}
/**
* 连续序列帧方案的正式动作 prompt。
*
* 这是“图像序列帧”动作生成链路使用的正式 prompt
* 不属于默认描述文本层。
*/
export function buildImageSequencePrompt(
animation: string,
promptText: string,
@@ -258,6 +323,15 @@ export function buildImageSequencePrompt(
.join(' ');
}
/**
* 通用动作视频方案的正式动作 prompt。
*
* 输入的 promptText 是动作描述文本;
* 输出的是可以直接提交给动作模型的视频 prompt。
*
* 当前仓库里它主要服务于非 Ark 的动作视频链路,
* 以及某些保留的动作生成策略。
*/
export function buildNpcAnimationPrompt(options: {
animation: string;
promptText: string;
@@ -309,6 +383,13 @@ export function buildNpcAnimationPrompt(options: {
.join(' ');
}
/**
* Ark 图生视频动作链路的正式动作 prompt。
*
* 当前自定义世界角色资产工坊的主动作生成流程,
* 最终会走到这个 builder。它会在共享模板的基础上
* 叠加 Ark 所需的首帧 / 尾帧约束与动作英文名约束。
*/
export function buildArkCharacterAnimationPrompt(options: {
animation: string;
promptText: string;
@@ -359,6 +440,11 @@ export function buildArkCharacterAnimationPrompt(options: {
.join(' ');
}
/**
* 当正式动作 prompt 触发审核或兼容问题时的保守兜底动作 prompt。
*
* 这条链路是正式动作生成的降级方案,不参与默认描述文本生成。
*/
export function buildFallbackModerationSafeAnimationPrompt(options: {
animation: string;
loop: boolean;

View File

@@ -28,10 +28,12 @@ export const NPC_CHAT_DIALOGUE_STRICT_SYSTEM_PROMPT = `你是角色扮演 RPG
硬性规则:
- 每一行都必须严格以“你:”或“角色名字:”开头。
- 第一行必须是“你:”开头。
- 总行数控制在 4 到 6 行。
- 玩家和对方至少各说 2 次。
- 这段内容只是聊天,不是做决定。
- 如果当前要求是“由 NPC 主动开口”,第一行必须是“角色名字:”开头,且第一句先是自然招呼或开场判断。
- 如果当前不是“由 NPC 主动开口”,第一行必须是“你:”开头。
- 如果这是双方第一次真正接触,对方第一次开口必须先是自然招呼或开场判断,不能写成第三人称占位旁白。
- 禁止在聊天里主动引导、建议、安排或预告交易、招募、切磋、战斗、送礼、求助、离开、继续前进、切换场景等其他 function。
- 禁止把情报直接写成对玩家的指令。
- 结束时要让玩家感觉到气氛、情报或关系发生了变化,但变化仍停留在聊天层面。`;
@@ -52,6 +54,7 @@ export const NPC_RECRUIT_DIALOGUE_SYSTEM_PROMPT = `你是角色扮演 RPG 的招
export const NPC_CHAT_TURN_REPLY_SYSTEM_PROMPT = `你是角色扮演 RPG 里的当前 NPC。
你只输出这名 NPC 此刻会对玩家说的一轮回复。
只输出纯中文口语回复正文不要输出角色名、引号、旁白、动作描写、Markdown、JSON 或解释。
- 如果这是第一次真正接触中的首轮回复,第一句必须先用自然招呼或开场判断起手,不能写成第三人称占位旁白。
回复长度控制在 1 到 3 句,必须紧接玩家刚说的话,自然推进气氛、情报或关系。`;
export const NPC_CHAT_TURN_SUGGESTION_SYSTEM_PROMPT = `你要为玩家生成下一轮可直接点击的 3 条聊天续写候选。
@@ -73,6 +76,10 @@ function readNumber(value: unknown, fallback = 0) {
return typeof value === 'number' && Number.isFinite(value) ? value : fallback;
}
function readBoolean(value: unknown, fallback = false) {
return typeof value === 'boolean' ? value : fallback;
}
function readStringArray(value: unknown) {
return Array.isArray(value)
? value
@@ -81,6 +88,22 @@ function readStringArray(value: unknown) {
: [];
}
function describeFirstContactRelationStance(value: unknown) {
const stance = readString(value);
switch (stance) {
case 'guarded':
return '戒备试探';
case 'neutral':
return '正常交流但仍不熟';
case 'cooperative':
return '已有善意,先确认合作节奏';
case 'bonded':
return '明显信任,但仍是第一次正式对上人';
default:
return '第一次真正接触';
}
}
function describeWorld(worldType: string) {
switch (worldType) {
case 'WUXIA':
@@ -384,11 +407,31 @@ export function buildStrictNpcChatDialoguePrompt(
const openingCampDialogue = readString(context?.openingCampDialogue);
const allowedTopics = readStringArray(context?.encounterAllowedTopics);
const blockedTopics = readStringArray(context?.encounterBlockedTopics);
const isFirstMeaningfulContact = readBoolean(
context?.isFirstMeaningfulContact,
false,
);
const npcInitiatesConversation = readBoolean(
payload.npcInitiatesConversation,
false,
);
const firstContactRelationStance = describeFirstContactRelationStance(
context?.firstContactRelationStance,
);
return [
buildNpcDialoguePromptBase(payload),
openingCampBackground ? `营地开场背景:${openingCampBackground}` : null,
openingCampDialogue ? `刚刚发生的第一段对话:${openingCampDialogue}` : null,
isFirstMeaningfulContact
? `当前接触阶段:第一次真正接触(${firstContactRelationStance})。对方第一次开口必须先给一句自然招呼或开场判断,再进入眼前话题。`
: null,
isFirstMeaningfulContact
? '禁止写成“某人看着你,像是在等你把话接下去”这类第三人称占位旁白,也不要用系统说明代替对白。'
: null,
npcInitiatesConversation
? `当前要求:由 ${encounter.npcName} 主动开口。第一行必须是“${encounter.npcName}:”,不要先替玩家说话。`
: '当前要求:玩家先挑起这段话,第一行必须是“你:”。',
allowedTopics.length > 0
? `当前更适合谈的内容:${allowedTopics.join('、')}`
: null,
@@ -427,21 +470,96 @@ export function buildNpcChatTurnReplyPrompt(
payload: NpcChatTurnRequest,
) {
const encounter = describeEncounter(payload.encounter);
const context = asRecord(payload.context);
const npcState = asRecord(payload.npcState);
const chatDirective = asRecord(payload.chatDirective);
const conversationHistory =
Array.isArray(payload.conversationHistory) && payload.conversationHistory.length > 0
? payload.conversationHistory
: payload.dialogue ?? payload.conversationHistory ?? [];
const openingCampBackground = readString(context?.openingCampBackground);
const openingCampDialogue = readString(context?.openingCampDialogue);
const allowedTopics = readStringArray(context?.encounterAllowedTopics);
const blockedTopics = readStringArray(context?.encounterBlockedTopics);
const isFirstMeaningfulContact = readBoolean(
context?.isFirstMeaningfulContact,
false,
);
const affinity = readNumber(npcState?.affinity, 0);
const chattedCount = readNumber(npcState?.chattedCount, 0);
const limitReason = readString(chatDirective?.limitReason);
const turnLimit = Math.max(0, readNumber(chatDirective?.turnLimit, 0));
const remainingTurns = Math.max(0, readNumber(chatDirective?.remainingTurns, 0));
const closingMode = readString(chatDirective?.closingMode);
const isLimitedNegativeAffinityChat =
limitReason === 'negative_affinity' && turnLimit > 0;
const isForeshadowCloseTurn =
closingMode === 'foreshadow_close' ||
readBoolean(chatDirective?.forceExitAfterTurn, false);
const hasNpcReplyInHistory = conversationHistory.some((item) => {
const turn = asRecord(item);
return readString(turn?.speaker) === 'npc';
});
const npcInitiatesConversation = readBoolean(
payload.npcInitiatesConversation,
false,
);
const isFirstNpcSpokenTurn =
isFirstMeaningfulContact && !hasNpcReplyInHistory && chattedCount <= 0;
const firstContactRelationStance = describeFirstContactRelationStance(
context?.firstContactRelationStance,
);
const playerMessage = payload.playerMessage.trim();
return [
buildNpcDialoguePromptBase(payload),
describeNpcConversationHistory(conversationHistory, encounter.npcName),
openingCampBackground ? `营地开场背景:${openingCampBackground}` : null,
openingCampDialogue ? `刚刚发生的第一段对话:${openingCampDialogue}` : null,
`当前关系值:${affinity}`,
`已聊天轮次:${chattedCount}`,
`玩家刚刚说:${payload.playerMessage}`,
`现在请只写 ${encounter.npcName} 这一轮会回复玩家的话`,
isFirstNpcSpokenTurn
? `当前接触阶段:第一次真正接触(${firstContactRelationStance})。这是这次聊天里 ${encounter.npcName} 第一次真正对玩家开口`
: null,
isFirstNpcSpokenTurn
? '第一句必须先用一句自然招呼或开场判断起手,再顺着玩家刚刚的话往下接。'
: null,
isFirstNpcSpokenTurn
? '不要写成“某人看着你,像是在等你把话接下去”这类第三人称占位旁白,也不要把整轮写成设定说明。'
: null,
npcInitiatesConversation
? `当前要求:这是 ${encounter.npcName} 主动开口的第一句,不要假装玩家已经先说过话。`
: null,
allowedTopics.length > 0
? `当前更适合先谈:${allowedTopics.join('、')}`
: null,
blockedTopics.length > 0
? `当前避免直接说破:${blockedTopics.join('、')}`
: null,
isLimitedNegativeAffinityChat
? `当前相遇属于负好感主角色有限聊天,本次总上限 ${turnLimit} 轮。`
: null,
isLimitedNegativeAffinityChat
? `在你回复完这一轮之后,还剩 ${remainingTurns} 轮可以继续聊。`
: null,
isLimitedNegativeAffinityChat && !isForeshadowCloseTurn
? '语气可以戒备、冷淡、带刺,但不要立刻转成开战,也不要把对话硬掐死。'
: null,
isForeshadowCloseTurn
? '这是最后一轮回复。必须带有收束感,但不能只用“别问了”“滚开”之类的话把聊天粗暴截断。'
: null,
isForeshadowCloseTurn
? '最后一轮必须抛出能推动后续剧情的明确铺垫,例如威胁、线索、条件、去处、人物、未说完的真相或下一步悬念。'
: null,
isForeshadowCloseTurn
? '回复后这轮聊天会结束,所以不要邀请继续闲聊,也不要直接宣布已经开战。'
: null,
npcInitiatesConversation
? '玩家此刻还没有先说话,请直接写 NPC 主动开口时会说的第一轮回复。'
: `玩家刚刚说:${playerMessage}`,
npcInitiatesConversation
? `现在请只写 ${encounter.npcName} 主动开口时会说的话。`
: `现在请只写 ${encounter.npcName} 这一轮会回复玩家的话。`,
]
.filter(Boolean)
.join('\n\n');

View File

@@ -66,6 +66,7 @@ export const npcChatDialogueRequestSchema = baseNpcChatSchema.extend({
character: jsonObjectSchema,
topic: z.string().trim().min(1),
resultSummary: z.string().optional().default(''),
npcInitiatesConversation: z.boolean().optional(),
}) satisfies z.ZodType<NpcChatDialogueRequest>;
export const npcChatTurnRequestSchema = baseNpcChatSchema
@@ -74,6 +75,7 @@ export const npcChatTurnRequestSchema = baseNpcChatSchema
dialogue: z.array(jsonObjectSchema).optional(),
playerMessage: z.string().trim().min(1),
npcState: jsonObjectSchema,
npcInitiatesConversation: z.boolean().optional(),
questOfferContext: npcChatQuestOfferContextSchema.nullable().optional(),
chatDirective: npcChatDirectiveSchema.nullable().optional(),
})

View File

@@ -44,6 +44,7 @@ import {
createNpcBattleMonster,
normalizeNpcPersistentState,
} from '../data/npcInteractions';
import { normalizePlayerProgressionState } from '../data/playerProgression';
import { getSceneHostileNpcPresetIds } from '../data/scenePresets';
import type { CharacterChatTarget } from '../hooks/useStoryGeneration';
import { getResourceLabelsForWorld } from '../services/customWorldPresentation';
@@ -64,6 +65,7 @@ import {
} from './BackstoryArchive';
import { CharacterAnimator } from './CharacterAnimator';
import {
buildCharacterSkillRenderId,
getCharacterDetailSpriteStyle,
getContributionVisualStyle,
getSkillDeliveryLabel,
@@ -71,8 +73,10 @@ import {
} from './CharacterInfoHelpers';
import {
CharacterAttributeGrid,
CharacterIdentityBadges,
CharacterSkillsList,
MultiplierContributionList,
PlayerLevelProgress,
StatusRow,
} from './CharacterInfoShared';
import { GENERIC_NPC_SCENE_SCALE } from './game-canvas/GameCanvasShared';
@@ -137,7 +141,8 @@ function resolveSkillPreviewMonsterId(gameState: GameState) {
return null;
}
const sceneMonsterId = getSceneHostileNpcPresetIds(gameState.currentScenePreset)[0] ?? null;
const sceneMonsterId =
getSceneHostileNpcPresetIds(gameState.currentScenePreset)[0] ?? null;
if (sceneMonsterId) {
return sceneMonsterId;
}
@@ -469,6 +474,45 @@ export function AdventureEntityModal({
privateChatUnlockAffinity != null &&
companionNpcState.affinity >= privateChatUnlockAffinity,
);
const normalizedPlayerProgression = normalizePlayerProgressionState(
gameState.playerProgression ?? null,
);
const selectedNpcLevel =
npcEncounter?.levelProfile?.level ??
npcBattleState?.levelProfile?.level ??
null;
const selectionRoleLabel =
selection?.kind === 'player'
? '队长'
: selection?.kind === 'companion'
? '同行'
: selection?.kind === 'npc' && npcEncounter && npcState
? getNpcBadge(
npcEncounter,
npcState.affinity,
Boolean(npcBattleState),
)
: null;
const selectionLevelText =
selection?.kind === 'player'
? `Lv.${normalizedPlayerProgression.level}`
: selection?.kind === 'companion'
? `参考 Lv.${normalizedPlayerProgression.level}`
: typeof selectedNpcLevel === 'number'
? `Lv.${selectedNpcLevel}`
: null;
const selectionRoleTone: 'amber' | 'sky' | 'rose' | 'emerald' | 'zinc' =
selection?.kind === 'player'
? 'amber'
: selection?.kind === 'companion'
? 'sky'
: selection?.kind === 'npc' && npcEncounter && npcState
? npcEncounter.hostile ||
Boolean(npcBattleState) ||
npcState.affinity < 0
? 'rose'
: 'emerald'
: 'zinc';
const title =
selection?.kind === 'player'
@@ -673,7 +717,10 @@ export function AdventureEntityModal({
)
: [];
const selectedSkill =
displayedSkills.find((skill) => skill.id === selectedSkillId) ?? null;
displayedSkills.find(
(skill, index) =>
buildCharacterSkillRenderId(skill, index) === selectedSkillId,
) ?? null;
const selectedSkillPreviewWorldType = gameState.worldType ?? null;
const selectedSkillPreviewMonsterId = selectedSkillPreviewWorldType
? resolveSkillPreviewMonsterId(gameState)
@@ -686,23 +733,30 @@ export function AdventureEntityModal({
inventory.find((item) => item.id === selectedItemId) ?? null;
const selectedSkillOwnerName =
detailCharacter?.name ?? npcEncounter?.npcName ?? title;
const recentChronicleEntries = gameState.storyEngineMemory?.chronicle?.slice(-3) ?? [];
const recentCarrierEchoes = (gameState.storyEngineMemory?.recentCarrierIds ?? [])
.map((carrierId) =>
gameState.playerInventory.find((item) => item.id === carrierId)?.runtimeMetadata?.storyFingerprint?.visibleClue
?? gameState.playerInventory.find((item) => item.id === carrierId)?.name
?? '',
const recentChronicleEntries =
gameState.storyEngineMemory?.chronicle?.slice(-3) ?? [];
const recentCarrierEchoes = (
gameState.storyEngineMemory?.recentCarrierIds ?? []
)
.map(
(carrierId) =>
gameState.playerInventory.find((item) => item.id === carrierId)
?.runtimeMetadata?.storyFingerprint?.visibleClue ??
gameState.playerInventory.find((item) => item.id === carrierId)?.name ??
'',
)
.filter(Boolean)
.slice(0, 3);
const sceneResidues = gameState.currentScenePreset?.narrativeResidues?.slice(0, 3) ?? [];
const selectedCompanionResolution =
detailCharacter
? gameState.storyEngineMemory?.companionResolutions?.find(
(resolution) => resolution.characterId === detailCharacter.id,
) ?? null
: null;
const relatedConsequences = (gameState.storyEngineMemory?.consequenceLedger ?? [])
const sceneResidues =
gameState.currentScenePreset?.narrativeResidues?.slice(0, 3) ?? [];
const selectedCompanionResolution = detailCharacter
? (gameState.storyEngineMemory?.companionResolutions?.find(
(resolution) => resolution.characterId === detailCharacter.id,
) ?? null)
: null;
const relatedConsequences = (
gameState.storyEngineMemory?.consequenceLedger ?? []
)
.filter((record) =>
detailCharacter
? record.relatedIds.includes(detailCharacter.id)
@@ -761,6 +815,14 @@ export function AdventureEntityModal({
{title}
</div>
<div className="mt-1 text-sm text-zinc-400">{subtitle}</div>
{selectionRoleLabel ? (
<CharacterIdentityBadges
roleLabel={selectionRoleLabel}
levelText={selectionLevelText}
roleTone={selectionRoleTone}
className="mt-2"
/>
) : null}
</div>
<button
type="button"
@@ -780,7 +842,9 @@ export function AdventureEntityModal({
{selection.kind === 'player' && playerCharacter ? (
playerCharacter.visual ? (
<MedievalNpcAnimator
visualSpec={buildMedievalNpcVisualFromCustomWorldVisual(playerCharacter.visual)}
visualSpec={buildMedievalNpcVisualFromCustomWorldVisual(
playerCharacter.visual,
)}
scale={2.08}
/>
) : (
@@ -798,7 +862,9 @@ export function AdventureEntityModal({
companionCharacter ? (
companionCharacter.visual ? (
<MedievalNpcAnimator
visualSpec={buildMedievalNpcVisualFromCustomWorldVisual(companionCharacter.visual)}
visualSpec={buildMedievalNpcVisualFromCustomWorldVisual(
companionCharacter.visual,
)}
scale={2.08}
/>
) : (
@@ -815,7 +881,9 @@ export function AdventureEntityModal({
) : npcCharacter ? (
npcCharacter.visual ? (
<MedievalNpcAnimator
visualSpec={buildMedievalNpcVisualFromCustomWorldVisual(npcCharacter.visual)}
visualSpec={buildMedievalNpcVisualFromCustomWorldVisual(
npcCharacter.visual,
)}
scale={2.08}
/>
) : (
@@ -824,7 +892,9 @@ export function AdventureEntityModal({
character={npcCharacter}
className="h-full w-full"
imageClassName="object-bottom"
style={getCharacterDetailSpriteStyle(npcCharacter)}
style={getCharacterDetailSpriteStyle(
npcCharacter,
)}
/>
)
) : hostileNpcPreset ? (
@@ -842,15 +912,6 @@ export function AdventureEntityModal({
/>
) : null}
</div>
{selection.kind === 'npc' && npcEncounter && npcState && (
<div className="mt-3 rounded-full border border-rose-400/25 bg-rose-500/10 px-3 py-1 text-[10px] tracking-[0.18em] text-rose-100">
{getNpcBadge(
npcEncounter,
npcState.affinity,
Boolean(npcBattleState),
)}
</div>
)}
<p className="mt-3 text-sm leading-relaxed text-zinc-300">
{description}
</p>
@@ -942,17 +1003,24 @@ export function AdventureEntityModal({
<div className="space-y-3">
{selectedCompanionResolution && (
<div className="rounded-xl border border-emerald-400/18 bg-emerald-500/8 px-3 py-2 text-xs text-emerald-100/85">
{selectedCompanionResolution.resolutionType} · {selectedCompanionResolution.summary}
{selectedCompanionResolution.resolutionType} ·{' '}
{selectedCompanionResolution.summary}
</div>
)}
{relatedConsequences.length > 0 && (
<div className="space-y-1">
{relatedConsequences.map((record, index) => (
<div
key={record.id || `consequence-${record.title}-${index}`}
key={
record.id ||
`consequence-${record.title}-${index}`
}
className="rounded-xl border border-white/8 bg-black/20 px-3 py-2 text-xs text-zinc-400"
>
<span className="text-white">{record.title}</span>
<span className="text-white">
{record.title}
</span>
{''}
{record.summary}
</div>
@@ -963,7 +1031,10 @@ export function AdventureEntityModal({
<div className="space-y-1">
{recentChronicleEntries.map((entry, index) => (
<div
key={entry.id || `chronicle-${entry.title}-${index}`}
key={
entry.id ||
`chronicle-${entry.title}-${index}`
}
className="rounded-xl border border-white/8 bg-black/20 px-3 py-2"
>
<div className="text-sm font-medium text-white">
@@ -985,10 +1056,15 @@ export function AdventureEntityModal({
<div className="space-y-1">
{sceneResidues.map((residue, index) => (
<div
key={residue.id || `residue-${residue.title}-${index}`}
key={
residue.id ||
`residue-${residue.title}-${index}`
}
className="rounded-xl border border-white/8 bg-black/20 px-3 py-2 text-xs text-zinc-400"
>
<span className="text-white">{residue.title}</span>
<span className="text-white">
{residue.title}
</span>
{''}
{residue.visibleClue}
</div>
@@ -1001,6 +1077,22 @@ export function AdventureEntityModal({
<Section title="属性">
<div className="space-y-4">
{selection.kind === 'player' ? (
<div className="rounded-xl border border-amber-300/18 bg-amber-500/8 px-3 py-3">
<div className="mb-2 text-[10px] uppercase tracking-[0.18em] text-amber-100/75">
</div>
<PlayerLevelProgress
level={normalizedPlayerProgression.level}
currentLevelXp={
normalizedPlayerProgression.currentLevelXp
}
xpToNextLevel={
normalizedPlayerProgression.xpToNextLevel
}
/>
</div>
) : null}
<div className="space-y-3">
<StatusRow
label={resourceLabels.hp}

View File

@@ -105,3 +105,101 @@ test('adventure panel treats negative affinity updates as relationship change sy
expect(html).toContain('关系变化');
expect(html).toContain('关系转冷 好感 -2');
});
test('adventure panel shows current act label and remaining turns for limited hostile npc chat', () => {
const currentStory: StoryMoment = {
text: '断桥客仍在压着最后那半句真相。',
displayMode: 'dialogue',
dialogue: [
{ speaker: 'player', text: '你到底还在替谁守着这座桥?' },
{ speaker: 'npc', speakerName: '断桥客', text: '你还没资格知道全名。' },
],
options: [],
npcChatState: {
npcId: 'npc-rival',
npcName: '断桥客',
turnCount: 3,
customInputPlaceholder: '输入你想对 TA 说的话',
sceneActId: 'scene-bridge-act-1',
turnLimit: 5,
remainingTurns: 2,
limitReason: 'negative_affinity',
forceExitAfterTurn: false,
},
};
const html = renderToStaticMarkup(
<AdventurePanel
aiError={null}
currentStory={currentStory}
isLoading={false}
displayedOptions={[]}
hideOptions={false}
canRefreshOptions={false}
onRefreshOptions={() => undefined}
onChoice={() => undefined}
onSubmitNpcChatInput={() => true}
onExitNpcChat={() => true}
onOpenCharacter={() => undefined}
onOpenInventory={() => undefined}
playerCharacter={createCharacter()}
worldType={WorldType.WUXIA}
quests={[]}
questUi={{
acknowledgeQuestCompletion: () => undefined,
claimQuestReward: () => null,
}}
npcChatQuestOfferUi={{
replacePendingOffer: async () => false,
abandonPendingOffer: () => false,
acceptPendingOffer: () => null,
}}
goalStack={{
northStarGoal: null,
activeGoal: null,
immediateStepGoal: null,
supportGoals: [],
}}
goalPulse={null}
onDismissGoalPulse={() => undefined}
battleRewardUi={{
reward: null,
dismiss: () => undefined,
}}
playerHp={100}
playerMaxHp={100}
playerMana={20}
playerMaxMana={20}
playerSkillCooldowns={{}}
inBattle={false}
currentNpcBattleMode={null}
statistics={{
playTimeMs: 0,
hostileNpcsDefeated: 0,
questsAccepted: 0,
questsCompleted: 0,
questsTurnedIn: 0,
itemsUsed: 0,
scenesTraveled: 0,
currentSceneName: '断桥口',
playerCurrency: 0,
inventoryItemCount: 0,
inventoryStackCount: 0,
activeCompanionCount: 0,
rosterCompanionCount: 0,
}}
musicVolume={0.6}
onMusicVolumeChange={() => undefined}
onSaveAndExit={() => undefined}
currentSceneActTitle="断桥口 · 对峙幕"
currentSceneActIndex={1}
currentSceneActCount={3}
/>,
);
expect(html).toContain('当前幕');
expect(html).toContain('断桥口 · 对峙幕');
expect(html).toContain('1/3');
expect(html).toContain('剩余交谈');
expect(html).toContain('2 轮');
});

View File

@@ -110,6 +110,9 @@ interface AdventurePanelProps {
onSaveAndExit: () => void;
chapterState?: ChapterState | null;
journeyBeat?: JourneyBeat | null;
currentSceneActTitle?: string | null;
currentSceneActIndex?: number | null;
currentSceneActCount?: number | null;
}
const AdventurePanelOverlays = lazy(async () => {
@@ -280,19 +283,6 @@ function formatPlayTime(playTimeMs: number) {
return `${minutes}${String(seconds).padStart(2, '0')}`;
}
function getPlayerProgressionRatio(
statistics: AdventurePanelProps['statistics'],
) {
const currentLevelXp = Math.max(0, statistics.playerCurrentLevelXp ?? 0);
const xpToNextLevel = Math.max(0, statistics.playerXpToNextLevel ?? 0);
if (xpToNextLevel <= 0) {
return 1;
}
return Math.max(0, Math.min(1, currentLevelXp / xpToNextLevel));
}
function getOptionGoalAffordanceClass(option: StoryOption) {
switch (option.goalAffordance?.relation) {
case 'advance':
@@ -675,6 +665,9 @@ export function AdventurePanel({
onSaveAndExit,
chapterState = null,
journeyBeat = null,
currentSceneActTitle = null,
currentSceneActIndex = null,
currentSceneActCount = null,
}: AdventurePanelProps) {
const isDialogueStory = currentStory.displayMode === 'dialogue';
const dialogueTurns = currentStory.dialogue ?? [];
@@ -931,13 +924,10 @@ export function AdventurePanel({
],
[statistics],
);
const playerLevel = Math.max(1, statistics.playerLevel ?? 1);
const playerCurrentLevelXp = Math.max(
0,
statistics.playerCurrentLevelXp ?? 0,
);
const playerXpToNextLevel = Math.max(0, statistics.playerXpToNextLevel ?? 0);
const playerProgressionRatio = getPlayerProgressionRatio(statistics);
const limitedNpcChatRemainingTurns =
npcChatState?.turnLimit && npcChatState.limitReason === 'negative_affinity'
? Math.max(0, npcChatState.remainingTurns ?? 0)
: null;
const shouldMountAdventureOverlays =
isGoalPanelOpen ||
isSettingsPanelOpen ||
@@ -1059,9 +1049,32 @@ export function AdventurePanel({
<div
ref={storyScrollContainerRef}
className="pixel-nine-slice pixel-panel mb-3 min-h-0 flex-1 overflow-y-auto pr-1 scrollbar-hide"
className="pixel-nine-slice pixel-panel mb-2 min-h-0 flex-1 overflow-y-auto pr-1 scrollbar-hide"
style={getNineSliceStyle(UI_CHROME.storyPanel)}
>
{(currentSceneActTitle || limitedNpcChatRemainingTurns !== null) && (
<div className="mb-3 flex flex-wrap items-center gap-2 px-1">
{currentSceneActTitle ? (
<div className="inline-flex items-center gap-2 rounded-full border border-sky-300/18 bg-sky-500/10 px-3 py-1 text-[10px] tracking-[0.16em] text-sky-100">
<span></span>
<span className="text-white/90">{currentSceneActTitle}</span>
{currentSceneActIndex && currentSceneActCount ? (
<span className="text-sky-100/65">
{currentSceneActIndex}/{currentSceneActCount}
</span>
) : null}
</div>
) : null}
{limitedNpcChatRemainingTurns !== null ? (
<div className="inline-flex items-center gap-2 rounded-full border border-rose-300/18 bg-rose-500/10 px-3 py-1 text-[10px] tracking-[0.16em] text-rose-100">
<span></span>
<span className="text-white/90">
{limitedNpcChatRemainingTurns}
</span>
</div>
) : null}
</div>
)}
{isDialogueStory ? (
<div className="space-y-3">
{dialogueTurns.length > 0 ? (
@@ -1083,6 +1096,8 @@ export function AdventurePanel({
</div>
</div>
))
) : isNpcChatMode && !isStoryStreaming ? (
<div className="h-1" aria-hidden="true" />
) : (
<div className="flex justify-start">
<div className="rounded-2xl border border-white/10 bg-black/20 px-3 py-2 text-sm text-zinc-400">
@@ -1098,29 +1113,8 @@ export function AdventurePanel({
)}
</div>
<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="mt-auto shrink-0 pb-[calc(env(safe-area-inset-bottom,0px)+0.35rem)]">
<div className="mb-1.5 flex flex-wrap items-center justify-between gap-2">
<div className="flex min-w-0 flex-wrap items-center gap-2">
<button
type="button"
@@ -1167,7 +1161,7 @@ export function AdventurePanel({
) : null}
</div>
<div className="space-y-2">
<div className="space-y-1.5">
{isLoading && !isStoryStreaming ? (
<div className="flex items-center justify-center space-x-2 p-4 text-zinc-600">
<Loader2 className="h-4 w-4 animate-spin" />
@@ -1275,7 +1269,7 @@ export function AdventurePanel({
);
})}
{isNpcChatMode && !isNpcQuestOfferMode ? (
<div className="pixel-nine-slice pixel-panel mt-0.5 border border-white/10 bg-black/25 p-1.5">
<div className="pixel-nine-slice pixel-panel border border-white/10 bg-black/25 p-1.5">
<div className="flex min-w-0 items-center gap-2">
<input
value={npcChatDraft}

View File

@@ -58,7 +58,7 @@ describe('CharacterAnimator portrait fallbacks', () => {
expect(image.style.animation).toContain(
'character-animator-portrait-death-fall',
);
expect(image.style.transform).toContain('rotate(90deg)');
expect(image.style.transform).toContain('rotate(-90deg)');
expect(image.style.transform).toContain('scaleX(-1)');
});
});

View File

@@ -87,7 +87,7 @@ const PORTRAIT_FALLBACK_ANIMATION: CharacterAnimationConfig = {
const FALLEN_PORTRAIT_STYLE: React.CSSProperties = {
imageRendering: 'pixelated',
transform: 'translateY(16%) rotate(90deg) scaleX(-1) scale(0.82)',
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',

View File

@@ -43,6 +43,19 @@ export function getSkillStyleLabel(skill: Character['skills'][number]) {
return SKILL_STYLE_LABELS[skill.style];
}
export function buildCharacterSkillRenderId(
skill: Character['skills'][number],
index: number,
) {
const normalizedId = skill.id.trim();
if (normalizedId) {
return normalizedId;
}
const fallbackSeed = skill.name.trim() || getSkillStyleLabel(skill) || 'skill';
return `skill-${fallbackSeed}-${index}`;
}
function getContributionHeatRatio(value: number) {
return getBuildContributionQualityRatio(value);
}

View File

@@ -0,0 +1,89 @@
/* @vitest-environment jsdom */
import { render, screen } from '@testing-library/react';
import userEvent from '@testing-library/user-event';
import { afterEach, expect, test, vi } from 'vitest';
import { AnimationState, type Character } from '../types';
import {
CharacterIdentityBadges,
CharacterSkillsList,
PlayerLevelProgress,
} from './CharacterInfoShared';
function createSkill(
name: string,
style: Character['skills'][number]['style'],
): Character['skills'][number] {
return {
id: '',
name,
animation: AnimationState.IDLE,
damage: 12,
manaCost: 4,
cooldownTurns: 2,
range: 1,
style,
};
}
afterEach(() => {
vi.restoreAllMocks();
});
test('CharacterSkillsList falls back to stable render ids when skill ids are empty', async () => {
const user = userEvent.setup();
const handleSelectSkill = vi.fn();
const consoleErrorSpy = vi
.spyOn(console, 'error')
.mockImplementation(() => undefined);
render(
<CharacterSkillsList
skills={[
createSkill('潮刃突进', 'burst'),
createSkill('雾行转位', 'mobility'),
]}
onSelectSkill={handleSelectSkill}
/>,
);
const buttons = screen.getAllByRole('button');
await user.click(buttons[0]!);
await user.click(buttons[1]!);
expect(handleSelectSkill).toHaveBeenNthCalledWith(1, 'skill-潮刃突进-0');
expect(handleSelectSkill).toHaveBeenNthCalledWith(2, 'skill-雾行转位-1');
const duplicateKeyCalls = consoleErrorSpy.mock.calls.filter((call) =>
call.some(
(arg) =>
typeof arg === 'string' &&
arg.includes('Encountered two children with the same key'),
),
);
expect(duplicateKeyCalls).toHaveLength(0);
});
test('CharacterIdentityBadges renders role and level chips together', () => {
render(
<CharacterIdentityBadges
roleLabel="队长"
roleTone="amber"
levelText="Lv.7"
/>,
);
expect(screen.getByText('队长')).toBeTruthy();
expect(screen.getByText('Lv.7')).toBeTruthy();
});
test('PlayerLevelProgress renders xp progress details', () => {
render(
<PlayerLevelProgress level={6} currentLevelXp={72} xpToNextLevel={120} />,
);
expect(screen.getByText('Lv.6')).toBeTruthy();
expect(screen.getByText('72/120')).toBeTruthy();
});

View File

@@ -12,6 +12,7 @@ import type {
WorldAttributeSchema,
} from '../types';
import {
buildCharacterSkillRenderId,
type ContributionRow,
formatAttributeMetricValue,
getAttributeBonusPillClassName,
@@ -56,6 +57,88 @@ export function StatusRow({
);
}
export function CharacterIdentityBadges({
roleLabel,
levelText = null,
roleTone = 'sky',
className = '',
}: {
roleLabel: string;
levelText?: string | null;
roleTone?: 'amber' | 'sky' | 'rose' | 'emerald' | 'zinc';
className?: string;
}) {
const roleClass =
roleTone === 'amber'
? 'border-amber-300/20 bg-amber-500/10 text-amber-100'
: roleTone === 'rose'
? 'border-rose-300/20 bg-rose-500/10 text-rose-100'
: roleTone === 'emerald'
? 'border-emerald-300/20 bg-emerald-500/10 text-emerald-100'
: roleTone === 'zinc'
? 'border-white/10 bg-black/20 text-zinc-200'
: 'border-sky-300/20 bg-sky-500/10 text-sky-100';
return (
<div className={`flex flex-wrap items-center gap-2 ${className}`.trim()}>
<span
className={`rounded-full border px-2.5 py-1 text-[10px] tracking-[0.16em] ${roleClass}`}
>
{roleLabel}
</span>
{levelText ? (
<span className="rounded-full border border-white/10 bg-black/20 px-2.5 py-1 text-[10px] tracking-[0.16em] text-zinc-200">
{levelText}
</span>
) : null}
</div>
);
}
export function PlayerLevelProgress({
level,
currentLevelXp,
xpToNextLevel,
className = '',
}: {
level: number;
currentLevelXp: number;
xpToNextLevel: number;
className?: string;
}) {
const safeLevel = Math.max(1, Math.round(level));
const safeCurrentLevelXp = Math.max(0, Math.round(currentLevelXp));
const safeXpToNextLevel = Math.max(0, Math.round(xpToNextLevel));
const ratio =
safeXpToNextLevel <= 0
? 1
: Math.max(
0,
Math.min(1, safeCurrentLevelXp / safeXpToNextLevel),
);
return (
<div className={className}>
<div className="flex items-center justify-between gap-3 text-[11px]">
<div className="font-semibold text-amber-50">Lv.{safeLevel}</div>
<div className="text-zinc-400">
{safeXpToNextLevel > 0
? `${safeCurrentLevelXp}/${safeXpToNextLevel}`
: '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: ratio <= 0 ? '0%' : `${Math.max(6, ratio * 100)}%`,
}}
/>
</div>
</div>
);
}
export function CharacterSkillsList({
skills,
onSelectSkill,
@@ -75,7 +158,8 @@ export function CharacterSkillsList({
return (
<div className="grid gap-2 sm:grid-cols-2">
{skills.map((skill) => {
{skills.map((skill, index) => {
const skillRenderId = buildCharacterSkillRenderId(skill, index);
const content = (
<>
<div className="flex items-center justify-between gap-2">
@@ -99,9 +183,9 @@ export function CharacterSkillsList({
if (onSelectSkill) {
return (
<button
key={skill.id}
key={skillRenderId}
type="button"
onClick={() => onSelectSkill(skill.id)}
onClick={() => onSelectSkill(skillRenderId)}
className="rounded-xl border border-white/8 bg-black/20 px-3 py-3 text-left text-sm text-zinc-300 transition-colors hover:border-sky-300/25 hover:bg-sky-500/8"
>
{content}
@@ -111,7 +195,7 @@ export function CharacterSkillsList({
return (
<div
key={skill.id}
key={skillRenderId}
className="rounded-lg border border-white/5 bg-black/20 px-3 py-3 text-sm text-zinc-300"
>
{content}

View File

@@ -1,6 +1,7 @@
import { AnimatePresence, motion } from 'motion/react';
import { useEffect, useMemo, useState } from 'react';
import { normalizePlayerProgressionState } from '../data/playerProgression';
import {
resolveAttributeSchema,
resolveCharacterAttributeProfile,
@@ -57,8 +58,10 @@ import {
} from './CharacterInfoHelpers';
import {
CharacterAttributeGrid,
CharacterIdentityBadges,
CharacterSkillsList,
MultiplierContributionList,
PlayerLevelProgress,
StatusRow,
} from './CharacterInfoShared';
import type { GameCanvasEntitySelection } from './GameCanvas';
@@ -69,6 +72,7 @@ interface CharacterPanelProps {
worldType: WorldType | null;
customWorldProfile?: CustomWorldProfile | null;
playerCharacter: Character;
playerProgression?: GameState['playerProgression'] | null;
playerHp: number;
playerMaxHp: number;
playerMana: number;
@@ -97,6 +101,7 @@ type PartyMember = {
mana: number;
maxMana: number;
isLeader: boolean;
levelText: string | null;
};
type EquipmentRow = {
@@ -140,6 +145,7 @@ export function CharacterPanel({
worldType,
customWorldProfile = null,
playerCharacter,
playerProgression = null,
playerHp,
playerMaxHp,
playerMana,
@@ -157,6 +163,10 @@ export function CharacterPanel({
const [selectedContributionLabel, setSelectedContributionLabel] = useState<
string | null
>(null);
const normalizedPlayerProgression =
normalizePlayerProgressionState(playerProgression);
const leaderLevelText = `Lv.${normalizedPlayerProgression.level}`;
const companionReferenceLevelText = `参考 Lv.${normalizedPlayerProgression.level}`;
const partyMembers = useMemo<PartyMember[]>(
() => [
@@ -165,28 +175,32 @@ export function CharacterPanel({
npcId: null,
renderState: null,
character: playerCharacter,
roleLabel: '闃熼暱',
roleLabel: '\u961f\u957f',
hp: playerHp,
maxHp: playerMaxHp,
mana: playerMana,
maxMana: playerMaxMana,
isLeader: true,
levelText: leaderLevelText,
},
...companionRenderStates.map((companion) => ({
id: companion.npcId,
npcId: companion.npcId,
renderState: companion,
character: companion.character,
roleLabel: '鍚岃',
roleLabel: '\u540c\u884c',
hp: companion.hp,
maxHp: companion.maxHp,
mana: companion.mana,
maxMana: companion.maxMana,
isLeader: false,
levelText: companionReferenceLevelText,
})),
],
[
companionReferenceLevelText,
companionRenderStates,
leaderLevelText,
playerCharacter,
playerHp,
playerMaxHp,
@@ -257,15 +271,16 @@ export function CharacterPanel({
: null;
const selectedMemberArcState =
selectedMember && !selectedMember.isLeader
? companionArcStates.find(
? (companionArcStates.find(
(arcState) => arcState.characterId === selectedMember.character.id,
) ?? null
) ?? null)
: null;
const selectedMemberResolution =
selectedMember && !selectedMember.isLeader
? companionResolutions.find(
(resolution) => resolution.characterId === selectedMember.character.id,
) ?? null
? (companionResolutions.find(
(resolution) =>
resolution.characterId === selectedMember.character.id,
) ?? null)
: null;
const selectedMemberPublicBackstory =
selectedMember && !selectedMember.isLeader && selectedMemberAffinity != null
@@ -410,11 +425,12 @@ export function CharacterPanel({
{member.character.title}
</div>
</div>
<span
className={`rounded-full px-2 py-0.5 text-[10px] ${member.isLeader ? 'bg-amber-500/10 text-amber-100' : 'bg-sky-500/10 text-sky-100'}`}
>
{member.roleLabel}
</span>
<CharacterIdentityBadges
roleLabel={member.roleLabel}
levelText={member.levelText}
roleTone={member.isLeader ? 'amber' : 'sky'}
className="shrink-0 justify-end"
/>
</div>
<div className="mt-2.5 space-y-2.5">
<StatusRow
@@ -591,8 +607,15 @@ export function CharacterPanel({
<div className="mt-1 truncate text-sm font-semibold text-white">
{selectedMember.character.name}
</div>
<div className="mt-1 flex items-center gap-2 text-[10px] tracking-[0.2em] text-zinc-500">
<span>{selectedMember.character.title}</span>
<div className="mt-1 text-[10px] tracking-[0.2em] text-zinc-500">
{selectedMember.character.title}
</div>
<div className="mt-2 flex flex-wrap items-center gap-2">
<CharacterIdentityBadges
roleLabel={selectedMember.roleLabel}
levelText={selectedMember.levelText}
roleTone={selectedMember.isLeader ? 'amber' : 'sky'}
/>
<span className="rounded-full border border-white/10 bg-black/20 px-2 py-0.5 text-[9px] text-zinc-200">
{getGenderLabel(selectedMember.character.gender)}
</span>
@@ -617,7 +640,9 @@ export function CharacterPanel({
<div className="flex h-36 w-full max-w-[15rem] items-end justify-center overflow-hidden rounded-2xl border border-white/10 bg-black/20 sm:h-40">
{selectedMember.character.visual ? (
<MedievalNpcAnimator
visualSpec={buildMedievalNpcVisualFromCustomWorldVisual(selectedMember.character.visual)}
visualSpec={buildMedievalNpcVisualFromCustomWorldVisual(
selectedMember.character.visual,
)}
scale={2.08}
/>
) : (
@@ -652,6 +677,22 @@ export function CharacterPanel({
</div>
<div className="space-y-3">
{selectedMember.isLeader && (
<div className="rounded-xl border border-amber-300/18 bg-amber-500/8 px-3 py-3">
<div className="mb-2 text-[10px] uppercase tracking-[0.18em] text-amber-100/75">
</div>
<PlayerLevelProgress
level={normalizedPlayerProgression.level}
currentLevelXp={
normalizedPlayerProgression.currentLevelXp
}
xpToNextLevel={
normalizedPlayerProgression.xpToNextLevel
}
/>
</div>
)}
<StatusRow
label={resourceLabels.hp}
current={selectedMember.hp}

View File

@@ -1,6 +1,6 @@
/* @vitest-environment jsdom */
import { render, screen, waitFor } from '@testing-library/react';
import { render, screen, waitFor, within } from '@testing-library/react';
import userEvent from '@testing-library/user-event';
import { useState } from 'react';
import { expect, test, vi } from 'vitest';
@@ -12,8 +12,8 @@ import type {
} from '../types';
import { CustomWorldEntityCatalog } from './CustomWorldEntityCatalog';
import {
CustomWorldEntityEditorModal,
type CustomWorldEditorTarget,
CustomWorldEntityEditorModal,
} from './CustomWorldEntityEditorModal';
vi.mock('../data/characterPresets', async () => {
@@ -24,6 +24,11 @@ vi.mock('../data/characterPresets', async () => {
return {
...actual,
buildCustomWorldPlayableCharacters: vi.fn(() => []),
buildCustomWorldRuntimeCharacters: vi.fn(() => []),
createCharacterSkillCooldowns: vi.fn(() => ({})),
getCharacterMaxHp: vi.fn(() => 180),
getCharacterMaxMana: vi.fn(() => 60),
setRuntimeCharacterOverrides: vi.fn(),
};
});
@@ -34,6 +39,8 @@ vi.mock('./CharacterAnimator', () => ({
vi.mock('../services/aiService', () => ({
generateCustomWorldSceneImage: vi.fn(),
generateCustomWorldSceneNpc: vi.fn(),
generateInitialStory: vi.fn(),
generateNextStep: vi.fn(),
}));
vi.mock('./CustomWorldNpcVisualEditor', () => ({
@@ -43,6 +50,19 @@ vi.mock('./CustomWorldNpcVisualEditor', () => ({
CustomWorldNpcVisualEditor: () => <div></div>,
}));
vi.mock('./game-shell/GameShellRuntime', () => ({
GameShellRuntime: ({
session,
}: {
session: { gameState: { currentScenePreset?: { name?: string } | null } };
}) => (
<div>
<div></div>
<div>{session.gameState.currentScenePreset?.name ?? '未进入场景'}</div>
</div>
),
}));
vi.mock('./asset-studio/characterAssetWorkflowPersistence', () => ({
fetchCharacterWorkflowCache: vi.fn().mockResolvedValue({ cache: null }),
generateCharacterPromptBundle: vi.fn().mockResolvedValue({
@@ -138,7 +158,19 @@ function createProfile(): CustomWorldProfile {
templateWorldType: 'WUXIA',
majorFactions: ['守潮盟', '沉钟会'],
coreConflicts: ['旧航道归属', '沉钟遗产争夺'],
attributeSchema: {},
attributeSchema: {
id: 'schema-1',
worldId: 'world-1',
schemaVersion: 1,
generatedFrom: {
worldType: 'WUXIA',
worldName: '潮雾群岛',
settingSummary: '潮雾群岛上的禁制与旧航道正在一起失衡。',
tone: '压抑、潮湿、带着未解旧伤。',
conflictCore: '旧航道归属',
},
slots: [],
},
playableNpcs: [createPlayableRole('playable-1', '沈砺')],
storyNpcs: [createStoryRole('story-1', '顾潮音')],
items: [],
@@ -189,6 +221,9 @@ function LandmarkEditorFlowHarness() {
return (
<>
<pre data-testid="landmark-profile-json" className="hidden">
{JSON.stringify(profile)}
</pre>
<CustomWorldEntityCatalog
profile={profile}
previewCharacters={[]}
@@ -209,6 +244,19 @@ function LandmarkEditorFlowHarness() {
);
}
function readLandmarkHarnessProfile() {
const content = screen.getByTestId('landmark-profile-json').textContent;
return JSON.parse(content || '{}') as CustomWorldProfile;
}
function getSceneActCard(index: number) {
const card = screen.getAllByTestId('scene-act-card')[index];
if (!card) {
throw new Error(`未找到第 ${index + 1} 个幕卡片`);
}
return card;
}
function CampEditorFlowHarness() {
const [profile, setProfile] = useState<CustomWorldProfile>({
...createProfileWithLandmark(),
@@ -548,3 +596,128 @@ test('开局场景图片保存后会同步更新编辑页和场景列表', async
);
});
});
test('场景编辑器会在场景内展示槽位化多幕配置并保存', async () => {
const user = userEvent.setup();
render(<LandmarkEditorFlowHarness />);
expect(screen.getByText('多幕配置')).toBeTruthy();
expect(screen.getAllByTestId('scene-act-card')).toHaveLength(3);
expect(screen.queryByText('幕标题')).toBeNull();
expect(screen.queryByText('幕摘要')).toBeNull();
expect(screen.queryByText('幕目标')).toBeNull();
expect(screen.queryByText('过渡铺垫')).toBeNull();
const firstActCard = getSceneActCard(0);
expect(within(firstActCard).getAllByTestId('scene-act-slot-button')).toHaveLength(3);
await user.click(within(firstActCard).getByRole('button', { name: '配置背景' }));
await waitFor(() => {
expect(screen.getByText('配置幕背景第1幕')).toBeTruthy();
});
const presetImage = screen.getByRole('img', { name: '幕背景预设 1' });
const presetSrc = presetImage.getAttribute('src');
const presetButton = presetImage.closest('button');
expect(presetButton).toBeTruthy();
if (!presetButton) {
throw new Error('未找到幕背景预设按钮');
}
await user.click(presetButton);
await user.click(screen.getByRole('button', { name: '保存背景' }));
await waitFor(() => {
expect(screen.queryByText('配置幕背景第1幕')).toBeNull();
});
await user.click(within(getSceneActCard(0)).getAllByTestId('scene-act-slot-button')[0]!);
await waitFor(() => {
expect(screen.getByText('配置角色第1幕 · 主角色槽位')).toBeTruthy();
});
await user.click(screen.getByRole('button', { name: /[\s\S]*/u }));
await user.click(screen.getByRole('button', { name: '保存角色' }));
await waitFor(() => {
expect(screen.queryByText('配置角色第1幕 · 主角色槽位')).toBeNull();
});
expect(
within(getSceneActCard(0)).getByRole('button', {
name: '配置第1个角色闻雪汀',
}),
).toBeTruthy();
await user.click(screen.getByRole('button', { name: //u }));
await waitFor(() => {
expect(screen.queryByText('编辑场景:沉钟栈桥')).toBeNull();
});
const savedProfile = readLandmarkHarnessProfile();
const savedSceneChapter = savedProfile.sceneChapterBlueprints?.find(
(entry) => entry.sceneId === 'landmark-1',
);
expect(savedSceneChapter).toBeTruthy();
expect(savedSceneChapter?.acts[0]?.backgroundImageSrc).toBe(presetSrc);
expect(savedSceneChapter?.acts[0]?.encounterNpcIds[0]).toBe('story-2');
expect(savedSceneChapter?.acts[0]?.primaryNpcId).toBe('story-2');
});
test('场景多幕支持新增删除和调序', async () => {
const user = userEvent.setup();
render(<LandmarkEditorFlowHarness />);
await user.click(screen.getByRole('button', { name: '新增一幕' }));
expect(screen.getAllByTestId('scene-act-card')).toHaveLength(4);
const secondActCard = getSceneActCard(1);
await user.click(within(secondActCard).getAllByTestId('scene-act-slot-button')[0]!);
await waitFor(() => {
expect(screen.getByText('配置角色第2幕 · 主角色槽位')).toBeTruthy();
});
await user.click(screen.getByRole('button', { name: /[\s\S]*/u }));
await user.click(screen.getByRole('button', { name: '保存角色' }));
await user.click(within(secondActCard).getByRole('button', { name: '下移' }));
const fourthActCard = getSceneActCard(3);
await user.click(within(fourthActCard).getByRole('button', { name: '删除' }));
expect(screen.getAllByTestId('scene-act-card')).toHaveLength(3);
await user.click(screen.getByRole('button', { name: //u }));
await waitFor(() => {
expect(screen.queryByText('编辑场景:沉钟栈桥')).toBeNull();
});
const savedProfile = readLandmarkHarnessProfile();
const savedSceneChapter = savedProfile.sceneChapterBlueprints?.find(
(entry) => entry.sceneId === 'landmark-1',
);
expect(savedSceneChapter?.acts).toHaveLength(3);
expect(savedSceneChapter?.acts[2]?.primaryNpcId).toBe('story-3');
});
test('场景幕预览会打开当前幕运行时面板', async () => {
const user = userEvent.setup();
render(<LandmarkEditorFlowHarness />);
await user.click(within(getSceneActCard(0)).getByRole('button', { name: '幕预览' }));
await waitFor(() => {
expect(screen.getByText('幕预览运行时')).toBeTruthy();
});
expect(screen.getAllByText('沉钟栈桥').length).toBeGreaterThan(0);
await user.click(screen.getByRole('button', { name: '关闭预览' }));
await waitFor(() => {
expect(screen.queryByText('幕预览运行时')).toBeNull();
});
});

File diff suppressed because it is too large Load Diff

View File

@@ -5,6 +5,7 @@ import {getLiveGamePlayTimeMs} from '../data/runtimeStats';
import type { HydratedSavedGameSnapshot } from '../persistence/runtimeSnapshotTypes';
import {getWorldCampScenePreset} from '../data/scenePresets';
import {BottomTab} from '../hooks/useGameFlow';
import {resolveActiveSceneActBlueprint} from '../services/customWorldSceneActRuntime';
import {
type BattleRewardUi,
type CharacterChatUi,
@@ -321,6 +322,33 @@ export function GameShell({session, story, entry, companions, audio}: GameShellP
() => getLiveGamePlayTimeMs(gameState.runtimeStats, clockNow),
[clockNow, gameState.runtimeStats],
);
const activeSceneAct = useMemo(
() => resolveActiveSceneActBlueprint({
profile: visibleGameState.customWorldProfile,
sceneId: visibleGameState.currentScenePreset?.id ?? null,
storyEngineMemory: visibleGameState.storyEngineMemory,
}),
[
visibleGameState.currentScenePreset?.id,
visibleGameState.customWorldProfile,
visibleGameState.storyEngineMemory,
],
);
const activeSceneChapter = useMemo(() => {
if (!visibleGameState.customWorldProfile || !visibleGameState.currentScenePreset?.id) {
return null;
}
return (
visibleGameState.customWorldProfile.sceneChapterBlueprints?.find(
entry => entry.sceneId === visibleGameState.currentScenePreset?.id
|| entry.linkedLandmarkIds.includes(visibleGameState.currentScenePreset?.id ?? ''),
) ?? null
);
}, [
visibleGameState.currentScenePreset?.id,
visibleGameState.customWorldProfile,
]);
const adventureStatistics = useMemo(
() => ({
@@ -415,8 +443,9 @@ export function GameShell({session, story, entry, companions, audio}: GameShellP
<div
className={`pixel-app-shell flex min-h-0 flex-1 flex-col ${isCharacterSelectionStage ? 'justify-center p-4 sm:p-5' : 'p-3 sm:p-4'}`}
style={{
background: isCharacterSelectionStage
? '#0d1016'
backgroundColor: isCharacterSelectionStage ? '#0d1016' : undefined,
backgroundImage: isCharacterSelectionStage
? undefined
: `linear-gradient(rgba(10, 12, 18, 0.55), rgba(10, 12, 18, 0.55)), url("${UI_CHROME.appBackground}")`,
backgroundPosition: isCharacterSelectionStage ? undefined : 'center',
backgroundRepeat: isCharacterSelectionStage ? undefined : 'repeat',
@@ -564,6 +593,18 @@ export function GameShell({session, story, entry, companions, audio}: GameShellP
journeyBeat={
visibleGameState.storyEngineMemory?.currentJourneyBeat ?? null
}
currentSceneActTitle={activeSceneAct?.title ?? null}
currentSceneActIndex={
activeSceneChapter && activeSceneAct
? (() => {
const actIndex = activeSceneChapter.acts.findIndex(
act => act.id === activeSceneAct.id,
);
return actIndex >= 0 ? actIndex + 1 : null;
})()
: null
}
currentSceneActCount={activeSceneChapter?.acts.length ?? null}
statistics={adventureStatistics}
musicVolume={musicVolume}
onMusicVolumeChange={onMusicVolumeChange}

View File

@@ -83,6 +83,9 @@ test('settings header uses a generic title instead of the phone number', () => {
expect(screen.queryByText(/^登录设备$/)).toBeNull();
expect(screen.queryByText(/^操作记录$/)).toBeNull();
expect(screen.queryByText('当前账号状态')).toBeNull();
expect(screen.queryByText('当前主题')).toBeNull();
expect(screen.queryByRole('button', { name: '退出登录' })).toBeNull();
expect(screen.queryByRole('button', { name: '退出全部设备' })).toBeNull();
});
test('account actions open in independent panels instead of inline expansion', async () => {
@@ -121,9 +124,13 @@ test('nested settings panels keep back navigation without an extra close action'
await user.click(screen.getByRole('button', { name: /账号信息/ }));
const accountDialog = screen.getByRole('dialog', { name: '账号信息' });
const accountHeader = accountDialog.firstElementChild as HTMLElement | null;
expect(
within(accountDialog).getByRole('button', { name: '返回' }),
).toBeTruthy();
expect(
accountHeader?.lastElementChild?.textContent?.includes('返回'),
).toBe(true);
expect(
within(accountDialog).queryByRole('button', { name: '关闭' }),
).toBeNull();
@@ -135,9 +142,14 @@ test('nested settings panels keep back navigation without an extra close action'
const changePhoneDialog = screen.getByRole('dialog', {
name: '绑定新手机号',
});
const changePhoneHeader =
changePhoneDialog.firstElementChild as HTMLElement | null;
expect(
within(changePhoneDialog).getByRole('button', { name: '返回' }),
).toBeTruthy();
expect(
changePhoneHeader?.lastElementChild?.textContent?.includes('返回'),
).toBe(true);
expect(
within(changePhoneDialog).queryByRole('button', { name: '关闭' }),
).toBeNull();
@@ -234,6 +246,12 @@ test('account panel includes merged security devices and audit sections', async
expect(within(accountDialog).getByText('手机号保护')).toBeTruthy();
expect(within(accountDialog).getByText('iPhone 15 Pro')).toBeTruthy();
expect(within(accountDialog).getByText('登录成功')).toBeTruthy();
expect(
within(accountDialog).getByRole('button', { name: '退出登录' }),
).toBeTruthy();
expect(
within(accountDialog).getByRole('button', { name: '退出全部设备' }),
).toBeTruthy();
});
test('legacy nested section requests now open the merged account panel', () => {

View File

@@ -173,7 +173,7 @@ function OverlayPanel({
onClick={onBack ?? onClose}
>
<div
className="platform-auth-card flex w-full flex-col overflow-hidden rounded-[28px] p-5 sm:max-w-3xl sm:p-6"
className="platform-auth-card flex max-h-full w-full min-h-0 flex-col overflow-hidden rounded-[28px] p-5 sm:max-w-3xl sm:p-6"
role="dialog"
aria-modal="true"
aria-label={title}
@@ -182,20 +182,8 @@ function OverlayPanel({
>
<div className="flex items-start justify-between gap-4">
<div className="min-w-0">
<div className="flex flex-wrap items-center gap-2">
{onBack ? (
<button
type="button"
autoFocus
className="platform-button platform-button--ghost min-h-0 rounded-full px-3 py-1.5 text-xs"
onClick={onBack}
>
</button>
) : null}
<div className="text-xs uppercase tracking-[0.28em] text-[var(--platform-cool-text)]">
{eyebrow}
</div>
<div className="text-xs uppercase tracking-[0.28em] text-[var(--platform-cool-text)]">
{eyebrow}
</div>
<div className="mt-2 text-2xl font-semibold text-[var(--platform-text-strong)]">
{title}
@@ -208,7 +196,16 @@ function OverlayPanel({
</div>
<div className="flex items-center gap-2">
{action}
{onBack ? null : (
{onBack ? (
<button
type="button"
autoFocus
className="platform-button platform-button--ghost min-h-0 rounded-full px-3 py-1.5 text-xs"
onClick={onBack}
>
</button>
) : (
<button
type="button"
className="platform-button platform-button--ghost min-h-0 rounded-full px-3 py-1.5 text-xs"
@@ -420,7 +417,7 @@ export function AccountModal({
onClick={onClose}
>
<div
className="platform-auth-card relative flex w-full max-w-5xl flex-col overflow-hidden rounded-[28px] p-5 sm:p-6"
className="platform-auth-card relative flex h-[min(100%,calc(100vh-2rem))] w-full max-w-5xl min-h-0 flex-col overflow-hidden rounded-[28px] p-5 sm:p-6"
role="dialog"
aria-modal="true"
aria-label="设置与账号安全"
@@ -443,7 +440,7 @@ export function AccountModal({
</div>
<div className="mt-5 min-h-0 flex-1 overflow-y-auto overscroll-y-contain pr-1">
<div ref={settingsHomeRef} className="space-y-4">
<div ref={settingsHomeRef} className="flex min-h-0 flex-col gap-4">
<div className="grid gap-3 sm:grid-cols-2">
{SETTINGS_SECTIONS.map((section) => (
<SettingsEntryCard
@@ -459,39 +456,6 @@ export function AccountModal({
/>
))}
</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)]">
{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>
@@ -503,7 +467,7 @@ export function AccountModal({
onBack={closeSectionPanel}
onClose={onClose}
>
<div className="space-y-4">
<div className="flex min-h-0 flex-col gap-4">
<div className="grid gap-3 md:grid-cols-2">
<ThemeOptionCard
active={platformTheme === 'light'}
@@ -548,7 +512,7 @@ export function AccountModal({
onBack={closeSectionPanel}
onClose={onClose}
>
<div className="space-y-4">
<div className="flex min-h-0 flex-col gap-4">
{accountNotice ? (
<div className="platform-banner platform-banner--success text-sm">
{accountNotice}
@@ -571,6 +535,27 @@ export function AccountModal({
))}
</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 className="platform-subpanel rounded-2xl px-4 py-4">
<div className="flex items-center justify-between gap-3">
<div>

View File

@@ -120,7 +120,7 @@ test('auth gate keeps platform content visible when phone login is available', a
);
expect(await screen.findByText('应用内容')).toBeTruthy();
expect(screen.getByRole('button', { name: '登录' })).toBeTruthy();
expect(screen.queryByRole('button', { name: '登录' })).toBeNull();
expect(screen.queryByText('先登录账号,再同步你的冒险进度。')).toBeNull();
expect(authMocks.ensureAutoAuthUser).not.toHaveBeenCalled();
});

View File

@@ -76,7 +76,6 @@ export function AuthGate({ children }: AuthGateProps) {
const [showSettingsModal, setShowSettingsModal] = useState(false);
const [initialSettingsSection, setInitialSettingsSection] =
useState<PlatformSettingsSection | null>(null);
const [showGlobalAccountActions, setShowGlobalAccountActions] = useState(true);
const [sessions, setSessions] = useState<AuthSessionSummary[]>([]);
const [loadingSessions, setLoadingSessions] = useState(false);
const [auditLogs, setAuditLogs] = useState<AuthAuditLogEntry[]>([]);
@@ -389,7 +388,6 @@ export function AuthGate({ children }: AuthGateProps) {
await logoutAuthUser();
setShowSettingsModal(false);
},
setGlobalAccountActionsVisible: setShowGlobalAccountActions,
musicVolume: settings.musicVolume,
setMusicVolume: settings.setMusicVolume,
platformTheme: settings.platformTheme,
@@ -516,38 +514,6 @@ export function AuthGate({ children }: AuthGateProps) {
<AuthUiContext.Provider value={authUiValue}>
<div className="relative">
<div className={`platform-theme ${platformThemeClass}`}>
{showGlobalAccountActions ? (
<div className="pointer-events-none fixed right-3 top-3 z-50 flex justify-end">
{readyUser ? (
<div className="platform-auth-card pointer-events-auto flex items-center gap-2 rounded-full px-3 py-2 text-xs text-[var(--platform-text-base)]">
<button
type="button"
className="platform-button platform-button--secondary min-h-0 rounded-full px-2.5 py-1 text-[11px]"
onClick={() => openAccountModal()}
>
{readyUser.displayName}
</button>
<button
type="button"
className="platform-button platform-button--ghost min-h-0 rounded-full px-2.5 py-1 text-[11px]"
onClick={() => {
void logoutAuthUser();
}}
>
退
</button>
</div>
) : (
<button
type="button"
className="platform-auth-card pointer-events-auto rounded-full px-3 py-2 text-xs font-medium text-[var(--platform-text-strong)] transition hover:-translate-y-px"
onClick={() => openLoginModal()}
>
</button>
)}
</div>
) : null}
{readyUser ? (
<AccountModal
user={readyUser}

View File

@@ -17,7 +17,6 @@ type AuthUiContextValue = {
openSettingsModal: (section?: PlatformSettingsSection) => void;
openAccountModal: () => void;
logout: () => Promise<void>;
setGlobalAccountActionsVisible: (visible: boolean) => void;
musicVolume: number;
setMusicVolume: (value: number) => void;
platformTheme: PlatformTheme;

View File

@@ -33,6 +33,7 @@ import {
RoleCharacterSprite,
SCENE_TRANSITION_LOWER_COMPANION_DELAY_S,
SCENE_TRANSITION_UPPER_COMPANION_DELAY_S,
SceneEncounterNpcSprite,
SceneEntityButton,
} from './GameCanvasShared';
@@ -403,7 +404,9 @@ export function GameCanvasEntityLayer({
style={{imageRendering: 'pixelated'}}
/>
</div>
) : peacefulResolvedCharacter ? (
) : peacefulResolvedCharacter &&
!encounter.visual &&
!encounter.imageSrc?.trim() ? (
<RoleCharacterSprite
state={AnimationState.IDLE}
character={peacefulResolvedCharacter}
@@ -417,11 +420,11 @@ export function GameCanvasEntityLayer({
className="scale-[1.82] origin-bottom"
/>
) : (
<MedievalNpcAnimator
<SceneEncounterNpcSprite
encounter={encounter}
className="drop-shadow-[0_8px_14px_rgba(0,0,0,0.38)]"
state={AnimationState.IDLE}
facing={peacefulNpcSpriteFacing}
scale={GENERIC_NPC_SCENE_SCALE}
className="drop-shadow-[0_8px_14px_rgba(0,0,0,0.38)]"
/>
)}
</div>

View File

@@ -2,7 +2,10 @@ import React, {useEffect, useState} from 'react';
import {getCharacterById} from '../../data/characterPresets';
import {METERS_TO_PIXELS} from '../../data/hostileNpcs';
import {buildMedievalNpcVisualFromCustomWorldVisual} from '../../data/medievalNpcVisuals';
import {
buildMedievalNpcVisual,
buildMedievalNpcVisualFromCustomWorldVisual,
} from '../../data/medievalNpcVisuals';
import {
AnimationState,
Character,
@@ -246,6 +249,87 @@ export function RoleCharacterSprite({
);
}
export function SceneEncounterNpcSprite({
encounter,
state,
facing,
className,
}: {
encounter: Encounter;
state: AnimationState;
facing: 'left' | 'right';
className?: string;
}) {
if (encounter.visual) {
return (
<MedievalNpcAnimator
visualSpec={buildMedievalNpcVisualFromCustomWorldVisual(encounter.visual)}
className={`origin-bottom ${className ?? ''}`.trim()}
scale={1.36}
facing={facing}
/>
);
}
if (encounter.imageSrc?.trim()) {
return (
<img
src={encounter.imageSrc.trim()}
alt={encounter.npcName}
className={`h-full w-full object-contain ${className ?? ''}`.trim()}
style={{
...DEFAULT_IMAGE_STYLE,
transform: facing === 'left' ? 'scaleX(-1)' : undefined,
transformOrigin: 'bottom center',
}}
/>
);
}
const runtimeCustomWorldCharacter =
encounter.characterId ? getCharacterById(encounter.characterId) : null;
if (runtimeCustomWorldCharacter?.visual) {
return (
<MedievalNpcAnimator
visualSpec={buildMedievalNpcVisualFromCustomWorldVisual(runtimeCustomWorldCharacter.visual)}
className={`origin-bottom ${className ?? ''}`.trim()}
scale={1.36}
facing={facing}
/>
);
}
if (runtimeCustomWorldCharacter) {
return (
<div
className="h-full w-full"
style={{transform: facing === 'left' ? 'scaleX(-1)' : undefined}}
>
<CharacterAnimator
state={state}
character={runtimeCustomWorldCharacter}
className={ROLE_CHARACTER_SPRITE_CLASS}
/>
</div>
);
}
return (
<MedievalNpcAnimator
visualSpec={buildMedievalNpcVisual({
id: encounter.id ?? encounter.npcName,
npcName: encounter.npcName,
npcDescription: encounter.npcDescription,
npcAvatar: encounter.npcAvatar,
context: encounter.context,
} as Encounter)}
className={`origin-bottom ${className ?? ''}`.trim()}
scale={1.36}
facing={facing}
/>
);
}
export function DialogueBubbleIcon({
active = false,
flip = false,

View File

@@ -117,12 +117,90 @@ test('custom world character selection stays stable when character ids are empty
render(
<CharacterSelectionFlow
worldType={WorldType.CUSTOM}
customWorldProfile={{} as CustomWorldProfile}
customWorldProfile={{
attributeSchema: {
id: 'schema:custom:test',
worldId: 'custom:test',
schemaVersion: 1,
generatedFrom: {
worldType: WorldType.CUSTOM,
worldName: '潮城',
settingSummary: '潮水与迷雾交织的港城。',
tone: '潮湿、危险、带着试探。',
conflictCore: '在涨落之间抢先一步。',
},
slots: [
{
slotId: 'axis_a',
name: '潮骨',
definition: '扛住潮压与正面冲击的底子。',
positiveSignals: [],
negativeSignals: [],
combatUseText: '顶住正面浪涌。',
socialUseText: '给人能扛事的可靠感。',
explorationUseText: '在风浪里稳住自己。',
},
{
slotId: 'axis_b',
name: '浪步',
definition: '顺潮借势、换位穿行的能力。',
positiveSignals: [],
negativeSignals: [],
combatUseText: '借势切线。',
socialUseText: '谈吐灵活。',
explorationUseText: '穿越复杂地形。',
},
{
slotId: 'axis_c',
name: '舟识',
definition: '辨流向、识潮眼的能力。',
positiveSignals: [],
negativeSignals: [],
combatUseText: '抓住变化时机。',
socialUseText: '看懂局势留白。',
explorationUseText: '辨认水路与遗痕。',
},
{
slotId: 'axis_d',
name: '潮魄',
definition: '在剧烈变化中仍敢推进的胆气。',
positiveSignals: [],
negativeSignals: [],
combatUseText: '顶着压力推进。',
socialUseText: '在冲突里压住场子。',
explorationUseText: '面对异变继续前探。',
},
{
slotId: 'axis_e',
name: '契汐',
definition: '与人和约定形成牵引的能力。',
positiveSignals: [],
negativeSignals: [],
combatUseText: '借协同形成连锁。',
socialUseText: '结盟、安抚与交换。',
explorationUseText: '从旧约中打开局面。',
},
{
slotId: 'axis_f',
name: '回澜',
definition: '在漫长消耗中回稳状态的能力。',
positiveSignals: [],
negativeSignals: [],
combatUseText: '久战不乱。',
socialUseText: '遇事沉静。',
explorationUseText: '在恶劣天气里保有余力。',
},
],
},
} as unknown as CustomWorldProfile}
onBack={() => {}}
onConfirm={handleConfirm}
/>,
);
expect(screen.getByText(/:/u)).toBeTruthy();
expect(screen.queryByText(/:/u)).toBeNull();
await user.click(screen.getByRole('button', { name: //u }));
await waitFor(() => {

View File

@@ -1,5 +1,12 @@
import {useCallback, useEffect, useMemo, useRef, useState} from 'react';
import {
buildCharacterAttributeProfile,
} from '../../data/attributeProfileGenerator';
import {
resolveAttributeSchema,
resolveCharacterAttributeProfile,
} from '../../data/attributeResolver';
import {
buildCustomWorldPlayableCharacters,
ROLE_TEMPLATE_CHARACTERS,
@@ -32,13 +39,6 @@ const CHARACTER_DISPLAY: Record<string, {name: string; title: string; role: stri
'fighter-4': {name: '装甲长矛手', title: '重装先锋', role: '前线', tags: ['守护', '稳定', '突破']},
};
const ATTRIBUTE_LABELS: Record<keyof Character['attributes'], string> = {
strength: '力量',
agility: '敏捷',
intelligence: '智力',
spirit: '精神',
};
function getGenderLabel(gender: Character['gender']) {
if (gender === 'female') return '女性';
if (gender === 'male') return '男性';
@@ -211,6 +211,22 @@ export function CharacterSelectionFlow({
const selectedCharacterMeta = selectedCharacter
? getCharacterMeta(selectedCharacter, {name: selectedCharacterDraft?.name})
: null;
const attributeSchema = useMemo(
() => resolveAttributeSchema(worldType, customWorldProfile),
[customWorldProfile, worldType],
);
const selectedAttributeProfile = useMemo(
() =>
selectedCharacter
? resolveCharacterAttributeProfile(
selectedCharacter,
worldType,
customWorldProfile,
)
?? buildCharacterAttributeProfile(selectedCharacter, attributeSchema)
: null,
[attributeSchema, customWorldProfile, selectedCharacter, worldType],
);
const selectedCharacterPersonalityTags = useMemo(
() => (selectedCharacterPreview ? getPersonalityTags(selectedCharacterPreview.personality) : []),
[selectedCharacterPreview],
@@ -363,10 +379,10 @@ export function CharacterSelectionFlow({
</span>
</div>
</div>
<div className="grid grid-cols-4 gap-1 text-[11px] text-zinc-300 sm:gap-1.5 sm:text-[13px]">
{Object.entries(selectedCharacter.attributes).map(([key, value]) => (
<div key={key} className="rounded-lg border border-white/6 bg-black/20 px-2 py-1.5 text-center sm:px-2.5">
{ATTRIBUTE_LABELS[key as keyof Character['attributes']]}: {value}
<div className="grid grid-cols-2 gap-1 text-[11px] text-zinc-300 sm:grid-cols-3 sm:gap-1.5 sm:text-[13px]">
{attributeSchema.slots.map((slot) => (
<div key={slot.slotId} className="rounded-lg border border-white/6 bg-black/20 px-2 py-1.5 text-center sm:px-2.5">
{slot.name}: {selectedAttributeProfile?.values?.[slot.slotId] ?? 0}
</div>
))}
</div>

View File

@@ -141,11 +141,15 @@ export function GameShellMainContent({
<div
className={`${isPlatformShell ? 'platform-main-shell' : 'pixel-app-shell'} flex min-h-0 flex-1 flex-col ${isCharacterSelectionStage ? 'justify-center p-4 sm:p-5' : 'p-3 sm:p-4'}`}
style={{
background: isPlatformShell
backgroundColor: isPlatformShell
? 'transparent'
: isCharacterSelectionStage
? '#0d1016'
: `linear-gradient(rgba(10, 12, 18, 0.55), rgba(10, 12, 18, 0.55)), url("${UI_CHROME.appBackground}")`,
? '#0d1016'
: undefined,
backgroundImage:
isPlatformShell || isCharacterSelectionStage
? undefined
: `linear-gradient(rgba(10, 12, 18, 0.55), rgba(10, 12, 18, 0.55)), url("${UI_CHROME.appBackground}")`,
backgroundPosition:
isPlatformShell || isCharacterSelectionStage ? undefined : 'center',
backgroundRepeat:

View File

@@ -1,9 +1,13 @@
import { AnimatePresence, motion } from 'motion/react';
import { lazy, Suspense } from 'react';
import type { CharacterChatUi, InventoryFlowUi, StoryGenerationNpcUi } from '../../hooks/useStoryGeneration';
import type {
CharacterChatUi,
InventoryFlowUi,
StoryGenerationNpcUi,
} from '../../hooks/useStoryGeneration';
import type { CompanionRenderState, GameState } from '../../types';
import { CHROME_ICONS, getNineSliceStyle,UI_CHROME } from '../../uiAssets';
import { CHROME_ICONS, getNineSliceStyle, UI_CHROME } from '../../uiAssets';
import type { GameCanvasEntitySelection } from '../GameCanvas';
import { PixelIcon } from '../PixelIcon';
import { ModalLoadingFallback, PanelLoadingFallback } from './GameShellLoaders';
@@ -120,7 +124,14 @@ export function GameShellOverlays({
return (
<>
{shouldMountAdventureEntityModal && (
<Suspense fallback={<ModalLoadingFallback label="正在加载冒险详情..." onClose={closeAdventureEntityModal} />}>
<Suspense
fallback={
<ModalLoadingFallback
label="正在加载冒险详情..."
onClose={closeAdventureEntityModal}
/>
}
>
<AdventureEntityModal
selection={selectedSceneEntity}
gameState={gameState}
@@ -146,10 +157,12 @@ export function GameShellOverlays({
transition={{ duration: 0.18, ease: 'easeOut' }}
className="pixel-nine-slice pixel-modal-shell flex max-h-[min(92vh,60rem)] w-full max-w-5xl flex-col overflow-hidden shadow-[0_24px_80px_rgba(0,0,0,0.55)]"
style={getNineSliceStyle(UI_CHROME.modalPanel)}
onClick={event => event.stopPropagation()}
onClick={(event) => event.stopPropagation()}
>
<div className="relative border-b border-white/10 px-4 py-3 sm:px-5 sm:py-4">
<div className="min-w-0 pr-10 text-sm font-semibold text-white">{overlayPanel === 'character' ? '队伍' : '背包'}</div>
<div className="min-w-0 pr-10 text-sm font-semibold text-white">
{overlayPanel === 'character' ? '队伍' : '背包'}
</div>
<button
type="button"
onClick={closeOverlayPanel}
@@ -160,11 +173,14 @@ export function GameShellOverlays({
</div>
<div className="flex min-h-0 flex-1 p-5">
{overlayPanel === 'character' ? (
<Suspense fallback={<PanelLoadingFallback label="正在加载队伍面板" />}>
<Suspense
fallback={<PanelLoadingFallback label="正在加载队伍面板" />}
>
<CharacterPanel
worldType={gameState.worldType}
customWorldProfile={gameState.customWorldProfile}
playerCharacter={gameState.playerCharacter}
playerProgression={gameState.playerProgression ?? null}
playerHp={gameState.playerHp}
playerMaxHp={gameState.playerMaxHp}
playerMana={gameState.playerMana}
@@ -178,7 +194,7 @@ export function GameShellOverlays({
closeOverlayPanel();
openCampModal();
}}
onOpenCharacterChat={target => {
onOpenCharacterChat={(target) => {
closeOverlayPanel();
characterChatUi.openChat(target);
}}
@@ -187,7 +203,9 @@ export function GameShellOverlays({
/>
</Suspense>
) : (
<Suspense fallback={<PanelLoadingFallback label="正在加载背包面板" />}>
<Suspense
fallback={<PanelLoadingFallback label="正在加载背包面板" />}
>
<InventoryPanel
playerCharacter={gameState.playerCharacter}
worldType={gameState.worldType}
@@ -214,7 +232,14 @@ export function GameShellOverlays({
</AnimatePresence>
{shouldMountCampModal && (
<Suspense fallback={<ModalLoadingFallback label="正在加载队伍营地..." onClose={closeCampModal} />}>
<Suspense
fallback={
<ModalLoadingFallback
label="正在加载队伍营地..."
onClose={closeCampModal}
/>
}
>
<CompanionCampModal
isOpen={showTeamModal}
playerCharacter={gameState.playerCharacter}
@@ -229,13 +254,20 @@ export function GameShellOverlays({
)}
{shouldMountMapModal && (
<Suspense fallback={<ModalLoadingFallback label="正在加载地图..." onClose={() => setIsMapOpen(false)} />}>
<Suspense
fallback={
<ModalLoadingFallback
label="正在加载地图..."
onClose={() => setIsMapOpen(false)}
/>
}
>
<MapModal
isOpen={isMapOpen}
currentScenePreset={gameState.currentScenePreset}
worldType={gameState.worldType}
canTravel={!gameState.inBattle && !isLoading}
onTravelToScene={scene => {
onTravelToScene={(scene) => {
const triggered = handleMapTravelToScene(scene.id);
if (triggered) {
setIsMapOpen(false);
@@ -248,7 +280,14 @@ export function GameShellOverlays({
)}
{shouldMountCharacterChatModal && (
<Suspense fallback={<ModalLoadingFallback label="正在加载角色聊天..." onClose={characterChatUi.closeChat} />}>
<Suspense
fallback={
<ModalLoadingFallback
label="正在加载角色聊天..."
onClose={characterChatUi.closeChat}
/>
}
>
<CharacterChatModal
modal={characterChatUi.modal}
onClose={characterChatUi.closeChat}
@@ -261,7 +300,9 @@ export function GameShellOverlays({
)}
{shouldMountNpcModals && (
<Suspense fallback={<ModalLoadingFallback label="正在加载角色交互..." />}>
<Suspense
fallback={<ModalLoadingFallback label="正在加载角色交互..." />}
>
<NpcModals gameState={gameState} npcUi={npcUi} />
</Suspense>
)}

View File

@@ -1,5 +1,6 @@
import { lazy, Suspense, useEffect } from 'react';
import { lazy, Suspense } from 'react';
import { normalizePlayerProgressionState } from '../../data/playerProgression';
import { UI_CHROME } from '../../uiAssets';
import { useAuthUi } from '../auth/AuthUiContext';
import { GameShellMainContent } from './GameShellMainContent';
@@ -19,7 +20,13 @@ const GameShellCanvasStage = lazy(async () => {
};
});
export function GameShellRuntime({session, story, entry, companions, audio}: GameShellProps) {
export function GameShellRuntime({
session,
story,
entry,
companions,
audio,
}: GameShellProps) {
const authUi = useAuthUi();
const isPlatformShell = !session.gameState.worldType;
const platformThemeClass =
@@ -60,12 +67,9 @@ export function GameShellRuntime({session, story, entry, companions, audio}: Gam
handleBackToWorldSelect,
handleCharacterSelect,
} = entry;
const {
companionRenderStates,
onBenchCompanion,
onActivateRosterCompanion,
} = companions;
const {musicVolume, onMusicVolumeChange} = audio;
const { companionRenderStates, onBenchCompanion, onActivateRosterCompanion } =
companions;
const { musicVolume, onMusicVolumeChange } = audio;
const {
selectionStage,
setSelectionStage,
@@ -103,24 +107,26 @@ export function GameShellRuntime({session, story, entry, companions, audio}: Gam
story,
companions,
});
useEffect(() => {
authUi?.setGlobalAccountActionsVisible(false);
return () => {
authUi?.setGlobalAccountActionsVisible(true);
};
}, [authUi]);
const playerProgression = normalizePlayerProgressionState(
visibleGameState.playerProgression ?? null,
);
const playerProgressionRatio =
playerProgression.xpToNextLevel <= 0
? 1
: Math.max(
0,
Math.min(
1,
playerProgression.currentLevelXp / playerProgression.xpToNextLevel,
),
);
return (
<div
className={`${isPlatformShell ? `platform-ui-shell platform-theme ${platformThemeClass} text-[var(--platform-text-strong)]` : 'fusion-pixel-app pixel-root-shell text-zinc-100'} flex h-screen max-h-screen flex-col overflow-hidden font-sans`}
style={{
background: isPlatformShell
? 'var(--platform-body-fill)'
: undefined,
backgroundImage: isPlatformShell
? undefined
? 'var(--platform-body-fill)'
: `linear-gradient(rgba(8, 10, 14, 0.82), rgba(8, 10, 14, 0.82)), url("${UI_CHROME.appBackground}")`,
backgroundPosition: isPlatformShell ? undefined : 'center',
backgroundRepeat: isPlatformShell ? undefined : 'repeat',
@@ -141,6 +147,36 @@ export function GameShellRuntime({session, story, entry, companions, audio}: Gam
/>
</Suspense>
{visibleGameState.playerCharacter && (
<div
className="pointer-events-none fixed z-[26] w-[4.5rem] drop-shadow-[0_2px_8px_rgba(0,0,0,0.75)]"
style={{
top: 'calc(env(safe-area-inset-top, 0px) + 0.65rem)',
left: 'calc(env(safe-area-inset-left, 0px) + 0.7rem)',
}}
>
<div className="flex items-end gap-1.5 text-amber-50">
<span className="text-[10px] font-semibold uppercase leading-none tracking-[0.14em] text-amber-100/80">
Lv
</span>
<span className="text-2xl font-black leading-none tracking-[-0.08em] text-white">
{playerProgression.level}
</span>
</div>
<div className="mt-1 h-1 overflow-hidden rounded-full bg-black/45">
<div
className="h-full rounded-full bg-[linear-gradient(90deg,rgba(251,191,36,0.65),rgba(254,240,138,0.95))]"
style={{
width:
playerProgressionRatio <= 0
? '0%'
: `${Math.max(8, playerProgressionRatio * 100)}%`,
}}
/>
</div>
</div>
)}
<GameShellMainContent
gameState={gameState}
visibleGameState={visibleGameState}

View File

@@ -9,8 +9,13 @@ import type {
NpcChatQuestOfferUi,
QuestFlowUi,
} from '../../hooks/useStoryGeneration';
import type { CompanionRenderState, GameState, StoryMoment, StoryOption } from '../../types';
import { getNineSliceStyle,TAB_ICONS, UI_CHROME } from '../../uiAssets';
import type {
CompanionRenderState,
GameState,
StoryMoment,
StoryOption,
} from '../../types';
import { getNineSliceStyle, TAB_ICONS, UI_CHROME } from '../../uiAssets';
import type { GameCanvasEntitySelection } from '../GameCanvas';
import { PixelIcon } from '../PixelIcon';
import { PanelLoadingFallback } from './GameShellLoaders';
@@ -110,11 +115,20 @@ export function GameShellStoryPanels({
<button
onClick={() => setBottomTab('character')}
className={`pixel-nine-slice pixel-pressable pixel-tab-button ${bottomTab === 'character' ? 'pixel-tab-button--active text-white' : 'text-zinc-300'}`}
style={getNineSliceStyle(bottomTab === 'character' ? UI_CHROME.tabActive : UI_CHROME.tabInactive, { paddingX: 10, paddingY: 8 })}
style={getNineSliceStyle(
bottomTab === 'character'
? UI_CHROME.tabActive
: UI_CHROME.tabInactive,
{ paddingX: 10, paddingY: 8 },
)}
>
<span className="pixel-tab-button__inner">
<PixelIcon
src={bottomTab === 'character' ? TAB_ICONS.character.active : TAB_ICONS.character.inactive}
src={
bottomTab === 'character'
? TAB_ICONS.character.active
: TAB_ICONS.character.inactive
}
className={`pixel-tab-button__icon ${bottomTab === 'character' ? 'opacity-100' : 'opacity-70'}`}
/>
<span className="pixel-tab-button__label"></span>
@@ -123,11 +137,20 @@ export function GameShellStoryPanels({
<button
onClick={() => setBottomTab('adventure')}
className={`pixel-nine-slice pixel-pressable pixel-tab-button ${bottomTab === 'adventure' ? 'pixel-tab-button--active text-white' : 'text-zinc-300'}`}
style={getNineSliceStyle(bottomTab === 'adventure' ? UI_CHROME.tabActive : UI_CHROME.tabInactive, { paddingX: 10, paddingY: 8 })}
style={getNineSliceStyle(
bottomTab === 'adventure'
? UI_CHROME.tabActive
: UI_CHROME.tabInactive,
{ paddingX: 10, paddingY: 8 },
)}
>
<span className="pixel-tab-button__inner">
<PixelIcon
src={bottomTab === 'adventure' ? TAB_ICONS.adventure.active : TAB_ICONS.adventure.inactive}
src={
bottomTab === 'adventure'
? TAB_ICONS.adventure.active
: TAB_ICONS.adventure.inactive
}
className={`pixel-tab-button__icon ${bottomTab === 'adventure' ? 'opacity-100' : 'opacity-70'}`}
/>
<span className="pixel-tab-button__label"></span>
@@ -136,11 +159,20 @@ export function GameShellStoryPanels({
<button
onClick={() => setBottomTab('inventory')}
className={`pixel-nine-slice pixel-pressable pixel-tab-button ${bottomTab === 'inventory' ? 'pixel-tab-button--active text-white' : 'text-zinc-300'}`}
style={getNineSliceStyle(bottomTab === 'inventory' ? UI_CHROME.tabActive : UI_CHROME.tabInactive, { paddingX: 10, paddingY: 8 })}
style={getNineSliceStyle(
bottomTab === 'inventory'
? UI_CHROME.tabActive
: UI_CHROME.tabInactive,
{ paddingX: 10, paddingY: 8 },
)}
>
<span className="pixel-tab-button__inner">
<PixelIcon
src={bottomTab === 'inventory' ? TAB_ICONS.inventory.active : TAB_ICONS.inventory.inactive}
src={
bottomTab === 'inventory'
? TAB_ICONS.inventory.active
: TAB_ICONS.inventory.inactive
}
className={`pixel-tab-button__icon ${bottomTab === 'inventory' ? 'opacity-100' : 'opacity-70'}`}
/>
<span className="pixel-tab-button__label"></span>
@@ -154,6 +186,7 @@ export function GameShellStoryPanels({
worldType={visibleGameState.worldType}
customWorldProfile={visibleGameState.customWorldProfile}
playerCharacter={playerCharacter}
playerProgression={visibleGameState.playerProgression ?? null}
playerHp={visibleGameState.playerHp}
playerMaxHp={visibleGameState.playerMaxHp}
playerMana={visibleGameState.playerMana}

View File

@@ -48,8 +48,10 @@ export type PlatformHomeTab = 'home' | 'create' | 'saves' | 'profile';
const PANEL_SURFACE_CLASS = 'platform-surface platform-surface--soft';
const HERO_SURFACE_CLASS =
'platform-surface platform-surface--hero platform-interactive-card';
const MOBILE_PAGE_STAGE_CLASS = 'platform-page-stage space-y-4 pb-2';
const DESKTOP_PAGE_STAGE_CLASS = 'platform-page-stage space-y-5 pb-4';
const MOBILE_PAGE_STAGE_CLASS =
'platform-page-stage platform-remap-surface space-y-4 pb-2';
const DESKTOP_PAGE_STAGE_CLASS =
'platform-page-stage platform-remap-surface space-y-5 pb-4';
function SectionHeader({ title, detail }: { title: string; detail: string }) {
return (
@@ -57,14 +59,18 @@ function SectionHeader({ title, detail }: { title: string; detail: string }) {
<div className="text-[10px] font-semibold tracking-[0.26em] text-zinc-500">
{detail}
</div>
<div className="mt-1 text-base font-semibold text-white">{title}</div>
<div className="mt-1 text-base font-semibold text-[var(--platform-text-strong)]">
{title}
</div>
</div>
);
}
function EmptyShelf({ text }: { text: string }) {
return (
<div className={`${PANEL_SURFACE_CLASS} rounded-[1.35rem] px-4 py-3 text-sm leading-6 text-zinc-300`}>
<div
className={`${PANEL_SURFACE_CLASS} rounded-[1.35rem] px-4 py-3 text-sm leading-6 text-zinc-300`}
>
{text}
</div>
);
@@ -82,7 +88,7 @@ function SaveArchivePreview({
return (
<div
aria-hidden="true"
className={`relative shrink-0 overflow-hidden rounded-[1.35rem] border border-white/12 bg-black/18 shadow-[0_16px_36px_rgba(15,23,42,0.18)] ${className}`}
className={`platform-remap-surface relative shrink-0 overflow-hidden rounded-[1.35rem] border border-white/12 bg-black/18 shadow-[var(--platform-desktop-hover-shadow)] ${className}`}
>
{entry.coverImageSrc ? (
<img
@@ -93,7 +99,7 @@ function SaveArchivePreview({
) : (
<div className="absolute inset-0 bg-[radial-gradient(circle_at_top_left,rgba(255,255,255,0.18),transparent_34%),linear-gradient(145deg,rgba(255,94,125,0.92),rgba(255,150,116,0.88))]" />
)}
<div className="absolute inset-0 bg-[linear-gradient(180deg,rgba(8,10,14,0.06),rgba(8,10,14,0.74))]" />
<div className="absolute inset-0 bg-[var(--platform-card-overlay-soft)]" />
<div className="absolute inset-x-0 bottom-0 px-2.5 py-2">
<span className="inline-flex max-w-full items-center rounded-full border border-white/15 bg-black/24 px-2.5 py-1 text-[9px] font-semibold tracking-[0.14em] text-white/88">
{label}
@@ -147,12 +153,10 @@ function WorldCard({
className="absolute bottom-2 right-2 h-24 w-24 object-contain opacity-25"
/>
) : null}
<div className="absolute inset-0 bg-[linear-gradient(180deg,rgba(8,10,14,0.14),rgba(8,10,14,0.9))]" />
<div className="absolute inset-0 bg-[var(--platform-card-overlay-strong)]" />
<div className="relative z-10 flex h-full flex-col">
<div className="flex items-start justify-between gap-3">
<span className="platform-pill platform-pill--warm">
{badge}
</span>
<span className="platform-pill platform-pill--warm">{badge}</span>
<span className="platform-pill platform-pill--neutral px-2.5">
{metaLabel}
</span>
@@ -233,7 +237,7 @@ function CreationLibraryCard({
className="absolute bottom-1.5 right-1.5 h-16 w-16 object-contain opacity-24 sm:h-20 sm:w-20"
/>
) : null}
<div className="absolute inset-0 bg-[linear-gradient(180deg,rgba(8,10,14,0.1),rgba(8,10,14,0.92))]" />
<div className="absolute inset-0 bg-[var(--platform-card-overlay-strong)]" />
<div className="relative z-10 flex h-full min-w-0 flex-col">
<div className="flex min-w-0 flex-wrap items-center gap-1.5">
<span
@@ -267,7 +271,9 @@ function CreationLibraryCard({
<span className="truncate">{primaryTag}</span>
</span>
<span className="inline-flex items-center gap-1 text-[11px] font-semibold text-zinc-200">
<span>{entry.visibility === 'published' ? '进入世界' : '继续创作'}</span>
<span>
{entry.visibility === 'published' ? '进入世界' : '继续创作'}
</span>
<ArrowRight className="h-3.5 w-3.5 shrink-0" />
</span>
</div>
@@ -286,7 +292,8 @@ function SaveArchiveCard({
onClick: () => void;
loading?: boolean;
}) {
const summaryText = entry.summaryText || entry.subtitle || '继续推进上一次保存的故事。';
const summaryText =
entry.summaryText || entry.subtitle || '继续推进上一次保存的故事。';
return (
<button
@@ -295,7 +302,7 @@ function SaveArchiveCard({
disabled={loading}
className={`platform-surface platform-surface--soft platform-interactive-card relative flex min-h-[13rem] w-full overflow-hidden p-3.5 text-left sm:min-h-[12.5rem] sm:p-4 ${loading ? 'opacity-80' : ''}`}
>
<div className="absolute inset-0 bg-[radial-gradient(circle_at_top_left,rgba(255,255,255,0.14),transparent_30%),radial-gradient(circle_at_right,rgba(255,205,178,0.14),transparent_28%),linear-gradient(180deg,rgba(8,10,14,0.22),rgba(8,10,14,0.9))]" />
<div className="absolute inset-0 bg-[var(--platform-card-overlay-deep)]" />
<div className="relative z-10 flex h-full w-full flex-col gap-3">
<div className="flex flex-wrap items-center justify-between gap-2">
<span className="platform-pill platform-pill--cool">ARCHIVE</span>
@@ -422,7 +429,7 @@ function DesktopTrendingItem({
<span>{`${rank}`.padStart(2, '0')}</span>
<span>{formatPlatformWorldTime(entry.publishedAt)}</span>
</div>
<div className="mt-2 line-clamp-1 text-lg font-semibold text-white">
<div className="mt-2 line-clamp-1 text-lg font-semibold text-[var(--platform-text-strong)]">
{entry.worldName}
</div>
<div className="mt-1 line-clamp-2 text-sm leading-6 text-zinc-300/86">
@@ -581,7 +588,9 @@ function ProfileStatCard({
<Icon className="h-4 w-4" />
<span className="text-[11px] tracking-[0.16em]">{label}</span>
</div>
<div className="mt-3 text-lg font-black text-white">{value}</div>
<div className="mt-3 text-lg font-black text-[var(--platform-text-strong)]">
{value}
</div>
</button>
);
}
@@ -615,7 +624,9 @@ function ProfileShortcutButton({
<div className="platform-profile-chip flex h-10 w-10 items-center justify-center rounded-full">
<Icon className="h-[1.125rem] w-[1.125rem]" />
</div>
<div className="text-sm font-semibold text-white">{label}</div>
<div className="text-sm font-semibold text-[var(--platform-text-strong)]">
{label}
</div>
</button>
);
}
@@ -756,7 +767,7 @@ export function PlatformHomeView({
? `${snapshotCharacterName} 的进度已保存,点这里回到上一次停下来的故事节点。`
: '从设定、角色到场景网络,先生成一版可玩的世界底稿,再继续精修和发布。'}
</div>
<div className="mt-4 flex items-center gap-2 text-sm font-semibold text-violet-100">
<div className="mt-4 flex items-center gap-2 text-sm font-semibold text-white/90">
<span>{hasSavedGame ? '继续推进故事' : '进入创作工作台'}</span>
<ArrowRight className="h-4 w-4" />
</div>
@@ -832,7 +843,7 @@ export function PlatformHomeView({
<div className="mt-2 max-w-[28rem] text-sm leading-6 text-zinc-200/88">
</div>
<div className="mt-4 flex items-center gap-2 text-sm font-semibold text-cyan-100">
<div className="mt-4 flex items-center gap-2 text-sm font-semibold text-white/90">
<span></span>
<ArrowRight className="h-4 w-4" />
</div>
@@ -904,7 +915,9 @@ export function PlatformHomeView({
) : (
<section className={`${PANEL_SURFACE_CLASS} px-4 py-3.5`}>
<div className="platform-subpanel rounded-[1.35rem] px-4 py-4">
<div className="text-base font-semibold text-white"></div>
<div className="text-base font-semibold text-[var(--platform-text-strong)]">
</div>
<button
type="button"
onClick={() => authUi?.openLoginModal()}
@@ -1074,7 +1087,9 @@ export function PlatformHomeView({
<Settings className="h-[1.125rem] w-[1.125rem]" />
</div>
<div>
<div className="text-base font-semibold text-white"></div>
<div className="text-base font-semibold text-[var(--platform-text-strong)]">
</div>
<div className="text-xs text-zinc-400"></div>
</div>
</div>
@@ -1085,7 +1100,9 @@ export function PlatformHomeView({
) : (
<section className={`${PANEL_SURFACE_CLASS} px-4 py-3.5`}>
<div className="platform-subpanel rounded-[1.35rem] px-4 py-4">
<div className="text-base font-semibold text-white"></div>
<div className="text-base font-semibold text-[var(--platform-text-strong)]">
</div>
<button
type="button"
onClick={() => authUi?.openLoginModal()}
@@ -1144,15 +1161,19 @@ export function PlatformHomeView({
<div className="max-w-[35rem]">
<div className="text-5xl font-semibold leading-[1.08] text-white">
{hasSavedGame ? snapshotWorldName : '把你的世界观直接变成可游玩的舞台'}
{hasSavedGame
? snapshotWorldName
: '把你的世界观直接变成可游玩的舞台'}
</div>
<div className="mt-4 text-base leading-8 text-zinc-200/86">
{hasSavedGame
? `${snapshotCharacterName} 的进度已经保存,桌面端可以直接从这里回到上一次停下来的关键节点。`
: '从设定、角色、世界结构到可玩流程,一次生成创作底稿,再继续精修并发布到平台广场。'}
</div>
<div className="mt-5 inline-flex items-center gap-2 rounded-full border border-violet-300/18 bg-violet-500/14 px-4 py-2 text-sm font-semibold text-violet-100">
<span>{hasSavedGame ? '继续推进故事' : '进入创作工作台'}</span>
<div className="mt-5 inline-flex items-center gap-2 rounded-full border border-white/18 bg-white/18 px-4 py-2 text-sm font-semibold text-white/92">
<span>
{hasSavedGame ? '继续推进故事' : '进入创作工作台'}
</span>
<ArrowRight className="h-4 w-4" />
</div>
</div>
@@ -1181,7 +1202,9 @@ export function PlatformHomeView({
<span className="text-zinc-500">
{`${index + 1}`.padStart(2, '0')}
</span>
<span className="line-clamp-1">{entry.worldName}</span>
<span className="line-clamp-1">
{entry.worldName}
</span>
</div>
</div>
);
@@ -1405,8 +1428,11 @@ export function PlatformHomeView({
</div>
<div
className="mt-4 border-t border-white/5 pt-3"
style={{ paddingBottom: 'calc(env(safe-area-inset-bottom) + 0.2rem)' }}
className="mt-4 border-t pt-3"
style={{
borderColor: 'var(--platform-line-soft)',
paddingBottom: 'calc(env(safe-area-inset-bottom) + 0.2rem)',
}}
>
<div className="platform-bottom-nav grid h-14 grid-cols-4 gap-1 rounded-[1.2rem] px-1 py-1">
<PlatformTabButton
@@ -1441,10 +1467,7 @@ export function PlatformHomeView({
<div className="platform-desktop-shell flex h-full min-h-0 flex-col p-5 xl:p-6">
<div className="platform-desktop-topbar flex items-center gap-4 px-5 py-4">
<div className="flex min-w-0 flex-1 items-center gap-5">
<PlatformBrandLogo
className="shrink-0"
decorative
/>
<PlatformBrandLogo className="shrink-0" decorative />
<div className="platform-desktop-search flex min-w-0 max-w-[34rem] flex-1 items-center gap-3 px-4 py-3 text-zinc-400">
<Search className="h-4 w-4 shrink-0" />
<span className="truncate text-sm">
@@ -1467,11 +1490,17 @@ export function PlatformHomeView({
onClick={openUserSurface}
className="platform-desktop-search flex items-center gap-3 px-3 py-2.5 text-left"
>
<span className="flex h-11 w-11 items-center justify-center rounded-full bg-[linear-gradient(135deg,rgba(91,108,255,0.9),rgba(61,217,255,0.82))] text-base font-black text-white shadow-[0_10px_22px_rgba(91,108,255,0.24)]">
<span
className="flex h-11 w-11 items-center justify-center rounded-full text-base font-black text-white"
style={{
background: 'var(--platform-profile-avatar-fill)',
boxShadow: 'var(--platform-profile-avatar-shadow)',
}}
>
{avatarLabel}
</span>
<span className="min-w-0">
<span className="block truncate text-sm font-semibold text-white">
<span className="block truncate text-sm font-semibold text-[var(--platform-text-strong)]">
{authUi?.user?.displayName || '进入账户'}
</span>
<span className="block truncate text-xs text-zinc-400">

View File

@@ -211,7 +211,6 @@ type TestAuthValue = {
openSettingsModal: (section?: PlatformSettingsSection) => void;
openAccountModal: () => void;
logout: () => Promise<void>;
setGlobalAccountActionsVisible: (visible: boolean) => void;
musicVolume: number;
setMusicVolume: (value: number) => void;
platformTheme: 'light' | 'dark';
@@ -229,7 +228,6 @@ function createAuthValue(overrides: Partial<TestAuthValue> = {}): TestAuthValue
openSettingsModal: () => {},
openAccountModal: () => {},
logout: async () => {},
setGlobalAccountActionsVisible: () => {},
musicVolume: 0.42,
setMusicVolume: () => {},
platformTheme: 'light',

View File

@@ -26,6 +26,7 @@ import {
scoreAttributeFit,
} from './attributeResolver';
import {
getCharacterAdventureOpening,
getCharacterById,
getCharacterCombatStats,
getCharacterEquipment,
@@ -985,6 +986,76 @@ function getFirstContactRelationStance(npcState: NpcPersistentState) {
return npcState.relationState?.stance ?? buildRelationState(npcState.affinity).stance;
}
function ensureDialogueSentence(text: string | null | undefined) {
const normalized = text?.trim() ?? '';
if (!normalized) {
return '';
}
return /[!?]$/u.test(normalized) ? normalized : `${normalized}`;
}
export function buildNpcChatOpeningText(
encounter: Encounter,
npcState: NpcPersistentState,
worldType: WorldType | null,
recruitCharacterOverride?: Character | null,
) {
const recruitCharacter =
recruitCharacterOverride ?? resolveEncounterRecruitCharacter(encounter);
const opening = recruitCharacter
? getCharacterAdventureOpening(recruitCharacter, worldType)
: null;
const stance = getFirstContactRelationStance(npcState);
if (isNpcFirstMeaningfulContact(encounter, npcState)) {
const greeting =
stance === 'guarded' ? '先打个招呼。' : '先和你打个招呼。';
const surfaceHook = ensureDialogueSentence(opening?.surfaceHook);
const immediateConcern = ensureDialogueSentence(opening?.immediateConcern);
const guardedMotive = ensureDialogueSentence(opening?.guardedMotive);
const fallbackLine =
stance === 'bonded'
? '这一步我既然亲自来了,就说明眼前这件事得先和你对齐。'
: stance === 'cooperative'
? '我先来和你碰个头,眼下这局势最好别各说各话。'
: stance === 'neutral'
? '我会出现在这里不是没有缘由,不过咱们最好先把眼前情况看清。'
: '前面的动静不太对,我想先看看你会怎么开口。';
if (
encounter.specialBehavior === 'camp_companion'
|| encounter.specialBehavior === 'initial_companion'
) {
return [
greeting,
surfaceHook || immediateConcern || fallbackLine,
surfaceHook && immediateConcern && surfaceHook !== immediateConcern
? immediateConcern
: null,
guardedMotive,
]
.filter(Boolean)
.join('');
}
return [greeting, immediateConcern || surfaceHook || fallbackLine]
.filter(Boolean)
.join('');
}
switch (stance) {
case 'bonded':
return '又见面了。你想先从哪件事接着说?';
case 'cooperative':
return '你开口吧,我先听听你想聊哪一件。';
case 'neutral':
return '先说吧,你想从哪里问起?';
default:
return '说吧,你想先问什么?';
}
}
export function getNpcFirstContactTopics(
encounter: Encounter,
npcState: NpcPersistentState,

View File

@@ -169,10 +169,14 @@ export function getWorldAttributeSchema(
customWorldProfile?: CustomWorldProfile | null,
) {
if (worldType === WorldType.CUSTOM && customWorldProfile) {
return (
resolveCustomWorldRuleProfile(customWorldProfile)?.attributeSchema
?? customWorldProfile.attributeSchema
);
try {
return (
resolveCustomWorldRuleProfile(customWorldProfile)?.attributeSchema
?? customWorldProfile.attributeSchema
);
} catch {
return customWorldProfile.attributeSchema;
}
}
if (worldType === WorldType.XIANXIA) {

View File

@@ -164,6 +164,44 @@ function createState(overrides: Partial<GameState> = {}): GameState {
} as GameState;
}
function createSceneActProfile(
primaryNpcId = 'npc-rival',
): NonNullable<GameState['customWorldProfile']> {
return {
id: 'custom-world-scene-act-test',
name: '断桥旧案',
summary: '用于测试场景幕主角色聊天规则。',
playableNpcs: [],
storyNpcs: [],
sceneChapterBlueprints: [
{
id: 'scene-bridge-chapter',
sceneId: 'scene-bridge',
title: '断桥口',
summary: '桥口旧账还没了结。',
linkedThreadIds: [],
linkedLandmarkIds: [],
acts: [
{
id: 'scene-bridge-act-1',
sceneId: 'scene-bridge',
title: '对峙幕',
summary: '玩家与断桥客正面碰头。',
stageCoverage: ['opening'],
backgroundImageSrc: '/bridge-act-1.png',
encounterNpcIds: [primaryNpcId, 'npc-bystander'],
primaryNpcId,
linkedThreadIds: [],
advanceRule: 'after_primary_contact',
actGoal: '逼近断桥旧案的核心线索。',
transitionHook: '桥下藏着还没灭的灯。',
},
],
},
],
} as unknown as NonNullable<GameState['customWorldProfile']>;
}
function createCurrentChatStory(): StoryMoment {
return {
text: '断桥客:你居然还敢来。\n你我只是想把话说清楚。',
@@ -195,6 +233,42 @@ function createCurrentChatStory(): StoryMoment {
};
}
function createLimitedPrimaryNpcChatStory(turnCount: number): StoryMoment {
return {
text: '断桥客还在压着不肯说完的话。',
options: [
createOption('npc_chat', '那你至少告诉我接下来该去哪', {
kind: 'npc',
npcId: 'npc-rival',
action: 'chat',
}),
],
displayMode: 'dialogue',
dialogue: [
{
speaker: 'npc',
speakerName: '断桥客',
text: '该听见的人还没到。',
},
{
speaker: 'player',
text: '你总得让我知道下一步该往哪边走。',
},
],
npcChatState: {
npcId: 'npc-rival',
npcName: '断桥客',
turnCount,
customInputPlaceholder: '输入你想对 TA 说的话',
sceneActId: 'scene-bridge-act-1',
turnLimit: 5,
remainingTurns: Math.max(0, 5 - turnCount),
limitReason: 'negative_affinity',
forceExitAfterTurn: false,
},
};
}
function createQuest(id: string, title: string): QuestLogEntry {
return {
id,
@@ -541,6 +615,170 @@ describe('npcEncounterActions', () => {
},
);
it('opens npc chat without injecting a local preset opening line', () => {
const encounter = createEncounter();
streamNpcChatTurnMock.mockResolvedValueOnce({
affinityDelta: 0,
affinityText: '这轮对话暂时没有带来明显关系变化。',
npcReply: '先站住。你想从哪一句开始问,我先听听。',
suggestions: ['我先问桥上出了什么事'],
});
const actions = createNpcEncounterActions({
gameState: createState({
currentEncounter: encounter,
npcInteractionActive: false,
}),
currentStory: {
text: '断桥客站在风口,等你先挑明来意。',
options: [],
},
});
expect(actions.enterNpcInteraction(encounter, '与断桥客搭话')).toBe(true);
const nextStory = actions.setCurrentStory.mock.calls.at(-1)?.[0] as StoryMoment;
expect(nextStory.displayMode).toBe('dialogue');
expect(nextStory.dialogue ?? []).toEqual([]);
expect(nextStory.text).toBe('');
expect(nextStory.npcChatState).toMatchObject({
npcId: 'npc-rival',
npcName: '断桥客',
turnCount: 0,
});
});
it('streams a model-driven npc-initiated opening on first meaningful contact', async () => {
const encounter = createEncounter();
streamNpcChatTurnMock.mockResolvedValueOnce({
affinityDelta: 0,
affinityText: '这轮对话暂时没有带来明显关系变化。',
npcReply: '先别急着拔话头。桥上的风向刚变,我得先确认你是来问旧账,还是来救人。',
suggestions: ['我先听你说桥上出了什么事', '你先说你在防谁', '我不是来翻旧账的'],
});
const actions = createNpcEncounterActions({
gameState: createState({
currentEncounter: encounter,
npcInteractionActive: false,
npcStates: {
'npc-rival': {
affinity: 8,
helpUsed: false,
chattedCount: 0,
giftsGiven: 0,
inventory: [],
recruited: false,
firstMeaningfulContactResolved: false,
},
},
}),
currentStory: {
text: '断桥客站在风口,等你先挑明来意。',
options: [],
},
});
expect(actions.enterNpcInteraction(encounter, '与断桥客搭话')).toBe(true);
await flushAsyncWork();
expect(streamNpcChatTurnMock).toHaveBeenCalledWith(
expect.anything(),
expect.anything(),
expect.objectContaining({
id: 'npc-rival',
}),
expect.anything(),
expect.anything(),
expect.anything(),
[],
'【NPC 主动开场】',
expect.anything(),
expect.objectContaining({
npcInitiatesConversation: true,
}),
);
const lastStory = actions.setCurrentStory.mock.calls.at(-1)?.[0] as StoryMoment;
expect(lastStory.streaming).toBe(false);
expect(lastStory.dialogue).toEqual([
{
speaker: 'npc',
speakerName: '断桥客',
text: '先别急着拔话头。桥上的风向刚变,我得先确认你是来问旧账,还是来救人。',
},
]);
expect(lastStory.npcChatState).toMatchObject({
npcId: 'npc-rival',
openingSource: 'npc_initiated',
turnCount: 0,
});
expect(lastStory.options.map((option) => option.actionText)).toEqual([
'我先听你说桥上出了什么事',
'你先说你在防谁',
'我不是来翻旧账的',
]);
});
it('removes any prefilled local opening line before the first model-driven npc reply', async () => {
streamNpcChatTurnMock.mockResolvedValueOnce({
affinityDelta: 1,
affinityText: '断桥客的语气稍微松了一点。',
npcReply: '先打个招呼。你盯着我看了这么久,总得先告诉我你想问哪一层。',
suggestions: ['我先问你刚才在防谁'],
});
const actions = createNpcEncounterActions({
currentStory: {
text: '先和你打个招呼。前面的风不太对。',
options: [
createOption('npc_chat', '先问问你刚才在留意什么', {
kind: 'npc',
npcId: 'npc-rival',
action: 'chat',
}),
],
displayMode: 'dialogue',
dialogue: [
{
speaker: 'npc',
speakerName: '断桥客',
text: '先和你打个招呼。前面的风不太对。',
},
],
npcChatState: {
npcId: 'npc-rival',
npcName: '断桥客',
turnCount: 0,
customInputPlaceholder: '输入你想对 TA 说的话',
openingSource: 'player_reply',
},
},
});
await expect(
actions.handleNpcChatTurn(createEncounter(), '你刚才到底在看什么?'),
).resolves.toBe(true);
expect(streamNpcChatTurnMock).toHaveBeenCalledWith(
expect.anything(),
expect.anything(),
expect.anything(),
expect.anything(),
expect.anything(),
expect.anything(),
[],
'你刚才到底在看什么?',
expect.anything(),
expect.anything(),
);
const lastStory = actions.setCurrentStory.mock.calls.at(-1)?.[0] as StoryMoment;
expect(
lastStory.dialogue?.some((turn) =>
turn.text.includes('先和你打个招呼。前面的风不太对。'),
),
).toBe(false);
});
it('passes the quest id through to the server runtime resolver for quest turn-in', async () => {
resolveServerRuntimeChoiceMock.mockResolvedValueOnce({
hydratedSnapshot: {
@@ -729,6 +967,134 @@ describe('npcEncounterActions', () => {
);
});
it('lets the current act primary npc enter limited chat even with negative affinity', () => {
const encounter = createEncounter();
streamNpcChatTurnMock.mockResolvedValueOnce({
affinityDelta: 0,
affinityText: '这轮对话暂时没有带来明显关系变化。',
npcReply: '先把来意说清楚,我再决定要不要把后半句给你。',
suggestions: ['你先说你到底在防谁'],
});
const actions = createNpcEncounterActions({
gameState: createState({
currentEncounter: encounter,
customWorldProfile: createSceneActProfile(),
npcInteractionActive: false,
npcStates: {
'npc-rival': {
affinity: -8,
helpUsed: false,
chattedCount: 0,
giftsGiven: 0,
inventory: [],
recruited: false,
},
},
}),
currentStory: {
text: '断桥客停在桥口,像是在等你自己把话说出来。',
options: [],
},
});
expect(actions.enterNpcInteraction(encounter, '与断桥客搭话')).toBe(true);
const nextStory = actions.setCurrentStory.mock.calls.at(-1)?.[0] as StoryMoment;
expect(nextStory.npcChatState).toMatchObject({
npcId: 'npc-rival',
sceneActId: 'scene-bridge-act-1',
turnLimit: 5,
remainingTurns: 5,
limitReason: 'negative_affinity',
});
expect(
nextStory.options.some((option) => option.functionId === 'npc_fight'),
).toBe(false);
expect(
nextStory.options.some(
(option) => option.functionId === 'battle_escape_breakout',
),
).toBe(false);
});
it('force exits limited hostile chat on the fifth turn and offers a continue option', async () => {
streamNpcChatTurnMock.mockResolvedValueOnce({
affinityDelta: 0,
affinityText: '这轮对话暂时没有带来明显关系变化。',
npcReply: '去城西废桥下找那盏没灭的灯。等你看见它,再来问我剩下那半句。',
suggestions: [],
chatDirective: {
turnLimit: 5,
remainingTurns: 0,
forceExit: true,
closingMode: 'foreshadow_close',
},
});
const actions = createNpcEncounterActions({
gameState: createState({
customWorldProfile: createSceneActProfile(),
npcStates: {
'npc-rival': {
affinity: -12,
helpUsed: false,
chattedCount: 4,
giftsGiven: 0,
inventory: [],
recruited: false,
},
},
}),
currentStory: createLimitedPrimaryNpcChatStory(4),
});
await expect(
actions.handleNpcChatTurn(
createEncounter(),
'那你至少告诉我,接下来该去哪里找答案。',
),
).resolves.toBe(true);
expect(streamNpcChatTurnMock).toHaveBeenCalledWith(
expect.anything(),
expect.anything(),
expect.objectContaining({
id: 'npc-rival',
}),
expect.anything(),
expect.anything(),
expect.anything(),
expect.anything(),
'那你至少告诉我,接下来该去哪里找答案。',
expect.anything(),
expect.objectContaining({
questOfferContext: null,
chatDirective: expect.objectContaining({
sceneActId: 'scene-bridge-act-1',
turnLimit: 5,
remainingTurns: 0,
limitReason: 'negative_affinity',
forceExitAfterTurn: true,
}),
}),
);
const lastStory = actions.setCurrentStory.mock.calls.at(-1)?.[0] as StoryMoment;
expect(lastStory.npcChatState).toBeUndefined();
expect(lastStory.options).toEqual([
expect.objectContaining({
functionId: 'story_continue_adventure',
actionText: '继续',
}),
]);
expect(lastStory.dialogue?.at(-1)).toEqual(
expect.objectContaining({
speaker: 'system',
text: '这轮交谈先在这里收束,对方留下的线索把你推向了下一步。',
}),
);
});
it('offers a pending quest after enough warmup chat turns with a positive-affinity npc', async () => {
const pendingQuest = createQuest('quest-bridge-offer', '断桥口的密信');
streamNpcChatTurnMock.mockResolvedValueOnce({

View File

@@ -21,6 +21,9 @@ import { resolveFunctionOption } from '../../data/stateFunctions';
import { applyStoryReasoningRecovery } from '../../data/storyRecovery';
import { streamNpcChatTurn } from '../../services/aiService';
import type { StoryGenerationContext } from '../../services/aiTypes';
import {
resolveLimitedPrimaryNpcChatState,
} from '../../services/customWorldSceneActRuntime';
import { generateQuestForNpcEncounter } from '../../services/questDirector';
import { appendStoryEngineCarrierMemory } from '../../services/storyEngine/echoMemory';
import { createHistoryMoment } from '../../services/storyHistory';
@@ -74,6 +77,14 @@ type BuildStoryContextExtras = {
encounterNpcStateOverride?: GameState['npcStates'][string] | null;
};
type NpcChatDirective = {
sceneActId?: string | null;
turnLimit?: number | null;
remainingTurns?: number | null;
limitReason?: 'negative_affinity' | null;
forceExitAfterTurn?: boolean;
} | null;
function isNpcEncounter(
encounter: GameState['currentEncounter'],
): encounter is Encounter {
@@ -103,6 +114,7 @@ export function createStoryNpcEncounterActions({
generateStoryForState,
getStoryGenerationHostileNpcs,
getAvailableOptionsForState,
buildContinueAdventureOption,
getNpcEncounterKey,
getResolvedNpcState,
updateNpcState,
@@ -587,9 +599,11 @@ export function createStoryNpcEncounterActions({
options: StoryOption[];
streaming: boolean;
turnCount: number;
chatDirective?: NpcChatDirective;
pendingQuestOffer?: {
quest: QuestLogEntry;
} | null;
openingSource?: 'npc_initiated' | 'player_reply';
}): StoryMoment => ({
text: params.dialogue.map((turn) => turn.text).join('\n'),
options: params.options,
@@ -601,6 +615,12 @@ export function createStoryNpcEncounterActions({
npcName: params.encounter.npcName,
turnCount: params.turnCount,
customInputPlaceholder: '输入你想对 TA 说的话',
openingSource: params.openingSource ?? 'player_reply',
sceneActId: params.chatDirective?.sceneActId ?? null,
turnLimit: params.chatDirective?.turnLimit ?? null,
remainingTurns: params.chatDirective?.remainingTurns ?? null,
limitReason: params.chatDirective?.limitReason ?? null,
forceExitAfterTurn: params.chatDirective?.forceExitAfterTurn ?? false,
pendingQuestOffer: params.pendingQuestOffer ?? null,
},
});
@@ -622,17 +642,51 @@ export function createStoryNpcEncounterActions({
});
};
const buildNpcChatOpeningDialogue = (encounter: Encounter) =>
const buildLegacyNpcChatOpeningPlaceholder = (encounter: Encounter) =>
`${encounter.npcName}看着你,像是在等你把话接下去。`;
const sanitizeNpcChatDialogueHistory = (
encounter: Encounter,
dialogue: NonNullable<StoryMoment['dialogue']>,
turnCount: number,
openingSource?: StoryMoment['npcChatState'] extends infer T
? T extends { openingSource?: infer U }
? U
: never
: never,
) => {
const legacyOpeningText = buildLegacyNpcChatOpeningPlaceholder(encounter);
return dialogue.filter((turn, index) => {
if (index !== 0 || turn.speaker !== 'npc') {
return true;
}
if (turn.text.trim() === legacyOpeningText) {
return false;
}
if (turnCount === 0 && dialogue.length === 1) {
return openingSource === 'npc_initiated';
}
return true;
});
};
const buildNpcChatDialogueHistory = (
encounter: Encounter,
turnCount: number,
) =>
currentStory?.npcChatState?.npcId === (encounter.id ?? encounter.npcName) &&
currentStory.dialogue
? [...currentStory.dialogue]
: [
{
speaker: 'npc' as const,
speakerName: encounter.npcName,
text: `${encounter.npcName}看着你,像是在等你把话接下去。`,
},
];
? sanitizeNpcChatDialogueHistory(
encounter,
currentStory.dialogue,
turnCount,
currentStory.npcChatState?.openingSource,
)
: [];
const buildHostileNpcDeclarationText = (
encounter: Encounter,
@@ -744,8 +798,10 @@ export function createStoryNpcEncounterActions({
encounter: Encounter,
selectedOption: StoryOption,
extraOptions: StoryOption[] = [],
chatDirective?: NpcChatDirective,
openingSource: 'npc_initiated' | 'player_reply' = 'player_reply',
) => {
const openingDialogue = buildNpcChatOpeningDialogue(encounter);
const openingDialogue = buildNpcChatDialogueHistory(encounter, 0);
setAiError(null);
setCurrentStory(
@@ -759,11 +815,144 @@ export function createStoryNpcEncounterActions({
),
streaming: false,
turnCount: 0,
chatDirective,
openingSource,
}),
);
return true;
};
const startNpcInitiatedOpening = async (
encounter: Encounter,
selectedOption: StoryOption,
extraOptions: StoryOption[] = [],
chatDirective?: NpcChatDirective,
) => {
const playerCharacter = gameState.playerCharacter;
if (!playerCharacter || !gameState.worldType) {
return enterNpcChat(
encounter,
selectedOption,
extraOptions,
chatDirective,
'npc_initiated',
);
}
const npcState = getResolvedNpcState(gameState, encounter);
const openingCampContext = buildOpeningCampChatContext(
gameState,
playerCharacter,
encounter,
);
const existingDialogue = buildNpcChatDialogueHistory(encounter, 0);
const openingOptions = buildNpcChatEntryOptions(
encounter,
selectedOption,
extraOptions,
);
setAiError(null);
setIsLoading(true);
setCurrentStory(
buildNpcChatStoryMoment({
encounter,
dialogue: existingDialogue,
options: [],
streaming: true,
turnCount: 0,
chatDirective,
openingSource: 'npc_initiated',
}),
);
try {
const chatTurn = await streamNpcChatTurn(
gameState.worldType,
playerCharacter,
encounter,
getStoryGenerationHostileNpcs(gameState),
gameState.storyHistory,
buildStoryContextFromState(gameState, {
lastFunctionId: 'npc_chat',
...openingCampContext,
encounterNpcStateOverride: npcState,
}),
existingDialogue,
'【NPC 主动开场】',
{
affinity: npcState.affinity,
chattedCount: npcState.chattedCount,
recruited: npcState.recruited,
},
{
onReplyUpdate: (text) => {
setCurrentStory(
buildNpcChatStoryMoment({
encounter,
dialogue: [
...existingDialogue,
{
speaker: 'npc',
speakerName: encounter.npcName,
text,
},
],
options: [],
streaming: true,
turnCount: 0,
chatDirective,
openingSource: 'npc_initiated',
}),
);
},
chatDirective,
npcInitiatesConversation: true,
},
);
if (!chatTurn?.npcReply?.trim()) {
throw new Error('NPC 主动开场结果为空');
}
setCurrentStory(
buildNpcChatStoryMoment({
encounter,
dialogue: [
...existingDialogue,
{
speaker: 'npc',
speakerName: encounter.npcName,
text: chatTurn.npcReply,
},
],
options: buildNpcChatTurnOptions(
encounter,
chatTurn.suggestions.length > 0
? chatTurn.suggestions
: openingOptions.map((option) => option.actionText),
),
streaming: false,
turnCount: 0,
chatDirective,
openingSource: 'npc_initiated',
}),
);
return true;
} catch (error) {
console.error('Failed to start npc initiated opening:', error);
setAiError(error instanceof Error ? error.message : 'NPC 主动开场失败');
return enterNpcChat(
encounter,
selectedOption,
extraOptions,
chatDirective,
'npc_initiated',
);
} finally {
setIsLoading(false);
}
};
const handleNpcChatTurn = async (
encounter: Encounter,
playerMessage: string,
@@ -780,7 +969,12 @@ export function createStoryNpcEncounterActions({
: null;
const existingDialogue =
currentStory?.dialogue && currentNpcChatState
? [...currentStory.dialogue]
? sanitizeNpcChatDialogueHistory(
encounter,
currentStory.dialogue,
currentNpcChatState.turnCount ?? 0,
currentNpcChatState.openingSource,
)
: [];
const dialogueWithPlayer = [
...existingDialogue,
@@ -790,6 +984,12 @@ export function createStoryNpcEncounterActions({
},
];
const nextTurnCount = (currentNpcChatState?.turnCount ?? 0) + 1;
const limitedChatDirective = resolveLimitedPrimaryNpcChatState({
state: gameState,
npcId: encounter.id ?? encounter.npcName,
affinity: npcState.affinity,
nextTurnCount,
});
const openingCampContext = buildOpeningCampChatContext(
gameState,
playerCharacter,
@@ -805,6 +1005,7 @@ export function createStoryNpcEncounterActions({
options: [],
streaming: true,
turnCount: nextTurnCount,
chatDirective: limitedChatDirective,
}),
);
@@ -843,13 +1044,17 @@ export function createStoryNpcEncounterActions({
options: [],
streaming: true,
turnCount: nextTurnCount,
chatDirective: limitedChatDirective,
}),
);
},
questOfferContext: {
state: gameState,
turnCount: nextTurnCount,
},
questOfferContext: limitedChatDirective
? null
: {
state: gameState,
turnCount: nextTurnCount,
},
chatDirective: limitedChatDirective,
},
);
@@ -912,8 +1117,45 @@ export function createStoryNpcEncounterActions({
const pendingQuest =
(chatTurn.pendingQuestOffer?.quest as QuestLogEntry | undefined) ??
null;
const resolvedChatDirective = limitedChatDirective
? {
sceneActId: limitedChatDirective.sceneActId ?? null,
turnLimit:
chatTurn.chatDirective?.turnLimit ??
limitedChatDirective.turnLimit ??
null,
remainingTurns:
chatTurn.chatDirective?.remainingTurns ??
limitedChatDirective.remainingTurns ??
null,
limitReason: limitedChatDirective.limitReason ?? null,
forceExitAfterTurn:
chatTurn.chatDirective?.forceExit ??
limitedChatDirective.forceExitAfterTurn ??
false,
}
: null;
const shouldForceExitAfterTurn =
resolvedChatDirective?.forceExitAfterTurn === true;
const pendingQuestIntroText =
chatTurn.pendingQuestOffer?.introText?.trim() || '';
if (shouldForceExitAfterTurn) {
const closingDialogue = [
...nextDialogue,
{
speaker: 'system' as const,
text: '这轮交谈先在这里收束,对方留下的线索把你推向了下一步。',
},
];
setCurrentStory({
text: closingDialogue.map((turn) => turn.text).join('\n'),
options: [buildContinueAdventureOption()],
displayMode: 'dialogue',
dialogue: closingDialogue,
streaming: false,
});
return true;
}
if (pendingQuest) {
setCurrentStory(
buildNpcChatStoryMoment({
@@ -931,6 +1173,7 @@ export function createStoryNpcEncounterActions({
options: buildPendingQuestOfferOptions(encounter),
streaming: false,
turnCount: nextTurnCount,
chatDirective: resolvedChatDirective,
pendingQuestOffer: {
quest: pendingQuest,
},
@@ -951,6 +1194,7 @@ export function createStoryNpcEncounterActions({
),
streaming: false,
turnCount: nextTurnCount,
chatDirective: resolvedChatDirective,
}),
);
return true;
@@ -967,6 +1211,7 @@ export function createStoryNpcEncounterActions({
),
streaming: false,
turnCount: nextTurnCount,
chatDirective: limitedChatDirective,
}),
);
return false;
@@ -1041,7 +1286,14 @@ export function createStoryNpcEncounterActions({
setGameState(nextState);
setAiError(null);
if (npcState.affinity < 0 || encounter.hostile) {
const limitedChatDirective = resolveLimitedPrimaryNpcChatState({
state: nextState,
npcId: encounter.id ?? encounter.npcName,
affinity: npcState.affinity,
nextTurnCount: 0,
});
if ((npcState.affinity < 0 || encounter.hostile) && !limitedChatDirective) {
setCurrentStory(
buildHostileNpcStoryMoment(
encounter,
@@ -1079,7 +1331,22 @@ export function createStoryNpcEncounterActions({
},
} satisfies StoryOption);
return enterNpcChat(encounter, seedChatOption, chatOptions.slice(1));
if (!currentStory?.npcChatState && !npcState.firstMeaningfulContactResolved) {
void startNpcInitiatedOpening(
encounter,
seedChatOption,
chatOptions.slice(1),
limitedChatDirective,
);
return true;
}
return enterNpcChat(
encounter,
seedChatOption,
chatOptions.slice(1),
limitedChatDirective,
);
};
const resolveServerNpcStoryAction = async (params: {

View File

@@ -66,6 +66,7 @@ import {
import { appendChronicleEntries } from '../../services/storyEngine/storyChronicle';
import { buildThemePackFromWorldProfile } from '../../services/storyEngine/themePack';
import { buildThreadContractsFromProfile } from '../../services/storyEngine/threadContract';
import { buildInitialSceneActRuntimeState } from '../../services/customWorldSceneActRuntime';
import {
collectStorySignals,
resolveSignalsToThreadUpdates,
@@ -216,6 +217,12 @@ function ensureSceneChapterQuestState(params: {
storyEngineMemory: {
...storyEngineMemory,
openedSceneChapterIds,
currentSceneActState:
buildInitialSceneActRuntimeState({
profile: params.nextState.customWorldProfile,
sceneId: scene.id,
storyEngineMemory,
}) ?? storyEngineMemory.currentSceneActState ?? null,
},
};
}
@@ -223,6 +230,12 @@ function ensureSceneChapterQuestState(params: {
const nextMemory = {
...storyEngineMemory,
openedSceneChapterIds: [...openedSceneChapterIds, scene.id],
currentSceneActState:
buildInitialSceneActRuntimeState({
profile: params.nextState.customWorldProfile,
sceneId: scene.id,
storyEngineMemory,
}) ?? storyEngineMemory.currentSceneActState ?? null,
};
const existingChapterQuest = getChapterQuestForScene(
params.nextState.quests,

View File

@@ -73,6 +73,7 @@ function createEncounter(overrides: Partial<Encounter> = {}): Encounter {
return {
id: 'camp-companion',
kind: 'npc',
characterId: 'sword-princess',
npcName: '沈砺',
npcDescription: '正靠在营地灯火旁观察风向。',
npcAvatar: '/npc.png',
@@ -152,9 +153,11 @@ describe('storyCampCompanion', () => {
WorldType.WUXIA,
);
expect(text).toContain('先和你打个招呼。');
expect(text).toContain('我来这里,是为了确认旧路尽头到底出了什么事。');
expect(text).toContain('沈砺:那就不要说得太快太多。');
expect(text).toContain('眼下的风向不对,我们不能直接把底牌亮出来。');
expect(text).toContain('我真正要找的东西,还不能让更多人知道。');
expect(text).not.toContain('像是在等你把话接下去');
});
it('summarizes the camp opening result with the current concern', () => {
@@ -168,7 +171,7 @@ describe('storyCampCompanion', () => {
expect(text).toContain('眼下的风向不对');
});
it('keeps chat and recruit options while appending the travel action for camp openings', () => {
it('keeps the opening camp options focused on继续交谈', () => {
const buildNpcStory = vi.fn(() =>
createStory('营地开场', [
createOption('npc_chat', '继续交谈'),
@@ -190,11 +193,7 @@ describe('storyCampCompanion', () => {
createEncounter(),
);
expect(options.map((option) => option.functionId)).toEqual([
'npc_chat',
'npc_recruit',
'camp_travel_home_scene',
]);
expect(options.map((option) => option.functionId)).toEqual(['npc_chat']);
});
it('uses AI follow-up options when the camp follow-up request succeeds and falls back on errors', async () => {

View File

@@ -7,9 +7,11 @@ import {
NPC_CHAT_FUNCTION,
NPC_FIGHT_FUNCTION,
NPC_LEAVE_FUNCTION,
NPC_RECRUIT_FUNCTION,
} from '../../data/functionCatalog';
import { buildInitialNpcState } from '../../data/npcInteractions';
import {
buildInitialNpcState,
buildNpcChatOpeningText,
} from '../../data/npcInteractions';
import {
getForwardScenePreset,
getScenePresetById,
@@ -57,15 +59,20 @@ export function buildInitialCompanionDialogueText(
encounter: Encounter,
worldType: WorldType | null,
) {
const opening = getCharacterAdventureOpening(character, worldType);
const surfaceHook =
opening?.surfaceHook ?? '这个地方与我来此的目的息息相关。';
const immediateConcern =
opening?.immediateConcern ?? '前方似乎有些不对劲,我们不能贸然前进。';
const guardedMotive =
opening?.guardedMotive ?? '我并非偶然来到这里,但我还不准备全盘托出。';
return `${encounter.npcName}看着你,先压低声音开口:“${immediateConcern}${guardedMotive}`;
const resolvedEncounter =
encounter.characterId === character.id
? encounter
: {
...encounter,
characterId: encounter.characterId ?? character.id,
};
const initialNpcState = buildInitialNpcState(resolvedEncounter, worldType);
return buildNpcChatOpeningText(
resolvedEncounter,
initialNpcState,
worldType,
character,
);
}
export function buildCampCompanionOpeningResultText(

File diff suppressed because it is too large Load Diff

View File

@@ -1,3 +1,21 @@
/**
* 自定义世界角色资产工坊的“默认描述文本种子”主源。
*
* 这份脚本只负责一件事:
* - 从当前角色对象已有字段里挑出最合适的文本,
* 作为资产工坊输入框的初始默认值
*
* 它不负责:
* - 直接调用 LLM 重新编译默认描述
* - 直接生成图像模型 prompt
* - 直接生成动作模型 prompt
*
* 当前真实调用状态:
* - CustomWorldRoleAssetStudioModal 的初始默认值主链,来自本文件
* - 也就是说,资产工坊页面打开时看到的“形象描述 / 动作描述”
* 当前优先取这里的本地字段映射,而不是后端
* /api/assets/character-prompts/generate 接口
*/
export type PromptDefaultRole = {
name: string;
title: string;
@@ -19,10 +37,18 @@ export type CustomWorldRolePromptBundle = {
scenePromptText: string;
};
/**
* 对角色字段做轻量清洗,确保作为输入框默认值时不会带多余空白。
*/
function cleanSeedText(value: string | undefined, maxLength: number) {
return (value ?? '').replace(/\s+/gu, ' ').trim().slice(0, maxLength);
}
/**
* 按优先级选择第一条可用文本。
*
* 这里是非常轻量的本地回退逻辑,不做任何“重新创作”或 prompt 扩写。
*/
function pickFirstDescription(
values: Array<string | undefined>,
maxLength: number,
@@ -37,6 +63,18 @@ function pickFirstDescription(
return '';
}
/**
* 资产工坊默认文本映射规则。
*
* 规则分层:
* - visualPromptText: 优先使用角色 visualDescription其次 description
* - animationPromptText: 优先使用 actionDescription其次 combatStyle
* - scenePromptText: 优先使用 sceneVisualDescription其次 backstory
*
* 注意:
* - 返回值只是“输入框默认文案”
* - 正式图像 / 动作模型 prompt 还会在后端继续编译
*/
export function buildDefaultRolePromptBundle(
role: PromptDefaultRole,
): CustomWorldRolePromptBundle {

View File

@@ -30,6 +30,11 @@ import {
getFunctionById,
getFunctionPromptDescription,
} from '../data/stateFunctions';
import type { StoryGenerationContext } from '../services/aiTypes';
import { buildCustomWorldReferenceText } from '../services/customWorld';
import { sanitizePromptNarrativeText } from '../services/narrativeLanguage';
import { describeGoalStackForPrompt } from '../services/storyEngine/goalDirector';
import { buildStoryPromptHistory } from '../services/storyHistory';
import {
Character,
CharacterGender,
@@ -40,11 +45,6 @@ import {
StoryOption,
WorldType,
} from '../types';
import type { StoryGenerationContext } from '../services/aiTypes';
import { buildCustomWorldReferenceText } from '../services/customWorld';
import { sanitizePromptNarrativeText } from '../services/narrativeLanguage';
import { describeGoalStackForPrompt } from '../services/storyEngine/goalDirector';
import { buildStoryPromptHistory } from '../services/storyHistory';
export const SYSTEM_PROMPT = `你是角色扮演 RPG 的剧情推进者,只能返回 JSON 对象不能输出解释、markdown 或代码块。
输出格式必须严格符合:
@@ -1817,8 +1817,11 @@ export function buildStrictNpcChatDialoguePrompt(
'不要让对方在聊天里推进交易、招募、切磋、战斗、送礼、求助、离开、继续前进、切换场景等其他 function。',
'不要替玩家做选择,不要用建议句、命令句或诱导句把聊天写成别的行为入口。',
'低揭示阶段时,宁可留钩子、先谈眼前局势,也不要把完整来历和目标一次说完。',
context.isFirstMeaningfulContact
? '如果这是第一次真正接触,对方第一次开口必须先用一句自然招呼或开场判断起手,不能写成“某人看着你,像是在等你把话接下去”这类第三人称占位旁白。'
: null,
'如果当前情景是初见或刚打完一轮冲突,优先写短句、观察句和试探句,不要写成正式自我介绍。',
].join('\n\n');
].filter(Boolean).join('\n\n');
}
export function buildNpcRecruitDialoguePrompt(

View File

@@ -979,6 +979,7 @@ export async function streamNpcChatTurn(
turnCount: number;
} | null;
chatDirective?: NpcChatTurnDirective | null;
npcInitiatesConversation?: boolean;
} = {},
) {
const payload = {
@@ -993,6 +994,7 @@ export async function streamNpcChatTurn(
dialogue: conversationHistory ?? [],
playerMessage,
npcState,
npcInitiatesConversation: options.npcInitiatesConversation ?? false,
questOfferContext: options.questOfferContext
? {
state: options.questOfferContext.state,

View File

@@ -116,6 +116,7 @@ export interface StoryNpcChatState {
npcName: string;
turnCount: number;
customInputPlaceholder?: string;
openingSource?: 'npc_initiated' | 'player_reply';
sceneActId?: string | null;
turnLimit?: number | null;
remainingTurns?: number | null;