20 KiB
RPG 运行态首段剧情启动修复记录(2026-04-26)
背景
点击 RPG 玩法测试作品进入游戏后,画布能正常显示地图、玩家和当前场景标题,但底部冒险区域为空,没有剧情文本和操作按钮。
进一步复查后确认,空白并不只是 currentStory 未生成。作品测试与正常进入游戏都应该走同一条链路:开局场景第一幕 -> 当前幕主 NPC 出现在对面 -> 直接开始聊天。正式运行态当时没有把第一幕的 NPC 配置合并进场景 NPC 列表,导致第一幕主 NPC 找不到。
问题定位
- RPG 作品进入运行态后,
handleCharacterSelect()会把GameState.currentScene切到Story,并设置玩家角色、场景和运行时状态。 - 冒险面板挂载条件是
visibleGameState.playerCharacter && visibleCurrentStory,而currentStory由useRpgRuntimeStoryController管理。 - 当前进入
Story后没有自动请求首段剧情,导致visibleCurrentStory一直为null,路由器不会挂载RpgRuntimePanelRouter,底部区域因此保持空白。 - 幕预览会显式构造
previewScenePreset、previewEncounter与currentSceneActState;正式运行态原先只从landmark.sceneNpcIds编译ScenePreset.npcs,没有把sceneChapterBlueprints[].acts[].encounterNpcIds / primaryNpcId / oppositeNpcId合并进去,所以第一幕主角色不会稳定出现。 - 首段普通剧情自动生成如果不避让
currentEncounter,会抢在 NPC 聊天流程前进入 loading,导致“直接和当前幕主 NPC 聊天”的产品语义被破坏。
落地约束
- 修复必须补齐真实运行态数据链路,不能只在 UI 上写静态提示文案。
- 首段剧情仍使用现有
generateInitialStory()和buildStoryFromResponse()处理,保持前端只负责表现和运行态装配。 - 请求失败时使用现有 fallback story 生成逻辑,保证冒险面板仍有可交互选项。
- 正常进入游戏和作品测试必须同源:优先从开局第一幕所在场景启动,并加载该幕的主 NPC / 遭遇。
- 第一幕已有
currentEncounter时,由 NPC 交互流接管首轮聊天,不再启动普通开局叙事。
本次修改
src/hooks/rpg-runtime-story/useRpgRuntimeStoryController.ts- 增加 Story 场景启动 effect:当存在
playerCharacter、worldType且currentScene === 'Story',同时currentStory为空时,自动调用generateStoryForState()请求首段剧情。 - 增加 request key,避免同一场景重复触发并发首段请求;请求结束后释放 key,允许失败后再次触发。
- 请求失败时设置
aiError,并回退到buildFallbackStoryForState()。
- 增加 Story 场景启动 effect:当存在
src/hooks/rpg-runtime-story/useRpgRuntimeStoryController.test.tsx- 覆盖“进入 Story 场景且首段剧情为空时自动请求开局剧情”。
- 覆盖“已有当前幕 NPC 遭遇时不抢先请求普通开局剧情”,保证 NPC 交互流可以直接接管首轮聊天。
src/hooks/rpg-session/useRpgSessionBootstrap.ts- 自定义世界选角进入游戏时,优先解析
sceneChapterBlueprints的第一章第一幕所在场景。 - 开局场景选择优先绑定
profile.landmarks[0]对应的章节/第一幕,避免章节数组顺序与场景列表顺序不一致时进入后续场景。 - 写入首个
currentSceneActState,让运行态背景、同幕角色和首个 encounter 使用同一套第一幕数据。 - 兼容旧作品:缺少多幕章节时,回退到第一个带场景角色的 landmark,而不是停在空营地。
- 自定义世界选角进入游戏时,优先解析
src/data/scenePresets.ts- 场景编译时把多幕配置中的
primaryNpcId、oppositeNpcId、encounterNpcIds合并进正式ScenePreset.npcs。 - 支持第一幕 NPC 只存在于多幕配置、不存在于旧
landmark.sceneNpcIds的新作品数据。
- 场景编译时把多幕配置中的
src/data/customWorldLibrary.ts- 保存档规范化时保留
acts[].sceneId,不再强制用章节sceneId覆盖,避免第一幕真实场景丢失。
- 保存档规范化时保留
src/services/customWorldSceneActRuntime.ts- 场景章节匹配同时识别运行态场景 id、landmark id、章节 linked landmark 和 act scene id。
- 当前幕 NPC 集合同时包含
primaryNpcId、oppositeNpcId与encounterNpcIds,避免生成数据只写对面角色时被运行态漏掉。 - 正式场景遭遇的焦点 NPC 优先读取
oppositeNpcId,再回退到primaryNpcId和首个 encounter NPC。
src/data/sceneEncounterPreviews.test.ts- 覆盖运行态场景 id 与 landmark id 不一致时仍能解析当前幕 NPC。
- 覆盖章节
sceneId是抽象值、第一幕act.sceneId才是真实 landmark,且只写oppositeNpcId时仍能解析当前幕 NPC。 - 覆盖当前幕对面 NPC 会优先成为正式场景 encounter。
src/hooks/useGameFlow.customWorld.test.tsx- 覆盖正常进入自定义世界时会进入第一幕场景,并加载只存在于第一幕配置里的对面 NPC。
- 覆盖章节数组第一项不是开局场景时,仍以第一个 landmark 的第一幕作为开局。
2026-04-27 复查修正
用户复测后确认:开局场景本身可以是 camp,不能再把 profile.landmarks[0] 当作更高优先级的“真实开局”。运行态必须直接信任 sceneChapterBlueprints[0].acts[0]:
src/hooks/rpg-session/useRpgSessionBootstrap.ts- 自定义世界确认角色后,开局场景优先解析第一章第一幕的
act.sceneId,再回退到章节sceneId与linkedLandmarkIds。 custom-scene-camp可以作为正式开局场景进入,不再被第一个 landmark 覆盖。- 只有缺少多幕章节时,才回退到旧作品的“带 NPC 地标 / 第一个地标”兼容逻辑。
- 自定义世界确认角色后,开局场景优先解析第一章第一幕的
src/services/customWorldSceneActRuntime.ts- 当前幕解析兼容精简 profile 和旧快照,避免
landmarks缺失时中断 NPC 聊天链路。 - 章节匹配继续同时识别运行态场景 id、camp id、landmark id、章节关联地标和 act scene id。
- 当前幕解析兼容精简 profile 和旧快照,避免
src/hooks/useGameFlow.customWorld.test.tsx- 新增接近真实运行态的 hook 组合断言:选择世界、确认角色后进入
custom-scene-camp的第一幕,当前 encounter 是oppositeNpcId对应的陆衡,并触发 NPC 主动开场聊天。
- 新增接近真实运行态的 hook 组合断言:选择世界、确认角色后进入
本轮修正后的产品语义:作品测试和正常进入游戏保持同源,进入游戏后以开局场景第一幕为准,直接加载当前幕对面 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。
2026-04-27 第二轮复查修正
用户继续复测后发现两类问题:
- 第一幕已经配置了
oppositeNpcId,但运行态对面角色仍可能不是第一幕主 NPC。 - 战斗后 React 报错
Encountered two children with the same key, monster-16。
本轮定位结论:
- 第一幕 encounter 选择原先先走“友好 NPC 池”。如果第一幕
oppositeNpcId是负好感或敌对标记角色,会被友好池过滤掉,随后可能回退到同幕其他角色,导致开局对面角色不对。 - 负好感有限聊天原先只识别
primaryNpcId。当产品语义要求oppositeNpcId是第一幕正面对话角色时,敌意对面角色仍应先开聊天,而不是直接触发战斗。 - 战斗奖励弹层按
battleReward.id + hostileNpc.id作为 key;同一场战斗击败两个同 preset 怪物时,两个条目都会是monster-16,从而触发 React 重复 key 报错。
本轮修改:
src/data/sceneEncounterPreviews.ts- 新增当前幕专用 NPC 池:只要角色属于
primaryNpcId / oppositeNpcId / encounterNpcIds,即使是负好感或敌对标记,也允许进入当前幕 encounter 候选。 - 只有非幕级随机遭遇继续使用原友好 NPC 池,避免误改普通野外战斗规则。
- 新增当前幕专用 NPC 池:只要角色属于
src/services/customWorldSceneActRuntime.ts- 负好感有限聊天同时识别
primaryNpcId与oppositeNpcId。 - 当前幕解析优先尊重
currentSceneActState.chapterId/currentActId,再回退到场景匹配,避免同一场景多章节时抢错当前幕。
- 负好感有限聊天同时识别
src/hooks/rpg-runtime-story/storyChoiceRuntime.ts- 战斗奖励中的
defeatedHostileNpcs增加renderKey,包含怪物 id、名称、位置与序号。
- 战斗奖励中的
src/components/rpg-runtime-panels/RpgAdventurePanelOverlays.tsx- 战斗奖励击败列表优先使用
renderKey作为 React key。
- 战斗奖励击败列表优先使用
本轮补充测试:
src/data/sceneEncounterPreviews.test.ts- 覆盖第一幕
oppositeNpcId是敌意角色时,仍作为正式 encounter,且不会自动进入战斗。
- 覆盖第一幕
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。
2026-04-27 第三轮复查修正
用户再次复测后确认,作品测试选完角色仍没有稳定等价于“开局场景第一幕的幕测试”。本轮重新沿真实入口链路复查:作品测试结果页进入世界后,会先进入角色选择页;真正的开局状态是在 handleCharacterSelect() 中生成。上一轮主要修在场景遭遇预览层,仍让自定义世界选角后的 currentEncounter 先置空,再由 ensureSceneEncounterPreview() 推断第一幕 NPC,因此一旦候选池、场景编译或角色敌意标记出现偏差,开局对面角色仍可能漂移。
本轮修正:
src/hooks/rpg-session/useRpgSessionBootstrap.ts- 在选角确认阶段显式解析
sceneChapterBlueprints[0].acts[0],将作品测试开局直接绑定到第一章第一幕。 - 第一幕 encounter 选择顺序固定为
oppositeNpcId -> primaryNpcId -> encounterNpcIds,并跳过当前玩家角色。 - 优先从当前场景编译出的
ScenePreset.npcs构造 encounter;如果第一幕角色只存在于storyNpcs/playableNpcs,也会直接从作品角色配置构造 encounter,不再依赖预览兜底。 - 为构造出的开局 encounter 同步初始化
npcStates,保证后续 NPC 主动开场聊天可以读取正确关系状态。
- 在选角确认阶段显式解析
src/hooks/useGameFlow.customWorld.test.tsx- 增加断言:选角后当前 encounter 必须是第一幕
oppositeNpcId对应的陆衡,不能回退到primaryNpcId。
- 增加断言:选角后当前 encounter 必须是第一幕
本轮语义收敛为:作品测试选择角色完成后,不再只是“进入自定义世界后生成一个场景遭遇”,而是直接加载开局场景第一幕的运行态快照;对面角色由第一幕 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。
2026-04-27 第四轮复查修正
用户复测后仍出现“进入作品测试没有显示幕配置角色”。本轮继续沿真实结果页入口复查,确认前几轮的第一幕解析已经覆盖 oppositeNpcId -> primaryNpcId -> encounterNpcIds,但作品测试入口仍可能在进入选角前拿到旧 profile。
定位结论:
- 结果页展示和自动保存期望消费
session.resultPreview.preview,或者在缺少 resultPreview 时消费draftProfile.legacyResultProfile。 rpgCreationPreviewAdapter.buildPreviewFromSession()原先优先规范化session.draftProfile,会把基础草稿骨架当成运行态 profile。- 当基础草稿骨架与结果页预览中的
sceneChapterBlueprints、角色、第一幕对面角色不一致时,作品测试即使后续严格读取第一幕,也会读取到旧世界数据。
本轮修正:
src/services/rpg-creation/rpgCreationPreviewAdapter.ts- 结果页 profile 解析顺序调整为:
session.resultPreview.preview -> draftProfile.legacyResultProfile -> draftProfile。 - 作品测试、结果页展示和自动保存使用同一份当前结果页 profile,避免选角后加载旧草稿骨架。
- 结果页 profile 解析顺序调整为:
src/services/rpg-creation/rpgCreationPreviewAdapter.test.ts- 覆盖
resultPreview.preview优先于draftProfile。 - 覆盖缺少 resultPreview 时回退到
draftProfile.legacyResultProfile。
- 覆盖
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。
2026-04-27 第五轮误导链路闭口
第四轮修复后,继续清理会误导后续迭代的旧入口:
src/services/rpg-creation/rpgCreationPreviewAdapter.ts- 删除普通
draftProfile -> CustomWorldProfile兜底。 - Agent 结果页 profile 只允许来自
session.resultPreview.preview,或缺少 resultPreview 时来自明确的draftProfile.legacyResultProfile兼容快照。 - 基础
draftProfile不再能被静默当作运行态 profile,避免作品测试再次读到旧草稿骨架。
- 删除普通
src/hooks/rpg-session/useRpgSessionBootstrap.ts- 自定义世界选角后的开局状态已经显式构造第一幕 encounter 时,直接返回开局状态。
- 不再把自定义世界开局交给
ensureSceneEncounterPreview()二次推断,避免旧的友好 NPC / 场景预览链路覆盖第一幕oppositeNpcId。
src/components/rpg-entry/useRpgCreationEnterWorld.ts与src/components/rpg-entry/useRpgCreationResultAutosave.ts- 移除“只读 session.draftProfile / draftProfile 是真相源”这类已经误导本次排查的注释。
- 明确作品测试读取当前结果页 profile,不静默回退到基础 draftProfile。
闭口后的主链路:结果页 profile -> 作品测试选角 -> 第一章第一幕 -> oppositeNpcId encounter。普通场景预览只作为非自定义世界或非开局场景的兜底,不再参与作品测试开局第一幕的角色裁决。
补充闭口:
- Agent 结果页作品测试与发布入口要求存在当前结果页 profile。
- 若当前结果页 profile 缺失,入口直接停止,不再使用
generatedCustomWorldProfile旧内存态兜底。
2026-04-27 第六轮入口引用与测试态收口
用户复测后再次出现“进入后没有正确显示幕配置角色,且没有进入聊天状态”。本轮继续把问题压回作品测试真实入口,确认前几轮在标准 oppositeNpcId 写法下可以正确进入聊天,但真实生成数据可能把第一幕角色引用写成运行时 NPC 形态,例如 character-npc-角色id,或混用角色 id、名称、标题、角色职责文本。旧引用解析只覆盖了部分标准形态,导致第一幕 encounter 解析失败后,运行态会退回普通开局剧情或其他场景角色,自然也不会进入该幕 NPC 聊天。
本轮修正:
src/services/customWorldRoleReferences.ts- 角色引用归一化新增
character-npc-*、npc-*、story-*、playable-*等运行时/草稿前缀剥离。 - 角色别名新增“职责+姓名”“姓名+职责”等组合,兼容生成器把
role文本写入幕槽位的情况。
- 角色引用归一化新增
src/hooks/rpg-session/useRpgSessionBootstrap.ts- 第一幕候选 NPC 解析在进入优先队列前先通过当前 profile 统一归一化。
- 跳过当前玩家角色时,不再只比较
character.id,而是同时用角色引用解析比较 id/name,避免玩家本人被oppositeNpcId的别名误判为对面 NPC。 - 自定义世界作品测试进入选择世界与选角后的运行态明确标记
runtimeMode: 'test'、runtimePersistenceDisabled: true,避免作品测试被普通游玩存档/自动保存链路污染。
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。