This commit is contained in:
2026-04-27 14:23:19 +08:00
parent 09d3fe59b3
commit fa2dbb310b
75 changed files with 7363 additions and 1487 deletions

View File

@@ -0,0 +1,152 @@
# RPG 运行态首段剧情启动修复记录2026-04-26
## 背景
点击 RPG 玩法测试作品进入游戏后,画布能正常显示地图、玩家和当前场景标题,但底部冒险区域为空,没有剧情文本和操作按钮。
进一步复查后确认,空白并不只是 `currentStory` 未生成。作品测试与正常进入游戏都应该走同一条链路:开局场景第一幕 -> 当前幕主 NPC 出现在对面 -> 直接开始聊天。正式运行态当时没有把第一幕的 NPC 配置合并进场景 NPC 列表,导致第一幕主 NPC 找不到。
## 问题定位
1. RPG 作品进入运行态后,`handleCharacterSelect()` 会把 `GameState.currentScene` 切到 `Story`,并设置玩家角色、场景和运行时状态。
2. 冒险面板挂载条件是 `visibleGameState.playerCharacter && visibleCurrentStory`,而 `currentStory``useRpgRuntimeStoryController` 管理。
3. 当前进入 `Story` 后没有自动请求首段剧情,导致 `visibleCurrentStory` 一直为 `null`,路由器不会挂载 `RpgRuntimePanelRouter`,底部区域因此保持空白。
4. 幕预览会显式构造 `previewScenePreset``previewEncounter``currentSceneActState`;正式运行态原先只从 `landmark.sceneNpcIds` 编译 `ScenePreset.npcs`,没有把 `sceneChapterBlueprints[].acts[].encounterNpcIds / primaryNpcId / oppositeNpcId` 合并进去,所以第一幕主角色不会稳定出现。
5. 首段普通剧情自动生成如果不避让 `currentEncounter`,会抢在 NPC 聊天流程前进入 loading导致“直接和当前幕主 NPC 聊天”的产品语义被破坏。
## 落地约束
1. 修复必须补齐真实运行态数据链路,不能只在 UI 上写静态提示文案。
2. 首段剧情仍使用现有 `generateInitialStory()``buildStoryFromResponse()` 处理,保持前端只负责表现和运行态装配。
3. 请求失败时使用现有 fallback story 生成逻辑,保证冒险面板仍有可交互选项。
4. 正常进入游戏和作品测试必须同源:优先从开局第一幕所在场景启动,并加载该幕的主 NPC / 遭遇。
5. 第一幕已有 `currentEncounter` 时,由 NPC 交互流接管首轮聊天,不再启动普通开局叙事。
## 本次修改
1. `src/hooks/rpg-runtime-story/useRpgRuntimeStoryController.ts`
- 增加 Story 场景启动 effect当存在 `playerCharacter``worldType``currentScene === 'Story'`,同时 `currentStory` 为空时,自动调用 `generateStoryForState()` 请求首段剧情。
- 增加 request key避免同一场景重复触发并发首段请求请求结束后释放 key允许失败后再次触发。
- 请求失败时设置 `aiError`,并回退到 `buildFallbackStoryForState()`
2. `src/hooks/rpg-runtime-story/useRpgRuntimeStoryController.test.tsx`
- 覆盖“进入 Story 场景且首段剧情为空时自动请求开局剧情”。
- 覆盖“已有当前幕 NPC 遭遇时不抢先请求普通开局剧情”,保证 NPC 交互流可以直接接管首轮聊天。
3. `src/hooks/rpg-session/useRpgSessionBootstrap.ts`
- 自定义世界选角进入游戏时,优先解析 `sceneChapterBlueprints` 的第一章第一幕所在场景。
- 开局场景选择优先绑定 `profile.landmarks[0]` 对应的章节/第一幕,避免章节数组顺序与场景列表顺序不一致时进入后续场景。
- 写入首个 `currentSceneActState`,让运行态背景、同幕角色和首个 encounter 使用同一套第一幕数据。
- 兼容旧作品:缺少多幕章节时,回退到第一个带场景角色的 landmark而不是停在空营地。
4. `src/data/scenePresets.ts`
- 场景编译时把多幕配置中的 `primaryNpcId``oppositeNpcId``encounterNpcIds` 合并进正式 `ScenePreset.npcs`
- 支持第一幕 NPC 只存在于多幕配置、不存在于旧 `landmark.sceneNpcIds` 的新作品数据。
5. `src/data/customWorldLibrary.ts`
- 保存档规范化时保留 `acts[].sceneId`,不再强制用章节 `sceneId` 覆盖,避免第一幕真实场景丢失。
6. `src/services/customWorldSceneActRuntime.ts`
- 场景章节匹配同时识别运行态场景 id、landmark id、章节 linked landmark 和 act scene id。
- 当前幕 NPC 集合同时包含 `primaryNpcId``oppositeNpcId``encounterNpcIds`,避免生成数据只写对面角色时被运行态漏掉。
- 正式场景遭遇的焦点 NPC 优先读取 `oppositeNpcId`,再回退到 `primaryNpcId` 和首个 encounter NPC。
7. `src/data/sceneEncounterPreviews.test.ts`
- 覆盖运行态场景 id 与 landmark id 不一致时仍能解析当前幕 NPC。
- 覆盖章节 `sceneId` 是抽象值、第一幕 `act.sceneId` 才是真实 landmark且只写 `oppositeNpcId` 时仍能解析当前幕 NPC。
- 覆盖当前幕对面 NPC 会优先成为正式场景 encounter。
8. `src/hooks/useGameFlow.customWorld.test.tsx`
- 覆盖正常进入自定义世界时会进入第一幕场景,并加载只存在于第一幕配置里的对面 NPC。
- 覆盖章节数组第一项不是开局场景时,仍以第一个 landmark 的第一幕作为开局。
## 2026-04-27 复查修正
用户复测后确认:开局场景本身可以是 `camp`,不能再把 `profile.landmarks[0]` 当作更高优先级的“真实开局”。运行态必须直接信任 `sceneChapterBlueprints[0].acts[0]`
1. `src/hooks/rpg-session/useRpgSessionBootstrap.ts`
- 自定义世界确认角色后,开局场景优先解析第一章第一幕的 `act.sceneId`,再回退到章节 `sceneId``linkedLandmarkIds`
- `custom-scene-camp` 可以作为正式开局场景进入,不再被第一个 landmark 覆盖。
- 只有缺少多幕章节时,才回退到旧作品的“带 NPC 地标 / 第一个地标”兼容逻辑。
2. `src/services/customWorldSceneActRuntime.ts`
- 当前幕解析兼容精简 profile 和旧快照,避免 `landmarks` 缺失时中断 NPC 聊天链路。
- 章节匹配继续同时识别运行态场景 id、camp id、landmark id、章节关联地标和 act scene id。
3. `src/hooks/useGameFlow.customWorld.test.tsx`
- 新增接近真实运行态的 hook 组合断言:选择世界、确认角色后进入 `custom-scene-camp` 的第一幕,当前 encounter 是 `oppositeNpcId` 对应的陆衡,并触发 NPC 主动开场聊天。
本轮修正后的产品语义:作品测试和正常进入游戏保持同源,进入游戏后以开局场景第一幕为准,直接加载当前幕对面 NPC并由 NPC 主动开启聊天。
## 验证
已执行:
```bash
npm test -- --run src/hooks/rpg-runtime-story/useRpgRuntimeStoryController.test.tsx
npm test -- --run src/hooks/useGameFlow.customWorld.test.tsx src/data/sceneEncounterPreviews.test.ts src/hooks/rpg-runtime-story/useRpgRuntimeStoryController.test.tsx
npx eslint src/data/customWorldLibrary.ts src/data/scenePresets.ts src/services/customWorldSceneActRuntime.ts src/hooks/rpg-session/useRpgSessionBootstrap.ts src/hooks/useGameFlow.customWorld.test.tsx src/data/sceneEncounterPreviews.test.ts src/hooks/rpg-runtime-story/useRpgRuntimeStoryController.ts src/hooks/rpg-runtime-story/useRpgRuntimeStoryController.test.tsx
npm run typecheck -- --pretty false
npm run check:encoding
```
局部测试、局部 ESLint、全量类型检查与编码检查均通过。后端代码未在本次任务中修改因此未执行 `npm run api-server:maincloud`
## 2026-04-27 第二轮复查修正
用户继续复测后发现两类问题:
1. 第一幕已经配置了 `oppositeNpcId`,但运行态对面角色仍可能不是第一幕主 NPC。
2. 战斗后 React 报错 `Encountered two children with the same key, monster-16`
本轮定位结论:
1. 第一幕 encounter 选择原先先走“友好 NPC 池”。如果第一幕 `oppositeNpcId` 是负好感或敌对标记角色,会被友好池过滤掉,随后可能回退到同幕其他角色,导致开局对面角色不对。
2. 负好感有限聊天原先只识别 `primaryNpcId`。当产品语义要求 `oppositeNpcId` 是第一幕正面对话角色时,敌意对面角色仍应先开聊天,而不是直接触发战斗。
3. 战斗奖励弹层按 `battleReward.id + hostileNpc.id` 作为 key同一场战斗击败两个同 preset 怪物时,两个条目都会是 `monster-16`,从而触发 React 重复 key 报错。
本轮修改:
1. `src/data/sceneEncounterPreviews.ts`
- 新增当前幕专用 NPC 池:只要角色属于 `primaryNpcId / oppositeNpcId / encounterNpcIds`,即使是负好感或敌对标记,也允许进入当前幕 encounter 候选。
- 只有非幕级随机遭遇继续使用原友好 NPC 池,避免误改普通野外战斗规则。
2. `src/services/customWorldSceneActRuntime.ts`
- 负好感有限聊天同时识别 `primaryNpcId``oppositeNpcId`
- 当前幕解析优先尊重 `currentSceneActState.chapterId/currentActId`,再回退到场景匹配,避免同一场景多章节时抢错当前幕。
3. `src/hooks/rpg-runtime-story/storyChoiceRuntime.ts`
- 战斗奖励中的 `defeatedHostileNpcs` 增加 `renderKey`,包含怪物 id、名称、位置与序号。
4. `src/components/rpg-runtime-panels/RpgAdventurePanelOverlays.tsx`
- 战斗奖励击败列表优先使用 `renderKey` 作为 React key。
本轮补充测试:
1. `src/data/sceneEncounterPreviews.test.ts`
- 覆盖第一幕 `oppositeNpcId` 是敌意角色时,仍作为正式 encounter且不会自动进入战斗。
2. `src/hooks/rpg-runtime-story/storyChoiceRuntime.test.ts`
- 覆盖同一场战斗击败两个 `monster-16` 时,奖励摘要生成唯一 `renderKey`
验证命令:
```bash
npm test -- --run src/data/sceneEncounterPreviews.test.ts src/hooks/rpg-runtime-story/storyChoiceRuntime.test.ts src/hooks/useGameFlow.customWorld.test.tsx
npx eslint src/data/sceneEncounterPreviews.ts src/data/sceneEncounterPreviews.test.ts src/services/customWorldSceneActRuntime.ts src/hooks/rpg-runtime-story/storyChoiceRuntime.ts src/hooks/rpg-runtime-story/storyChoiceRuntime.test.ts src/hooks/rpg-runtime-story/uiTypes.ts src/components/rpg-runtime-panels/RpgAdventurePanelOverlays.tsx
```
以上局部测试与局部 ESLint 已通过。后端代码未在本轮修改中触碰,因此不需要执行 `npm run api-server:maincloud`
## 2026-04-27 第三轮复查修正
用户再次复测后确认,作品测试选完角色仍没有稳定等价于“开局场景第一幕的幕测试”。本轮重新沿真实入口链路复查:作品测试结果页进入世界后,会先进入角色选择页;真正的开局状态是在 `handleCharacterSelect()` 中生成。上一轮主要修在场景遭遇预览层,仍让自定义世界选角后的 `currentEncounter` 先置空,再由 `ensureSceneEncounterPreview()` 推断第一幕 NPC因此一旦候选池、场景编译或角色敌意标记出现偏差开局对面角色仍可能漂移。
本轮修正:
1. `src/hooks/rpg-session/useRpgSessionBootstrap.ts`
- 在选角确认阶段显式解析 `sceneChapterBlueprints[0].acts[0]`,将作品测试开局直接绑定到第一章第一幕。
- 第一幕 encounter 选择顺序固定为 `oppositeNpcId -> primaryNpcId -> encounterNpcIds`,并跳过当前玩家角色。
- 优先从当前场景编译出的 `ScenePreset.npcs` 构造 encounter如果第一幕角色只存在于 `storyNpcs/playableNpcs`,也会直接从作品角色配置构造 encounter不再依赖预览兜底。
- 为构造出的开局 encounter 同步初始化 `npcStates`,保证后续 NPC 主动开场聊天可以读取正确关系状态。
2. `src/hooks/useGameFlow.customWorld.test.tsx`
- 增加断言:选角后当前 encounter 必须是第一幕 `oppositeNpcId` 对应的陆衡,不能回退到 `primaryNpcId`
本轮语义收敛为:作品测试选择角色完成后,不再只是“进入自定义世界后生成一个场景遭遇”,而是直接加载开局场景第一幕的运行态快照;对面角色由第一幕 `oppositeNpcId` 决定,并由 NPC 主动开启聊天。
验证命令:
```bash
npm test -- --run src/hooks/useGameFlow.customWorld.test.tsx src/data/sceneEncounterPreviews.test.ts src/hooks/rpg-runtime-story/storyChoiceRuntime.test.ts
npx eslint src/hooks/rpg-session/useRpgSessionBootstrap.ts src/hooks/useGameFlow.customWorld.test.tsx src/data/sceneEncounterPreviews.ts src/services/customWorldSceneActRuntime.ts src/hooks/rpg-runtime-story/storyChoiceRuntime.ts src/hooks/rpg-runtime-story/storyChoiceRuntime.test.ts src/hooks/rpg-runtime-story/uiTypes.ts src/components/rpg-runtime-panels/RpgAdventurePanelOverlays.tsx
npm run typecheck -- --pretty false
```
以上局部测试、局部 ESLint 与全量类型检查已通过。后端代码未在本轮修改中触碰,因此仍不需要执行 `npm run api-server:maincloud`