diff --git a/.hermes/shared-memory/pitfalls.md b/.hermes/shared-memory/pitfalls.md index 89753df6..cc541fc3 100644 --- a/.hermes/shared-memory/pitfalls.md +++ b/.hermes/shared-memory/pitfalls.md @@ -38,13 +38,29 @@ - 验证:`npm run test -- src/components/rpg-entry/useRpgCreationEnterWorld.test.tsx`;确认已发布场景下 `syncAgentDraftResultProfile` 与 `executePublishWorld` 均未被调用。 - 关联:`src/components/rpg-entry/useRpgCreationEnterWorld.ts`、`src/components/platform-entry/PlatformEntryFlowShellImpl.tsx`、`docs/【玩法创作】平台入口与玩法链路-2026-05-15.md`。 -## RPG 点击启动黑屏先查 profile 归一化和角色选择兜底 +## RPG 点击启动黑屏 / 默认 profile 先查 profile 归一化和摘要覆盖 -- 现象:作品详情点击“启动”后页面切到 RPG runtime,但用户只看到黑屏或空白;DevTools 里可能同时看到旧自动存档 `/api/runtime/save/snapshot` 被主动 cancel。 -- 原因:`/custom-world-library` / `/custom-world-gallery` 详情接口可能返回历史或摘要式 `profile`,缺少 `playableNpcs`、`storyNpcs`、`landmarks`、`attributeSchema` 等运行态字段;前端 client 若直接把该对象传给 runtime,角色选择首屏会在 `buildCustomWorldPlayableCharacters(profile)` 或后续属性解析处抛错。`save/snapshot (canceled)` 通常是切 runtime 或卸载时 `AbortController` 取消旧自动存档,不是黑屏根因。 -- 处理:RPG 入口作品库 client 在所有返回 `CustomWorldLibraryEntry` 的接口边界统一调用 `normalizeCustomWorldProfileRecord`,并用 `profileId/worldName/subtitle/summaryText` 补齐旧数据缺字段;角色选择页对角色生成异常或空数组回退默认角色,并保留返回按钮/轻量空态;顶层 runtime 懒加载 fallback 不使用纯 `null`。 -- 验证:`npm run test -- src/services/rpg-entry/rpgEntryLibraryClient.test.ts`、`npm run test -- src/components/rpg-entry/RpgEntryCharacterSelectView.test.tsx`、`npm run typecheck`。 -- 关联:`src/services/rpg-entry/rpgEntryLibraryClient.ts`、`src/components/rpg-entry/RpgEntryCharacterSelectView.tsx`、`src/App.tsx`、`src/components/rpg-runtime-shell/RpgRuntimeShell.tsx`、`docs/【玩法创作】平台入口与玩法链路-2026-05-15.md`。 +- 现象:作品详情点击“启动”后页面切到 RPG runtime,但用户只看到黑屏、空白,或进入默认角色 / 默认 profile;从作品详情点“作品编辑”后开局 CG、封面、角色图、技能动作预览、初始物品图标或场景背景图丢失;DevTools 里可能同时看到旧自动存档 `/api/runtime/save/snapshot` 被主动 cancel。 +- 原因:`/custom-world-library` / `/custom-world-gallery` 详情接口可能返回历史或摘要式 `profile`,缺少 `playableNpcs`、`storyNpcs`、`landmarks`、`attributeSchema` 等运行态字段;前端 client 若直接把该对象传给 runtime,角色选择首屏会在 `buildCustomWorldPlayableCharacters(profile)` 或后续属性解析处抛错。另一类常见原因是详情接口已回读完整 profile 后,`savedCustomWorldEntries` 里的列表摘要又把 `selectedDetailEntry` 覆盖回空 profile,导致启动或编辑时只剩卡片摘要。发布 / 回读 result-view 若返回字段更少的旧视图,也可能把当前结果页已编辑资产降级掉。`save/snapshot (canceled)` 通常是切 runtime 或卸载时 `AbortController` 取消旧自动存档,不是黑屏根因。 +- 处理:RPG 入口作品库 client 在所有返回 `CustomWorldLibraryEntry` 的接口边界统一调用 `normalizeCustomWorldProfileRecord`,并用 `profileId/worldName/subtitle/summaryText` 补齐旧数据缺字段;详情页已拿到运行态字段或资产槽位更多的完整 profile 时,不允许列表摘要覆盖当前详情;同一 `profile.id` 下,正式进入世界发布 / 回读不得用字段更少的后端旧视图降级当前结果页 profile。`normalizeCustomWorldProfileRecord` 必须近似无损保留 `cover`、`openingCg`、`camp.narrativeResidues`、`landmark.visualDescription/narrativeResidues`、`skills[].actionPreviewConfig`、`initialItems[].iconSrc`、`attributeSchema`、角色 `attributeProfile` 和 `sceneChapterBlueprints[].acts[]` 的背景与结构字段;只有背景资产的 act 也不能被过滤。角色选择页对角色生成异常或空数组回退默认角色,并保留返回按钮/轻量空态;顶层 runtime 懒加载 fallback 不使用纯 `null`。 +- 验证:`npm run test -- src/components/rpg-entry/RpgEntryFlowShell.agent.interaction.test.tsx -t "creation hub published work start uses loaded detail profile instead of library summary|creation hub published work edit keeps loaded detail profile assets instead of library summary"`;`npm run test -- src/data/customWorldLibrary.test.ts -t "保留结果页封面和关键图片资产槽位|近似无损保留编辑态和运行态结构字段|保留只有背景资产的场景幕"`;`npm run test -- src/components/rpg-entry/useRpgEntryAgentDraftRestore.test.tsx -t "默认封面和角色编辑结构差异也不能被列表摘要覆盖"`;`npm run test -- src/components/rpg-entry/useRpgCreationEnterWorld.test.tsx -t "正式进入世界回读结果页字段更少时不降级当前完整 profile"`;`npm run typecheck`。 +- 关联:`src/components/rpg-entry/useRpgEntryLibraryDetail.ts`、`src/components/rpg-entry/useRpgCreationEnterWorld.ts`、`src/data/customWorldLibrary.ts`、`src/services/rpg-entry/rpgEntryLibraryClient.ts`、`src/components/rpg-entry/RpgEntryCharacterSelectView.tsx`、`src/App.tsx`、`src/components/rpg-runtime-shell/RpgRuntimeShell.tsx`、`docs/【玩法创作】平台入口与玩法链路-2026-05-15.md`。 + +## RPG 战后一轮战斗后卡在观察/试探/调息先查 post-battle finalization + +- 现象:RPG 一轮战斗胜利后,运行态只显示默认 `观察周围迹象 / 主动出声试探 / 原地调息`,这些按钮只有文字反馈;点“继续冒险”后又回到同样选项,点探索只播退场/进场动画,场景和剧情不推进。 +- 原因:终局战斗 action 如果只走通用 `resolve_story_runtime_action` fallback,而没有在后端调用 `finalize_post_battle_resolution(...)`,就不会持久写入 `story_continue_adventure`、`deferredOptions` 和下一幕 `currentSceneActState`。另外旧 bootstrap 快照可能只有 `connectedSceneIds` / `forwardSceneId`、没有 `connections`,战后选项生成若只读 `connections` 也会退回 `idle_explore_forward` 循环。 +- 处理:`module-runtime-story` 在 story action 投影后统一调用 post-battle finalization;`idle_explore_forward` 清理战斗态并生成下一段遭遇预览;`idle_travel_next_scene` / `camp_travel_home_scene` 由后端写入新 `currentScenePreset`、场景 act 状态、遭遇预览和 `runtimeStats.scenesTraveled`。前端只负责播放继续、探索和切场景动画,不承接正式剧情推进真相。 +- 验证:`cargo test -p module-runtime-story --manifest-path server-rs\Cargo.toml battle_tests -- --nocapture` 应覆盖战斗终局持久化 `story_continue_adventure`、`deferredOptions`、下一幕 act,以及 `idle_travel_next_scene` 真正切换场景。 +- 关联:`server-rs/crates/module-runtime-story/src/session_action.rs`、`server-rs/crates/module-runtime-story/src/post_battle.rs`、`server-rs/crates/module-runtime-story/src/battle_tests.rs`、`docs/【玩法创作】平台入口与玩法链路-2026-05-15.md`。 + +## RPG 战斗飘字不要只靠低对比红绿文字 + +- 现象:暗色或棕黑噪声背景下,战斗伤害飘字看起来像背景纹理,尤其是远端敌人头顶的小号红字几乎不可读。 +- 原因:旧 `CombatFloatingNumber` 主要依赖 `text-rose-200` / `text-emerald-200` 和 8px 同色 glow;在暗红、棕黑、像素噪声背景上,颜色与背景混在一起,1px 深色描边也不足以形成轮廓。 +- 处理:飘字本体使用高亮近白文字、小面积半透明深色底、明显深色描边和多层黑色阴影;只增强瞬时反馈,不新增说明面板,不遮挡主要战斗画面。 +- 验证:`npm run test -- src/components/game-canvas/GameCanvasEntityLayer.test.tsx` 覆盖伤害/治疗飘字样式策略;运行态截图中敌方头顶伤害数字应能在暗场景上辨认。 +- 关联:`src/components/game-canvas/GameCanvasEntityLayer.tsx`、`docs/【项目基线】当前产品与工程约束-2026-05-15.md`。 ## Windows provision 下载截断要断点续传而不是回退目标机下载 @@ -1119,3 +1135,11 @@ - 处理:打开草稿时把持久化 `generationStatus=generating` 等同于生成中 notice,恢复对应玩法生成进度页;恢复计时使用作品摘要 `updatedAt` 推导 `startedAtMs`。 - 验证:`npm test -- src/components/rpg-entry/RpgEntryFlowShell.agent.interaction.test.tsx -t "persisted generating"`。 - 关联:`src/components/platform-entry/PlatformEntryFlowShellImpl.tsx`、`src/components/rpg-entry/RpgEntryFlowShell.agent.interaction.test.tsx`、`docs/【玩法创作】平台入口与玩法链路-2026-05-15.md`。 + +## 存档选择入口不要只藏在“玩过”弹窗里 + +- 现象:用户有 RPG / 拼图运行态存档,但平台底部 `草稿` Tab 只展示作品架,个人中心只有点击 `玩过` 后才可能看到“可继续”,导致看起来没有存档选择入口。 +- 原因:`/api/profile/save-archives` 已在入口 bootstrap 加载,但前端只把 `saveEntries` 注入 `ProfilePlayedWorksModal`;没有独立的存档入口。 +- 处理:个人中心 `常用功能` 必须保留 `存档` 快捷入口,点击后打开独立存档选择弹窗并复用 `SaveArchiveCard`;恢复仍走 `/api/profile/save-archives/{worldKey}`,拼图存档继续走拼图 resume 分支,RPG 走 `handleContinueGame(snapshot)`。 +- 验证:`npm run test -- src/components/rpg-entry/RpgEntryFlowShell.agent.interaction.test.tsx -t "profile page exposes save archive picker"`。 +- 关联:`src/components/rpg-entry/RpgEntryHomeView.tsx`、`src/components/platform-entry/PlatformEntryFlowShellImpl.tsx`、`src/components/rpg-entry/useRpgEntryBootstrap.ts`、`docs/【玩法创作】平台入口与玩法链路-2026-05-15.md`。 diff --git a/docs/【玩法创作】平台入口与玩法链路-2026-05-15.md b/docs/【玩法创作】平台入口与玩法链路-2026-05-15.md index ffb0af2e..1d9bcfd5 100644 --- a/docs/【玩法创作】平台入口与玩法链路-2026-05-15.md +++ b/docs/【玩法创作】平台入口与玩法链路-2026-05-15.md @@ -54,9 +54,13 @@ Agent session 已进入 `published` 后,结果页按钮只能执行“进入 `legacyResultProfile` 只作为历史结果页 profile 兼容兜底;编译正式 profile 时,session 草稿内已保存字段优先于 legacy 字段,legacy 只能补缺失字段。`publish_world` 不再接受前端临时传入的 legacy 载荷;历史兼容路径中 legacy 缺省或显式为 `null` 时等价于未提供,不得因此报 `custom_world.compile.legacy_result_profile_json 不是合法 JSON object`。真正的数组、字符串、数字等非 object legacy 载荷仍应拒绝。 -RPG 结果页开局 CG 是 `profile.openingCg` 资产槽位:`api-server` 负责 VectorEngine / OSS 副作用并返回故事板和视频引用,前端只把结果写回当前 profile;`sync_result_profile`、作品库保存和 `normalizeCustomWorldProfileRecord` 都必须保留该槽位。若生成成功后画面短暂显示又变回空白,优先检查父层重新同步或 profile 归一化是否把 `openingCg` 丢掉,而不是先怀疑已生成资源本身失效。 +RPG 结果页开局 CG 是 `profile.openingCg` 资产槽位:`api-server` 负责 VectorEngine / OSS 副作用并返回故事板和视频引用,前端只把结果写回当前 profile;`sync_result_profile`、作品库保存和 `normalizeCustomWorldProfileRecord` 都必须保留该槽位。封面是 `profile.cover` 资产槽位,默认封面也要保留 `sourceType='default'` 和 `characterRoleIds`,不能因为没有 `imageSrc` 就当作空封面。若生成成功后画面短暂显示又变回空白,优先检查父层重新同步或 profile 归一化是否把 `openingCg` / `cover` 丢掉,而不是先怀疑已生成资源本身失效。 -RPG 从作品架、广场详情或作品号搜索点击“启动”前,入口 client 必须把后端返回的完整 `profile` 先经过 `normalizeCustomWorldProfileRecord`,并用作品条目的 `profileId/worldName/subtitle/summaryText` 补齐旧数据缺失字段;运行态和详情页不得直接消费未归一化的旧 profile。角色选择页还需要在角色数组异常或为空时回退默认角色,并显示可返回的轻量空态,不能 `return null` 造成黑屏。运行态懒加载 fallback 必须可见,不能用纯 `null` 让用户误判为黑屏。 +RPG 从作品架、广场详情或作品号搜索点击“启动”前,入口 client 必须把后端返回的完整 `profile` 先经过 `normalizeCustomWorldProfileRecord`,并用作品条目的 `profileId/worldName/subtitle/summaryText` 补齐旧数据缺失字段;运行态和详情页不得直接消费未归一化的旧 profile。作品架列表或 `savedCustomWorldEntries` 中的摘要 profile 只可用于卡片展示,不可在详情接口已回读完整 profile 后覆盖 `selectedDetailEntry`;若摘要缺少 `playableNpcs`、`storyNpcs`、`landmarks`、`items`、`sceneChapterBlueprints`、`cover`、`openingCg`、`skills[].actionPreviewConfig`、`initialItems[].iconSrc`、`attributeSchema`、角色 `attributeProfile`、场景残留或场景幕背景资产,启动和编辑必须继续使用详情 profile,否则会进入默认角色 / 默认 profile,或在编辑页丢 CG、封面、技能预览和初始物品图标。正式“进入世界”发布 / 回读结果页时,同一 `profile.id` 下也不得用字段更少的后端旧视图降级当前结果页完整 profile。角色选择页还需要在角色数组异常或为空时回退默认角色,并显示可返回的轻量空态,不能 `return null` 造成黑屏。运行态懒加载 fallback 必须可见,不能用纯 `null` 让用户误判为黑屏。 + +RPG 运行态的战斗终局、继续冒险、继续探索和切场景都属于服务端 runtime 快照真相:`module-runtime-story` 必须在终局战斗 action 后调用 post-battle finalization,持久写入 `story_continue_adventure`、`deferredOptions`、`deferredRuntimeState.storyEngineMemory.currentSceneActState` 和清理后的战斗状态;`idle_travel_next_scene` / `camp_travel_home_scene` 必须由后端写入新的 `currentScenePreset`、`currentSceneActState`、`currentEncounter` 和 `runtimeStats.scenesTraveled`。前端只播放退场、进场和继续按钮表现,不能用默认 `观察/试探/调息` fallback 或本地动画假装推进剧情。旧 bootstrap 快照可能只有 `connectedSceneIds` / `forwardSceneId` 而没有 `connections`,后端生成战后旅行选项时必须兼容这些字段。 + +RPG / 拼图等运行态存档选择入口统一在个人中心 `常用功能 > 存档` 暴露为独立弹窗;“玩过”弹窗可以继续合并展示可继续存档,但不能成为唯一入口。前端只展示 `/api/profile/save-archives` 返回的列表并在用户选择后调用对应恢复接口,不能本地拼装或筛选正式存档真相。 ## 拼图 diff --git a/docs/【项目基线】当前产品与工程约束-2026-05-15.md b/docs/【项目基线】当前产品与工程约束-2026-05-15.md index 7b539e81..c8e6db7f 100644 --- a/docs/【项目基线】当前产品与工程约束-2026-05-15.md +++ b/docs/【项目基线】当前产品与工程约束-2026-05-15.md @@ -94,6 +94,7 @@ server-rs + Axum + SpacetimeDB 8. 图像输入通用 UI 统一走 `src/components/common/CreativeImageInputPanel.tsx`。外层页面持有业务状态,组件只承担上传卡、预览、参考图缩略图、AI 重绘开关、错误展示和提交按钮。 9. 发现页 `分类` 子频道的筛选必须打开独立 dialog / drawer / modal,至少支持玩法类型过滤与排序切换;筛选结果为空时显示空状态,不把筛选内容展开在当前列表下方。 10. “我的”页泥点、游戏时长、玩过三张统计卡只展示各自标签和值,内容居中且不换行,不在统计区底部展示“更新于”时间。 +11. RPG 等运行态的战斗飘字、血量变化和即时反馈必须在暗色、噪声高的场景背景上保持可读:使用高亮文字、深色描边、强阴影或小面积半透明底,不只依赖红/绿文字本身表达伤害或治疗。 ## 文案与编码 diff --git a/server-rs/crates/module-runtime-story/src/battle_tests.rs b/server-rs/crates/module-runtime-story/src/battle_tests.rs index a7d41413..51b9c3fe 100644 --- a/server-rs/crates/module-runtime-story/src/battle_tests.rs +++ b/server-rs/crates/module-runtime-story/src/battle_tests.rs @@ -5,8 +5,8 @@ use shared_contracts::runtime_story::{ }; use crate::{ - battle::resolve_battle_action, build_status_patch, read_bool_field, read_i32_field, - read_optional_string_field, + StoryRuntimeActionResolveInput, battle::resolve_battle_action, build_status_patch, + read_bool_field, read_i32_field, read_optional_string_field, resolve_story_runtime_action, }; fn build_battle_fixture() -> serde_json::Value { @@ -61,6 +61,115 @@ fn build_request(function_id: &str, option_text: &str) -> RuntimeStoryActionRequ } } +fn build_runtime_action_request( + function_id: &str, + action_text: &str, + payload: Option, +) -> shared_contracts::story::ResolveStoryRuntimeActionRequest { + shared_contracts::story::ResolveStoryRuntimeActionRequest { + story_session_id: "storysess-1".to_string(), + client_version: Some(1), + function_id: function_id.to_string(), + action_text: action_text.to_string(), + target_id: None, + payload, + } +} + +fn build_custom_world_profile_with_two_landmarks() -> serde_json::Value { + json!({ + "id": "profile-1", + "name": "雾桥旧约", + "summary": "雾桥边的旧约正在复苏。", + "camp": { + "id": "camp-1", + "name": "雾桥营地", + "description": "营火压着雾气。", + "connections": [ + { + "targetLandmarkId": "landmark-1", + "relativePosition": "forward", + "summary": "沿桥面继续前进" + }, + { + "targetLandmarkId": "landmark-2", + "relativePosition": "right", + "summary": "转入雾中支路" + } + ] + }, + "landmarks": [ + { + "id": "landmark-1", + "name": "断桥口", + "description": "桥口挂着旧灯。" + }, + { + "id": "landmark-2", + "name": "雾中渡", + "description": "渡口只有潮声。" + } + ], + "storyNpcs": [ + { + "id": "npc-bridge", + "name": "桥影", + "description": "桥下逼来的敌影", + "initialAffinity": -20 + }, + { + "id": "npc-ferryman", + "name": "摆渡人", + "description": "守着雾中渡的人", + "initialAffinity": 0 + } + ], + "sceneChapterBlueprints": [ + { + "id": "chapter-camp", + "sceneId": "camp-1", + "linkedLandmarkIds": ["camp-1"], + "acts": [ + { + "id": "act-camp-1", + "sceneId": "camp-1", + "oppositeNpcId": "npc-bridge" + }, + { + "id": "act-camp-2", + "sceneId": "camp-1", + "oppositeNpcId": "npc-ferryman" + } + ] + }, + { + "id": "chapter-landmark-1", + "sceneId": "landmark-1", + "linkedLandmarkIds": ["landmark-1"], + "acts": [ + { + "id": "act-landmark-1", + "sceneId": "landmark-1", + "oppositeNpcId": "npc-ferryman" + } + ] + } + ] + }) +} + +fn build_story_runtime_snapshot( + game_state: serde_json::Value, + current_story: Option, +) -> shared_contracts::story::StoryRuntimeSnapshotPayload { + shared_contracts::story::StoryRuntimeSnapshotPayload { + saved_at: None, + bottom_tab: "adventure".to_string(), + game_state, + current_story, + } +} + #[test] fn battle_resolution_prefers_player_defeat_when_both_sides_fall_in_same_turn() { let request = build_request("battle_all_in_crush", "全力压制"); @@ -89,3 +198,210 @@ fn battle_resolution_prefers_player_defeat_when_both_sides_fall_in_same_turn() { Some("defeat".to_string()) ); } + +#[test] +fn terminal_battle_action_persists_post_battle_continue_story() { + let mut game_state = build_battle_fixture(); + game_state["runtimeSessionId"] = json!("runtime-1"); + game_state["currentScene"] = json!("Story"); + game_state["worldType"] = json!("CUSTOM"); + game_state["playerHp"] = json!(30); + game_state["customWorldProfile"] = build_custom_world_profile_with_two_landmarks(); + game_state["currentScenePreset"] = json!({ + "id": "custom-scene-camp", + "name": "雾桥营地", + "description": "营火压着雾气。", + "connectedSceneIds": ["custom-scene-landmark-1", "custom-scene-landmark-2"], + "forwardSceneId": "custom-scene-landmark-1", + "treasureHints": [], + "npcs": [] + }); + game_state["storyEngineMemory"] = json!({ + "currentSceneActState": { + "sceneId": "camp-1", + "chapterId": "chapter-camp", + "currentActId": "act-camp-1", + "currentActIndex": 0, + "completedActIds": [], + "visitedActIds": ["act-camp-1"] + } + }); + + let output = resolve_story_runtime_action(StoryRuntimeActionResolveInput { + story_session_id: "storysess-1".to_string(), + runtime_session_id: "runtime-1".to_string(), + snapshot: build_story_runtime_snapshot(game_state, None), + request: build_runtime_action_request("battle_all_in_crush", "全力压制", None), + }) + .expect("terminal battle should resolve"); + + assert_eq!( + output.presentation.battle.unwrap().outcome.as_deref(), + Some("victory") + ); + assert_eq!( + output.presentation.options[0].function_id, + "story_continue_adventure" + ); + assert_eq!( + output.snapshot.current_story.as_ref().unwrap()["options"][0]["functionId"], + json!("story_continue_adventure") + ); + assert!( + output.snapshot.current_story.as_ref().unwrap()["deferredOptions"] + .as_array() + .is_some_and(|items| { + items + .iter() + .any(|item| item["functionId"] == json!("idle_travel_next_scene")) + }) + ); + assert_eq!( + output.snapshot.current_story.as_ref().unwrap()["deferredRuntimeState"]["storyEngineMemory"] + ["currentSceneActState"]["currentActId"], + json!("act-camp-2") + ); + assert_eq!( + output.snapshot.game_state["storyEngineMemory"]["currentSceneActState"]["currentActId"], + json!("act-camp-2") + ); +} + +#[test] +fn idle_travel_next_scene_changes_scene_from_target_payload() { + let game_state = json!({ + "runtimeSessionId": "runtime-1", + "runtimeActionVersion": 1, + "currentScene": "Story", + "worldType": "CUSTOM", + "customWorldProfile": build_custom_world_profile_with_two_landmarks(), + "playerHp": 30, + "playerMaxHp": 40, + "playerMana": 10, + "playerMaxMana": 20, + "playerCurrency": 0, + "playerInventory": [], + "playerEquipment": { "weapon": null, "armor": null, "relic": null }, + "runtimeStats": { + "hostileNpcsDefeated": 0, + "itemsUsed": 0, + "questsAccepted": 0, + "scenesTraveled": 0, + "playTimeMs": 0, + "lastPlayTickAt": null + }, + "currentScenePreset": { + "id": "custom-scene-camp", + "name": "雾桥营地", + "description": "营火压着雾气。", + "connectedSceneIds": ["custom-scene-landmark-1", "custom-scene-landmark-2"], + "connections": [ + { + "sceneId": "custom-scene-landmark-1", + "relativePosition": "forward", + "summary": "沿桥面继续前进" + } + ], + "forwardSceneId": "custom-scene-landmark-1", + "treasureHints": [], + "npcs": [] + }, + "currentEncounter": null, + "npcInteractionActive": false, + "sceneHostileNpcs": [], + "inBattle": false, + "storyHistory": [], + "storyEngineMemory": {} + }); + + let output = resolve_story_runtime_action(StoryRuntimeActionResolveInput { + story_session_id: "storysess-1".to_string(), + runtime_session_id: "runtime-1".to_string(), + snapshot: build_story_runtime_snapshot(game_state, None), + request: build_runtime_action_request( + "idle_travel_next_scene", + "向前走,前往断桥口", + Some(json!({ "targetSceneId": "custom-scene-landmark-1" })), + ), + }) + .expect("travel action should resolve"); + + assert_eq!( + output.snapshot.game_state["currentScenePreset"]["id"], + json!("custom-scene-landmark-1") + ); + assert_eq!( + output.snapshot.game_state["runtimeStats"]["scenesTraveled"], + json!(1) + ); + assert_eq!( + output.snapshot.game_state["currentEncounter"]["id"], + json!("npc-ferryman") + ); + assert_eq!( + output.snapshot.game_state["storyEngineMemory"]["currentSceneActState"]["currentActId"], + json!("act-landmark-1") + ); + assert!(output.presentation.options.iter().any(|option| { + option.function_id == "idle_travel_next_scene" + || option.function_id == "idle_explore_forward" + })); +} + +#[test] +fn idle_travel_next_scene_normalizes_custom_landmark_id_payload() { + let game_state = json!({ + "runtimeSessionId": "runtime-1", + "runtimeActionVersion": 1, + "currentScene": "Story", + "worldType": "CUSTOM", + "customWorldProfile": build_custom_world_profile_with_two_landmarks(), + "playerHp": 30, + "playerMaxHp": 40, + "playerMana": 10, + "playerMaxMana": 20, + "playerCurrency": 0, + "playerInventory": [], + "playerEquipment": { "weapon": null, "armor": null, "relic": null }, + "runtimeStats": { + "hostileNpcsDefeated": 0, + "itemsUsed": 0, + "questsAccepted": 0, + "scenesTraveled": 0, + "playTimeMs": 0, + "lastPlayTickAt": null + }, + "currentScenePreset": { + "id": "custom-scene-camp", + "name": "雾桥营地", + "description": "营火压着雾气。", + "connectedSceneIds": ["landmark-1", "landmark-2"], + "forwardSceneId": "landmark-2", + "treasureHints": [], + "npcs": [] + }, + "currentEncounter": null, + "npcInteractionActive": false, + "sceneHostileNpcs": [], + "inBattle": false, + "storyHistory": [], + "storyEngineMemory": {} + }); + + let output = resolve_story_runtime_action(StoryRuntimeActionResolveInput { + story_session_id: "storysess-1".to_string(), + runtime_session_id: "runtime-1".to_string(), + snapshot: build_story_runtime_snapshot(game_state, None), + request: build_runtime_action_request( + "idle_travel_next_scene", + "前往雾中渡", + Some(json!({ "targetSceneId": "landmark-2" })), + ), + }) + .expect("raw custom landmark id should resolve"); + + assert_eq!( + output.snapshot.game_state["currentScenePreset"]["id"], + json!("custom-scene-landmark-2") + ); +} diff --git a/server-rs/crates/module-runtime-story/src/lib.rs b/server-rs/crates/module-runtime-story/src/lib.rs index 53e06300..485377f1 100644 --- a/server-rs/crates/module-runtime-story/src/lib.rs +++ b/server-rs/crates/module-runtime-story/src/lib.rs @@ -76,7 +76,9 @@ pub use options::{ build_static_runtime_story_option, build_story_option_from_runtime_option, infer_option_scope, }; pub use post_battle::{ - finalize_post_battle_resolution, is_terminal_battle_outcome, resolve_post_battle_story_options, + clear_post_battle_state, ensure_scene_act_state, ensure_scene_encounter_preview, + finalize_post_battle_resolution, is_terminal_battle_outcome, resolve_forward_scene_id, + resolve_post_battle_story_options, resolve_runtime_scene_preset, }; pub use projection::{StoryRuntimeProjectionSource, build_story_runtime_projection}; pub use prompt_context::{RuntimeStoryPromptContextExtras, build_runtime_story_prompt_context}; diff --git a/server-rs/crates/module-runtime-story/src/post_battle.rs b/server-rs/crates/module-runtime-story/src/post_battle.rs index f804bfa4..efeabe8f 100644 --- a/server-rs/crates/module-runtime-story/src/post_battle.rs +++ b/server-rs/crates/module-runtime-story/src/post_battle.rs @@ -2,10 +2,11 @@ use serde_json::{Value, json}; use shared_contracts::runtime_story::RuntimeStoryOptionView; use crate::{ - CONTINUE_ADVENTURE_FUNCTION_ID, build_static_runtime_story_option, + CONTINUE_ADVENTURE_FUNCTION_ID, build_custom_scene_preset, build_static_runtime_story_option, build_story_option_from_runtime_option, ensure_json_object, read_array_field, read_bool_field, - read_field, read_i32_field, read_object_field, read_optional_string_field, write_bool_field, - write_i32_field, write_null_field, write_string_field, + read_field, read_i32_field, read_object_field, read_optional_string_field, + resolve_custom_runtime_scene_id, write_bool_field, write_i32_field, write_null_field, + write_string_field, }; const WUXIA_FIRST_SCENE_ID: &str = "wuxia-bamboo-road"; @@ -36,6 +37,8 @@ pub fn finalize_post_battle_resolution( return None; } + let original_scene_act_state = current_scene_act_state(game_state); + if outcome == "defeat" { return Some(finalize_defeat_revive(game_state, fallback_options)); } @@ -45,6 +48,7 @@ pub fn finalize_post_battle_resolution( game_state, result_text, fallback_options, + original_scene_act_state, )); } @@ -64,13 +68,14 @@ fn finalize_victory_or_spar( game_state: &mut Value, result_text: &str, fallback_options: Vec, + original_scene_act_state: Option, ) -> PostBattleFinalization { clear_post_battle_state(game_state); let is_last_act = is_current_scene_act_last(game_state); let next_act_state = if is_last_act { None } else { - resolve_next_scene_act_runtime_state(game_state) + resolve_next_scene_act_runtime_state(game_state, original_scene_act_state.as_ref()) }; if let Some(next_act_state) = next_act_state { write_current_scene_act_state(game_state, next_act_state); @@ -141,7 +146,7 @@ fn finalize_defeat_revive( { write_current_scene_act_state(game_state, first_act_state); } - ensure_first_scene_encounter_preview(game_state); + ensure_scene_encounter_preview(game_state); let story_text = if first_scene.name.is_empty() { "你在战斗中倒下,随后重新醒来。".to_string() @@ -160,7 +165,7 @@ fn finalize_defeat_revive( } } -fn clear_post_battle_state(game_state: &mut Value) { +pub fn clear_post_battle_state(game_state: &mut Value) { write_null_field(game_state, "currentEncounter"); write_bool_field(game_state, "npcInteractionActive", false); ensure_json_object(game_state).insert("sceneHostileNpcs".to_string(), Value::Array(Vec::new())); @@ -421,7 +426,7 @@ fn write_first_scene(game_state: &mut Value, scene: &RuntimeScene) { ); } -fn ensure_first_scene_encounter_preview(game_state: &mut Value) { +pub fn ensure_scene_encounter_preview(game_state: &mut Value) { if read_bool_field(game_state, "inBattle").unwrap_or(false) { return; } @@ -436,7 +441,13 @@ fn ensure_first_scene_encounter_preview(game_state: &mut Value) { }; let scene_id = read_object_field(game_state, "currentScenePreset") .and_then(|scene| read_optional_string_field(scene, "id")); - let focus_npc_id = resolve_active_scene_act_focus_npc_id(profile, scene_id.as_deref()); + let current_act_id = current_scene_act_state(game_state) + .and_then(|state| read_optional_string_field(&state, "currentActId")); + let focus_npc_id = resolve_active_scene_act_focus_npc_id( + profile, + scene_id.as_deref(), + current_act_id.as_deref(), + ); let Some(focus_npc_id) = focus_npc_id else { return; }; @@ -450,6 +461,22 @@ fn ensure_first_scene_encounter_preview(game_state: &mut Value) { ); } +pub fn ensure_scene_act_state(game_state: &mut Value) { + if read_bool_field(game_state, "inBattle").unwrap_or(false) { + return; + } + let Some(scene_id) = read_object_field(game_state, "currentScenePreset") + .and_then(|scene| read_optional_string_field(scene, "id")) + else { + return; + }; + let Some(act_state) = build_initial_scene_act_runtime_state(game_state, scene_id.as_str()) + else { + return; + }; + write_current_scene_act_state(game_state, act_state); +} + fn build_scene_travel_options(game_state: &Value) -> Vec { let Some(current_scene) = read_object_field(game_state, "currentScenePreset") else { return vec![build_static_runtime_story_option( @@ -459,32 +486,53 @@ fn build_scene_travel_options(game_state: &Value) -> Vec )]; }; let current_scene_id = read_optional_string_field(current_scene, "id"); - let mut options = read_array_field(current_scene, "connections") + let forward_scene_id = read_optional_string_field(current_scene, "forwardSceneId"); + let mut option_scene_ids = Vec::new(); + let mut options = Vec::new(); + + for connection in read_array_field(current_scene, "connections") { + let Some(scene_id) = read_optional_string_field(connection, "sceneId") else { + continue; + }; + if current_scene_id.as_deref() == Some(scene_id.as_str()) + || option_scene_ids.iter().any(|id| id == scene_id.as_str()) + { + continue; + } + let relative_position = read_optional_string_field(connection, "relativePosition") + .unwrap_or_else(|| "forward".to_string()); + options.push(build_scene_travel_option( + game_state, + scene_id.as_str(), + relative_position.as_str(), + )); + option_scene_ids.push(scene_id); + } + + for scene_id in read_array_field(current_scene, "connectedSceneIds") .into_iter() - .filter_map(|connection| { - let scene_id = read_optional_string_field(connection, "sceneId")?; - if current_scene_id.as_deref() == Some(scene_id.as_str()) { - return None; - } - let relative_position = read_optional_string_field(connection, "relativePosition") - .unwrap_or_else(|| "forward".to_string()); - let scene_name = resolve_scene_name(game_state, scene_id.as_str()) - .unwrap_or_else(|| scene_id.clone()); - Some(RuntimeStoryOptionView { - payload: Some(json!({ "targetSceneId": scene_id })), - ..build_static_runtime_story_option( - "idle_travel_next_scene", - format!( - "{},前往{}", - direction_text(relative_position.as_str()), - scene_name - ) - .as_str(), - "story", - ) - }) - }) - .collect::>(); + .filter_map(|scene_id| scene_id.as_str().map(str::to_string)) + .chain(forward_scene_id.clone()) + { + // 中文注释:bootstrap 生成的旧快照常只有 connectedSceneIds / forwardSceneId, + // 没有展开 connections;这里也要生成旅行 action,避免战后只剩默认 idle 选项循环。 + if current_scene_id.as_deref() == Some(scene_id.as_str()) + || option_scene_ids.iter().any(|id| id == scene_id.as_str()) + { + continue; + } + let relative_position = if forward_scene_id.as_deref() == Some(scene_id.as_str()) { + "forward" + } else { + "portal" + }; + options.push(build_scene_travel_option( + game_state, + scene_id.as_str(), + relative_position, + )); + option_scene_ids.push(scene_id); + } if options.is_empty() { options.push(build_static_runtime_story_option( @@ -497,6 +545,163 @@ fn build_scene_travel_options(game_state: &Value) -> Vec options } +fn build_scene_travel_option( + game_state: &Value, + scene_id: &str, + relative_position: &str, +) -> RuntimeStoryOptionView { + let scene_name = + resolve_scene_name(game_state, scene_id).unwrap_or_else(|| scene_id.to_string()); + RuntimeStoryOptionView { + payload: Some(json!({ "targetSceneId": scene_id })), + ..build_static_runtime_story_option( + "idle_travel_next_scene", + format!("{},前往{}", direction_text(relative_position), scene_name).as_str(), + "story", + ) + } +} + +pub fn resolve_runtime_scene_preset(game_state: &Value, scene_id: &str) -> Option { + let normalized_scene_id = scene_id.trim(); + if normalized_scene_id.is_empty() { + return None; + } + + if let Some(profile) = read_object_field(game_state, "customWorldProfile") + && let Some(scene) = build_custom_scene_preset( + profile, + resolve_custom_runtime_scene_id(profile, normalized_scene_id).as_str(), + ) + { + return Some(scene); + } + + resolve_builtin_runtime_scene_preset(game_state, normalized_scene_id) +} + +pub fn resolve_forward_scene_id(game_state: &Value) -> Option { + read_object_field(game_state, "currentScenePreset").and_then(|scene| { + read_optional_string_field(scene, "forwardSceneId") + .or_else(|| { + read_array_field(scene, "connections") + .into_iter() + .find_map(|connection| read_optional_string_field(connection, "sceneId")) + }) + .or_else(|| { + read_array_field(scene, "connectedSceneIds") + .into_iter() + .find_map(|scene_id| scene_id.as_str().map(str::to_string)) + }) + }) +} + +fn resolve_builtin_runtime_scene_preset(game_state: &Value, scene_id: &str) -> Option { + let template = builtin_runtime_scene_template(scene_id)?; + Some(json!({ + "id": template.id, + "name": template.name, + "description": template.description, + "imageSrc": read_object_field(game_state, "currentScenePreset") + .and_then(|scene| read_optional_string_field(scene, "imageSrc")) + .unwrap_or_default(), + "connectedSceneIds": template.connected_scene_ids, + "connections": template.connections, + "forwardSceneId": template.forward_scene_id, + "treasureHints": template.treasure_hints, + "npcs": [], + })) +} + +fn builtin_runtime_scene_template(scene_id: &str) -> Option { + let is_xianxia = matches!( + scene_id, + "xianxia-cloud-gate" + | "xianxia-floating-isle" + | "xianxia-celestial-corridor" + | "xianxia-star-vessel" + ); + if is_xianxia { + return Some(RuntimeScene { + id: scene_id.to_string(), + name: match scene_id { + "xianxia-floating-isle" => "浮空灵岛", + "xianxia-celestial-corridor" => "天门长廊", + "xianxia-star-vessel" => "星槎泊台", + _ => XIANXIA_FIRST_SCENE_NAME, + } + .to_string(), + description: match scene_id { + "xianxia-floating-isle" => "浮岛边缘灵雾翻涌,远处有阵纹一明一暗。", + "xianxia-celestial-corridor" => "长廊悬在云海上方,符光沿石柱缓慢游走。", + "xianxia-star-vessel" => "星槎泊在云海边缘,船身仍有星砂微光。", + _ => XIANXIA_FIRST_SCENE_DESCRIPTION, + } + .to_string(), + image_src: String::new(), + connected_scene_ids: vec![ + "xianxia-cloud-gate".to_string(), + "xianxia-floating-isle".to_string(), + "xianxia-celestial-corridor".to_string(), + ] + .into_iter() + .filter(|id| id != scene_id) + .collect(), + connections: vec![json!({ + "sceneId": if scene_id == "xianxia-cloud-gate" { "xianxia-celestial-corridor" } else { "xianxia-cloud-gate" }, + "relativePosition": if scene_id == "xianxia-cloud-gate" { "forward" } else { "back" }, + "summary": "沿主路继续移动" + })], + forward_scene_id: Some(if scene_id == "xianxia-cloud-gate" { + "xianxia-celestial-corridor".to_string() + } else { + "xianxia-cloud-gate".to_string() + }), + treasure_hints: vec!["云阶边缘的灵光残痕".to_string()], + npcs: Vec::new(), + }); + } + + Some(RuntimeScene { + id: scene_id.to_string(), + name: match scene_id { + "wuxia-mountain-gate" => "山门石阶", + "wuxia-mist-woods" => "迷雾竹林", + "wuxia-ferry-bridge" => "渡口断桥", + _ => WUXIA_FIRST_SCENE_NAME, + } + .to_string(), + description: match scene_id { + "wuxia-mountain-gate" => "山门石阶覆着苔痕,旧旗在风里压得很低。", + "wuxia-mist-woods" => "迷雾在竹林间翻卷,脚下泥印很快又被雾水抹平。", + "wuxia-ferry-bridge" => "渡口断桥横在冷水上,桥边灯笼只剩半截残光。", + _ => WUXIA_FIRST_SCENE_DESCRIPTION, + } + .to_string(), + image_src: String::new(), + connected_scene_ids: vec![ + "wuxia-bamboo-road".to_string(), + "wuxia-mountain-gate".to_string(), + "wuxia-mist-woods".to_string(), + ] + .into_iter() + .filter(|id| id != scene_id) + .collect(), + connections: vec![json!({ + "sceneId": if scene_id == "wuxia-bamboo-road" { "wuxia-mountain-gate" } else { "wuxia-bamboo-road" }, + "relativePosition": if scene_id == "wuxia-bamboo-road" { "forward" } else { "back" }, + "summary": "沿主路继续移动" + })], + forward_scene_id: Some(if scene_id == "wuxia-bamboo-road" { + "wuxia-mountain-gate".to_string() + } else { + "wuxia-bamboo-road".to_string() + }), + treasure_hints: vec!["路边半埋的旧物".to_string()], + npcs: Vec::new(), + }) +} + fn resolve_scene_name(game_state: &Value, scene_id: &str) -> Option { if read_object_field(game_state, "currentScenePreset") .and_then(|scene| read_optional_string_field(scene, "id")) @@ -553,7 +758,10 @@ fn direction_text(relative_position: &str) -> &'static str { } } -fn resolve_next_scene_act_runtime_state(game_state: &Value) -> Option { +fn resolve_next_scene_act_runtime_state( + game_state: &Value, + current_act_state_override: Option<&Value>, +) -> Option { let profile = read_object_field(game_state, "customWorldProfile")?; let scene_id = read_object_field(game_state, "currentScenePreset") .and_then(|scene| read_optional_string_field(scene, "id")); @@ -563,7 +771,9 @@ fn resolve_next_scene_act_runtime_state(game_state: &Value) -> Option { if acts.is_empty() { return None; } - let runtime_state = build_initial_scene_act_runtime_state(game_state, scene_id_text)?; + let runtime_state = current_act_state_override + .cloned() + .or_else(|| build_initial_scene_act_runtime_state(game_state, scene_id_text))?; let current_act_id = read_optional_string_field(&runtime_state, "currentActId"); let current_index = acts .iter() @@ -762,9 +972,17 @@ fn resolve_scene_aliases(profile: &Value, scene_id: &str) -> Vec { fn resolve_active_scene_act_focus_npc_id( profile: &Value, scene_id: Option<&str>, + current_act_id: Option<&str>, ) -> Option { let chapter = resolve_scene_chapter_blueprint(profile, scene_id)?; - let act_state = read_array_field(chapter, "acts").first().copied()?; + let acts = read_array_field(chapter, "acts"); + let act_state = current_act_id + .and_then(|act_id| { + acts.iter() + .copied() + .find(|act| read_optional_string_field(act, "id").as_deref() == Some(act_id)) + }) + .or_else(|| acts.first().copied())?; read_optional_string_field(act_state, "oppositeNpcId") .or_else(|| read_optional_string_field(act_state, "primaryNpcId")) .or_else(|| { diff --git a/server-rs/crates/module-runtime-story/src/session_action.rs b/server-rs/crates/module-runtime-story/src/session_action.rs index c7ad6421..93d9b25a 100644 --- a/server-rs/crates/module-runtime-story/src/session_action.rs +++ b/server-rs/crates/module-runtime-story/src/session_action.rs @@ -14,16 +14,18 @@ use crate::{ build_current_build_toast, build_npc_gift_result_text, build_runtime_story_option_from_story_option, build_runtime_story_view_model, build_static_runtime_story_option, build_status_patch, build_story_option_from_runtime_option, - clear_encounter_state, clone_inventory_item_with_quantity, current_encounter_name, - ensure_json_object, find_player_inventory_entry, normalize_equipment_slot_id, - normalize_required_string, npc_buyback_price, npc_purchase_price, + clear_encounter_state, clear_post_battle_state, clone_inventory_item_with_quantity, + current_encounter_name, ensure_json_object, ensure_scene_act_state, + ensure_scene_encounter_preview, finalize_post_battle_resolution, find_player_inventory_entry, + normalize_equipment_slot_id, normalize_required_string, npc_buyback_price, npc_purchase_price, project_story_engine_after_action, read_array_field, read_bool_field, read_field, read_i32_field, read_inventory_item_name, read_object_field, read_optional_string_field, read_player_equipment_item, read_player_inventory_values, read_runtime_session_id, read_u32_field, recruit_companion_to_party, remove_inventory_item_from_list, resolve_action_text, resolve_battle_action, resolve_equipment_slot_for_item, resolve_forge_craft_action, resolve_forge_dismantle_action, resolve_forge_reforge_action, - resolve_npc_gift_affinity_gain, restore_player_resource, simple_story_resolution, + resolve_forward_scene_id, resolve_npc_gift_affinity_gain, resolve_post_battle_story_options, + resolve_runtime_scene_preset, restore_player_resource, simple_story_resolution, write_bool_field, write_i32_field, write_null_field, write_player_equipment_item, write_player_inventory_values, write_runtime_npc_interaction_view, write_string_field, write_u32_field, @@ -97,23 +99,7 @@ pub fn resolve_story_runtime_action( requested_runtime_session_id.as_str(), ); - let mut options = resolution - .presentation_options - .take() - .unwrap_or_else(|| build_fallback_runtime_story_options(&game_state)); - if options.is_empty() { - options = build_fallback_runtime_story_options(&game_state); - } - - let story_text = resolution - .story_text - .clone() - .unwrap_or_else(|| resolution.result_text.clone()); let history_result_text = resolution.result_text.clone(); - let saved_current_story = resolution - .saved_current_story - .take() - .unwrap_or_else(|| build_current_story(story_text.as_str(), &options)); append_story_history( &mut game_state, @@ -132,6 +118,37 @@ pub fn resolve_story_runtime_action( .and_then(|battle| battle.outcome.as_deref()), ); + if let Some(post_battle) = finalize_post_battle_resolution( + &mut game_state, + history_result_text.as_str(), + resolution + .battle + .as_ref() + .and_then(|battle| battle.outcome.as_deref()), + Vec::new(), + ) { + resolution.story_text = Some(post_battle.story_text); + resolution.presentation_options = Some(post_battle.presentation_options); + resolution.saved_current_story = Some(post_battle.saved_current_story); + } + + let mut options = resolution + .presentation_options + .take() + .unwrap_or_else(|| build_fallback_runtime_story_options(&game_state)); + if options.is_empty() { + options = build_fallback_runtime_story_options(&game_state); + } + + let story_text = resolution + .story_text + .clone() + .unwrap_or_else(|| resolution.result_text.clone()); + let saved_current_story = resolution + .saved_current_story + .take() + .unwrap_or_else(|| build_current_story(story_text.as_str(), &options)); + let mut patches = vec![RuntimeStoryPatch::StoryHistoryAppend { action_text: resolution.action_text.clone(), result_text: history_result_text.clone(), @@ -212,11 +229,10 @@ fn resolve_runtime_story_choice_action( resolve_action_text("主动出声试探", request), "你的喊话打破了当前静场,周围潜着的动静也更难继续藏住。", )), - "idle_explore_forward" => Ok(simple_story_resolution( - game_state, - resolve_action_text("继续向前探索", request), - "你没有停在原地,而是继续向前压,把下一段遭遇主动推到自己面前。", - )), + "idle_explore_forward" => resolve_idle_explore_forward_action(game_state, request), + "idle_travel_next_scene" | "camp_travel_home_scene" => { + resolve_idle_travel_next_scene_action(game_state, request) + } "idle_observe_signs" => Ok(simple_story_resolution( game_state, resolve_action_text("观察周围迹象", request), @@ -309,6 +325,62 @@ fn resolve_continue_adventure_action( }) } +fn resolve_idle_explore_forward_action( + game_state: &mut Value, + request: &RuntimeStoryActionRequest, +) -> Result { + // 中文注释:探索前进是战后继续链路的一环,必须在后端清掉战斗态并生成下一段遭遇预览。 + // 前端只播放表现动画,不能只靠本地状态把同一组 idle 选项重新展示一遍。 + clear_post_battle_state(game_state); + ensure_scene_encounter_preview(game_state); + Ok(StoryResolution { + action_text: resolve_action_text("继续向前探索", request), + result_text: "你没有停在原地,而是继续向前压,把下一段遭遇主动推到自己面前。".to_string(), + story_text: None, + presentation_options: Some(resolve_post_battle_story_options(game_state)), + saved_current_story: None, + patches: vec![build_status_patch(game_state)], + battle: None, + toast: None, + }) +} + +fn resolve_idle_travel_next_scene_action( + game_state: &mut Value, + request: &RuntimeStoryActionRequest, +) -> Result { + // 中文注释:切场景会改变 currentScenePreset、章节 act 状态和运行统计, + // 这些都是 runtime 快照真相,不能只在前端播放退场/进场动画。 + let payload = request.action.payload.as_ref(); + let target_scene_id = payload + .and_then(|payload| read_optional_string_field(payload, "targetSceneId")) + .or_else(|| resolve_forward_scene_id(game_state)) + .ok_or_else(|| "idle_travel_next_scene 缺少 targetSceneId".to_string())?; + let next_scene = resolve_runtime_scene_preset(game_state, target_scene_id.as_str()) + .ok_or_else(|| format!("未找到目标场景:{target_scene_id}"))?; + let next_scene_name = + read_optional_string_field(&next_scene, "name").unwrap_or_else(|| target_scene_id.clone()); + + clear_post_battle_state(game_state); + ensure_json_object(game_state).insert("currentScenePreset".to_string(), next_scene); + write_i32_field(game_state, "playerX", 0); + write_string_field(game_state, "playerFacing", "right"); + ensure_scene_act_state(game_state); + ensure_scene_encounter_preview(game_state); + increment_runtime_stat_local(game_state, "scenesTraveled", 1); + + Ok(StoryResolution { + action_text: resolve_action_text(&format!("前往{next_scene_name}"), request), + result_text: format!("你离开当前区域,抵达了{next_scene_name}。"), + story_text: None, + presentation_options: Some(resolve_post_battle_story_options(game_state)), + saved_current_story: None, + patches: vec![build_status_patch(game_state)], + battle: None, + toast: None, + }) +} + fn resolve_npc_preview_talk_action( game_state: &mut Value, request: &RuntimeStoryActionRequest, diff --git a/src/components/game-canvas/GameCanvasEntityLayer.test.tsx b/src/components/game-canvas/GameCanvasEntityLayer.test.tsx index 6b26af15..86a1f313 100644 --- a/src/components/game-canvas/GameCanvasEntityLayer.test.tsx +++ b/src/components/game-canvas/GameCanvasEntityLayer.test.tsx @@ -7,7 +7,10 @@ import { type Encounter, type SceneHostileNpc, } from '../../types'; -import { GameCanvasEntityLayer } from './GameCanvasEntityLayer'; +import { + GameCanvasEntityLayer, + getCombatFloatingNumberPresentation, +} from './GameCanvasEntityLayer'; import { CHARACTER_COMBAT_HP_TOP_PX, ENTITY_CONTAINER_REM, @@ -125,6 +128,21 @@ function renderEntityLayer(effectNpcId: string | null) { } describe('GameCanvasEntityLayer', () => { + it('keeps combat floating numbers readable on dark noisy battle backgrounds', () => { + const damage = getCombatFloatingNumberPresentation(false); + const healing = getCombatFloatingNumberPresentation(true); + + expect(damage.toneClass).toContain('bg-rose-950/72'); + expect(damage.toneClass).toContain('text-rose-50'); + expect(damage.textStyle.WebkitTextStroke).toContain('rgba(127, 29, 29'); + expect(damage.textStyle.textShadow).toContain('rgba(0, 0, 0'); + + expect(healing.toneClass).toContain('bg-emerald-950/70'); + expect(healing.toneClass).toContain('text-emerald-50'); + expect(healing.textStyle.WebkitTextStroke).toContain('rgba(6, 78, 59'); + expect(healing.textStyle.textShadow).toContain('rgba(0, 0, 0'); + }); + it('uses mirrored stage anchors for player and opponent containers', () => { expect(getMirroredStageEntityLeft('15%', 'player')).toBe('15%'); expect(getMirroredStageEntityLeft('15%', 'opponent')).toBe(`calc(100% - 15% - ${ENTITY_CONTAINER_REM}rem)`); diff --git a/src/components/game-canvas/GameCanvasEntityLayer.tsx b/src/components/game-canvas/GameCanvasEntityLayer.tsx index f374e938..19928c93 100644 --- a/src/components/game-canvas/GameCanvasEntityLayer.tsx +++ b/src/components/game-canvas/GameCanvasEntityLayer.tsx @@ -1,5 +1,5 @@ import {motion} from 'motion/react'; -import {type ReactNode, useEffect, useMemo, useRef, useState} from 'react'; +import {type CSSProperties, type ReactNode, useEffect, useMemo, useRef, useState} from 'react'; import {getCharacterById} from '../../data/characterPresets'; import {getFacingTowardPlayer, MONSTERS_BY_WORLD} from '../../data/hostileNpcs'; @@ -130,6 +130,45 @@ function getSceneTransitionMotionConfig( }; } +export function getCombatFloatingNumberPresentation(isHealing: boolean): { + toneClass: string; + textStyle: CSSProperties; +} { + const textShadow = [ + '0 1px 0 rgba(0, 0, 0, 0.98)', + '0 0 8px rgba(0, 0, 0, 0.92)', + '0 0 16px rgba(0, 0, 0, 0.72)', + ].join(', '); + + if (isHealing) { + return { + toneClass: [ + 'border-emerald-100/70', + 'bg-emerald-950/70', + 'text-emerald-50', + 'shadow-[0_0_18px_rgba(52,211,153,0.55)]', + ].join(' '), + textStyle: { + WebkitTextStroke: '1.45px rgba(6, 78, 59, 0.95)', + textShadow, + }, + }; + } + + return { + toneClass: [ + 'border-rose-100/75', + 'bg-rose-950/72', + 'text-rose-50', + 'shadow-[0_0_20px_rgba(248,113,113,0.68)]', + ].join(' '), + textStyle: { + WebkitTextStroke: '1.55px rgba(127, 29, 29, 0.98)', + textShadow, + }, + }; +} + function CombatFloatingNumber({ event, onDone, @@ -139,23 +178,20 @@ function CombatFloatingNumber({ }) { const isHealing = event.delta > 0; const deltaText = `${isHealing ? '+' : ''}${event.delta}`; - const colorClass = isHealing ? 'text-emerald-200' : 'text-rose-200'; - const glowClass = isHealing - ? 'drop-shadow-[0_0_8px_rgba(52,211,153,0.9)]' - : 'drop-shadow-[0_0_8px_rgba(248,113,113,0.9)]'; + const presentation = getCombatFloatingNumberPresentation(isHealing); return ( onDone(event.id)} - className={`pointer-events-none absolute -top-16 left-1/2 z-[14] -translate-x-1/2 text-lg font-black leading-none ${colorClass} ${glowClass}`} + className={`pointer-events-none absolute -top-[4.65rem] left-1/2 z-[38] flex min-w-[2.4rem] -translate-x-1/2 select-none items-center justify-center rounded-full border px-1.5 py-0.5 text-[1.45rem] font-black leading-none tracking-[-0.04em] sm:text-[1.6rem] ${presentation.toneClass}`} data-testid={`combat-feedback-${event.targetKey}`} aria-label={`战斗数值 ${deltaText}`} > - + {deltaText} diff --git a/src/components/rpg-entry/RpgEntryFlowShell.agent.interaction.test.tsx b/src/components/rpg-entry/RpgEntryFlowShell.agent.interaction.test.tsx index 053e09bb..79b25eef 100644 --- a/src/components/rpg-entry/RpgEntryFlowShell.agent.interaction.test.tsx +++ b/src/components/rpg-entry/RpgEntryFlowShell.agent.interaction.test.tsx @@ -35,6 +35,7 @@ import type { CustomWorldLibraryEntry, } from '../../../packages/shared/src/contracts/runtime'; import type { HydratedSavedGameSnapshot } from '../../persistence/runtimeSnapshotTypes'; +import { normalizeCustomWorldProfileRecord } from '../../data/customWorldLibrary'; import { readPublicWorkCodeFromLocationSearch, resolveSelectionStageFromPath, @@ -1921,6 +1922,14 @@ const compiledAgentDraftSession: CustomWorldAgentSessionSnapshot = { }, }; +const compiledAgentResultPreview = normalizeCustomWorldProfileRecord( + compiledAgentDraftSession.resultPreview?.preview, +); + +if (!compiledAgentResultPreview) { + throw new Error('failed to normalize compiled agent result preview'); +} + function buildResultViewForSession( session: CustomWorldAgentSessionSnapshot, ): RpgCreationResultView { @@ -8011,6 +8020,288 @@ test('agent draft result test button enters current draft without publish gate', ).toBe(false); }, 10_000); +test('agent draft result test button enters the opened draft profile instead of a previous session', async () => { + const user = userEvent.setup(); + const handleCustomWorldSelect = vi.fn(); + + const previousDraftSession = { + ...compiledAgentDraftSession, + sessionId: 'custom-world-agent-session-1', + resultPreview: { + ...compiledAgentDraftSession.resultPreview!, + publishReady: false, + canEnterWorld: true, + preview: { + ...compiledAgentResultPreview, + id: 'agent-draft-custom-world-agent-session-1', + name: '潮雾列岛', + summary: '上一份草稿内容,不能被本次启动复用。', + playableNpcs: [ + { + ...compiledAgentResultPreview.playableNpcs[0]!, + id: 'playable-previous-1', + name: '沈砺', + }, + ], + sessionId: 'custom-world-agent-session-1', + }, + }, + } satisfies CustomWorldAgentSessionSnapshot; + const openedDraftSession = { + ...compiledAgentDraftSession, + sessionId: 'custom-world-agent-session-2', + resultPreview: { + ...compiledAgentDraftSession.resultPreview!, + publishReady: false, + canEnterWorld: true, + preview: { + ...compiledAgentResultPreview, + id: 'agent-draft-custom-world-agent-session-2', + name: '星砂废都', + subtitle: '坠星沙海与废都钟楼', + summary: '本次从草稿架打开的目标草稿内容。', + playerGoal: '找到废都钟楼下被星砂掩埋的旧约。', + playableNpcs: [ + { + ...compiledAgentResultPreview.playableNpcs[0]!, + id: 'playable-opened-1', + name: '砂眠', + title: '废都引路人', + }, + ], + storyNpcs: [], + landmarks: [ + { + ...compiledAgentResultPreview.landmarks[0]!, + id: 'landmark-opened-1', + name: '坠星钟楼', + }, + ], + sessionId: 'custom-world-agent-session-2', + }, + }, + } satisfies CustomWorldAgentSessionSnapshot; + const sessionsById = new Map([ + [previousDraftSession.sessionId, previousDraftSession], + [openedDraftSession.sessionId, openedDraftSession], + ]); + + vi.mocked(getRpgCreationOperation).mockResolvedValue({ + operationId: 'operation-draft-foundation-1', + type: 'draft_foundation', + status: 'completed', + phaseLabel: '世界底稿已生成', + phaseDetail: '第一版世界底稿和草稿卡已经整理完成。', + progress: 100, + error: null, + }); + vi.mocked(getRpgCreationSession).mockImplementation(async (sessionId) => { + const session = sessionsById.get(sessionId); + if (!session) { + throw new Error(`Missing test session: ${sessionId}`); + } + return session; + }); + vi.mocked(getRpgCreationResultView).mockImplementation(async (sessionId) => { + const session = sessionsById.get(sessionId); + if (!session) { + throw new Error(`Missing test result view: ${sessionId}`); + } + return buildResultViewForSession(session); + }); + vi.mocked(listRpgCreationWorks).mockResolvedValue([ + buildExistingRpgDraftWork({ + workId: 'draft:custom-world-agent-session-1', + title: '潮雾列岛', + summary: '上一份草稿内容,不能被本次启动复用。', + sessionId: 'custom-world-agent-session-1', + playableNpcCount: 1, + landmarkCount: 1, + }), + buildExistingRpgDraftWork({ + workId: 'draft:custom-world-agent-session-2', + title: '星砂废都', + subtitle: '待完善草稿', + summary: '本次从草稿架打开的目标草稿内容。', + sessionId: 'custom-world-agent-session-2', + playableNpcCount: 1, + landmarkCount: 1, + }), + ]); + + render(); + + await openDraftHub(user); + const draftPanel = getPlatformTabPanel('saves'); + await user.click( + await within(draftPanel).findByRole('button', { + name: /继续完善《星砂废都》/u, + }), + ); + expect(await screen.findByText('世界档案', {}, { timeout: 5000 })).toBeTruthy(); + expect(screen.getByText('星砂废都')).toBeTruthy(); + + await user.click( + await screen.findByRole('button', { name: '作品测试' }, { timeout: 5000 }), + ); + + await waitFor(() => { + expect(handleCustomWorldSelect).toHaveBeenCalledWith( + expect.objectContaining({ + id: 'agent-draft-custom-world-agent-session-2', + name: '星砂废都', + summary: '本次从草稿架打开的目标草稿内容。', + playableNpcs: [ + expect.objectContaining({ + id: 'playable-opened-1', + name: '砂眠', + }), + ], + }), + expect.objectContaining({ + mode: 'play', + disablePersistence: true, + returnStage: 'custom-world-result', + }), + ); + }); + expect( + vi + .mocked(executeRpgCreationAction) + .mock.calls.some(([, payload]) => payload?.action === 'publish_world'), + ).toBe(false); +}, 10_000); + +test('agent draft result start button enters the opened published draft profile instead of a previous session', async () => { + const user = userEvent.setup(); + const handleCustomWorldSelect = vi.fn(); + + const previousDraftSession = { + ...compiledAgentDraftSession, + sessionId: 'custom-world-agent-session-1', + stage: 'published', + resultPreview: { + ...compiledAgentDraftSession.resultPreview!, + publishReady: true, + canEnterWorld: true, + preview: { + ...compiledAgentResultPreview, + id: 'agent-draft-custom-world-agent-session-1', + name: '潮雾列岛', + summary: '上一份已发布草稿内容,不能被本次启动复用。', + sessionId: 'custom-world-agent-session-1', + }, + }, + } satisfies CustomWorldAgentSessionSnapshot; + const openedPublishedDraftSession = { + ...compiledAgentDraftSession, + sessionId: 'custom-world-agent-session-2', + stage: 'published', + resultPreview: { + ...compiledAgentDraftSession.resultPreview!, + publishReady: true, + canEnterWorld: true, + preview: { + ...compiledAgentResultPreview, + id: 'agent-draft-custom-world-agent-session-2', + name: '星砂废都', + subtitle: '坠星沙海与废都钟楼', + summary: '本次从草稿架打开且已发布的目标草稿内容。', + playableNpcs: [ + { + ...compiledAgentResultPreview.playableNpcs[0]!, + id: 'playable-opened-1', + name: '砂眠', + title: '废都引路人', + }, + ], + sessionId: 'custom-world-agent-session-2', + }, + }, + } satisfies CustomWorldAgentSessionSnapshot; + const sessionsById = new Map([ + [previousDraftSession.sessionId, previousDraftSession], + [openedPublishedDraftSession.sessionId, openedPublishedDraftSession], + ]); + + vi.mocked(getRpgCreationSession).mockImplementation(async (sessionId) => { + const session = sessionsById.get(sessionId); + if (!session) { + throw new Error(`Missing test session: ${sessionId}`); + } + return session; + }); + vi.mocked(getRpgCreationResultView).mockImplementation(async (sessionId) => { + const session = sessionsById.get(sessionId); + if (!session) { + throw new Error(`Missing test result view: ${sessionId}`); + } + return buildResultViewForSession(session); + }); + vi.mocked(listRpgCreationWorks).mockResolvedValue([ + buildExistingRpgDraftWork({ + workId: 'draft:custom-world-agent-session-1', + title: '潮雾列岛', + summary: '上一份已发布草稿内容,不能被本次启动复用。', + stage: 'published', + stageLabel: '已发布', + sessionId: 'custom-world-agent-session-1', + playableNpcCount: 1, + landmarkCount: 1, + canEnterWorld: true, + }), + buildExistingRpgDraftWork({ + workId: 'draft:custom-world-agent-session-2', + title: '星砂废都', + subtitle: '已发布草稿', + summary: '本次从草稿架打开且已发布的目标草稿内容。', + stage: 'published', + stageLabel: '已发布', + sessionId: 'custom-world-agent-session-2', + playableNpcCount: 1, + landmarkCount: 1, + canEnterWorld: true, + }), + ]); + + render(); + + await openDraftHub(user); + const draftPanel = getPlatformTabPanel('saves'); + await user.click( + await within(draftPanel).findByRole('button', { + name: /继续完善《星砂废都》/u, + }), + ); + expect(await screen.findByText('世界档案', {}, { timeout: 5000 })).toBeTruthy(); + expect(screen.getByText('星砂废都')).toBeTruthy(); + + await user.click( + await screen.findByRole('button', { name: '进入世界' }, { timeout: 5000 }), + ); + + await waitFor(() => { + expect(handleCustomWorldSelect).toHaveBeenCalledWith( + expect.objectContaining({ + id: 'agent-draft-custom-world-agent-session-2', + name: '星砂废都', + summary: '本次从草稿架打开且已发布的目标草稿内容。', + playableNpcs: [ + expect.objectContaining({ + id: 'playable-opened-1', + name: '砂眠', + }), + ], + }), + ); + }); + expect( + vi + .mocked(executeRpgCreationAction) + .mock.calls.some(([, payload]) => payload?.action === 'publish_world'), + ).toBe(false); +}, 10_000); + test('agent result view does not keep legacy publish blockers when preview uses anchorContent and sceneChapterBlueprints', async () => { const user = userEvent.setup(); @@ -8057,7 +8348,7 @@ test('agent result view does not keep legacy publish blockers when preview uses publishReady: true, blockers: [], preview: { - ...compiledAgentDraftSession.resultPreview!.preview, + ...compiledAgentResultPreview, settingText: '被海雾吞没的旧航路群岛', anchorContent: { worldPromise: @@ -8695,6 +8986,65 @@ test('save tab can resume a selected archive directly into the game', async () = }); }); +test('profile page exposes save archive picker as a direct entry', async () => { + const user = userEvent.setup(); + const handleContinueGame = vi.fn(); + + vi.mocked(listProfileSaveArchives).mockResolvedValue([ + { + worldKey: 'custom:world-1', + ownerUserId: null, + profileId: 'world-1', + worldType: 'CUSTOM', + worldName: '潮雾列岛', + subtitle: '旧灯塔与失控航路', + summaryText: '回到旧灯塔继续推进调查。', + coverImageSrc: null, + lastPlayedAt: '2026-04-19T12:00:00.000Z', + }, + ]); + vi.mocked(resumeProfileSaveArchive).mockResolvedValue({ + entry: { + worldKey: 'custom:world-1', + ownerUserId: null, + profileId: 'world-1', + worldType: 'CUSTOM', + worldName: '潮雾列岛', + subtitle: '旧灯塔与失控航路', + summaryText: '回到旧灯塔继续推进调查。', + coverImageSrc: null, + lastPlayedAt: '2026-04-19T12:00:00.000Z', + }, + snapshot: { + version: 2, + savedAt: '2026-04-19T12:00:00.000Z', + bottomTab: 'adventure', + currentStory: null, + gameState: { + worldType: 'CUSTOM', + }, + } as HydratedSavedGameSnapshot, + }); + + render(); + + await clickFirstButtonByName(user, '我的'); + const shortcutRegion = await screen.findByRole('region', { name: '常用功能' }); + await user.click(within(shortcutRegion).getByRole('button', { name: /存档/u })); + + const closeButton = await screen.findByLabelText('关闭存档'); + const modal = closeButton.closest('.fixed') as HTMLElement; + expect(modal).toBeTruthy(); + expect(within(modal).getByText('SAVES')).toBeTruthy(); + + await user.click(within(modal).getByRole('button', { name: /潮雾列岛/u })); + + await waitFor(() => { + expect(resumeProfileSaveArchive).toHaveBeenCalledWith('custom:world-1'); + expect(handleContinueGame).toHaveBeenCalledTimes(1); + }); +}); + test('creation hub published work can open detail view before deleting from detail page', async () => { const user = userEvent.setup(); @@ -8928,6 +9278,342 @@ test('creation hub published work experience button enters world directly', asyn expect(handleCustomWorldSelect).toHaveBeenCalledTimes(1); }); +test('creation hub published work start uses loaded detail profile instead of library summary', async () => { + const user = userEvent.setup(); + const handleCustomWorldSelect = vi.fn(); + const workProfileId = 'world-detail-launch-1'; + const summaryEntry = buildMockRpgGalleryDetail({ + ownerUserId: mockAuthUser.id, + profileId: workProfileId, + publicWorkCode: 'work-detail-launch-1', + authorPublicUserCode: mockAuthUser.publicUserCode, + visibility: 'published', + publishedAt: '2026-04-20T10:00:00.000Z', + updatedAt: '2026-04-20T10:00:00.000Z', + authorDisplayName: mockAuthUser.displayName, + worldName: '星砂废都', + subtitle: '坠星沙海与废都钟楼', + summaryText: '列表摘要只提供卡片信息,不能作为运行态 profile。', + coverImageSrc: null, + themeMode: 'tide', + playableNpcCount: 1, + landmarkCount: 1, + likeCount: 0, + }); + summaryEntry.profile = { + ...summaryEntry.profile, + name: '默认档案', + summary: '列表摘要不含运行态角色。', + playableNpcs: [], + storyNpcs: [], + landmarks: [], + }; + const detailEntry = buildMockRpgGalleryDetail({ + ...summaryEntry, + summaryText: '详情接口返回完整草稿内容。', + }); + detailEntry.profile = { + ...detailEntry.profile, + name: '星砂废都', + subtitle: '坠星沙海与废都钟楼', + summary: '详情接口返回完整草稿内容。', + playableNpcs: [ + { + ...compiledAgentResultPreview.playableNpcs[0]!, + id: 'playable-stardust-1', + name: '砂眠', + title: '废都引路人', + }, + ], + landmarks: [ + { + ...compiledAgentResultPreview.landmarks[0]!, + id: 'landmark-stardust-1', + name: '坠星钟楼', + }, + ], + }; + + vi.mocked(listRpgCreationWorks).mockResolvedValue([ + { + workId: `published:${workProfileId}`, + sourceType: 'published_profile', + status: 'published', + title: '星砂废都', + subtitle: '坠星沙海与废都钟楼', + summary: '详情接口返回完整草稿内容。', + coverImageSrc: null, + coverRenderMode: 'image', + coverCharacterImageSrcs: [], + updatedAt: '2026-04-20T10:00:00.000Z', + publishedAt: '2026-04-20T10:00:00.000Z', + stage: null, + stageLabel: '已发布', + playableNpcCount: 1, + landmarkCount: 1, + roleVisualReadyCount: 1, + roleAnimationReadyCount: 0, + roleAssetSummaryLabel: null, + sessionId: null, + profileId: workProfileId, + canResume: false, + canEnterWorld: true, + }, + ]); + vi.mocked(listRpgEntryWorldLibrary).mockResolvedValue([summaryEntry]); + vi.mocked( + rpgEntryLibraryServiceMocks.getRpgEntryWorldLibraryDetail, + ).mockResolvedValue(detailEntry); + + render(); + + await openDraftHub(user); + await user.click(await screen.findByRole('button', { name: /查看详情/u })); + await waitFor(() => { + expect( + rpgEntryLibraryServiceMocks.getRpgEntryWorldLibraryDetail, + ).toHaveBeenCalledWith(workProfileId); + }); + await user.click(await screen.findByRole('button', { name: '启动' })); + + await waitFor(() => { + expect(handleCustomWorldSelect).toHaveBeenCalledWith( + expect.objectContaining({ + id: workProfileId, + name: '星砂废都', + summary: '详情接口返回完整草稿内容。', + playableNpcs: [ + expect.objectContaining({ + id: 'playable-stardust-1', + name: '砂眠', + }), + ], + landmarks: [ + expect.objectContaining({ + id: 'landmark-stardust-1', + name: '坠星钟楼', + }), + ], + }), + ); + }); + expect(handleCustomWorldSelect).toHaveBeenCalledTimes(1); +}); + +test('creation hub published work edit keeps loaded detail profile assets instead of library summary', async () => { + const user = userEvent.setup(); + const workProfileId = 'world-detail-edit-assets-1'; + const summaryEntry = buildMockRpgGalleryDetail({ + ownerUserId: mockAuthUser.id, + profileId: workProfileId, + publicWorkCode: 'work-detail-edit-assets-1', + authorPublicUserCode: mockAuthUser.publicUserCode, + visibility: 'published', + publishedAt: '2026-04-20T10:00:00.000Z', + updatedAt: '2026-04-20T10:00:00.000Z', + authorDisplayName: mockAuthUser.displayName, + worldName: '星砂废都', + subtitle: '坠星沙海与废都钟楼', + summaryText: '列表摘要字段齐全但不含详情资产。', + coverImageSrc: null, + themeMode: 'tide', + playableNpcCount: 1, + landmarkCount: 1, + likeCount: 0, + }); + summaryEntry.profile = { + ...summaryEntry.profile, + name: '星砂废都', + summary: '列表摘要字段齐全但不含详情资产。', + playableNpcs: [ + { + ...compiledAgentResultPreview.playableNpcs[0]!, + id: 'playable-stardust-1', + name: '砂眠', + imageSrc: undefined, + }, + ], + storyNpcs: [ + { + ...compiledAgentResultPreview.storyNpcs[0]!, + id: 'story-clock-keeper-1', + name: '钟守', + imageSrc: undefined, + }, + ], + landmarks: [ + { + ...compiledAgentResultPreview.landmarks[0]!, + id: 'landmark-stardust-1', + name: '坠星钟楼', + imageSrc: undefined, + }, + ], + sceneChapterBlueprints: [ + { + id: 'scene-chapter-stardust-1', + sceneId: 'landmark-stardust-1', + title: '坠星钟楼', + summary: '星砂覆盖钟楼入口,钟守等待第一位访客。', + sceneTaskDescription: '调查钟楼旧铃自鸣的原因。', + linkedThreadIds: [], + linkedLandmarkIds: ['landmark-stardust-1'], + acts: [ + { + id: 'act-stardust-opening-1', + sceneId: 'landmark-stardust-1', + title: '第一幕', + summary: '砂眠带玩家进入坠星钟楼。', + stageCoverage: ['opening'], + backgroundImageSrc: undefined, + encounterNpcIds: ['playable-stardust-1'], + primaryNpcId: 'playable-stardust-1', + oppositeNpcId: 'story-clock-keeper-1', + eventDescription: '钟楼旧铃忽然自鸣。', + linkedThreadIds: [], + advanceRule: 'after_primary_contact', + actGoal: '进入钟楼。', + transitionHook: '星砂开始倒流。', + }, + ], + }, + ], + cover: null, + openingCg: null, + }; + const detailEntry = buildMockRpgGalleryDetail({ + ...summaryEntry, + summaryText: '详情接口返回完整草稿内容。', + }); + detailEntry.profile = { + ...summaryEntry.profile, + summary: '详情接口返回完整草稿内容。', + cover: { + sourceType: 'generated', + imageSrc: '/assets/custom-world/star-waste-cover.png', + characterRoleIds: ['playable-stardust-1'], + }, + openingCg: { + id: 'opening-cg-stardust-1', + status: 'ready', + storyboardImageSrc: '/assets/custom-world/opening-storyboard.png', + videoSrc: '/assets/custom-world/opening.mp4', + imageModel: 'gpt-image-2', + videoModel: 'doubao-seedance-2-0-fast-260128', + aspectRatio: '16:9', + imageSize: '2k', + videoResolution: '480p', + durationSeconds: 15, + pointCost: 80, + estimatedWaitMinutes: 10, + updatedAt: '2026-05-21T00:00:00.000Z', + }, + camp: { + id: 'camp-stardust-1', + name: '废都营地', + description: '钟楼阴影下的临时营地。', + imageSrc: '/assets/custom-world/star-waste-camp.png', + sceneNpcIds: ['playable-stardust-1'], + connections: [], + }, + playableNpcs: [ + { + ...summaryEntry.profile.playableNpcs[0]!, + imageSrc: '/assets/custom-world/playable-stardust-1.png', + }, + ], + storyNpcs: [ + { + ...summaryEntry.profile.storyNpcs[0]!, + imageSrc: '/assets/custom-world/story-clock-keeper-1.png', + }, + ], + landmarks: [ + { + ...summaryEntry.profile.landmarks[0]!, + imageSrc: '/assets/custom-world/landmark-stardust-1.png', + }, + ], + sceneChapterBlueprints: [ + { + ...summaryEntry.profile.sceneChapterBlueprints![0]!, + acts: [ + { + ...summaryEntry.profile.sceneChapterBlueprints![0]!.acts[0]!, + backgroundImageSrc: + '/assets/custom-world/act-stardust-opening-1.png', + }, + ], + }, + ], + }; + + vi.mocked(listRpgCreationWorks).mockResolvedValue([ + { + workId: `published:${workProfileId}`, + sourceType: 'published_profile', + status: 'published', + title: '星砂废都', + subtitle: '坠星沙海与废都钟楼', + summary: '列表摘要字段齐全但不含详情资产。', + coverImageSrc: null, + coverRenderMode: 'image', + coverCharacterImageSrcs: [], + updatedAt: '2026-04-20T10:00:00.000Z', + publishedAt: '2026-04-20T10:00:00.000Z', + stage: null, + stageLabel: '已发布', + playableNpcCount: 1, + landmarkCount: 1, + roleVisualReadyCount: 0, + roleAnimationReadyCount: 0, + roleAssetSummaryLabel: null, + sessionId: null, + profileId: workProfileId, + canResume: false, + canEnterWorld: true, + }, + ]); + vi.mocked(listRpgEntryWorldLibrary).mockResolvedValue([summaryEntry]); + vi.mocked( + rpgEntryLibraryServiceMocks.getRpgEntryWorldLibraryDetail, + ).mockResolvedValue(detailEntry); + + render(); + + await openDraftHub(user); + await user.click(await screen.findByRole('button', { name: /查看详情/u })); + await waitFor(() => { + expect( + rpgEntryLibraryServiceMocks.getRpgEntryWorldLibraryDetail, + ).toHaveBeenCalledWith(workProfileId); + }); + await user.click(await screen.findByRole('button', { name: '作品编辑' })); + + expect(await screen.findByText('世界档案', {}, { timeout: 5000 })).toBeTruthy(); + expect( + document.querySelector('video[src="/assets/custom-world/opening.mp4"]'), + ).toBeTruthy(); + + await user.click(screen.getByRole('button', { name: /场景\s+2/u })); + expect((await screen.findByAltText('废都营地')).getAttribute('src')).toBe( + '/assets/custom-world/star-waste-camp.png', + ); + expect(screen.getByAltText('坠星钟楼-第一幕').getAttribute('src')).toBe( + '/assets/custom-world/act-stardust-opening-1.png', + ); + + await user.click(screen.getByRole('button', { name: /可扮演角色\s+1/u })); + expect((await screen.findByAltText('砂眠')).getAttribute('src')).toBe( + '/assets/custom-world/playable-stardust-1.png', + ); + + await user.click(screen.getByRole('button', { name: /场景角色\s+1/u })); + expect((await screen.findByAltText('钟守')).getAttribute('src')).toBe( + '/assets/custom-world/story-clock-keeper-1.png', + ); +}); + test('creation hub published work card reveals delete action after card action reveal', async () => { const user = userEvent.setup(); diff --git a/src/components/rpg-entry/RpgEntryHomeView.tsx b/src/components/rpg-entry/RpgEntryHomeView.tsx index c3a0f991..e6c44104 100644 --- a/src/components/rpg-entry/RpgEntryHomeView.tsx +++ b/src/components/rpg-entry/RpgEntryHomeView.tsx @@ -1,5 +1,6 @@ import { AlertCircle, + Archive, ArrowRight, BookOpen, Camera, @@ -233,7 +234,8 @@ const WECHAT_JS_SDK_URL = 'https://res.wx.qq.com/open/js/jweixin-1.6.0.js'; const WECHAT_PAY_CONFIRM_RETRY_DELAYS_MS = [800, 1600, 3000] as const; const WECHAT_NATIVE_PAY_QR_IMAGE_SIZE = 180; -type ProfilePopupPanel = 'invite' | 'redeem' | 'community'; +type ProfileReferralPanel = 'invite' | 'redeem' | 'community'; +type ProfilePopupPanel = ProfileReferralPanel | 'saveArchives'; type RechargeTab = 'points' | 'membership'; type WechatMiniProgramPaymentStatus = 'success' | 'fail' | 'cancel'; type WechatPayResult = { @@ -3306,7 +3308,7 @@ function ProfileReferralModal({ onRedeemCodeChange, onSubmitRedeemCode, }: { - panel: ProfilePopupPanel; + panel: ProfileReferralPanel; center: ProfileReferralInviteCenterResponse | null; isLoading: boolean; isSubmittingRedeem: boolean; @@ -3477,6 +3479,66 @@ function ProfileReferralModal({ ); } +function ProfileSaveArchivesModal({ + saveEntries, + saveError, + isResumingSaveWorldKey, + onClose, + onResumeSave, +}: { + saveEntries: ProfileSaveArchiveSummary[]; + saveError: string | null; + isResumingSaveWorldKey: string | null; + onClose: () => void; + onResumeSave: (entry: ProfileSaveArchiveSummary) => void; +}) { + return ( +
+
+ +
+
+
+ SAVES +
+
存档
+
+ + {saveError ? ( +
+ {saveError} +
+ ) : null} + + {saveEntries.length > 0 ? ( +
+ {saveEntries.map((entry) => ( + onResumeSave(entry)} + /> + ))} +
+ ) : ( +
+ 暂无存档 +
+ )} +
+
+
+ ); +} + function ProfilePlayedWorksModal({ stats, isLoading, @@ -4504,7 +4566,7 @@ export function RpgEntryHomeView({ loadReferralCenter(); }, [activeTab, authUi?.user?.createdAt, isAuthenticated, loadReferralCenter]); - const openProfilePopupPanel = (panel: ProfilePopupPanel) => { + const openProfilePopupPanel = (panel: ProfileReferralPanel) => { setProfilePopupPanel(panel); setReferralError(null); setReferralSuccess(null); @@ -5842,6 +5904,16 @@ export function RpgEntryHomeView({ icon={showRechargeEntry ? Coins : Ticket} onClick={openRechargeOrRewardCodeModal} /> + 0 + ? `${saveEntries.length}个可继续` + : '继续游玩' + } + icon={Archive} + onClick={() => setProfilePopupPanel('saveArchives')} + /> {showRechargeEntry ? ( - {profilePopupPanel ? ( + {profilePopupPanel === 'saveArchives' ? ( + setProfilePopupPanel(null)} + onResumeSave={onResumeSave} + /> + ) : profilePopupPanel ? ( ) : null} - {profilePopupPanel ? ( + {profilePopupPanel === 'saveArchives' ? ( + setProfilePopupPanel(null)} + onResumeSave={onResumeSave} + /> + ) : profilePopupPanel ? ( | null | undefined, +) { + if (!profile) { + return 0; + } + + return ( + (profile.playableNpcs?.length ?? 0) + + (profile.storyNpcs?.length ?? 0) + + (profile.items?.length ?? 0) + + (profile.landmarks?.length ?? 0) + + (profile.sceneChapterBlueprints?.length ?? 0) + ); +} + +export function countCustomWorldProfileAssetSlots( + profile: Partial | null | undefined, +) { + if (!profile) { + return 0; + } + + return [ + profile.cover?.imageSrc, + profile.openingCg?.storyboardImageSrc, + profile.openingCg?.videoSrc, + profile.openingCg?.posterImageSrc, + profile.camp?.imageSrc, + ...(profile.playableNpcs ?? []).flatMap((role) => [ + role.imageSrc, + role.generatedVisualAssetId, + role.generatedAnimationSetId, + ...(role.skills ?? []).flatMap((skill) => [ + skill.actionPreviewConfig?.basePath, + skill.actionPreviewConfig?.previewVideoPath, + skill.actionPreviewConfig?.file, + ]), + ...role.initialItems.flatMap((item) => [item.iconSrc]), + ...Object.values(role.animationMap ?? {}).flatMap((config) => [ + config?.basePath, + config?.previewVideoPath, + config?.file, + ]), + ]), + ...(profile.storyNpcs ?? []).flatMap((npc) => [ + npc.imageSrc, + npc.generatedVisualAssetId, + npc.generatedAnimationSetId, + ...(npc.skills ?? []).flatMap((skill) => [ + skill.actionPreviewConfig?.basePath, + skill.actionPreviewConfig?.previewVideoPath, + skill.actionPreviewConfig?.file, + ]), + ...npc.initialItems.flatMap((item) => [item.iconSrc]), + ...Object.values(npc.animationMap ?? {}).flatMap((config) => [ + config?.basePath, + config?.previewVideoPath, + config?.file, + ]), + ]), + ...(profile.items ?? []).flatMap((item) => [item.iconSrc, item.sourcePath]), + ...(profile.landmarks ?? []).map((landmark) => landmark.imageSrc), + ...(profile.sceneChapterBlueprints ?? []).flatMap((chapter) => + chapter.acts.flatMap((act) => [ + act.backgroundImageSrc, + act.backgroundAssetId, + ]), + ), + ].filter((value) => value?.trim()).length; +} + +export function countCustomWorldProfileStructuredSlots( + profile: Partial | null | undefined, +) { + if (!profile) { + return 0; + } + + return [ + profile.cover, + profile.attributeSchema, + profile.themePack, + profile.storyGraph, + profile.knowledgeFacts?.length ? profile.knowledgeFacts : null, + profile.threadContracts?.length ? profile.threadContracts : null, + profile.anchorContent, + profile.creatorIntent, + profile.anchorPack, + profile.lockState, + profile.ownedSettingLayers, + profile.generationMode, + profile.generationStatus, + profile.scenarioPackId, + profile.campaignPackId, + ...(profile.playableNpcs ?? []).flatMap((role) => [ + role.attributeProfile, + ...(role.skills ?? []).map((skill) => skill.actionPreviewConfig), + ...role.initialItems, + ]), + ...(profile.storyNpcs ?? []).flatMap((npc) => [ + npc.attributeProfile, + ...(npc.skills ?? []).map((skill) => skill.actionPreviewConfig), + ...npc.initialItems, + ]), + ...(profile.landmarks ?? []).flatMap((landmark) => [ + landmark.visualDescription, + landmark.narrativeResidues?.length ? landmark.narrativeResidues : null, + ]), + ...((profile.camp?.narrativeResidues ?? []).length + ? [profile.camp?.narrativeResidues] + : []), + ...(profile.sceneChapterBlueprints ?? []).flatMap((chapter) => [ + chapter.sceneTaskDescription, + chapter.linkedThreadIds.length ? chapter.linkedThreadIds : null, + chapter.linkedLandmarkIds.length ? chapter.linkedLandmarkIds : null, + ...chapter.acts.flatMap((act) => [ + act.eventDescription, + act.linkedThreadIds.length ? act.linkedThreadIds : null, + act.actGoal, + act.transitionHook, + ]), + ]), + ].filter(Boolean).length; +} + +export function getCustomWorldProfileCompletenessScore( + profile: Partial | null | undefined, +) { + return ( + countCustomWorldProfileDetailSlots(profile) + + countCustomWorldProfileAssetSlots(profile) + + countCustomWorldProfileStructuredSlots(profile) + ); +} + +export function chooseMoreCompleteCustomWorldProfile( + fallbackProfile: CustomWorldProfile, + candidateProfile: CustomWorldProfile | null | undefined, +) { + if (!candidateProfile) { + return fallbackProfile; + } + + if (candidateProfile.id !== fallbackProfile.id) { + return candidateProfile; + } + + // 中文注释:发布 / 回读可能只返回列表摘要或旧快照。 + // 同一个 profileId 下,进入世界不能把当前结果页的封面、CG、角色资产降级掉。 + return getCustomWorldProfileCompletenessScore(candidateProfile) >= + getCustomWorldProfileCompletenessScore(fallbackProfile) + ? candidateProfile + : fallbackProfile; +} diff --git a/src/components/rpg-entry/useRpgCreationEnterWorld.test.tsx b/src/components/rpg-entry/useRpgCreationEnterWorld.test.tsx index 8a8cb6ca..f90ca87d 100644 --- a/src/components/rpg-entry/useRpgCreationEnterWorld.test.tsx +++ b/src/components/rpg-entry/useRpgCreationEnterWorld.test.tsx @@ -340,4 +340,136 @@ describe('useRpgCreationEnterWorld', () => { 'published-profile', ); }); + + + it('正式进入世界回读结果页字段更少时不降级当前完整 profile', async () => { + const resultProfile = { + ...buildProfile({ + id: 'draft-profile-rich-assets', + name: '星砂废都', + imageSrc: '/generated-characters/draft-role/portrait.png', + }), + cover: { + sourceType: 'generated' as const, + imageSrc: '/generated-custom-world-covers/star-waste/cover.webp', + characterRoleIds: ['draft-profile-rich-assets-role'], + }, + openingCg: { + id: 'opening-cg-stardust', + status: 'ready' as const, + storyboardImageSrc: '/generated-custom-world-scenes/opening/storyboard.png', + videoSrc: '/generated-custom-world-scenes/opening/opening.mp4', + imageModel: 'gpt-image-2' as const, + videoModel: 'doubao-seedance-2-0-fast-260128', + aspectRatio: '16:9' as const, + imageSize: '2k' as const, + videoResolution: '480p' as const, + durationSeconds: 15 as const, + pointCost: 80 as const, + estimatedWaitMinutes: 10 as const, + updatedAt: '2026-05-21T00:00:00.000Z', + }, + sceneChapterBlueprints: [ + { + id: 'scene-chapter-stardust', + sceneId: 'landmark-stardust', + title: '钟楼第一夜', + summary: '钟楼第一夜。', + sceneTaskDescription: '进入钟楼。', + linkedThreadIds: [], + linkedLandmarkIds: ['landmark-stardust'], + acts: [ + { + id: 'act-stardust-opening', + sceneId: 'landmark-stardust', + title: '第一幕', + summary: '砂眠带玩家进入坠星钟楼。', + stageCoverage: ['opening' as const], + backgroundImageSrc: + '/assets/custom-world/act-stardust-opening.png', + backgroundAssetId: 'asset-act-stardust-opening', + encounterNpcIds: ['draft-profile-rich-assets-role'], + primaryNpcId: 'draft-profile-rich-assets-role', + oppositeNpcId: 'draft-profile-rich-assets-role', + eventDescription: '钟楼旧铃忽然自鸣。', + linkedThreadIds: [], + advanceRule: 'after_primary_contact' as const, + actGoal: '进入钟楼。', + transitionHook: '星砂开始倒流。', + }, + ], + }, + ], + } satisfies CustomWorldProfile; + const stalePublishedProfile = { + ...resultProfile, + name: '星砂废都', + cover: null, + openingCg: null, + playableNpcs: [], + sceneChapterBlueprints: null, + } satisfies CustomWorldProfile; + const handleCustomWorldSelect = vi.fn(); + const setGeneratedCustomWorldProfile = vi.fn(); + const syncAgentDraftResultProfile = vi.fn(async () => ({ + profile: resultProfile, + view: buildResultView({ + stage: 'ready_to_publish', + profile: resultProfile, + canEnterWorld: false, + }), + })); + const executePublishWorld = vi.fn(async () => buildSession('published')); + const syncAgentCreationResultView = vi.fn(async () => + buildResultView({ + stage: 'published', + profile: stalePublishedProfile, + canEnterWorld: true, + }), + ); + + function Harness() { + const { enterWorldFromCurrentResult } = useRpgCreationEnterWorld({ + isAgentDraftResultView: true, + activeAgentSessionId: 'session-1', + currentAgentSessionStage: 'ready_to_publish', + generatedCustomWorldProfile: resultProfile, + handleCustomWorldSelect, + syncAgentDraftResultProfile, + executePublishWorld, + syncAgentCreationResultView, + setGeneratedCustomWorldProfile, + }); + + return ( + + ); + } + + const { getByText } = render(); + await act(async () => { + getByText('进入世界').click(); + }); + + const launchedProfile = handleCustomWorldSelect.mock.calls[0]?.[0]; + expect(launchedProfile?.id).toBe('draft-profile-rich-assets'); + expect(launchedProfile?.cover?.imageSrc).toBe( + '/generated-custom-world-covers/star-waste/cover.webp', + ); + expect(launchedProfile?.openingCg?.videoSrc).toBe( + '/generated-custom-world-scenes/opening/opening.mp4', + ); + expect(launchedProfile?.playableNpcs[0]?.imageSrc).toBe( + '/generated-characters/draft-role/portrait.png', + ); + expect( + launchedProfile?.sceneChapterBlueprints?.[0]?.acts[0] + ?.backgroundImageSrc, + ).toBe('/assets/custom-world/act-stardust-opening.png'); + }); }); diff --git a/src/components/rpg-entry/useRpgCreationEnterWorld.ts b/src/components/rpg-entry/useRpgCreationEnterWorld.ts index 89f6cee1..b05b079f 100644 --- a/src/components/rpg-entry/useRpgCreationEnterWorld.ts +++ b/src/components/rpg-entry/useRpgCreationEnterWorld.ts @@ -5,6 +5,7 @@ import type { CustomWorldAgentSessionSnapshot } from '../../../packages/shared/s import type { CustomWorldRuntimeLaunchOptions } from '../platform-entry/platformEntryTypes'; import { rpgCreationPreviewAdapter } from '../../services/rpg-creation/rpgCreationPreviewAdapter'; import type { CustomWorldProfile } from '../../types'; +import { chooseMoreCompleteCustomWorldProfile } from './rpgProfileCompleteness'; type UseRpgCreationEnterWorldParams = { isAgentDraftResultView: boolean; @@ -82,9 +83,10 @@ export function useRpgCreationEnterWorld( if (currentAgentSessionStage === 'published') { const latestView = await syncAgentCreationResultView(activeAgentSessionId); - const publishedProfile = - rpgCreationPreviewAdapter.buildPreviewFromResultView(latestView) ?? - generatedCustomWorldProfile; + const publishedProfile = chooseMoreCompleteCustomWorldProfile( + generatedCustomWorldProfile, + rpgCreationPreviewAdapter.buildPreviewFromResultView(latestView), + ); // 中文注释:已发布会话的“进入世界”只读取后端结果页真相, // 不能再同步草稿或重复发送 publish_world,否则会被发布阶段门槛拒绝。 setGeneratedCustomWorldProfile(publishedProfile); @@ -110,17 +112,18 @@ export function useRpgCreationEnterWorld( if (canEnterPublishedWorld) { const latestView = await syncAgentCreationResultView(activeAgentSessionId); - return ( - rpgCreationPreviewAdapter.buildPreviewFromResultView(latestView) ?? - latestProfile + return chooseMoreCompleteCustomWorldProfile( + latestProfile, + rpgCreationPreviewAdapter.buildPreviewFromResultView(latestView), ); } await executePublishWorld(); const latestView = await syncAgentCreationResultView(activeAgentSessionId); - const publishedProfile = - rpgCreationPreviewAdapter.buildPreviewFromResultView(latestView) ?? - latestProfile; + const publishedProfile = chooseMoreCompleteCustomWorldProfile( + latestProfile, + rpgCreationPreviewAdapter.buildPreviewFromResultView(latestView), + ); setGeneratedCustomWorldProfile(publishedProfile); return publishedProfile; diff --git a/src/components/rpg-entry/useRpgEntryAgentDraftRestore.test.tsx b/src/components/rpg-entry/useRpgEntryAgentDraftRestore.test.tsx index c8e34e28..aae04d9a 100644 --- a/src/components/rpg-entry/useRpgEntryAgentDraftRestore.test.tsx +++ b/src/components/rpg-entry/useRpgEntryAgentDraftRestore.test.tsx @@ -1,17 +1,19 @@ /** @vitest-environment jsdom */ import { act, render } from '@testing-library/react'; +import { useEffect, useState } from 'react'; import { beforeEach, describe, expect, it, vi } from 'vitest'; import type { CustomWorldAgentSessionSnapshot } from '../../../packages/shared/src/contracts/customWorldAgent'; import type { CustomWorldWorkSummary } from '../../../packages/shared/src/contracts/customWorldWorkSummary'; import type { RpgCreationResultView } from '../../../packages/shared/src/contracts/rpgCreationResultView'; +import type { CustomWorldLibraryEntry } from '../../../packages/shared/src/contracts/runtime'; import { executeRpgCreationAction, getRpgCreationOperation, upsertRpgWorldProfile, } from '../../services/rpg-creation'; -import { type CustomWorldProfile,WorldType } from '../../types'; +import { type CustomWorldProfile, WorldType } from '../../types'; import { useRpgCreationResultAutosave } from './useRpgCreationResultAutosave'; import { useRpgEntryLibraryDetail } from './useRpgEntryLibraryDetail'; @@ -64,6 +66,30 @@ function buildProfile(name: string): CustomWorldProfile { }; } +function buildLibraryEntry( + profile: CustomWorldProfile, +): CustomWorldLibraryEntry { + return { + ownerUserId: 'user-1', + profileId: profile.id, + publicWorkCode: null, + authorPublicUserCode: null, + profile, + visibility: 'published' as const, + publishedAt: '2026-04-25T00:00:00.000Z', + updatedAt: '2026-04-25T00:00:00.000Z', + authorDisplayName: '测试玩家', + worldName: profile.name, + subtitle: profile.subtitle, + summaryText: profile.summary, + coverImageSrc: null, + themeMode: 'tide' as const, + playableNpcCount: profile.playableNpcs.length, + landmarkCount: profile.landmarks.length, + likeCount: 0, + }; +} + function buildSession( overrides: Partial = {}, ): CustomWorldAgentSessionSnapshot { @@ -221,6 +247,361 @@ describe('RPG Agent 草稿恢复', () => { expect(setSelectionStage).not.toHaveBeenCalledWith('custom-world-result'); }); + it('作品详情已加载完整编辑资产时列表摘要不能覆盖 selectedDetailEntry', async () => { + const fullProfile: CustomWorldProfile = { + ...buildProfile('星砂废都'), + id: 'profile-stardust', + cover: { + sourceType: 'default', + imageSrc: null, + characterRoleIds: ['playable-shamian'], + }, + playableNpcs: [ + { + id: 'playable-shamian', + name: '砂眠', + title: '废都引路人', + role: '主角代理', + description: '追查旧约的人。', + backstory: '从星砂潮汐里醒来。', + personality: '冷静。', + motivation: '找到旧约。', + combatStyle: '踏砂突进。', + initialAffinity: 45, + relationshipHooks: [], + tags: [], + relations: [], + backstoryReveal: { + publicSummary: '废都引路人。', + privateChatUnlockAffinity: 60, + chapters: [], + }, + skills: [ + { + id: 'skill-star-step', + name: '星砂步', + summary: '踏砂突进。', + style: 'mobility', + actionPreviewConfig: { + folder: 'characters/shamian', + prefix: 'skill_', + frames: 8, + basePath: '/assets/custom-world/shamian/skill', + previewVideoPath: '/assets/custom-world/shamian/skill.mp4', + }, + }, + ], + initialItems: [ + { + id: 'item-sand-compass', + name: '星砂罗盘', + category: '专属物品', + quantity: 1, + rarity: 'rare', + description: '能指出旧约埋藏方向。', + tags: [], + iconSrc: '/assets/custom-world/items/sand-compass.png', + }, + ], + imageSrc: '/assets/custom-world/playable-shamian.png', + attributeProfile: { + schemaId: 'schema-星砂废都', + values: { axis_a: 8 }, + topTraits: ['星砂共鸣'], + evidence: [ + { slotId: 'axis_a', reason: '能听见星砂潮汐。' }, + ], + }, + }, + ], + sceneChapterBlueprints: [ + { + id: 'scene-chapter-clocktower', + sceneId: 'landmark-clocktower', + title: '钟楼第一夜', + summary: '钟楼第一夜。', + sceneTaskDescription: '进入钟楼。', + linkedThreadIds: [], + linkedLandmarkIds: ['landmark-clocktower'], + acts: [ + { + id: 'act-clocktower-opening', + sceneId: 'landmark-clocktower', + title: '第一幕', + summary: '砂眠带玩家进入坠星钟楼。', + stageCoverage: ['opening'], + backgroundImageSrc: + '/assets/custom-world/act-clocktower-opening.png', + backgroundAssetId: 'asset-act-clocktower-opening', + encounterNpcIds: ['playable-shamian'], + primaryNpcId: 'playable-shamian', + oppositeNpcId: 'playable-shamian', + eventDescription: '钟楼旧铃忽然自鸣。', + linkedThreadIds: ['thread-old-vow'], + advanceRule: 'after_primary_contact', + actGoal: '进入钟楼。', + transitionHook: '星砂开始倒流。', + }, + ], + }, + ], + }; + const summaryProfile: CustomWorldProfile = { + ...fullProfile, + cover: null, + playableNpcs: [ + { + ...fullProfile.playableNpcs[0]!, + skills: [ + { + id: 'skill-star-step', + name: '星砂步', + summary: '踏砂突进。', + style: 'mobility', + }, + ], + initialItems: [ + { + ...fullProfile.playableNpcs[0]!.initialItems[0]!, + iconSrc: undefined, + }, + ], + imageSrc: undefined, + attributeProfile: undefined, + }, + ], + sceneChapterBlueprints: [ + { + ...fullProfile.sceneChapterBlueprints![0]!, + acts: [ + { + ...fullProfile.sceneChapterBlueprints![0]!.acts[0]!, + backgroundImageSrc: undefined, + backgroundAssetId: undefined, + linkedThreadIds: [], + actGoal: '', + transitionHook: '', + }, + ], + }, + ], + }; + const detailEntry = buildLibraryEntry(fullProfile); + const summaryEntry = buildLibraryEntry(summaryProfile); + const selectedEntries: CustomWorldLibraryEntry[] = []; + + function Harness() { + const [selectedDetailEntry, setSelectedDetailEntry] = useState< + CustomWorldLibraryEntry | null + >(detailEntry); + useEffect(() => { + if (selectedDetailEntry) { + selectedEntries.push(selectedDetailEntry); + } + }, [selectedDetailEntry]); + useRpgEntryLibraryDetail({ + userId: 'user-1', + selectedDetailEntry, + setSelectedDetailEntry, + savedCustomWorldEntries: [summaryEntry], + setSavedCustomWorldEntries: vi.fn(), + setGeneratedCustomWorldProfile: vi.fn(), + setCustomWorldError: vi.fn(), + setCustomWorldAutoSaveError: vi.fn(), + setCustomWorldAutoSaveState: vi.fn(), + setCustomWorldGenerationViewSource: vi.fn(), + setCustomWorldResultViewSource: vi.fn(), + setSelectionStage: vi.fn(), + setPlatformTabToCreate: vi.fn(), + setPlatformTabToDraft: vi.fn(), + setPlatformError: vi.fn(), + appendBrowseHistoryEntry: vi.fn(async () => {}), + refreshCustomWorldWorks: vi.fn(async () => []), + refreshPublishedGallery: vi.fn(async () => []), + persistAgentUiState: vi.fn(), + syncAgentCreationResultView: vi.fn(), + buildDraftResultProfile: () => null, + suppressAgentDraftResultAutoOpen: vi.fn(), + releaseAgentDraftResultAutoOpenSuppression: vi.fn(), + resetAutoSaveTrackingToIdle: vi.fn(), + markAutoSavedProfile: vi.fn(), + }); + return null; + } + + render(); + + await act(async () => {}); + const lastSelected = selectedEntries.at(-1); + expect(lastSelected?.profile.cover?.characterRoleIds).toEqual([ + 'playable-shamian', + ]); + expect( + lastSelected?.profile.playableNpcs[0]?.skills[0]?.actionPreviewConfig + ?.previewVideoPath, + ).toBe('/assets/custom-world/shamian/skill.mp4'); + expect(lastSelected?.profile.playableNpcs[0]?.initialItems[0]?.iconSrc).toBe( + '/assets/custom-world/items/sand-compass.png', + ); + expect(lastSelected?.profile.playableNpcs[0]?.attributeProfile).toBeTruthy(); + expect( + lastSelected?.profile.sceneChapterBlueprints?.[0]?.acts[0] + ?.backgroundImageSrc, + ).toBe('/assets/custom-world/act-clocktower-opening.png'); + }); + + it('默认封面和角色编辑结构差异也不能被列表摘要覆盖', async () => { + const fullRole = { + id: 'playable-shamian', + name: '砂眠', + title: '废都引路人', + role: '主角代理', + description: '追查旧约的人。', + backstory: '从星砂潮汐里醒来。', + personality: '冷静。', + motivation: '找到旧约。', + combatStyle: '踏砂突进。', + initialAffinity: 45, + relationshipHooks: [], + tags: [], + relations: [], + backstoryReveal: { + publicSummary: '废都引路人。', + privateChatUnlockAffinity: 60, + chapters: [], + }, + skills: [ + { + id: 'skill-star-step', + name: '星砂步', + summary: '踏砂突进。', + style: 'mobility', + actionPreviewConfig: { + folder: 'characters/shamian', + prefix: 'skill_', + frames: 8, + basePath: '/assets/custom-world/shamian/skill', + previewVideoPath: '/assets/custom-world/shamian/skill.mp4', + }, + }, + ], + initialItems: [ + { + id: 'item-sand-compass', + name: '星砂罗盘', + category: '专属物品', + quantity: 1, + rarity: 'rare', + description: '能指出旧约埋藏方向。', + tags: [], + iconSrc: '/assets/custom-world/items/sand-compass.png', + }, + ], + attributeProfile: { + schemaId: 'schema-星砂废都', + values: { axis_a: 8 }, + topTraits: ['星砂共鸣'], + evidence: [ + { slotId: 'axis_a', reason: '能听见星砂潮汐。' }, + ], + }, + } satisfies CustomWorldProfile['playableNpcs'][number]; + const fullProfile: CustomWorldProfile = { + ...buildProfile('星砂废都'), + id: 'profile-stardust-structure', + cover: { + sourceType: 'default', + imageSrc: null, + characterRoleIds: ['playable-shamian'], + }, + playableNpcs: [fullRole], + }; + const summaryProfile: CustomWorldProfile = { + ...fullProfile, + cover: null, + playableNpcs: [ + { + ...fullRole, + skills: [ + { + id: 'skill-star-step', + name: '星砂步', + summary: '踏砂突进。', + style: 'mobility', + }, + ], + initialItems: [ + { + ...fullRole.initialItems[0]!, + iconSrc: undefined, + }, + ], + attributeProfile: undefined, + }, + ], + }; + const detailEntry = buildLibraryEntry(fullProfile); + const summaryEntry = buildLibraryEntry(summaryProfile); + const selectedEntries: CustomWorldLibraryEntry[] = []; + + function Harness() { + const [selectedDetailEntry, setSelectedDetailEntry] = useState< + CustomWorldLibraryEntry | null + >(detailEntry); + useEffect(() => { + if (selectedDetailEntry) { + selectedEntries.push(selectedDetailEntry); + } + }, [selectedDetailEntry]); + useRpgEntryLibraryDetail({ + userId: 'user-1', + selectedDetailEntry, + setSelectedDetailEntry, + savedCustomWorldEntries: [summaryEntry], + setSavedCustomWorldEntries: vi.fn(), + setGeneratedCustomWorldProfile: vi.fn(), + setCustomWorldError: vi.fn(), + setCustomWorldAutoSaveError: vi.fn(), + setCustomWorldAutoSaveState: vi.fn(), + setCustomWorldGenerationViewSource: vi.fn(), + setCustomWorldResultViewSource: vi.fn(), + setSelectionStage: vi.fn(), + setPlatformTabToCreate: vi.fn(), + setPlatformTabToDraft: vi.fn(), + setPlatformError: vi.fn(), + appendBrowseHistoryEntry: vi.fn(async () => {}), + refreshCustomWorldWorks: vi.fn(async () => []), + refreshPublishedGallery: vi.fn(async () => []), + persistAgentUiState: vi.fn(), + syncAgentCreationResultView: vi.fn(), + buildDraftResultProfile: () => null, + suppressAgentDraftResultAutoOpen: vi.fn(), + releaseAgentDraftResultAutoOpenSuppression: vi.fn(), + resetAutoSaveTrackingToIdle: vi.fn(), + markAutoSavedProfile: vi.fn(), + }); + return null; + } + + render(); + + await act(async () => {}); + const lastSelected = selectedEntries.at(-1); + expect(lastSelected?.profile.cover?.characterRoleIds).toEqual([ + 'playable-shamian', + ]); + expect( + lastSelected?.profile.playableNpcs[0]?.skills[0]?.actionPreviewConfig + ?.previewVideoPath, + ).toBe('/assets/custom-world/shamian/skill.mp4'); + expect(lastSelected?.profile.playableNpcs[0]?.initialItems[0]?.iconSrc).toBe( + '/assets/custom-world/items/sand-compass.png', + ); + expect(lastSelected?.profile.playableNpcs[0]?.attributeProfile?.values).toEqual( + { axis_a: 8 }, + ); + }); + it('Agent 结果页自动保存先回写 session,再保存后端 result-view profile', async () => { const oldProfile = buildProfile('旧前端快照'); const latestProfile = { diff --git a/src/components/rpg-entry/useRpgEntryLibraryDetail.ts b/src/components/rpg-entry/useRpgEntryLibraryDetail.ts index d5666566..20d46aad 100644 --- a/src/components/rpg-entry/useRpgEntryLibraryDetail.ts +++ b/src/components/rpg-entry/useRpgEntryLibraryDetail.ts @@ -21,6 +21,11 @@ import { unpublishRpgEntryWorldProfile, } from '../../services/rpg-entry/rpgEntryLibraryClient'; import type { CustomWorldProfile } from '../../types'; +import { + countCustomWorldProfileAssetSlots, + countCustomWorldProfileDetailSlots, + countCustomWorldProfileStructuredSlots, +} from './rpgProfileCompleteness'; import { resolveRpgEntryErrorMessage } from './rpgEntryShared'; import type { CustomWorldAutoSaveState, @@ -86,6 +91,46 @@ function isMissingRpgEntryAgentSessionError(error: unknown) { ); } +function shouldKeepSelectedDetailProfile( + selectedEntry: CustomWorldLibraryEntry, + nextOwnedEntry: CustomWorldLibraryEntry, +) { + if ( + selectedEntry.ownerUserId !== nextOwnedEntry.ownerUserId || + selectedEntry.profileId !== nextOwnedEntry.profileId + ) { + return false; + } + + const selectedDetailCount = countCustomWorldProfileDetailSlots( + selectedEntry.profile, + ); + const nextDetailCount = countCustomWorldProfileDetailSlots( + nextOwnedEntry.profile, + ); + const selectedAssetSlotCount = countCustomWorldProfileAssetSlots( + selectedEntry.profile, + ); + const nextAssetSlotCount = countCustomWorldProfileAssetSlots( + nextOwnedEntry.profile, + ); + const selectedStructuredSlotCount = + countCustomWorldProfileStructuredSlots(selectedEntry.profile); + const nextStructuredSlotCount = countCustomWorldProfileStructuredSlots( + nextOwnedEntry.profile, + ); + const expectedRuntimeCount = + nextOwnedEntry.playableNpcCount + nextOwnedEntry.landmarkCount; + + // 作品架列表只保证卡片摘要,不能在详情接口已经拿到完整运行态字段后覆盖详情。 + return ( + (selectedDetailCount > nextDetailCount && + expectedRuntimeCount > nextDetailCount) || + selectedAssetSlotCount > nextAssetSlotCount || + selectedStructuredSlotCount > nextStructuredSlotCount + ); +} + /** * 负责平台详情、创作作品入口和结果页打开路径。 * 平台壳层只消费“打开哪个面板”的结果,不再自己拼接恢复流程细节。 @@ -136,6 +181,10 @@ export function useRpgEntryLibraryDetail( entry.profileId === selectedDetailEntry.profileId, ); if (nextOwnedEntry && nextOwnedEntry !== selectedDetailEntry) { + if (shouldKeepSelectedDetailProfile(selectedDetailEntry, nextOwnedEntry)) { + return; + } + setSelectedDetailEntry(nextOwnedEntry); } }, [savedCustomWorldEntries, selectedDetailEntry, setSelectedDetailEntry]); diff --git a/src/data/customWorldLibrary.test.ts b/src/data/customWorldLibrary.test.ts index 6643c7b2..66c593ae 100644 --- a/src/data/customWorldLibrary.test.ts +++ b/src/data/customWorldLibrary.test.ts @@ -196,4 +196,287 @@ describe('normalizeCustomWorldProfileRecord role asset descriptions', () => { '/generated-custom-world-scenes/opening/storyboard.png', ); }); + + it('保留结果页封面和关键图片资产槽位', () => { + const profile = normalizeCustomWorldProfileRecord({ + name: '星砂废都', + settingText: '坠星沙海与废都钟楼', + cover: { + sourceType: 'generated', + imageSrc: '/generated-custom-world-covers/star-waste/cover.webp', + characterRoleIds: ['playable-shamian'], + }, + camp: { + id: 'camp-star-waste', + name: '废都营地', + description: '钟楼阴影下的临时营地。', + imageSrc: '/assets/custom-world/camp-star-waste.png', + sceneNpcIds: ['playable-shamian'], + connections: [], + }, + playableNpcs: [ + { + id: 'playable-shamian', + name: '砂眠', + title: '废都引路人', + role: '主角代理', + imageSrc: '/assets/custom-world/playable-shamian.png', + }, + ], + storyNpcs: [ + { + id: 'story-clock-keeper', + name: '钟守', + title: '钟楼守夜者', + role: '第一幕主NPC', + imageSrc: '/assets/custom-world/story-clock-keeper.png', + }, + ], + landmarks: [ + { + id: 'landmark-clocktower', + name: '坠星钟楼', + description: '半截钟楼被星砂埋住。', + imageSrc: '/assets/custom-world/landmark-clocktower.png', + sceneNpcIds: ['story-clock-keeper'], + connections: [], + }, + ], + sceneChapterBlueprints: [ + { + id: 'scene-chapter-clocktower', + sceneId: 'landmark-clocktower', + title: '钟楼第一夜', + acts: [ + { + id: 'act-clocktower-opening', + sceneId: 'landmark-clocktower', + title: '第一幕', + summary: '砂眠带玩家进入坠星钟楼。', + backgroundImageSrc: + '/assets/custom-world/act-clocktower-opening.png', + encounterNpcIds: ['砂眠', '钟守'], + primaryNpcId: '砂眠', + oppositeNpcId: '钟守', + }, + ], + }, + ], + }); + + expect(profile?.cover?.sourceType).toBe('generated'); + expect(profile?.cover?.imageSrc).toBe( + '/generated-custom-world-covers/star-waste/cover.webp', + ); + expect(profile?.camp?.imageSrc).toBe( + '/assets/custom-world/camp-star-waste.png', + ); + expect(profile?.playableNpcs[0]?.imageSrc).toBe( + '/assets/custom-world/playable-shamian.png', + ); + expect(profile?.storyNpcs[0]?.imageSrc).toBe( + '/assets/custom-world/story-clock-keeper.png', + ); + expect(profile?.landmarks[0]?.imageSrc).toBe( + '/assets/custom-world/landmark-clocktower.png', + ); + expect( + profile?.sceneChapterBlueprints?.[0]?.acts[0]?.backgroundImageSrc, + ).toBe('/assets/custom-world/act-clocktower-opening.png'); + }); + + it('近似无损保留编辑态和运行态结构字段', () => { + const profile = normalizeCustomWorldProfileRecord({ + name: '星砂废都', + settingText: '坠星沙海与废都钟楼', + attributeSchema: { + id: 'schema-stardust', + worldId: 'world-stardust', + schemaVersion: 1, + generatedFrom: { + worldType: 'CUSTOM', + worldName: '星砂废都', + settingSummary: '坠星沙海与废都钟楼', + tone: '苍凉', + conflictCore: '旧约与星砂潮汐冲突', + }, + slots: [ + { slotId: 'axis_a', name: '星砂共鸣' }, + { slotId: 'axis_b', name: '废都步法' }, + { slotId: 'axis_c', name: '钟楼感知' }, + { slotId: 'axis_d', name: '旧约心火' }, + { slotId: 'axis_e', name: '尘缘牵引' }, + { slotId: 'axis_f', name: '潮汐玄息' }, + ], + }, + camp: { + id: 'camp-star-waste', + name: '废都营地', + description: '钟楼阴影下的临时营地。', + narrativeResidues: [ + { + id: 'residue-camp-1', + summary: '营地火盆里混着星砂。', + }, + ], + }, + playableNpcs: [ + { + id: 'playable-shamian', + name: '砂眠', + title: '废都引路人', + role: '主角代理', + skills: [ + { + id: 'skill-star-step', + name: '星砂步', + summary: '踏砂突进。', + style: 'mobility', + actionPreviewConfig: { + folder: 'characters/shamian', + prefix: 'skill_', + frames: 8, + basePath: '/assets/custom-world/shamian/skill', + previewVideoPath: '/assets/custom-world/shamian/skill.mp4', + file: 'skill.png', + }, + }, + ], + initialItems: [ + { + id: 'item-sand-compass', + name: '星砂罗盘', + category: '专属物品', + quantity: 1, + rarity: 'rare', + description: '能指出旧约埋藏方向。', + tags: ['旧约'], + iconSrc: '/assets/custom-world/items/sand-compass.png', + }, + ], + attributeProfile: { + schemaId: 'schema-stardust', + values: { axis_a: 8, axis_b: 7 }, + topTraits: ['星砂共鸣'], + evidence: [ + { slotId: 'axis_a', reason: '能听见星砂潮汐。' }, + ], + }, + }, + ], + storyNpcs: [ + { + id: 'story-clock-keeper', + name: '钟守', + title: '钟楼守夜者', + role: '第一幕主NPC', + attributeProfile: { + schemaId: 'schema-stardust', + values: { axis_c: 9 }, + topTraits: ['钟楼感知'], + evidence: [ + { slotId: 'axis_c', reason: '能辨认旧铃回声。' }, + ], + }, + }, + ], + landmarks: [ + { + id: 'landmark-clocktower', + name: '坠星钟楼', + description: '半截钟楼被星砂埋住。', + visualDescription: '钟楼外墙布满蓝白星砂结晶。', + narrativeResidues: [ + { + id: 'residue-clocktower-1', + summary: '钟楼第十三声铃响仍未散去。', + }, + ], + }, + ], + sceneChapterBlueprints: [ + { + id: 'scene-chapter-clocktower', + sceneId: 'landmark-clocktower', + title: '钟楼第一夜', + acts: [ + { + id: 'act-clocktower-opening', + sceneId: 'landmark-clocktower', + title: '第一幕', + summary: '砂眠带玩家进入坠星钟楼。', + backgroundAssetId: 'asset-act-clocktower-opening', + backgroundImageSrc: + '/assets/custom-world/act-clocktower-opening.png', + eventDescription: '钟楼旧铃忽然自鸣。', + linkedThreadIds: ['thread-old-vow'], + advanceRule: 'after_primary_contact', + actGoal: '进入钟楼。', + transitionHook: '星砂开始倒流。', + }, + ], + }, + ], + }); + + expect(profile?.attributeSchema.id).toBe('schema-stardust'); + expect(profile?.attributeSchema.slots[0]?.name).toBe('星砂共鸣'); + expect(profile?.camp?.narrativeResidues?.[0]?.summary).toBe( + '营地火盆里混着星砂。', + ); + expect( + profile?.playableNpcs[0]?.skills[0]?.actionPreviewConfig + ?.previewVideoPath, + ).toBe('/assets/custom-world/shamian/skill.mp4'); + expect(profile?.playableNpcs[0]?.initialItems[0]?.iconSrc).toBe( + '/assets/custom-world/items/sand-compass.png', + ); + expect(profile?.playableNpcs[0]?.attributeProfile?.values.axis_a).toBe(8); + expect(profile?.storyNpcs[0]?.attributeProfile?.topTraits).toContain( + '钟楼感知', + ); + expect(profile?.landmarks[0]?.visualDescription).toBe( + '钟楼外墙布满蓝白星砂结晶。', + ); + expect(profile?.landmarks[0]?.narrativeResidues?.[0]?.summary).toBe( + '钟楼第十三声铃响仍未散去。', + ); + const act = profile?.sceneChapterBlueprints?.[0]?.acts[0]; + expect(act?.backgroundAssetId).toBe('asset-act-clocktower-opening'); + expect(act?.eventDescription).toBe('钟楼旧铃忽然自鸣。'); + expect(act?.linkedThreadIds).toEqual(['thread-old-vow']); + expect(act?.actGoal).toBe('进入钟楼。'); + expect(act?.transitionHook).toBe('星砂开始倒流。'); + }); + + it('保留只有背景资产的场景幕,避免恢复详情时丢失场景 CG', () => { + const profile = normalizeCustomWorldProfileRecord({ + name: '星砂废都', + settingText: '坠星沙海与废都钟楼', + sceneChapterBlueprints: [ + { + id: 'scene-chapter-clocktower', + sceneId: 'landmark-clocktower', + title: '钟楼第一夜', + acts: [ + { + id: 'act-background-only', + sceneId: 'landmark-clocktower', + backgroundAssetId: 'asset-background-only', + backgroundImageSrc: '/assets/custom-world/background-only.png', + backgroundPromptText: '坠星钟楼被蓝白星砂照亮。', + }, + ], + }, + ], + }); + + const act = profile?.sceneChapterBlueprints?.[0]?.acts[0]; + expect(act?.id).toBe('act-background-only'); + expect(act?.backgroundImageSrc).toBe( + '/assets/custom-world/background-only.png', + ); + expect(act?.backgroundAssetId).toBe('asset-background-only'); + expect(act?.backgroundPromptText).toBe('坠星钟楼被蓝白星砂照亮。'); + }); }); diff --git a/src/data/customWorldLibrary.ts b/src/data/customWorldLibrary.ts index 8b53f3e8..3b4971a7 100644 --- a/src/data/customWorldLibrary.ts +++ b/src/data/customWorldLibrary.ts @@ -28,6 +28,7 @@ import { CustomWorldNpcVisualGearType, CustomWorldNpcVisualRace, CustomWorldOpeningCgProfile, + CustomWorldCoverProfile, CustomWorldPlayableNpc, CustomWorldProfile, CustomWorldRoleInitialItem, @@ -864,6 +865,7 @@ function normalizeLandmark( id: toText(value.id, `saved-landmark-${index + 1}`), name, description: toText(value.description), + visualDescription: toText(value.visualDescription) || undefined, imageSrc: toText(value.imageSrc) || undefined, narrativeResidues: preserveStructuredRecordArray( @@ -909,7 +911,10 @@ function normalizeCampScene( summary: toText(connection.summary) || toText(connection.description), })) .filter((connection) => connection.targetLandmarkId), - narrativeResidues: null, + narrativeResidues: + preserveStructuredRecordArray( + value.narrativeResidues, + ) ?? null, }; } @@ -989,6 +994,13 @@ function normalizeSceneActBlueprint( const advanceRule = toText(value.advanceRule); const title = toText(value.title); const summary = toText(value.summary); + const backgroundImageSrc = toText(value.backgroundImageSrc); + const backgroundPromptText = toText(value.backgroundPromptText); + const backgroundAssetId = toText(value.backgroundAssetId); + const eventDescription = toText(value.eventDescription); + const linkedThreadIds = toStringArray(value.linkedThreadIds); + const actGoal = toText(value.actGoal); + const transitionHook = toText(value.transitionHook); const primaryNpcId = resolveCustomWorldRoleIdReference( profileRoles, toText(value.primaryNpcId, encounterNpcIds[0] ?? ''), @@ -998,7 +1010,18 @@ function normalizeSceneActBlueprint( toText(value.oppositeNpcId, primaryNpcId), ); - if (!title && !summary && encounterNpcIds.length === 0) { + if ( + !title && + !summary && + encounterNpcIds.length === 0 && + !backgroundImageSrc && + !backgroundPromptText && + !backgroundAssetId && + !eventDescription && + linkedThreadIds.length === 0 && + !actGoal && + !transitionHook + ) { return null; } @@ -1011,26 +1034,26 @@ function normalizeSceneActBlueprint( stageCoverage.length > 0 ? stageCoverage : index === 0 - ? ['opening'] - : ['climax', 'aftermath'], - backgroundImageSrc: toText(value.backgroundImageSrc) || undefined, - backgroundPromptText: toText(value.backgroundPromptText) || undefined, - backgroundAssetId: toText(value.backgroundAssetId) || undefined, + ? ['opening'] + : ['climax', 'aftermath'], + backgroundImageSrc: backgroundImageSrc || undefined, + backgroundPromptText: backgroundPromptText || undefined, + backgroundAssetId: backgroundAssetId || undefined, encounterNpcIds, primaryNpcId, oppositeNpcId, eventDescription: toText( - value.eventDescription, + eventDescription, oppositeNpcId ? `第 ${index + 1} 幕中,玩家与${oppositeNpcId}正面接触,推动当前场景事件升级。` : `第 ${index + 1} 幕中,玩家处理当前场景的关键事件。`, ), - linkedThreadIds: toStringArray(value.linkedThreadIds), + linkedThreadIds, advanceRule: SCENE_ACT_ADVANCE_RULES.has(advanceRule as never) ? (advanceRule as SceneActBlueprint['advanceRule']) : 'after_active_step_complete', - actGoal: toText(value.actGoal), - transitionHook: toText(value.transitionHook), + actGoal, + transitionHook, }; } @@ -1159,6 +1182,9 @@ function normalizeProfile(value: unknown): CustomWorldProfile | null { const openingCg = preserveStructuredRecord( value.openingCg, ); + const cover = preserveStructuredRecord( + value.cover, + ); const normalizedProfile = { id: toText(value.id, `saved-custom-world-${Date.now().toString(36)}`), settingText, @@ -1184,6 +1210,7 @@ function normalizeProfile(value: unknown): CustomWorldProfile | null { .map((entry, index) => normalizeItem(entry, index)) .filter((entry): entry is CustomWorldItem => Boolean(entry)) : [], + cover, openingCg, camp, landmarks: normalizeCustomWorldLandmarks({ diff --git a/src/hooks/useGameFlow.customWorld.test.tsx b/src/hooks/useGameFlow.customWorld.test.tsx index 1a91f766..2b862281 100644 --- a/src/hooks/useGameFlow.customWorld.test.tsx +++ b/src/hooks/useGameFlow.customWorld.test.tsx @@ -776,3 +776,123 @@ test('custom world opening act accepts runtime npc id references and still start }), ); }); + +test('switching between custom worlds sends the newly selected profile to runtime bootstrap', async () => { + const user = userEvent.setup(); + const oldProfile = buildSavedProfile(); + const openedDraftProfile = normalizeCustomWorldProfileRecord({ + ...oldProfile, + id: 'opened-draft-profile', + name: '星砂废都', + subtitle: '坠星沙海与废都钟楼', + summary: '本次从草稿架打开并启动的目标草稿。', + settingText: '星砂覆盖旧废都,钟楼下埋着旧约。', + playableNpcs: [ + { + ...oldProfile.playableNpcs[0], + id: 'opened-playable-1', + name: '砂眠', + title: '废都引路人', + }, + ], + }); + + if (!openedDraftProfile) { + throw new Error('failed to build opened draft profile'); + } + const normalizedOpenedDraftProfile = openedDraftProfile; + + function SwitchWorldHarness() { + const { + gameState, + handleCustomWorldSelect, + handleCharacterSelect, + } = useRpgSessionBootstrap(); + const openedCharacters = buildCustomWorldPlayableCharacters( + normalizedOpenedDraftProfile, + ); + const selectedCharacter = openedCharacters[0] ?? null; + + return ( +
+ + + +
+          {JSON.stringify({
+            profileId: gameState.customWorldProfile?.id ?? null,
+            profileName: gameState.customWorldProfile?.name ?? null,
+          })}
+        
+
+ ); + } + + runtimeStoryClientMocks.beginRuntimeStorySession.mockResolvedValue( + buildRuntimeStoryBootstrapSnapshot({ + profile: normalizedOpenedDraftProfile, + character: buildCustomWorldPlayableCharacters( + normalizedOpenedDraftProfile, + )[0]!, + }), + ); + + render(); + + await user.click(screen.getByRole('button', { name: '选择旧世界' })); + await waitFor(() => { + expect( + JSON.parse(screen.getByTestId('state-snapshot').textContent ?? '{}') + .profileName, + ).toBe('回潮群岛'); + }); + + await user.click(screen.getByRole('button', { name: '选择打开草稿' })); + await waitFor(() => { + expect( + JSON.parse(screen.getByTestId('state-snapshot').textContent ?? '{}') + .profileName, + ).toBe('星砂废都'); + }); + + await user.click(screen.getByRole('button', { name: '确认草稿角色' })); + + await waitFor(() => { + expect(runtimeStoryClientMocks.beginRuntimeStorySession).toHaveBeenCalledWith( + expect.objectContaining({ + customWorldProfile: expect.objectContaining({ + id: 'opened-draft-profile', + name: '星砂废都', + summary: '本次从草稿架打开并启动的目标草稿。', + }), + character: expect.objectContaining({ + id: 'opened-playable-1', + name: '砂眠', + }), + }), + ); + }); +});