fix: preserve rpg custom world detail profiles

This commit is contained in:
kdletters
2026-05-22 03:14:11 +08:00
parent a9d23a8a44
commit d74457faa2
19 changed files with 2726 additions and 109 deletions

View File

@@ -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<CustomWorldProfile>` 的接口边界统一调用 `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<CustomWorldProfile>` 的接口边界统一调用 `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`

View File

@@ -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` 返回的列表并在用户选择后调用对应恢复接口,不能本地拼装或筛选正式存档真相。
## 拼图

View File

@@ -94,6 +94,7 @@ server-rs + Axum + SpacetimeDB
8. 图像输入通用 UI 统一走 `src/components/common/CreativeImageInputPanel.tsx`。外层页面持有业务状态组件只承担上传卡、预览、参考图缩略图、AI 重绘开关、错误展示和提交按钮。
9. 发现页 `分类` 子频道的筛选必须打开独立 dialog / drawer / modal至少支持玩法类型过滤与排序切换筛选结果为空时显示空状态不把筛选内容展开在当前列表下方。
10. “我的”页泥点、游戏时长、玩过三张统计卡只展示各自标签和值,内容居中且不换行,不在统计区底部展示“更新于”时间。
11. RPG 等运行态的战斗飘字、血量变化和即时反馈必须在暗色、噪声高的场景背景上保持可读:使用高亮文字、深色描边、强阴影或小面积半透明底,不只依赖红/绿文字本身表达伤害或治疗。
## 文案与编码

View File

@@ -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<serde_json::Value>,
) -> 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<serde_json::Value>,
) -> 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")
);
}

View File

@@ -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};

View File

@@ -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<RuntimeStoryOptionView>,
original_scene_act_state: Option<Value>,
) -> 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<RuntimeStoryOptionView> {
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<RuntimeStoryOptionView>
)];
};
let current_scene_id = read_optional_string_field(current_scene, "id");
let mut options = read_array_field(current_scene, "connections")
.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 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());
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::<Vec<_>>();
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(|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<RuntimeStoryOptionView>
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<Value> {
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<String> {
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<Value> {
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<RuntimeScene> {
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<String> {
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<Value> {
fn resolve_next_scene_act_runtime_state(
game_state: &Value,
current_act_state_override: Option<&Value>,
) -> Option<Value> {
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<Value> {
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<String> {
fn resolve_active_scene_act_focus_npc_id(
profile: &Value,
scene_id: Option<&str>,
current_act_id: Option<&str>,
) -> Option<String> {
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(|| {

View File

@@ -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<StoryResolution, String> {
// 中文注释:探索前进是战后继续链路的一环,必须在后端清掉战斗态并生成下一段遭遇预览。
// 前端只播放表现动画,不能只靠本地状态把同一组 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<StoryResolution, String> {
// 中文注释:切场景会改变 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,

View File

@@ -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)`);

View File

@@ -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 (
<motion.div
key={event.id}
initial={{opacity: 0, y: 10, scale: 0.76}}
animate={{opacity: [0, 1, 1, 0], y: [10, -12, -31, -50], scale: [0.76, 1.22, 1, 0.9]}}
initial={{opacity: 0, y: 8, scale: 0.72}}
animate={{opacity: [0, 1, 1, 0], y: [8, -14, -36, -58], scale: [0.72, 1.18, 1.04, 0.92]}}
transition={{duration: 0.92, ease: 'easeOut'}}
onAnimationComplete={() => 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}`}
>
<span className="[-webkit-text-stroke:1px_rgba(24,24,27,0.76)]">
<span style={presentation.textStyle}>
{deltaText}
</span>
</motion.div>

View File

@@ -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(<TestWrapper withAuth onSelectWorld={handleCustomWorldSelect} />);
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(<TestWrapper withAuth onSelectWorld={handleCustomWorldSelect} />);
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(<TestWrapper withAuth onContinueGame={handleContinueGame} />);
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(<TestWrapper withAuth onSelectWorld={handleCustomWorldSelect} />);
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(<TestWrapper withAuth />);
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();

View File

@@ -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 (
<div className="fixed inset-0 z-[80] flex items-center justify-center bg-black/48 px-3 py-5">
<div className="relative max-h-[min(92vh,42rem)] w-full max-w-[38rem] overflow-hidden rounded-[1.35rem] bg-white text-zinc-950 shadow-2xl">
<button
type="button"
onClick={onClose}
className="absolute right-3 top-3 z-10 flex h-8 w-8 items-center justify-center rounded-full bg-white/80 text-[#ff4056] shadow-sm"
aria-label="关闭存档"
>
×
</button>
<div className="max-h-[min(92vh,42rem)] overflow-y-auto px-4 pb-5 pt-4 sm:px-5">
<div className="pr-10">
<div className="text-[10px] font-black tracking-[0.22em] text-[#ff4056]">
SAVES
</div>
<div className="mt-1 text-2xl font-black"></div>
</div>
{saveError ? (
<div className="mt-4 rounded-xl border border-rose-200 bg-rose-50 px-3 py-2 text-sm text-rose-700">
{saveError}
</div>
) : null}
{saveEntries.length > 0 ? (
<div className="mt-5 grid gap-3">
{saveEntries.map((entry) => (
<SaveArchiveCard
key={`${entry.worldKey}:profile-archive`}
entry={entry}
loading={isResumingSaveWorldKey === entry.worldKey}
onClick={() => onResumeSave(entry)}
/>
))}
</div>
) : (
<div className="mt-5 rounded-xl bg-zinc-50 px-4 py-5 text-center text-sm font-semibold text-zinc-500">
</div>
)}
</div>
</div>
</div>
);
}
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}
/>
<ProfileShortcutButton
label="存档"
subLabel={
saveEntries.length > 0
? `${saveEntries.length}个可继续`
: '继续游玩'
}
icon={Archive}
onClick={() => setProfilePopupPanel('saveArchives')}
/>
{showRechargeEntry ? (
<ProfileShortcutButton
label="兑换码"
@@ -6430,7 +6502,15 @@ export function RpgEntryHomeView({
))}
</div>
</div>
{profilePopupPanel ? (
{profilePopupPanel === 'saveArchives' ? (
<ProfileSaveArchivesModal
saveEntries={saveEntries}
saveError={saveError}
isResumingSaveWorldKey={isResumingSaveWorldKey}
onClose={() => setProfilePopupPanel(null)}
onResumeSave={onResumeSave}
/>
) : profilePopupPanel ? (
<ProfileReferralModal
panel={profilePopupPanel}
center={referralCenter}
@@ -6595,7 +6675,15 @@ export function RpgEntryHomeView({
onClaim={claimTaskReward}
/>
) : null}
{profilePopupPanel ? (
{profilePopupPanel === 'saveArchives' ? (
<ProfileSaveArchivesModal
saveEntries={saveEntries}
saveError={saveError}
isResumingSaveWorldKey={isResumingSaveWorldKey}
onClose={() => setProfilePopupPanel(null)}
onResumeSave={onResumeSave}
/>
) : profilePopupPanel ? (
<ProfileReferralModal
panel={profilePopupPanel}
center={referralCenter}

View File

@@ -0,0 +1,157 @@
import type { CustomWorldProfile } from '../../types';
export function countCustomWorldProfileDetailSlots(
profile: Partial<CustomWorldProfile> | 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<CustomWorldProfile> | 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<CustomWorldProfile> | 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<CustomWorldProfile> | 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;
}

View File

@@ -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 (
<button
type="button"
onClick={() => void enterWorldFromCurrentResult()}
>
</button>
);
}
const { getByText } = render(<Harness />);
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');
});
});

View File

@@ -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;

View File

@@ -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<CustomWorldProfile> {
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> = {},
): 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<CustomWorldProfile>[] = [];
function Harness() {
const [selectedDetailEntry, setSelectedDetailEntry] = useState<
CustomWorldLibraryEntry<CustomWorldProfile> | 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(<Harness />);
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<CustomWorldProfile>[] = [];
function Harness() {
const [selectedDetailEntry, setSelectedDetailEntry] = useState<
CustomWorldLibraryEntry<CustomWorldProfile> | 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(<Harness />);
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 = {

View File

@@ -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<CustomWorldProfile>,
nextOwnedEntry: CustomWorldLibraryEntry<CustomWorldProfile>,
) {
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]);

View File

@@ -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('坠星钟楼被蓝白星砂照亮。');
});
});

View File

@@ -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<SceneNarrativeResidue>(
@@ -909,7 +911,10 @@ function normalizeCampScene(
summary: toText(connection.summary) || toText(connection.description),
}))
.filter((connection) => connection.targetLandmarkId),
narrativeResidues: null,
narrativeResidues:
preserveStructuredRecordArray<SceneNarrativeResidue>(
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;
}
@@ -1013,24 +1036,24 @@ function normalizeSceneActBlueprint(
: index === 0
? ['opening']
: ['climax', 'aftermath'],
backgroundImageSrc: toText(value.backgroundImageSrc) || undefined,
backgroundPromptText: toText(value.backgroundPromptText) || undefined,
backgroundAssetId: toText(value.backgroundAssetId) || undefined,
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<CustomWorldOpeningCgProfile>(
value.openingCg,
);
const cover = preserveStructuredRecord<CustomWorldCoverProfile>(
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({

View File

@@ -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 (
<div>
<button
type="button"
onClick={() => handleCustomWorldSelect(oldProfile, { mode: 'play' })}
>
</button>
<button
type="button"
onClick={() =>
handleCustomWorldSelect(normalizedOpenedDraftProfile, {
mode: 'play',
})
}
>
稿
</button>
<button
type="button"
onClick={() => {
if (selectedCharacter) {
handleCharacterSelect(selectedCharacter);
}
}}
>
稿
</button>
<pre data-testid="state-snapshot">
{JSON.stringify({
profileId: gameState.customWorldProfile?.id ?? null,
profileName: gameState.customWorldProfile?.name ?? null,
})}
</pre>
</div>
);
}
runtimeStoryClientMocks.beginRuntimeStorySession.mockResolvedValue(
buildRuntimeStoryBootstrapSnapshot({
profile: normalizedOpenedDraftProfile,
character: buildCustomWorldPlayableCharacters(
normalizedOpenedDraftProfile,
)[0]!,
}),
);
render(<SwitchWorldHarness />);
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: '砂眠',
}),
}),
);
});
});