Files
Genarrative/docs/technical/RPG_RUNTIME_OPENING_STORY_BOOTSTRAP_FIX_2026-04-26.md
2026-04-27 22:50:18 +08:00

20 KiB
Raw Blame History

RPG 运行态首段剧情启动修复记录2026-04-26

背景

点击 RPG 玩法测试作品进入游戏后,画布能正常显示地图、玩家和当前场景标题,但底部冒险区域为空,没有剧情文本和操作按钮。

进一步复查后确认,空白并不只是 currentStory 未生成。作品测试与正常进入游戏都应该走同一条链路:开局场景第一幕 -> 当前幕主 NPC 出现在对面 -> 直接开始聊天。正式运行态当时没有把第一幕的 NPC 配置合并进场景 NPC 列表,导致第一幕主 NPC 找不到。

问题定位

  1. RPG 作品进入运行态后,handleCharacterSelect() 会把 GameState.currentScene 切到 Story,并设置玩家角色、场景和运行时状态。
  2. 冒险面板挂载条件是 visibleGameState.playerCharacter && visibleCurrentStory,而 currentStoryuseRpgRuntimeStoryController 管理。
  3. 当前进入 Story 后没有自动请求首段剧情,导致 visibleCurrentStory 一直为 null,路由器不会挂载 RpgRuntimePanelRouter,底部区域因此保持空白。
  4. 幕预览会显式构造 previewScenePresetpreviewEncountercurrentSceneActState;正式运行态原先只从 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当存在 playerCharacterworldTypecurrentScene === '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
    • 场景编译时把多幕配置中的 primaryNpcIdoppositeNpcIdencounterNpcIds 合并进正式 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 集合同时包含 primaryNpcIdoppositeNpcIdencounterNpcIds,避免生成数据只写对面角色时被运行态漏掉。
    • 正式场景遭遇的焦点 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,再回退到章节 sceneIdlinkedLandmarkIds
    • 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 主动开启聊天。

验证

已执行:

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
    • 负好感有限聊天同时识别 primaryNpcIdoppositeNpcId
    • 当前幕解析优先尊重 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

验证命令:

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 主动开启聊天。

验证命令:

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

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 启动聊天。

验证命令:

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:maincloud

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.tssrc/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 时,选角后仍必须命中陆衡,并由陆衡主动进入聊天。
    • 增加作品测试态断言,确保测试入口不参与普通持久化。

补充验证命令:

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:maincloud