From 1c72066bab81f96295d17133c8f54a232ee45fb2 Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?=E9=AB=98=E7=89=A9?= <253518756@qq.com> Date: Mon, 20 Apr 2026 15:45:14 +0800 Subject: [PATCH] 1 --- AGENTS.md | 2 +- UI_CODING_STANDARD.md | 1 + ...TER_ASSET_PROMPT_CHAIN_AUDIT_2026-04-20.md | 379 ++++ docs/audits/README.md | 1 + ...SHIP_AND_PRIVATE_CHAT_DESIGN_2026-04-04.md | 8 + ...PTER_NPC_AUTO_SCALING_DESIGN_2026-04-20.md | 24 +- ...ND_LOGIN_MODAL_GATING_DESIGN_2026-04-19.md | 6 + docs/experience/AGENT_UI_CHANGELOG.md | 83 +- docs/experience/MOBILE_UI_DEV_EXPERIENCE.md | 2 + ...TER_VISUAL_ANIMATION_MVP_PRD_2026-04-04.md | 4 +- ...R_PHASE5_IMPLEMENTATION_PLAN_2026-04-14.md | 2 +- ...RST_CUSTOM_WORLD_CREATOR_PRD_2026-04-12.md | 4 +- ...REATOR_AND_GAMEPLAY_FLOW_PRD_2026-04-20.md | 152 +- ...ED_ROLE_ATTRIBUTE_SYSTEM_PRD_2026-04-02.md | 5 + ...AB_SETTINGS_AND_SECURITY_PRD_2026-04-16.md | 21 +- ...ANIMATION_TECHNICAL_SOLUTION_2026-04-04.md | 4 +- ...ATOR_IMPLEMENTATION_PROGRESS_2026-04-20.md | 66 +- packages/shared/src/contracts/story.ts | 2 + packages/shared/src/prompts/qwenSprite.ts | 31 + .../src/modules/ai/chatOrchestrator.ts | 74 +- .../src/modules/ai/orchestrator.test.ts | 313 ++++ .../modules/combat/combatResolutionService.ts | 114 +- .../src/modules/npc/npcInteractionService.ts | 61 +- .../chapterProgressionPlanner.test.ts | 225 +++ .../progression/chapterProgressionPlanner.ts | 480 +++++ .../hostileProgressionService.test.ts | 182 ++ .../progression/hostileProgressionService.ts | 353 ++++ .../progression/npcLevelResolver.test.ts | 82 + .../modules/progression/npcLevelResolver.ts | 106 ++ .../src/modules/story/runtimeSession.ts | 22 + .../modules/story/storyActionRoutes.test.ts | 532 ++++++ .../src/prompts/characterAssetPrompts.ts | 86 + server-node/src/prompts/chatPromptBuilders.ts | 124 +- server-node/src/services/chatService.ts | 2 + src/components/AdventureEntityModal.tsx | 162 +- .../AdventurePanel.npcChat.test.tsx | 98 ++ src/components/AdventurePanel.tsx | 86 +- src/components/CharacterAnimator.test.tsx | 2 +- src/components/CharacterAnimator.tsx | 2 +- src/components/CharacterInfoHelpers.ts | 13 + src/components/CharacterInfoShared.test.tsx | 89 + src/components/CharacterInfoShared.tsx | 92 +- src/components/CharacterPanel.tsx | 71 +- .../CustomWorldEntityEditorModal.test.tsx | 179 +- .../CustomWorldEntityEditorModal.tsx | 1564 ++++++++++++++++- src/components/GameShell.tsx | 45 +- src/components/auth/AccountModal.test.tsx | 18 + src/components/auth/AccountModal.tsx | 91 +- src/components/auth/AuthGate.test.tsx | 2 +- src/components/auth/AuthGate.tsx | 34 - src/components/auth/AuthUiContext.ts | 1 - .../game-canvas/GameCanvasEntityLayer.tsx | 11 +- .../game-canvas/GameCanvasShared.tsx | 86 +- .../CharacterSelectionFlow.test.tsx | 80 +- .../game-shell/CharacterSelectionFlow.tsx | 38 +- .../game-shell/GameShellMainContent.tsx | 10 +- .../game-shell/GameShellOverlays.tsx | 67 +- .../game-shell/GameShellRuntime.tsx | 76 +- .../game-shell/GameShellStoryPanels.tsx | 49 +- .../game-shell/PlatformHomeView.tsx | 97 +- ...meSelectionFlow.agent.interaction.test.tsx | 2 - src/data/npcInteractions.ts | 71 + src/data/worldAttributeSchemas.ts | 12 +- src/hooks/story/npcEncounterActions.test.ts | 366 ++++ src/hooks/story/npcEncounterActions.ts | 301 +++- src/hooks/story/progressionActions.ts | 13 + src/hooks/story/storyCampCompanion.test.ts | 13 +- src/hooks/story/storyCampCompanion.ts | 29 +- src/index.css | 1353 +++++++++----- src/prompts/customWorldRolePromptDefaults.ts | 38 + src/prompts/storyPromptBuilders.ts | 15 +- src/services/aiService.ts | 2 + src/types/story.ts | 1 + 73 files changed, 7814 insertions(+), 1018 deletions(-) create mode 100644 docs/audits/CHARACTER_ASSET_PROMPT_CHAIN_AUDIT_2026-04-20.md create mode 100644 server-node/src/modules/progression/chapterProgressionPlanner.test.ts create mode 100644 server-node/src/modules/progression/chapterProgressionPlanner.ts create mode 100644 server-node/src/modules/progression/hostileProgressionService.test.ts create mode 100644 server-node/src/modules/progression/hostileProgressionService.ts create mode 100644 server-node/src/modules/progression/npcLevelResolver.test.ts create mode 100644 server-node/src/modules/progression/npcLevelResolver.ts create mode 100644 src/components/CharacterInfoShared.test.tsx diff --git a/AGENTS.md b/AGENTS.md index 5cb0b911..cf5aef51 100644 --- a/AGENTS.md +++ b/AGENTS.md @@ -1,7 +1,7 @@ # AGENTS.md ## 项目约束 -- 前端工程node版本使用22.22.2 +- 代码需要有完善的中文注释 - 在落地工程修改前检查是否有详细指导本次落地的文档,若没有文档或文档的完善程度仍有落地过程中编码级别的歧义优先优化文档后落地工程迭代。 - 对工程的修改不仅要落地到代码更面,还要更改对应文档,若没有生成新的文档,文档统一存在doc目录中 - 不要擅自把现有中文文案、注释、剧情、文档改写成英文,除非用户明确要求翻译。 diff --git a/UI_CODING_STANDARD.md b/UI_CODING_STANDARD.md index 8fce4c39..936d3232 100644 --- a/UI_CODING_STANDARD.md +++ b/UI_CODING_STANDARD.md @@ -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 diff --git a/docs/audits/CHARACTER_ASSET_PROMPT_CHAIN_AUDIT_2026-04-20.md b/docs/audits/CHARACTER_ASSET_PROMPT_CHAIN_AUDIT_2026-04-20.md new file mode 100644 index 00000000..4a54ff04 --- /dev/null +++ b/docs/audits/CHARACTER_ASSET_PROMPT_CHAIN_AUDIT_2026-04-20.md @@ -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 主链只用了前者,导致出现明显的冗余和认知混乱。** diff --git a/docs/audits/README.md b/docs/audits/README.md index af0182cb..9890e3e9 100644 --- a/docs/audits/README.md +++ b/docs/audits/README.md @@ -15,6 +15,7 @@ - [FUNCTION_RUNTIME_FULL_TEST_AUDIT_2026-04-16.md](./FUNCTION_RUNTIME_FULL_TEST_AUDIT_2026-04-16.md):Function 运行时完整测试、服务端承接验证与当前门禁缺口。 - [ITEM_AND_BUILD_PRD_AUDIT_2026-04-05.md](./ITEM_AND_BUILD_PRD_AUDIT_2026-04-05.md):物品生成与 Build 标签系统对 PRD 的落地情况。 - [CUSTOM_WORLD_CREATOR_TOOL_AUDIT_2026-04-08.md](./CUSTOM_WORLD_CREATOR_TOOL_AUDIT_2026-04-08.md):自定义世界创作工具当前问题、体验断层和优化优先级审计。 +- [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):未引用垃圾、旧入口残留、前后端双份真相与后端迁移项的专项审计。 diff --git a/docs/design/COMPANION_FIRST_CONTACT_RELATIONSHIP_AND_PRIVATE_CHAT_DESIGN_2026-04-04.md b/docs/design/COMPANION_FIRST_CONTACT_RELATIONSHIP_AND_PRIVATE_CHAT_DESIGN_2026-04-04.md index e3d5bf6a..644a9831 100644 --- a/docs/design/COMPANION_FIRST_CONTACT_RELATIONSHIP_AND_PRIVATE_CHAT_DESIGN_2026-04-04.md +++ b/docs/design/COMPANION_FIRST_CONTACT_RELATIONSHIP_AND_PRIVATE_CHAT_DESIGN_2026-04-04.md @@ -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 生成 也就是说: diff --git a/docs/design/LEVEL_PROGRESS_AND_CHAPTER_NPC_AUTO_SCALING_DESIGN_2026-04-20.md b/docs/design/LEVEL_PROGRESS_AND_CHAPTER_NPC_AUTO_SCALING_DESIGN_2026-04-20.md index 8deb90f7..c6502283 100644 --- a/docs/design/LEVEL_PROGRESS_AND_CHAPTER_NPC_AUTO_SCALING_DESIGN_2026-04-20.md +++ b/docs/design/LEVEL_PROGRESS_AND_CHAPTER_NPC_AUTO_SCALING_DESIGN_2026-04-20.md @@ -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. 目标 diff --git a/docs/design/PLATFORM_HOME_PUBLIC_BROWSE_AND_LOGIN_MODAL_GATING_DESIGN_2026-04-19.md b/docs/design/PLATFORM_HOME_PUBLIC_BROWSE_AND_LOGIN_MODAL_GATING_DESIGN_2026-04-19.md index 372f9e19..8b1d8574 100644 --- a/docs/design/PLATFORM_HOME_PUBLIC_BROWSE_AND_LOGIN_MODAL_GATING_DESIGN_2026-04-19.md +++ b/docs/design/PLATFORM_HOME_PUBLIC_BROWSE_AND_LOGIN_MODAL_GATING_DESIGN_2026-04-19.md @@ -127,6 +127,12 @@ - 未登录:弹出登录弹窗,并缓存 `action` - 登录成功:自动执行缓存的 `action` +账号入口补充约束: + +- 不再提供 `AuthGate` 层右上角固定悬浮的全局登录 / 账号信息入口 +- 登录触发统一来自页面内受保护动作、个人页、存档页等明确入口 +- 账号信息面板只通过页面内按钮打开,不在平台右上角常驻悬浮 + ## 4.2 平台首页数据加载 `PreGameSelectionFlow` 在未登录时只读取: diff --git a/docs/experience/AGENT_UI_CHANGELOG.md b/docs/experience/AGENT_UI_CHANGELOG.md index 94bf6d96..0c83ad74 100644 --- a/docs/experience/AGENT_UI_CHANGELOG.md +++ b/docs/experience/AGENT_UI_CHANGELOG.md @@ -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` | 小图标 ``,`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` | 小图标 ``,`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`。_ diff --git a/docs/experience/MOBILE_UI_DEV_EXPERIENCE.md b/docs/experience/MOBILE_UI_DEV_EXPERIENCE.md index 7288de06..64e227af 100644 --- a/docs/experience/MOBILE_UI_DEV_EXPERIENCE.md +++ b/docs/experience/MOBILE_UI_DEV_EXPERIENCE.md @@ -94,6 +94,8 @@ - 全局账号信息条挂在这里,会直接压住场景、敌人血条或顶部提示,手机端尤其明显。 - 结论: 账号入口应收回平台首页、个人页或设置面板,不要在实际冒险主场景常驻悬浮显示。 +- 当前仓库已进一步收口为: + 不再提供右上角全局账号悬浮条,统一只保留页面内入口与独立账号面板。 ## 5. 队伍面板经验 diff --git a/docs/prd/AI_CHARACTER_VISUAL_ANIMATION_MVP_PRD_2026-04-04.md b/docs/prd/AI_CHARACTER_VISUAL_ANIMATION_MVP_PRD_2026-04-04.md index e50d1951..3fbabacc 100644 --- a/docs/prd/AI_CHARACTER_VISUAL_ANIMATION_MVP_PRD_2026-04-04.md +++ b/docs/prd/AI_CHARACTER_VISUAL_ANIMATION_MVP_PRD_2026-04-04.md @@ -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` | 可选 | 缺失时默认走主图向后倒地过渡动画,最终停在翻转倒地姿态 | 这里“必生成”指的是: diff --git a/docs/prd/AI_NATIVE_AGENT_FIRST_CUSTOM_WORLD_CREATOR_PHASE5_IMPLEMENTATION_PLAN_2026-04-14.md b/docs/prd/AI_NATIVE_AGENT_FIRST_CUSTOM_WORLD_CREATOR_PHASE5_IMPLEMENTATION_PLAN_2026-04-14.md index d03205f8..6e9c4215 100644 --- a/docs/prd/AI_NATIVE_AGENT_FIRST_CUSTOM_WORLD_CREATOR_PHASE5_IMPLEMENTATION_PLAN_2026-04-14.md +++ b/docs/prd/AI_NATIVE_AGENT_FIRST_CUSTOM_WORLD_CREATOR_PHASE5_IMPLEMENTATION_PLAN_2026-04-14.md @@ -251,7 +251,7 @@ kind === 'character'; 1. `run / attack` 为固定必生成动作 2. 角色已配置技能时,对应技能动作也属于必生成动作 -3. `idle / die` 只作为可选增强,缺失时分别走主图静止 / 主图倒地过渡动画兜底,死亡动画最终停在翻转倒地姿态 +3. `idle / die` 只作为可选增强,缺失时分别走主图静止 / 主图向后倒地过渡动画兜底,死亡动画带轻微过冲回落,最终停在翻转倒地姿态 ### 阶段 D:动作发布 diff --git a/docs/prd/AI_NATIVE_AGENT_FIRST_CUSTOM_WORLD_CREATOR_PRD_2026-04-12.md b/docs/prd/AI_NATIVE_AGENT_FIRST_CUSTOM_WORLD_CREATOR_PRD_2026-04-12.md index eb8c9b46..c4cf9d55 100644 --- a/docs/prd/AI_NATIVE_AGENT_FIRST_CUSTOM_WORLD_CREATOR_PRD_2026-04-12.md +++ b/docs/prd/AI_NATIVE_AGENT_FIRST_CUSTOM_WORLD_CREATOR_PRD_2026-04-12.md @@ -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` 缺失时运行时默认播放主图向后倒地过渡动画,并通过轻微过冲回落让动作更自然,最终停在翻转倒地姿态 说明: diff --git a/docs/prd/AI_NATIVE_SCENE_MULTI_ACT_CREATOR_AND_GAMEPLAY_FLOW_PRD_2026-04-20.md b/docs/prd/AI_NATIVE_SCENE_MULTI_ACT_CREATOR_AND_GAMEPLAY_FLOW_PRD_2026-04-20.md index fc7b75c4..747d8ffa 100644 --- a/docs/prd/AI_NATIVE_SCENE_MULTI_ACT_CREATOR_AND_GAMEPLAY_FLOW_PRD_2026-04-20.md +++ b/docs/prd/AI_NATIVE_SCENE_MULTI_ACT_CREATOR_AND_GAMEPLAY_FLOW_PRD_2026-04-20.md @@ -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 列表,第一位自动成为主角色。 diff --git a/docs/prd/AI_NATIVE_UNIFIED_ROLE_ATTRIBUTE_SYSTEM_PRD_2026-04-02.md b/docs/prd/AI_NATIVE_UNIFIED_ROLE_ATTRIBUTE_SYSTEM_PRD_2026-04-02.md index 0938d455..4ffb2bcd 100644 --- a/docs/prd/AI_NATIVE_UNIFIED_ROLE_ATTRIBUTE_SYSTEM_PRD_2026-04-02.md +++ b/docs/prd/AI_NATIVE_UNIFIED_ROLE_ATTRIBUTE_SYSTEM_PRD_2026-04-02.md @@ -963,6 +963,11 @@ behaviorVectors: Array<{ 3. 一句解释文本 4. 怪物的“敌意关系状态” +补一条 UI 落地约束: + +- 包括选角流、角色面板、详情弹窗在内,所有属性展示入口都必须直接读取当前世界的 `WorldAttributeSchema.slots`。 +- 禁止回退显示 `力量 / 敏捷 / 智力 / 精神` 这类旧四维占位文案,除非该入口明确处于旧数据迁移调试模式。 + ## 11.3 对玩家的信息揭示分层 不是所有 NPC 初见时都展示完整属性。 diff --git a/docs/prd/MY_TAB_SETTINGS_AND_SECURITY_PRD_2026-04-16.md b/docs/prd/MY_TAB_SETTINGS_AND_SECURITY_PRD_2026-04-16.md index 86735fca..7c46a5c3 100644 --- a/docs/prd/MY_TAB_SETTINGS_AND_SECURITY_PRD_2026-04-16.md +++ b/docs/prd/MY_TAB_SETTINGS_AND_SECURITY_PRD_2026-04-16.md @@ -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 diff --git a/docs/technical/AI_CHARACTER_ANIMATION_TECHNICAL_SOLUTION_2026-04-04.md b/docs/technical/AI_CHARACTER_ANIMATION_TECHNICAL_SOLUTION_2026-04-04.md index 35669b1d..1506ee3c 100644 --- a/docs/technical/AI_CHARACTER_ANIMATION_TECHNICAL_SOLUTION_2026-04-04.md +++ b/docs/technical/AI_CHARACTER_ANIMATION_TECHNICAL_SOLUTION_2026-04-04.md @@ -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` | 可选 | 模板生成;缺失时默认主图向后倒地过渡动画,带轻微过冲回落,最终停在翻转倒地姿态 | 这里“必填”指的是: diff --git a/docs/technical/SCENE_MULTI_ACT_CREATOR_IMPLEMENTATION_PROGRESS_2026-04-20.md b/docs/technical/SCENE_MULTI_ACT_CREATOR_IMPLEMENTATION_PROGRESS_2026-04-20.md index f2ef4c67..6c2377c8 100644 --- a/docs/technical/SCENE_MULTI_ACT_CREATOR_IMPLEMENTATION_PROGRESS_2026-04-20.md +++ b/docs/technical/SCENE_MULTI_ACT_CREATOR_IMPLEMENTATION_PROGRESS_2026-04-20.md @@ -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 创作闭环 -这样可以先把“能跑”补齐,再把“编辑体验”补完整。 +这样可以先把“能跑”继续扩成“能切幕”,再把“发布质量门槛”和“完整创作闭环”补完整。 diff --git a/packages/shared/src/contracts/story.ts b/packages/shared/src/contracts/story.ts index f21b109a..effcf75a 100644 --- a/packages/shared/src/contracts/story.ts +++ b/packages/shared/src/contracts/story.ts @@ -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; diff --git a/packages/shared/src/prompts/qwenSprite.ts b/packages/shared/src/prompts/qwenSprite.ts index f53da868..8e1ecd9e 100644 --- a/packages/shared/src/prompts/qwenSprite.ts +++ b/packages/shared/src/prompts/qwenSprite.ts @@ -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; diff --git a/server-node/src/modules/ai/chatOrchestrator.ts b/server-node/src/modules/ai/chatOrchestrator.ts index 1eb7a2f0..ab286506 100644 --- a/server-node/src/modules/ai/chatOrchestrator.ts +++ b/server-node/src/modules/ai/chatOrchestrator.ts @@ -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 { + 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(); diff --git a/server-node/src/modules/ai/orchestrator.test.ts b/server-node/src/modules/ai/orchestrator.test.ts index 943ee5f8..bcc4cab1 100644 --- a/server-node/src/modules/ai/orchestrator.test.ts +++ b/server-node/src/modules/ai/orchestrator.test.ts @@ -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( diff --git a/server-node/src/modules/combat/combatResolutionService.ts b/server-node/src/modules/combat/combatResolutionService.ts index 21152e8b..42d864d2 100644 --- a/server-node/src/modules/combat/combatResolutionService.ts +++ b/server-node/src/modules/combat/combatResolutionService.ts @@ -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> | null; + itemEffect?: NonNullable< + ReturnType + > | 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[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, - turns: number, -) { +function tickCooldownMap(cooldowns: Record, 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[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[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[0]) ?? - [], + (session.rawGameState.activeBuildBuffs as Parameters< + typeof appendBuildBuffs + >[0]) ?? [], action.buildBuffs as Parameters[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); diff --git a/server-node/src/modules/npc/npcInteractionService.ts b/server-node/src/modules/npc/npcInteractionService.ts index b5401555..71ae0cf7 100644 --- a/server-node/src/modules/npc/npcInteractionService.ts +++ b/server-node/src/modules/npc/npcInteractionService.ts @@ -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; + 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: diff --git a/server-node/src/modules/progression/chapterProgressionPlanner.test.ts b/server-node/src/modules/progression/chapterProgressionPlanner.test.ts new file mode 100644 index 00000000..e7f0144c --- /dev/null +++ b/server-node/src/modules/progression/chapterProgressionPlanner.test.ts @@ -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'); +}); diff --git a/server-node/src/modules/progression/chapterProgressionPlanner.ts b/server-node/src/modules/progression/chapterProgressionPlanner.ts new file mode 100644 index 00000000..4815d30e --- /dev/null +++ b/server-node/src/modules/progression/chapterProgressionPlanner.ts @@ -0,0 +1,480 @@ +import type { + CustomWorldProfile, + SceneActBlueprint, + SceneActStage, + SceneChapterBlueprint, +} from '../custom-world/runtimeTypes.js'; + +type JsonRecord = Record; + +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; +} diff --git a/server-node/src/modules/progression/hostileProgressionService.test.ts b/server-node/src/modules/progression/hostileProgressionService.test.ts new file mode 100644 index 00000000..031cf445 --- /dev/null +++ b/server-node/src/modules/progression/hostileProgressionService.test.ts @@ -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); +}); diff --git a/server-node/src/modules/progression/hostileProgressionService.ts b/server-node/src/modules/progression/hostileProgressionService.ts new file mode 100644 index 00000000..9bf75b3d --- /dev/null +++ b/server-node/src/modules/progression/hostileProgressionService.ts @@ -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; + +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 = { + guide: 0, + ambient: 0, + support: 0, + hostile_standard: 0, + hostile_elite: 10, + hostile_boss: 24, + rival: 6, +}; + +const ROLE_XP_MULTIPLIER: Record = { + 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, + }), + }; +} diff --git a/server-node/src/modules/progression/npcLevelResolver.test.ts b/server-node/src/modules/progression/npcLevelResolver.test.ts new file mode 100644 index 00000000..f346ac19 --- /dev/null +++ b/server-node/src/modules/progression/npcLevelResolver.test.ts @@ -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'); +}); diff --git a/server-node/src/modules/progression/npcLevelResolver.ts b/server-node/src/modules/progression/npcLevelResolver.ts new file mode 100644 index 00000000..252afd01 --- /dev/null +++ b/server-node/src/modules/progression/npcLevelResolver.ts @@ -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 = { + 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', + }; +} diff --git a/server-node/src/modules/story/runtimeSession.ts b/server-node/src/modules/story/runtimeSession.ts index f307a000..7a87dfff 100644 --- a/server-node/src/modules/story/runtimeSession.ts +++ b/server-node/src/modules/story/runtimeSession.ts @@ -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), }; } diff --git a/server-node/src/modules/story/storyActionRoutes.test.ts b/server-node/src/modules/story/storyActionRoutes.test.ts index 337f7b13..aac1bf83 100644 --- a/server-node/src/modules/story/storyActionRoutes.test.ts +++ b/server-node/src/modules/story/storyActionRoutes.test.ts @@ -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', diff --git a/server-node/src/prompts/characterAssetPrompts.ts b/server-node/src/prompts/characterAssetPrompts.ts index 0d173694..b85228eb 100644 --- a/server-node/src/prompts/characterAssetPrompts.ts +++ b/server-node/src/prompts/characterAssetPrompts.ts @@ -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; diff --git a/server-node/src/prompts/chatPromptBuilders.ts b/server-node/src/prompts/chatPromptBuilders.ts index 65009b11..cebcc172 100644 --- a/server-node/src/prompts/chatPromptBuilders.ts +++ b/server-node/src/prompts/chatPromptBuilders.ts @@ -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'); diff --git a/server-node/src/services/chatService.ts b/server-node/src/services/chatService.ts index 020ecb6f..d5c3691d 100644 --- a/server-node/src/services/chatService.ts +++ b/server-node/src/services/chatService.ts @@ -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; 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(), }) diff --git a/src/components/AdventureEntityModal.tsx b/src/components/AdventureEntityModal.tsx index 400036e0..a531d811 100644 --- a/src/components/AdventureEntityModal.tsx +++ b/src/components/AdventureEntityModal.tsx @@ -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}
{subtitle}
+ {selectionRoleLabel ? ( + + ) : null} + ); +} + +function SceneActStagePlayerSprite({ + character, +}: { + character: Character | null; +}) { + if (!character) { + return null; + } + + return ( +
+
+ {character.name} +
+
+ +
+
+ ); +} + +function SceneActStagePreview({ + actLabel, + imageSrc, + fallbackImageSrc, + previewCharacter, + slotNpcs, + onSlotClick, +}: { + actLabel: string; + imageSrc?: string | null; + fallbackImageSrc?: string | null; + previewCharacter: Character | null; + slotNpcs: Array; + onSlotClick: (slotIndex: number) => void; +}) { + return ( + +
+
+ {actLabel} +
+
+
+
+ +
+ {slotNpcs.map((npc, slotIndex) => { + const layout = SCENE_ACT_SLOT_LAYOUTS[slotIndex]; + if (!layout) { + return null; + } + + return ( +
+ onSlotClick(slotIndex)} + /> +
+ ); + })} + + ); +} + +function SceneActNpcSlotPickerModal({ + actLabel, + slotIndex, + currentNpcId, + availableNpcs, + onApply, + onClose, +}: { + actLabel: string; + slotIndex: number; + currentNpcId?: string | null; + availableNpcs: CustomWorldNpc[]; + onApply: (npcId: string | null) => void; + onClose: () => void; +}) { + const [draftNpcId, setDraftNpcId] = useState(currentNpcId?.trim() || ''); + const selectedNpc = + availableNpcs.find((npc) => npc.id === draftNpcId) ?? + availableNpcs.find((npc) => npc.id === currentNpcId) ?? + null; + const slotLabel = slotIndex === 0 ? '主角色槽位' : `角色槽位 ${slotIndex + 1}`; + + useEffect(() => { + setDraftNpcId(currentNpcId?.trim() || ''); + }, [currentNpcId]); + + return ( + +
+
+
+ 当前角色 +
+
+ {selectedNpc ? ( +
+
+ +
+
+ {selectedNpc.name} +
+
+ {selectedNpc.title || selectedNpc.role || '未填写身份'} +
+
+
+
+ ) : ( +
+ 当前槽位还没有角色。 +
+ )} +
+
+ +
+
+ 可选角色 +
+
+ {availableNpcs.length > 0 ? ( + availableNpcs.map((npc) => { + const isSelected = npc.id === draftNpcId; + + return ( + + ); + }) + ) : ( +
+ 请先在场景内 NPC 中为这个场景分配角色。 +
+ )} +
+
+ +
+ {selectedNpc ? ( + { + onApply(null); + onClose(); + }} + tone="rose" + /> + ) : null} + + { + if (!draftNpcId) { + window.alert('请先选择角色。'); + return; + } + onApply(draftNpcId); + onClose(); + }} + tone="sky" + disabled={!draftNpcId} + /> +
+
+
+ ); +} + +function buildSceneActPreviewSceneNpc(npc: CustomWorldNpc): SceneNpc { + return { + id: npc.id, + name: npc.name, + title: npc.title, + role: npc.role, + avatar: (npc.imageSrc ?? npc.name.slice(0, 1)) || '?', + description: npc.description || `${npc.name}会在当前幕与你正面相遇。`, + initialAffinity: npc.initialAffinity, + hostile: false, + recruitable: (npc.initialAffinity ?? 0) >= 0, + functions: ['trade', 'fight', 'spar', 'help', 'chat', 'recruit', 'gift'], + backstory: npc.backstory, + personality: npc.personality, + motivation: npc.motivation, + combatStyle: npc.combatStyle, + relationshipHooks: [...npc.relationshipHooks], + tags: [...npc.tags], + backstoryReveal: npc.backstoryReveal, + skills: npc.skills.map((skill) => ({ ...skill })), + initialItems: npc.initialItems.map((item) => ({ + ...item, + tags: [...item.tags], + })), + imageSrc: npc.imageSrc, + visual: npc.visual, + narrativeProfile: npc.narrativeProfile, + attributeProfile: npc.attributeProfile, + }; +} + +function buildSceneActPreviewScenePreset(params: { + landmark: CustomWorldLandmark; + act: SceneActBlueprint; + encounterNpcs: CustomWorldNpc[]; +}): ScenePresetInfo { + return { + id: params.landmark.id, + name: params.landmark.name, + description: params.landmark.description, + imageSrc: + params.act.backgroundImageSrc?.trim() || + params.landmark.imageSrc?.trim() || + '', + connectedSceneIds: [], + connections: [], + treasureHints: [], + narrativeResidues: params.landmark.narrativeResidues ?? [], + npcs: params.encounterNpcs.map(buildSceneActPreviewSceneNpc), + }; +} + +function SceneActPreviewRuntime({ + profile, + landmark, + chapter, + actIndex, + onClose, +}: { + profile: CustomWorldProfile; + landmark: CustomWorldLandmark; + chapter: SceneChapterBlueprint; + actIndex: number; + onClose: () => void; +}) { + const authUi = useAuthUi(); + const act = chapter.acts[actIndex] ?? null; + const encounterNpcs = useMemo( + () => + (act?.encounterNpcIds ?? []) + .map((npcId) => + profile.storyNpcs.find((entry) => entry.id === npcId) ?? null, + ) + .filter((npc): npc is CustomWorldNpc => Boolean(npc)), + [act?.encounterNpcIds, profile.storyNpcs], + ); + const previewScenePreset = useMemo( + () => + act + ? buildSceneActPreviewScenePreset({ + landmark, + act, + encounterNpcs, + }) + : null, + [act, encounterNpcs, landmark], + ); + const previewEncounter = useMemo(() => { + const primaryNpc = encounterNpcs[0]; + if (!primaryNpc) { + return null; + } + + return buildEncounterFromSceneNpc( + buildSceneActPreviewSceneNpc(primaryNpc), + RESOLVED_ENTITY_X_METERS, + ); + }, [encounterNpcs]); + const previewCharacter = useMemo( + () => + buildCustomWorldPlayableCharacters(profile)[0] ?? + ROLE_TEMPLATE_CHARACTERS[0] ?? + null, + [profile], + ); + const previewActRuntimeState = useMemo( + () => + act + ? { + sceneId: chapter.sceneId, + chapterId: chapter.id, + currentActId: act.id, + currentActIndex: actIndex, + completedActIds: chapter.acts + .slice(0, actIndex) + .map((entry) => entry.id), + visitedActIds: chapter.acts + .slice(0, actIndex + 1) + .map((entry) => entry.id), + } + : null, + [act, actIndex, chapter], + ); + const hasBootstrappedRef = useRef(false); + const { + gameState, + setGameState, + bottomTab, + setBottomTab, + isMapOpen, + setIsMapOpen, + resetGame, + handleCustomWorldSelect, + handleCharacterSelect, + } = useGameFlow(); + const combatFlow = useCombatFlow({ + setGameState, + }); + const storyFlow = useStoryGeneration({ + gameState, + setGameState, + buildResolvedChoiceState: combatFlow.buildResolvedChoiceState, + playResolvedChoice: combatFlow.playResolvedChoice, + }); + const { companionRenderStates, buildCompanionRenderStates } = + useNpcInteractionFlow(gameState); + const isPreviewReady = + gameState.currentScene === 'Story' && + Boolean(gameState.playerCharacter) && + gameState.currentScenePreset?.id === landmark.id; + + useEffect(() => { + if ( + hasBootstrappedRef.current || + !act || + !previewCharacter || + !previewScenePreset || + !previewEncounter || + !previewActRuntimeState + ) { + return; + } + + hasBootstrappedRef.current = true; + storyFlow.resetStoryState(); + setBottomTab('adventure'); + setIsMapOpen(false); + handleCustomWorldSelect(profile); + handleCharacterSelect(previewCharacter); + setGameState((current) => ({ + ...current, + worldType: WorldType.CUSTOM, + customWorldProfile: profile, + currentScene: 'Story', + currentScenePreset: previewScenePreset, + currentEncounter: previewEncounter, + npcInteractionActive: false, + sceneHostileNpcs: [], + inBattle: false, + storyHistory: [], + chapterState: null, + campaignState: null, + currentBattleNpcId: null, + currentNpcBattleMode: null, + currentNpcBattleOutcome: null, + playerX: 0, + playerOffsetY: 0, + playerFacing: 'right', + playerActionMode: 'idle', + scrollWorld: false, + animationState: AnimationState.IDLE, + activeCombatEffects: [], + activeBuildBuffs: [], + lastObserveSignsSceneId: null, + lastObserveSignsReport: null, + storyEngineMemory: { + ...(current.storyEngineMemory ?? createEmptyStoryEngineMemoryState()), + currentChapter: null, + currentSceneActState: previewActRuntimeState, + }, + })); + }, [ + act, + handleCharacterSelect, + handleCustomWorldSelect, + landmark.id, + previewActRuntimeState, + previewCharacter, + previewEncounter, + previewScenePreset, + profile, + setBottomTab, + setGameState, + setIsMapOpen, + storyFlow, + ]); + + if (!act || !previewCharacter || !previewScenePreset || !previewEncounter) { + return ( +
+ 当前幕还缺少可预览的主角色。 +
+ ); + } + + if (!isPreviewReady) { + return ( +
+ 正在载入这一幕的游戏流程... +
+ ); + } + + return ( + undefined, + handleStartNewGame: () => { + resetGame(); + onClose(); + }, + handleSaveAndExit: onClose, + handleCustomWorldSelect, + handleBackToWorldSelect: onClose, + handleCharacterSelect, + }} + companions={{ + companionRenderStates, + buildCompanionRenderStates, + onBenchCompanion: () => undefined, + onActivateRosterCompanion: () => undefined, + }} + audio={{ + musicVolume: authUi?.musicVolume ?? 0.6, + onMusicVolumeChange: authUi?.setMusicVolume ?? (() => {}), + }} + /> + ); +} + +function SceneActPreviewModal({ + profile, + landmark, + chapter, + actIndex, + onClose, +}: { + profile: CustomWorldProfile; + landmark: CustomWorldLandmark; + chapter: SceneChapterBlueprint; + actIndex: number; + onClose: () => void; +}) { + const act = chapter.acts[actIndex] ?? null; + + if (!act) { + return null; + } + + return createPortal( +
+
+
+
+ 幕预览 +
+
+ {act.title.trim() || buildDefaultSceneActTitle(actIndex)} +
+
+ +
+ +
, + document.body, + ); +} + function ConnectionDirectionSlot({ direction, targetName, @@ -1682,6 +2725,117 @@ function SceneImageGenerationModal({ ); } +function SceneActBackgroundModal({ + profile, + landmark, + actLabel, + currentImageSrc, + fallbackImageSrc, + onApply, + onClose, +}: { + profile: CustomWorldProfile; + landmark: CustomWorldLandmark; + actLabel: string; + currentImageSrc?: string | null; + fallbackImageSrc?: string | null; + onApply: (imageSrc?: string | null) => void; + onClose: () => void; +}) { + const presetImages = useMemo(() => getAllCustomWorldSceneImages(), []); + const [draftImageSrc, setDraftImageSrc] = useDraft(currentImageSrc?.trim() || ''); + const [isAiGenerateOpen, setIsAiGenerateOpen] = useState(false); + const previewImageSrc = draftImageSrc || fallbackImageSrc || ''; + + return ( + <> + +
+
+ +
+ setDraftImageSrc('')} + tone="sky" + /> + setIsAiGenerateOpen(true)} /> +
+
+ +
+
+ 预设背景 +
+
+ {presetImages.map((src, index) => { + const isSelected = src === draftImageSrc; + + return ( + + ); + })} +
+
+ +
+ + { + onApply(draftImageSrc || fallbackImageSrc || undefined); + onClose(); + }} + tone="sky" + /> +
+
+
+ + {isAiGenerateOpen ? ( + { + setDraftImageSrc(result.imageSrc); + }} + onClose={() => setIsAiGenerateOpen(false)} + /> + ) : null} + + ); +} + const FIXED_COVER_IMAGE_SIZE = '1600*900'; function buildGeneratedCoverProfile( @@ -4037,11 +5191,47 @@ function LandmarkEditor({ const [isGeneratingSceneNpc, setIsGeneratingSceneNpc] = useState(false); const [activeConnectionDirection, setActiveConnectionDirection] = useState(null); + const [activeSceneActSlotPickerState, setActiveSceneActSlotPickerState] = + useState<{ + actIndex: number; + slotIndex: number; + } | null>(null); + const [activeSceneActBackgroundIndex, setActiveSceneActBackgroundIndex] = + useState(null); + const [activeSceneActPreviewIndex, setActiveSceneActPreviewIndex] = + useState(null); const [npcEditorState, setNpcEditorState] = useState<{ mode: 'create' | 'edit'; npc: CustomWorldNpc; } | null>(null); const presetImages = useMemo(() => getAllCustomWorldSceneImages(), []); + const resolvedInitialLandmarkImageSrc = useMemo(() => { + const landmarkIndex = profile.landmarks.findIndex( + (entry) => entry.id === landmark.id, + ); + + return resolveCustomWorldLandmarkImage( + profile, + landmark, + landmarkIndex >= 0 ? landmarkIndex : profile.landmarks.length, + profile.landmarks + .filter((entry) => entry.id !== landmark.id) + .map((entry) => entry.imageSrc) + .filter((imageSrc): imageSrc is string => Boolean(imageSrc)), + ); + }, [landmark, profile]); + const initialSceneChapterDraft = useMemo( + () => + resolveSceneChapterBlueprintDraft({ + profile, + landmark, + fallbackImageSrc: resolvedInitialLandmarkImageSrc, + }), + [landmark, profile, resolvedInitialLandmarkImageSrc], + ); + const [sceneChapterDraft, setSceneChapterDraft] = useDraft( + initialSceneChapterDraft, + ); const resolvedDraftImageSrc = useMemo(() => { const landmarkIndex = profile.landmarks.findIndex( (entry) => entry.id === draft.id, @@ -4061,6 +5251,47 @@ function LandmarkEditor({ () => new Map(draftStoryNpcs.map((npc) => [npc.id, npc])), [draftStoryNpcs], ); + const sceneNpcOptions = useMemo( + () => + dedupeTextValues(draft.sceneNpcIds) + .map((npcId) => storyNpcById.get(npcId)) + .filter((npc): npc is CustomWorldNpc => Boolean(npc)), + [draft.sceneNpcIds, storyNpcById], + ); + const previewPlayableCharacter = useMemo( + () => + buildCustomWorldPlayableCharacters({ + ...profile, + storyNpcs: draftStoryNpcs, + })[0] ?? + ROLE_TEMPLATE_CHARACTERS[0] ?? + null, + [draftStoryNpcs, profile], + ); + const renderedSceneChapterDraft = useMemo( + () => + sanitizeSceneChapterBlueprint({ + chapter: sceneChapterDraft, + landmark: { + ...draft, + sceneNpcIds: dedupeTextValues(draft.sceneNpcIds), + }, + fallbackImageSrc: resolvedDraftImageSrc, + }), + [draft, resolvedDraftImageSrc, sceneChapterDraft], + ); + const activeSceneActSlotDraft = + activeSceneActSlotPickerState + ? renderedSceneChapterDraft.acts[activeSceneActSlotPickerState.actIndex] ?? null + : null; + const activeSceneActBackgroundDraft = + activeSceneActBackgroundIndex !== null + ? renderedSceneChapterDraft.acts[activeSceneActBackgroundIndex] ?? null + : null; + const activeSceneActPreviewDraft = + activeSceneActPreviewIndex !== null + ? renderedSceneChapterDraft.acts[activeSceneActPreviewIndex] ?? null + : null; const availableTargetLandmarks = useMemo( () => profile.landmarks.filter((entry) => entry.id !== draft.id), [draft.id, profile.landmarks], @@ -4079,6 +5310,16 @@ function LandmarkEditor({ landmarks: nextLandmarks, }; }, [draft, draftStoryNpcs, mode, profile]); + const previewProfile = useMemo( + () => ({ + ...editableProfile, + sceneChapterBlueprints: upsertSceneChapterBlueprint( + editableProfile.sceneChapterBlueprints, + renderedSceneChapterDraft, + ), + }), + [editableProfile, renderedSceneChapterDraft], + ); const directionalConnections = useMemo( () => buildDirectionalConnections(draft.connections, availableTargetLandmarks), [availableTargetLandmarks, draft.connections], @@ -4134,9 +5375,18 @@ function LandmarkEditor({ () => JSON.stringify(draftStoryNpcs), [draftStoryNpcs], ); + const initialSceneChapterSnapshot = useMemo( + () => JSON.stringify(initialSceneChapterDraft), + [initialSceneChapterDraft], + ); + const currentSceneChapterSnapshot = useMemo( + () => JSON.stringify(renderedSceneChapterDraft), + [renderedSceneChapterDraft], + ); const hasUnsavedChanges = initialLandmarkSnapshot !== currentLandmarkSnapshot || - initialStoryNpcSnapshot !== currentStoryNpcSnapshot; + initialStoryNpcSnapshot !== currentStoryNpcSnapshot || + initialSceneChapterSnapshot !== currentSceneChapterSnapshot; const handleRequestClose = () => { if (!hasUnsavedChanges) { @@ -4146,6 +5396,30 @@ function LandmarkEditor({ setIsCloseConfirmOpen(true); }; + const updateSceneActDraft = ( + updater: (chapter: SceneChapterBlueprint) => SceneChapterBlueprint, + ) => { + setSceneChapterDraft((current) => + sanitizeSceneChapterBlueprint({ + chapter: updater( + sanitizeSceneChapterBlueprint({ + chapter: current, + landmark: { + ...draft, + sceneNpcIds: dedupeTextValues(draft.sceneNpcIds), + }, + fallbackImageSrc: resolvedDraftImageSrc, + }), + ), + landmark: { + ...draft, + sceneNpcIds: dedupeTextValues(draft.sceneNpcIds), + }, + fallbackImageSrc: resolvedDraftImageSrc, + }), + ); + }; + const removeSceneNpc = (npcId: string) => { setDraft((current) => ({ ...current, @@ -4160,6 +5434,64 @@ function LandmarkEditor({ })); }; + const addSceneAct = () => { + if (renderedSceneChapterDraft.acts.length >= MAX_SCENE_ACT_COUNT) { + window.alert(`每个场景最多只能配置 ${MAX_SCENE_ACT_COUNT} 幕。`); + return; + } + + updateSceneActDraft((current) => { + const nextActCount = current.acts.length + 1; + return { + ...current, + acts: [ + ...current.acts, + buildDefaultSceneActBlueprint({ + sceneId: draft.id, + sceneName: draft.name, + sceneSummary: draft.description, + encounterNpcIds: dedupeTextValues(draft.sceneNpcIds), + backgroundImageSrc: resolvedDraftImageSrc, + linkedThreadIds: current.linkedThreadIds, + index: current.acts.length, + actCount: nextActCount, + }), + ], + }; + }); + }; + + const removeSceneAct = (index: number) => { + if (renderedSceneChapterDraft.acts.length <= MIN_SCENE_ACT_COUNT) { + window.alert(`每个场景至少需要保留 ${MIN_SCENE_ACT_COUNT} 幕。`); + return; + } + + updateSceneActDraft((current) => ({ + ...current, + acts: current.acts.filter((_act, actIndex) => actIndex !== index), + })); + }; + + const moveSceneAct = (index: number, delta: number) => { + updateSceneActDraft((current) => ({ + ...current, + acts: moveArrayItem(current.acts, index, index + delta), + })); + }; + + const updateSceneActField = ( + index: number, + updater: (act: SceneActBlueprint) => SceneActBlueprint, + ) => { + updateSceneActDraft((current) => ({ + ...current, + acts: current.acts.map((act, actIndex) => + actIndex === index ? updater(act) : act, + ), + })); + }; + const updateDirectionalConnection = ( direction: CardinalConnectionDirection, targetLandmarkId: string, @@ -4238,7 +5570,7 @@ function LandmarkEditor({ const saveLandmarkProfile = () => { const sanitizedDraft = { ...draft, - sceneNpcIds: [...new Set(draft.sceneNpcIds)], + sceneNpcIds: dedupeTextValues(draft.sceneNpcIds), connections: buildDirectionalConnections( draft.connections, availableTargetLandmarks, @@ -4266,11 +5598,37 @@ function LandmarkEditor({ : profile.landmarks.map((entry) => entry.id === sanitizedDraft.id ? sanitizedDraft : entry, ); - - onSaveProfile({ + const syncedLandmarks = syncLandmarksWithStoryNpcs(nextLandmarks, draftStoryNpcs); + const savedLandmark = + syncedLandmarks.find((entry) => entry.id === sanitizedDraft.id) ?? sanitizedDraft; + const savedLandmarkIndex = syncedLandmarks.findIndex( + (entry) => entry.id === savedLandmark.id, + ); + const nextProfileBase = { ...profile, storyNpcs: draftStoryNpcs, - landmarks: syncLandmarksWithStoryNpcs(nextLandmarks, draftStoryNpcs), + landmarks: syncedLandmarks, + }; + const nextSceneChapterBlueprint = sanitizeSceneChapterBlueprint({ + chapter: renderedSceneChapterDraft, + landmark: savedLandmark, + fallbackImageSrc: resolveCustomWorldLandmarkImage( + nextProfileBase, + savedLandmark, + savedLandmarkIndex >= 0 ? savedLandmarkIndex : syncedLandmarks.length, + syncedLandmarks + .filter((entry) => entry.id !== savedLandmark.id) + .map((entry) => entry.imageSrc) + .filter((imageSrc): imageSrc is string => Boolean(imageSrc)), + ), + }); + + onSaveProfile({ + ...nextProfileBase, + sceneChapterBlueprints: upsertSceneChapterBlueprint( + profile.sceneChapterBlueprints, + nextSceneChapterBlueprint, + ), }); onClose(); }; @@ -4396,6 +5754,127 @@ function LandmarkEditor({ )}
+ = MAX_SCENE_ACT_COUNT + ? `已满 ${MAX_SCENE_ACT_COUNT} 幕` + : '新增一幕' + } + onClick={addSceneAct} + tone="sky" + disabled={renderedSceneChapterDraft.acts.length >= MAX_SCENE_ACT_COUNT} + /> + } + > + {renderedSceneChapterDraft.acts.map((act, index) => { + const actLabel = act.title.trim() || buildDefaultSceneActTitle(index); + const encounterSlotIds = buildSceneActEncounterSlotIds( + act.encounterNpcIds, + ); + const encounterSlotNpcs = encounterSlotIds.map( + (npcId) => (npcId ? storyNpcById.get(npcId) ?? null : null), + ); + + return ( +
+
+
+
+ 第{index + 1}幕 +
+
+ {actLabel} +
+
+
+ moveSceneAct(index, -1)} + disabled={index === 0} + /> + moveSceneAct(index, 1)} + disabled={index >= renderedSceneChapterDraft.acts.length - 1} + /> + removeSceneAct(index)} + disabled={ + renderedSceneChapterDraft.acts.length <= MIN_SCENE_ACT_COUNT + } + /> +
+
+ +
+
+ { + if (sceneNpcOptions.length === 0) { + window.alert('请先为场景分配 NPC,再配置这一幕的角色槽位。'); + return; + } + if ( + slotIndex > 0 && + !encounterSlotNpcs[slotIndex - 1] + ) { + window.alert('请先配置前一个角色槽位。'); + return; + } + setActiveSceneActSlotPickerState({ + actIndex: index, + slotIndex, + }); + }} + /> +
+
+
+ 幕背景 +
+
+ {act.backgroundImageSrc === resolvedDraftImageSrc + ? '当前跟随场景主图' + : '已配置独立背景'} +
+
+
+ setActiveSceneActBackgroundIndex(index)} + tone="sky" + /> + { + if (!encounterSlotNpcs[0]) { + window.alert('请先为这一幕配置主角色,再开始预览。'); + return; + } + setActiveSceneActPreviewIndex(index); + }} + /> +
+
+
+
+
+ ); + })} +
@@ -4451,6 +5930,65 @@ function LandmarkEditor({ onClose={() => setIsNpcPickerOpen(false)} /> ) : null} + {activeSceneActBackgroundDraft && activeSceneActBackgroundIndex !== null ? ( + + updateSceneActField(activeSceneActBackgroundIndex, (current) => ({ + ...current, + backgroundImageSrc: imageSrc || undefined, + })) + } + onClose={() => setActiveSceneActBackgroundIndex(null)} + /> + ) : null} + {activeSceneActSlotDraft && activeSceneActSlotPickerState ? ( + + updateSceneActField(activeSceneActSlotPickerState.actIndex, (current) => { + const encounterNpcIds = assignSceneActEncounterSlotId( + current.encounterNpcIds, + activeSceneActSlotPickerState.slotIndex, + npcId, + ); + + return { + ...current, + encounterNpcIds, + primaryNpcId: encounterNpcIds[0] ?? '', + }; + }) + } + onClose={() => setActiveSceneActSlotPickerState(null)} + /> + ) : null} + {activeSceneActPreviewDraft && activeSceneActPreviewIndex !== null ? ( + setActiveSceneActPreviewIndex(null)} + /> + ) : null} {activeConnectionDirection ? ( 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
{ + 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} diff --git a/src/components/auth/AccountModal.test.tsx b/src/components/auth/AccountModal.test.tsx index ae658867..f88a269d 100644 --- a/src/components/auth/AccountModal.test.tsx +++ b/src/components/auth/AccountModal.test.tsx @@ -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', () => { diff --git a/src/components/auth/AccountModal.tsx b/src/components/auth/AccountModal.tsx index c3c5cb35..bba8c7fe 100644 --- a/src/components/auth/AccountModal.tsx +++ b/src/components/auth/AccountModal.tsx @@ -173,7 +173,7 @@ function OverlayPanel({ onClick={onBack ?? onClose} >
-
- {onBack ? ( - - ) : null} -
- {eyebrow} -
+
+ {eyebrow}
{title} @@ -208,7 +196,16 @@ function OverlayPanel({
{action} - {onBack ? null : ( + {onBack ? ( + + ) : ( - -
@@ -503,7 +467,7 @@ export function AccountModal({ onBack={closeSectionPanel} onClose={onClose} > -
+
-
+
{accountNotice ? (
{accountNotice} @@ -571,6 +535,27 @@ export function AccountModal({ ))}
+
+ + +
+
diff --git a/src/components/auth/AuthGate.test.tsx b/src/components/auth/AuthGate.test.tsx index e5919476..7514003f 100644 --- a/src/components/auth/AuthGate.test.tsx +++ b/src/components/auth/AuthGate.test.tsx @@ -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(); }); diff --git a/src/components/auth/AuthGate.tsx b/src/components/auth/AuthGate.tsx index 30431036..31894296 100644 --- a/src/components/auth/AuthGate.tsx +++ b/src/components/auth/AuthGate.tsx @@ -76,7 +76,6 @@ export function AuthGate({ children }: AuthGateProps) { const [showSettingsModal, setShowSettingsModal] = useState(false); const [initialSettingsSection, setInitialSettingsSection] = useState(null); - const [showGlobalAccountActions, setShowGlobalAccountActions] = useState(true); const [sessions, setSessions] = useState([]); const [loadingSessions, setLoadingSessions] = useState(false); const [auditLogs, setAuditLogs] = useState([]); @@ -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) {
- {showGlobalAccountActions ? ( -
- {readyUser ? ( -
- - -
- ) : ( - - )} -
- ) : null} {readyUser ? ( void; openAccountModal: () => void; logout: () => Promise; - setGlobalAccountActionsVisible: (visible: boolean) => void; musicVolume: number; setMusicVolume: (value: number) => void; platformTheme: PlatformTheme; diff --git a/src/components/game-canvas/GameCanvasEntityLayer.tsx b/src/components/game-canvas/GameCanvasEntityLayer.tsx index 4422cb4d..80c4a17b 100644 --- a/src/components/game-canvas/GameCanvasEntityLayer.tsx +++ b/src/components/game-canvas/GameCanvasEntityLayer.tsx @@ -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'}} />
- ) : peacefulResolvedCharacter ? ( + ) : peacefulResolvedCharacter && + !encounter.visual && + !encounter.imageSrc?.trim() ? ( ) : ( - )}
diff --git a/src/components/game-canvas/GameCanvasShared.tsx b/src/components/game-canvas/GameCanvasShared.tsx index e96f7f74..ac3c552a 100644 --- a/src/components/game-canvas/GameCanvasShared.tsx +++ b/src/components/game-canvas/GameCanvasShared.tsx @@ -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 ( + + ); + } + + if (encounter.imageSrc?.trim()) { + return ( + {encounter.npcName} + ); + } + + const runtimeCustomWorldCharacter = + encounter.characterId ? getCharacterById(encounter.characterId) : null; + if (runtimeCustomWorldCharacter?.visual) { + return ( + + ); + } + + if (runtimeCustomWorldCharacter) { + return ( +
+ +
+ ); + } + + return ( + + ); +} + export function DialogueBubbleIcon({ active = false, flip = false, diff --git a/src/components/game-shell/CharacterSelectionFlow.test.tsx b/src/components/game-shell/CharacterSelectionFlow.test.tsx index ee710beb..7f3be518 100644 --- a/src/components/game-shell/CharacterSelectionFlow.test.tsx +++ b/src/components/game-shell/CharacterSelectionFlow.test.tsx @@ -117,12 +117,90 @@ test('custom world character selection stays stable when character ids are empty render( {}} onConfirm={handleConfirm} />, ); + expect(screen.getByText(/潮骨:/u)).toBeTruthy(); + expect(screen.queryByText(/力量:/u)).toBeNull(); + await user.click(screen.getByRole('button', { name: /闻潮/u })); await waitFor(() => { diff --git a/src/components/game-shell/CharacterSelectionFlow.tsx b/src/components/game-shell/CharacterSelectionFlow.tsx index f8ef080e..ef1d605d 100644 --- a/src/components/game-shell/CharacterSelectionFlow.tsx +++ b/src/components/game-shell/CharacterSelectionFlow.tsx @@ -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 = { - 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({
-
- {Object.entries(selectedCharacter.attributes).map(([key, value]) => ( -
- {ATTRIBUTE_LABELS[key as keyof Character['attributes']]}: {value} +
+ {attributeSchema.slots.map((slot) => ( +
+ {slot.name}: {selectedAttributeProfile?.values?.[slot.slotId] ?? 0}
))}
diff --git a/src/components/game-shell/GameShellMainContent.tsx b/src/components/game-shell/GameShellMainContent.tsx index f55a0c75..e62282ba 100644 --- a/src/components/game-shell/GameShellMainContent.tsx +++ b/src/components/game-shell/GameShellMainContent.tsx @@ -141,11 +141,15 @@ export function GameShellMainContent({
{shouldMountAdventureEntityModal && ( - }> + + } + > event.stopPropagation()} + onClick={(event) => event.stopPropagation()} >
-
{overlayPanel === 'character' ? '队伍' : '背包'}
+
+ {overlayPanel === 'character' ? '队伍' : '背包'} +
); } function EmptyShelf({ text }: { text: string }) { return ( -
+
{text}
); @@ -82,7 +88,7 @@ function SaveArchivePreview({ return (