# 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`。 ## 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`。 ## 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`。 ## 2026-04-27 第四轮复查修正 用户复测后仍出现“进入作品测试没有显示幕配置角色”。本轮继续沿真实结果页入口复查,确认前几轮的第一幕解析已经覆盖 `oppositeNpcId -> primaryNpcId -> encounterNpcIds`,但作品测试入口仍可能在进入选角前拿到旧 profile。 定位结论: 1. 结果页展示和自动保存期望消费 `session.resultPreview.preview`,或者在缺少 resultPreview 时消费 `draftProfile.legacyResultProfile`。 2. `rpgCreationPreviewAdapter.buildPreviewFromSession()` 原先优先规范化 `session.draftProfile`,会把基础草稿骨架当成运行态 profile。 3. 当基础草稿骨架与结果页预览中的 `sceneChapterBlueprints`、角色、第一幕对面角色不一致时,作品测试即使后续严格读取第一幕,也会读取到旧世界数据。 本轮修正: 1. `src/services/rpg-creation/rpgCreationPreviewAdapter.ts` - 结果页 profile 解析顺序调整为:`session.resultPreview.preview -> draftProfile.legacyResultProfile -> draftProfile`。 - 作品测试、结果页展示和自动保存使用同一份当前结果页 profile,避免选角后加载旧草稿骨架。 2. `src/services/rpg-creation/rpgCreationPreviewAdapter.test.ts` - 覆盖 `resultPreview.preview` 优先于 `draftProfile`。 - 覆盖缺少 resultPreview 时回退到 `draftProfile.legacyResultProfile`。 3. `src/components/rpg-entry/useRpgCreationEnterWorld.test.tsx` - 将作品测试入口断言改为使用“结果页当前 profile”,保证入口语义与 UI 展示一致。 本轮语义补齐为:结果页点“作品测试”后,先用当前结果页 profile 进入角色选择;选完角色后再直接加载该 profile 的开局场景第一幕,并把第一幕 `oppositeNpcId` 作为对面 NPC 启动聊天。 验证命令: ```bash npm test -- --run src/services/rpg-creation/rpgCreationPreviewAdapter.test.ts src/components/rpg-entry/useRpgCreationEnterWorld.test.tsx src/components/rpg-entry/RpgEntryFlowShell.agent.interaction.test.tsx src/hooks/useGameFlow.customWorld.test.tsx src/data/sceneEncounterPreviews.test.ts src/hooks/rpg-runtime-story/storyChoiceRuntime.test.ts src/data/customWorldLibrary.test.ts npx eslint src/services/rpg-creation/rpgCreationPreviewAdapter.ts src/services/rpg-creation/rpgCreationPreviewAdapter.test.ts src/components/rpg-entry/useRpgCreationEnterWorld.test.tsx src/components/rpg-entry/RpgEntryFlowShell.agent.interaction.test.tsx src/hooks/useGameFlow.customWorld.test.tsx src/data/sceneEncounterPreviews.ts src/data/sceneEncounterPreviews.test.ts src/hooks/rpg-runtime-story/storyChoiceRuntime.ts src/hooks/rpg-runtime-story/storyChoiceRuntime.test.ts src/data/customWorldLibrary.ts src/data/customWorldLibrary.test.ts src/data/scenePresets.ts src/services/customWorldSceneActRuntime.ts src/hooks/rpg-session/useRpgSessionBootstrap.ts src/services/customWorldRoleReferences.ts src/services/big-fish-gallery/bigFishGalleryClient.ts npm run typecheck -- --pretty false npm run check:encoding ``` 以上相关测试、局部 ESLint、全量类型检查与编码检查均通过。后端代码未在本轮修改中触碰,因此未执行 `npm run api-server`。 ## 2026-04-27 第五轮误导链路闭口 第四轮修复后,继续清理会误导后续迭代的旧入口: 1. `src/services/rpg-creation/rpgCreationPreviewAdapter.ts` - 删除普通 `draftProfile -> CustomWorldProfile` 兜底。 - Agent 结果页 profile 只允许来自 `session.resultPreview.preview`,或缺少 resultPreview 时来自明确的 `draftProfile.legacyResultProfile` 兼容快照。 - 基础 `draftProfile` 不再能被静默当作运行态 profile,避免作品测试再次读到旧草稿骨架。 2. `src/hooks/rpg-session/useRpgSessionBootstrap.ts` - 自定义世界选角后的开局状态已经显式构造第一幕 encounter 时,直接返回开局状态。 - 不再把自定义世界开局交给 `ensureSceneEncounterPreview()` 二次推断,避免旧的友好 NPC / 场景预览链路覆盖第一幕 `oppositeNpcId`。 3. `src/components/rpg-entry/useRpgCreationEnterWorld.ts` 与 `src/components/rpg-entry/useRpgCreationResultAutosave.ts` - 移除“只读 session.draftProfile / draftProfile 是真相源”这类已经误导本次排查的注释。 - 明确作品测试读取当前结果页 profile,不静默回退到基础 draftProfile。 闭口后的主链路:结果页 profile -> 作品测试选角 -> 第一章第一幕 -> `oppositeNpcId` encounter。普通场景预览只作为非自定义世界或非开局场景的兜底,不再参与作品测试开局第一幕的角色裁决。 补充闭口: 1. Agent 结果页作品测试与发布入口要求存在当前结果页 profile。 2. 若当前结果页 profile 缺失,入口直接停止,不再使用 `generatedCustomWorldProfile` 旧内存态兜底。 ## 2026-04-27 第六轮入口引用与测试态收口 用户复测后再次出现“进入后没有正确显示幕配置角色,且没有进入聊天状态”。本轮继续把问题压回作品测试真实入口,确认前几轮在标准 `oppositeNpcId` 写法下可以正确进入聊天,但真实生成数据可能把第一幕角色引用写成运行时 NPC 形态,例如 `character-npc-角色id`,或混用角色 id、名称、标题、角色职责文本。旧引用解析只覆盖了部分标准形态,导致第一幕 encounter 解析失败后,运行态会退回普通开局剧情或其他场景角色,自然也不会进入该幕 NPC 聊天。 本轮修正: 1. `src/services/customWorldRoleReferences.ts` - 角色引用归一化新增 `character-npc-*`、`npc-*`、`story-*`、`playable-*` 等运行时/草稿前缀剥离。 - 角色别名新增“职责+姓名”“姓名+职责”等组合,兼容生成器把 `role` 文本写入幕槽位的情况。 2. `src/hooks/rpg-session/useRpgSessionBootstrap.ts` - 第一幕候选 NPC 解析在进入优先队列前先通过当前 profile 统一归一化。 - 跳过当前玩家角色时,不再只比较 `character.id`,而是同时用角色引用解析比较 id/name,避免玩家本人被 `oppositeNpcId` 的别名误判为对面 NPC。 - 自定义世界作品测试进入选择世界与选角后的运行态明确标记 `runtimeMode: 'test'`、`runtimePersistenceDisabled: true`,避免作品测试被普通游玩存档/自动保存链路污染。 3. `src/hooks/useGameFlow.customWorld.test.tsx` - 增加复现测试:当第一幕 `oppositeNpcId` 写成 `character-npc-story-act-only` 时,选角后仍必须命中陆衡,并由陆衡主动进入聊天。 - 增加作品测试态断言,确保测试入口不参与普通持久化。 补充验证命令: ```bash npm test -- --run src/hooks/useGameFlow.customWorld.test.tsx src/hooks/rpg-runtime-story/useRpgRuntimeStoryController.test.tsx npx eslint src/hooks/rpg-session/useRpgSessionBootstrap.ts src/hooks/useGameFlow.customWorld.test.tsx src/services/customWorldRoleReferences.ts npm run typecheck -- --pretty false ``` 以上测试、ESLint 与类型检查已通过。后端代码未在本轮修改中触碰,因此仍不需要执行 `npm run api-server`。