diff --git a/.hermes/shared-memory/pitfalls.md b/.hermes/shared-memory/pitfalls.md index 9c47745c..89753df6 100644 --- a/.hermes/shared-memory/pitfalls.md +++ b/.hermes/shared-memory/pitfalls.md @@ -30,6 +30,22 @@ - 验证:`cargo test -p module-custom-world publish_setting_text --manifest-path server-rs\Cargo.toml`;`cargo check -p spacetime-module --manifest-path server-rs\Cargo.toml`。 - 关联:`server-rs/crates/module-custom-world/src/application.rs`、`server-rs/crates/spacetime-module/src/custom_world.rs`、`docs/【玩法创作】平台入口与玩法链路-2026-05-15.md`。 +## RPG 已发布结果页进入世界不能重复 publish_world + +- 现象:RPG 草稿发布成功后,按钮文案已变为“进入世界”,但点击仍请求 `POST /api/runtime/custom-world/agent/sessions/{sessionId}/actions` 且 payload 为 `{"action":"publish_world"}`,后端返回 `publish_world is only available during object_refining, visual_refining, long_tail_review or ready_to_publish`。 +- 原因:按钮文案依据 agent session `stage === 'published'` 切换,但点击处理仍走发布协调路径;如果前端只依赖草稿同步回包判断是否已发布,回包为空或缺少可进入状态时就会继续重复发送 `publish_world`。 +- 处理:进入世界协调器接收当前 agent session stage;当 stage 已为 `published` 时,只调用 `result-view` 回读已发布 profile 并启动运行态,不再调用 `sync_result_profile` 或 `publish_world`。 +- 验证:`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 runtime,但用户只看到黑屏或空白;DevTools 里可能同时看到旧自动存档 `/api/runtime/save/snapshot` 被主动 cancel。 +- 原因:`/custom-world-library` / `/custom-world-gallery` 详情接口可能返回历史或摘要式 `profile`,缺少 `playableNpcs`、`storyNpcs`、`landmarks`、`attributeSchema` 等运行态字段;前端 client 若直接把该对象传给 runtime,角色选择首屏会在 `buildCustomWorldPlayableCharacters(profile)` 或后续属性解析处抛错。`save/snapshot (canceled)` 通常是切 runtime 或卸载时 `AbortController` 取消旧自动存档,不是黑屏根因。 +- 处理:RPG 入口作品库 client 在所有返回 `CustomWorldLibraryEntry` 的接口边界统一调用 `normalizeCustomWorldProfileRecord`,并用 `profileId/worldName/subtitle/summaryText` 补齐旧数据缺字段;角色选择页对角色生成异常或空数组回退默认角色,并保留返回按钮/轻量空态;顶层 runtime 懒加载 fallback 不使用纯 `null`。 +- 验证:`npm run test -- src/services/rpg-entry/rpgEntryLibraryClient.test.ts`、`npm run test -- src/components/rpg-entry/RpgEntryCharacterSelectView.test.tsx`、`npm run typecheck`。 +- 关联:`src/services/rpg-entry/rpgEntryLibraryClient.ts`、`src/components/rpg-entry/RpgEntryCharacterSelectView.tsx`、`src/App.tsx`、`src/components/rpg-runtime-shell/RpgRuntimeShell.tsx`、`docs/【玩法创作】平台入口与玩法链路-2026-05-15.md`。 + ## Windows provision 下载截断要断点续传而不是回退目标机下载 - 现象:`Genarrative-Server-Provision` 在 `Download Provision Tool Archives` 阶段出现 `curl: (18) end of response ... bytes missing`,常见于 `otelcol-contrib_0.151.0_linux_amd64.tar.gz` 等 GitHub release 大文件。 diff --git a/docs/【玩法创作】平台入口与玩法链路-2026-05-15.md b/docs/【玩法创作】平台入口与玩法链路-2026-05-15.md index 2584be6b..ffb0af2e 100644 --- a/docs/【玩法创作】平台入口与玩法链路-2026-05-15.md +++ b/docs/【玩法创作】平台入口与玩法链路-2026-05-15.md @@ -48,12 +48,16 @@ RPG 是历史既有链路例外:当前仍使用对话式 Agent 共创工作台 RPG API 仍沿用历史命名空间:`/api/runtime/custom-world*`、`/api/story/*`、`/api/runtime/chat/*`。这些路由在 `api-server` 入口熔断中统一映射到 `rpg`,只按 `open` 判断是否允许调用;`visible` 只控制创作页入口展示和作品架可见性。 -RPG Agent 结果页发布动作的前端契约只保证提交 `{ action: 'publish_world' }`;后端发布时以当前 `custom_world_agent_session.draft_profile_json` 为草稿真相,从 `settingText`、`creatorIntent.rawSettingText`、`creatorIntent.worldHook`、`worldHook`、`anchorContent.worldPromise(.hook)`、`summary`、`name/title` 依次派生正式 `setting_text`,最后才回退 `seed_text`。不要把 `seed_text` 当作唯一设定来源,旧会话可能为空。 +RPG Agent 结果页点击发布或发布并进入世界时,必须先把结果页当前 profile 通过 `sync_result_profile` 保存回 `custom_world_agent_session.draft_profile_json`,再发送发布动作;发布动作前端契约只允许提交 `{ action: 'publish_world' }`,`api-server` 只补作者公开信息,不转发 `profile`、`draftProfile`、`legacyResultProfile` 或 `settingText`。`spacetime-module` 发布时只读取当前 session 的 `draft_profile_json` 作为草稿真相,从 `settingText`、`creatorIntent.rawSettingText`、`creatorIntent.worldHook`、`worldHook`、`anchorContent.worldPromise(.hook)`、`summary`、`name/title` 依次派生正式 `setting_text`,最后才回退 `seed_text`。不要把 `seed_text` 当作唯一设定来源,旧会话可能为空。 -`legacyResultProfile` 只作为历史结果页 profile 兼容兜底;`publish_world` 请求缺省或显式为 `null` 时等价于未提供,编译正式 profile 时不得因此报 `custom_world.compile.legacy_result_profile_json 不是合法 JSON object`。真正的数组、字符串、数字等非 object legacy 载荷仍应拒绝。 +Agent session 已进入 `published` 后,结果页按钮只能执行“进入世界”:前端需先通过 `result-view` 回读已发布 profile 并启动运行态,不得再次调用 `sync_result_profile` 或发送 `{ action: 'publish_world' }`。`publish_world` 只允许在 `object_refining`、`visual_refining`、`long_tail_review`、`ready_to_publish` 等发布前阶段触发;否则会被后端阶段门槛拒绝。 + +`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 从作品架、广场详情或作品号搜索点击“启动”前,入口 client 必须把后端返回的完整 `profile` 先经过 `normalizeCustomWorldProfileRecord`,并用作品条目的 `profileId/worldName/subtitle/summaryText` 补齐旧数据缺失字段;运行态和详情页不得直接消费未归一化的旧 profile。角色选择页还需要在角色数组异常或为空时回退默认角色,并显示可返回的轻量空态,不能 `return null` 造成黑屏。运行态懒加载 fallback 必须可见,不能用纯 `null` 让用户误判为黑屏。 + ## 拼图 当前拼图链路: diff --git a/server-rs/crates/api-server/src/custom_world.rs b/server-rs/crates/api-server/src/custom_world.rs index d5fcb403..8695904b 100644 --- a/server-rs/crates/api-server/src/custom_world.rs +++ b/server-rs/crates/api-server/src/custom_world.rs @@ -1626,39 +1626,20 @@ pub async fn execute_custom_world_agent_action( ) })? } else if action == "publish_world" { - let mut publish_payload = serde_json::to_value(&payload).map_err(|error| { + let publish_payload = serialize_publish_world_action_payload( + resolve_author_public_user_code(&state, &authenticated, &request_context)?, + resolve_author_display_name(&state, &authenticated), + ) + .map_err(|error| { custom_world_error_response( &request_context, AppError::from_status(StatusCode::BAD_REQUEST).with_details(json!({ "provider": "custom-world-agent", - "message": format!("action payload JSON 序列化失败:{error}"), + "message": error, })), ) })?; - if let Some(object) = publish_payload.as_object_mut() { - // 发布到广场时必须写入真实作者公开信息,避免 gallery 投影落成匿名兜底数据。 - object.insert( - "authorPublicUserCode".to_string(), - Value::String(resolve_author_public_user_code( - &state, - &authenticated, - &request_context, - )?), - ); - object.insert( - "authorDisplayName".to_string(), - Value::String(resolve_author_display_name(&state, &authenticated)), - ); - } - serde_json::to_string(&publish_payload).map_err(|error| { - custom_world_error_response( - &request_context, - AppError::from_status(StatusCode::BAD_REQUEST).with_details(json!({ - "provider": "custom-world-agent", - "message": format!("action payload JSON 序列化失败:{error}"), - })), - ) - })? + publish_payload } else { serde_json::to_string(&payload).map_err(|error| { custom_world_error_response( @@ -1734,6 +1715,23 @@ fn serialize_sync_result_profile_action_payload( .map_err(|error| format!("action payload JSON 序列化失败:{error}")) } +fn serialize_publish_world_action_payload( + author_public_user_code: String, + author_display_name: String, +) -> Result { + // 中文注释:发布动作只提交动作名和作者公开信息。 + // 结果页当前 profile 必须先通过 sync_result_profile 写入 session; + // SpacetimeDB 发布时再从 session.draft_profile_json 读取草稿真相,避免前端 + // draftProfile / legacyResultProfile / profile 旧载荷覆盖刚保存的内容。 + let payload_value = json!({ + "action": "publish_world", + "authorPublicUserCode": author_public_user_code, + "authorDisplayName": author_display_name, + }); + serde_json::to_string(&payload_value) + .map_err(|error| format!("action payload JSON 序列化失败:{error}")) +} + fn canonicalize_custom_world_library_profile_payload( mut profile: Value, ) -> Result<(Value, CustomWorldProfileMetadata), String> { @@ -3414,6 +3412,36 @@ mod tests { ); } + #[test] + fn publish_world_payload_only_contains_action_and_author_identity() { + let payload_json = + serialize_publish_world_action_payload("TN-0001".to_string(), "潮汐作者".to_string()) + .expect("publish payload serializes"); + let payload_value: Value = + serde_json::from_str(&payload_json).expect("payload should be valid JSON"); + let object = payload_value + .as_object() + .expect("publish payload should be object"); + + assert_eq!(object.len(), 3); + assert_eq!( + object.get("action").and_then(Value::as_str), + Some("publish_world") + ); + assert_eq!( + object.get("authorPublicUserCode").and_then(Value::as_str), + Some("TN-0001") + ); + assert_eq!( + object.get("authorDisplayName").and_then(Value::as_str), + Some("潮汐作者") + ); + assert!(!object.contains_key("profile")); + assert!(!object.contains_key("draftProfile")); + assert!(!object.contains_key("legacyResultProfile")); + assert!(!object.contains_key("settingText")); + } + #[test] fn custom_world_library_profile_payload_is_canonicalized_on_server() { let (profile, metadata) = canonicalize_custom_world_library_profile_payload(json!({ diff --git a/server-rs/crates/module-custom-world/src/application.rs b/server-rs/crates/module-custom-world/src/application.rs index 57f8f41c..510a977c 100644 --- a/server-rs/crates/module-custom-world/src/application.rs +++ b/server-rs/crates/module-custom-world/src/application.rs @@ -544,7 +544,7 @@ pub fn build_custom_world_published_profile_compile_snapshot( let subtitle = resolve_text_field(&draft, &legacy, "subtitle").unwrap_or_default(); let summary_text = resolve_text_field(&draft, &legacy, "summary").unwrap_or_default(); let cover_image_src = resolve_cover_image_src(&draft, &legacy); - let theme_mode = resolve_theme_mode(&legacy); + let theme_mode = resolve_theme_mode(&draft, &legacy); let playable_npc_count = count_distinct_roles(draft.get("playableNpcs"), draft.get("storyNpcs")); let landmark_count = to_array(draft.get("landmarks")).len() as u32; @@ -912,11 +912,17 @@ fn resolve_text_field( legacy: &Map, key: &str, ) -> Option { + // 中文注释:发布链路的草稿真相来自 session.draft_profile_json, + // legacyResultProfile 只补历史草稿缺失字段,不能覆盖结果页刚保存的内容。 to_text(draft.get(key)).or_else(|| to_text(legacy.get(key))) } -fn resolve_theme_mode(legacy: &Map) -> CustomWorldThemeMode { - to_text(legacy.get("themeMode")) +fn resolve_theme_mode( + draft: &Map, + legacy: &Map, +) -> CustomWorldThemeMode { + to_text(draft.get("themeMode")) + .or_else(|| to_text(legacy.get("themeMode"))) .and_then(|value| CustomWorldThemeMode::from_client_str(&value)) .unwrap_or(CustomWorldThemeMode::Mythic) } @@ -1067,6 +1073,47 @@ mod tests { assert_eq!(error, CustomWorldFieldError::InvalidLegacyResultProfileJson); } + #[test] + fn published_profile_compile_prefers_saved_draft_over_legacy_profile() { + let input = CustomWorldPublishedProfileCompileInput { + draft_profile_json: json!({ + "name": "结果页保存后的世界", + "summary": "发布前最后一次填写的摘要。", + "themeMode": "tide", + "playableNpcs": [], + "storyNpcs": [], + "landmarks": [] + }) + .to_string(), + legacy_result_profile_json: Some( + json!({ + "name": "旧结果页世界", + "summary": "旧摘要不应覆盖保存草稿。", + "themeMode": "mythic" + }) + .to_string(), + ), + ..build_test_compile_input(None) + }; + + let snapshot = build_custom_world_published_profile_compile_snapshot(input) + .expect("compile should prefer saved draft"); + let payload: Value = serde_json::from_str(&snapshot.compiled_profile_payload_json) + .expect("compiled payload should be json"); + + assert_eq!(snapshot.world_name, "结果页保存后的世界"); + assert_eq!(snapshot.summary_text, "发布前最后一次填写的摘要。"); + assert_eq!(snapshot.theme_mode, CustomWorldThemeMode::Tide); + assert_eq!( + payload.get("name").and_then(Value::as_str), + Some("结果页保存后的世界") + ); + assert_eq!( + payload.get("summary").and_then(Value::as_str), + Some("发布前最后一次填写的摘要。") + ); + } + #[test] fn publish_setting_text_falls_back_to_draft_profile_when_seed_is_empty() { let payload = Map::new(); @@ -1079,8 +1126,7 @@ mod tests { .cloned() .expect("draft profile should be object"); - let setting_text = - resolve_custom_world_publish_setting_text(&payload, &draft_profile, ""); + let setting_text = resolve_custom_world_publish_setting_text(&payload, &draft_profile, ""); assert_eq!(setting_text, "海雾会吞掉记错航线的人。"); } diff --git a/server-rs/crates/spacetime-module/src/custom_world.rs b/server-rs/crates/spacetime-module/src/custom_world.rs index c7d8eeca..a3249fa3 100644 --- a/server-rs/crates/spacetime-module/src/custom_world.rs +++ b/server-rs/crates/spacetime-module/src/custom_world.rs @@ -2593,13 +2593,10 @@ fn execute_publish_world_action( ) -> Result { ensure_publishable_stage(session.stage, "publish_world")?; - let draft_profile = - if let Some(explicit) = payload.get("draftProfile").and_then(JsonValue::as_object) { - explicit.clone() - } else { - parse_optional_session_object(session.draft_profile_json.as_deref()) - .ok_or_else(|| "publish_world requires draft_profile_json".to_string())? - }; + // 中文注释:发布动作不再信任前端携带的 draftProfile。 + // 点击发布前,结果页 profile 必须先通过 sync_result_profile 写回 + // custom_world_agent_session.draft_profile_json;正式发布只读取这份会话真相。 + let draft_profile = read_publish_world_draft_profile_from_session(session)?; let gate = summarize_publish_gate_from_json( &session.session_id, session.stage, @@ -2613,18 +2610,9 @@ fn execute_publish_world_action( )); } - let profile_id = payload - .get("profileId") - .and_then(JsonValue::as_str) - .map(str::trim) - .filter(|value| !value.is_empty()) - .map(ToOwned::to_owned) - .unwrap_or_else(|| gate.profile_id.clone()); + let profile_id = gate.profile_id.clone(); let setting_text = resolve_publish_world_setting_text(payload, &draft_profile, session); - let legacy_result_profile_json = payload - .get("legacyResultProfile") - .map(serialize_json_value) - .transpose()?; + let legacy_result_profile_json = None; let author_public_user_code = read_optional_text_field(payload, &["authorPublicUserCode"]) .unwrap_or_else(|| build_public_user_code_from_owner_user_id(&session.owner_user_id)); let author_display_name = read_optional_text_field(payload, &["authorDisplayName"]) @@ -2669,6 +2657,13 @@ fn execute_publish_world_action( Ok(build_custom_world_agent_operation_snapshot(&operation)) } +fn read_publish_world_draft_profile_from_session( + session: &CustomWorldAgentSession, +) -> Result, String> { + parse_optional_session_object(session.draft_profile_json.as_deref()) + .ok_or_else(|| "publish_world requires draft_profile_json".to_string()) +} + fn execute_revert_checkpoint_action( ctx: &ReducerContext, session: &CustomWorldAgentSession, @@ -5256,6 +5251,26 @@ mod tests { ); } + #[test] + fn publish_world_draft_profile_comes_from_session_not_payload() { + let session = build_test_custom_world_agent_session( + "seed", + RpgAgentStage::ReadyToPublish, + Some(r#"{"id":"saved-profile","name":"已保存草稿"}"#), + ); + let draft_profile = + read_publish_world_draft_profile_from_session(&session).expect("session draft exists"); + + assert_eq!( + draft_profile.get("id").and_then(JsonValue::as_str), + Some("saved-profile") + ); + assert_eq!( + draft_profile.get("name").and_then(JsonValue::as_str), + Some("已保存草稿") + ); + } + #[test] fn custom_world_agent_session_direct_work_content_ignores_empty_created_session() { let empty_session = diff --git a/src/App.tsx b/src/App.tsx index 5ede559e..e16ff02f 100644 --- a/src/App.tsx +++ b/src/App.tsx @@ -32,6 +32,16 @@ const RpgRuntimeApp = lazy(async () => { }; }); +function RuntimeLoadingFallback() { + return ( +
+
+ 正在启动 +
+
+ ); +} + function isRpgRuntimeRoute(pathname: string) { const normalizedPath = normalizeAppPath(pathname); return ( @@ -126,7 +136,7 @@ export default function App() { if (isRuntimeActive) { return ( - + }> { diff --git a/src/components/platform-entry/PlatformEntryFlowShellImpl.tsx b/src/components/platform-entry/PlatformEntryFlowShellImpl.tsx index 834740ae..7b928788 100644 --- a/src/components/platform-entry/PlatformEntryFlowShellImpl.tsx +++ b/src/components/platform-entry/PlatformEntryFlowShellImpl.tsx @@ -3216,6 +3216,7 @@ export function PlatformEntryFlowShellImpl({ const enterWorldCoordinator = useRpgCreationEnterWorld({ isAgentDraftResultView: sessionController.isAgentDraftResultView, activeAgentSessionId: sessionController.activeAgentSessionId, + currentAgentSessionStage: sessionController.agentSession?.stage ?? null, generatedCustomWorldProfile: sessionController.generatedCustomWorldProfile, handleCustomWorldSelect, syncAgentDraftResultProfile: diff --git a/src/components/rpg-entry/RpgEntryCharacterSelectView.test.tsx b/src/components/rpg-entry/RpgEntryCharacterSelectView.test.tsx index d394f878..72ee4cea 100644 --- a/src/components/rpg-entry/RpgEntryCharacterSelectView.test.tsx +++ b/src/components/rpg-entry/RpgEntryCharacterSelectView.test.tsx @@ -15,7 +15,51 @@ import { import { RpgEntryCharacterSelectView } from './RpgEntryCharacterSelectView'; vi.mock('../../data/characterPresets', () => ({ - ROLE_TEMPLATE_CHARACTERS: [], + ROLE_TEMPLATE_CHARACTERS: [ + { + id: 'fallback-hero', + name: '兜底侠', + title: '默认角色', + description: '兜底角色', + backstory: '兜底背景', + personality: '冷静 果断', + gender: 'unknown', + portrait: '/portraits/fallback.png', + attributes: { + strength: 8, + agility: 8, + intelligence: 8, + spirit: 8, + }, + attributeProfile: { + schemaId: 'schema:custom:fallback', + values: { + axis_a: 8, + axis_b: 8, + axis_c: 8, + axis_d: 8, + axis_e: 8, + axis_f: 8, + }, + evidence: [], + }, + attributeProfiles: { + CUSTOM: { + schemaId: 'schema:custom:fallback', + values: { + axis_a: 8, + axis_b: 8, + axis_c: 8, + axis_d: 8, + axis_e: 8, + axis_f: 8, + }, + evidence: [], + }, + }, + skills: [], + }, + ], buildCustomWorldPlayableCharacters: vi.fn(), })); @@ -190,3 +234,46 @@ test('custom world character selection stays stable when character ids are empty expect(duplicateKeyCalls).toHaveLength(0); }); + +test('custom world character selection falls back instead of rendering a blank screen when profile characters are malformed', () => { + vi.spyOn(console, 'warn').mockImplementation(() => undefined); + vi.mocked(buildCustomWorldPlayableCharacters).mockImplementation(() => { + throw new TypeError('profile.playableNpcs is not iterable'); + }); + + render( + {}} + onConfirm={() => {}} + />, + ); + + expect(screen.getByText('选择你的角色')).toBeTruthy(); + expect(screen.getAllByText('兜底侠').length).toBeGreaterThan(0); + expect(screen.getByRole('button', { name: /进入营地/u })).toBeTruthy(); +}); diff --git a/src/components/rpg-entry/RpgEntryCharacterSelectView.tsx b/src/components/rpg-entry/RpgEntryCharacterSelectView.tsx index c7a45f6d..8f3be02f 100644 --- a/src/components/rpg-entry/RpgEntryCharacterSelectView.tsx +++ b/src/components/rpg-entry/RpgEntryCharacterSelectView.tsx @@ -112,6 +112,19 @@ function buildSelectionCharacterKey(character: Character, index: number) { return `selection-character-${index}-${fallbackSeed}`; } +function resolveSelectionCharacters(profile: CustomWorldProfile | null) { + try { + const characters = profile + ? buildCustomWorldPlayableCharacters(profile) + : ROLE_TEMPLATE_CHARACTERS; + + return characters.length > 0 ? characters : ROLE_TEMPLATE_CHARACTERS; + } catch (error) { + console.warn('自定义世界角色数据异常,已回退默认角色。', error); + return ROLE_TEMPLATE_CHARACTERS; + } +} + function applyCharacterSelectionDraft( character: Character | null, draft?: CharacterSelectionDraft | null, @@ -209,7 +222,7 @@ export function RpgEntryCharacterSelectView({ onConfirm, }: RpgEntryCharacterSelectViewProps) { const selectionCharacters = useMemo( - () => (customWorldProfile ? buildCustomWorldPlayableCharacters(customWorldProfile) : ROLE_TEMPLATE_CHARACTERS), + () => resolveSelectionCharacters(customWorldProfile), [customWorldProfile], ); const selectionEntries = useMemo( @@ -329,7 +342,18 @@ export function RpgEntryCharacterSelectView({ }; if (!selectedCharacter || !selectedCharacterMeta) { - return null; + return ( +
+ +
角色数据暂不可用
+
+ ); } return ( diff --git a/src/components/rpg-entry/useRpgCreationEnterWorld.test.tsx b/src/components/rpg-entry/useRpgCreationEnterWorld.test.tsx index e048fd65..8a8cb6ca 100644 --- a/src/components/rpg-entry/useRpgCreationEnterWorld.test.tsx +++ b/src/components/rpg-entry/useRpgCreationEnterWorld.test.tsx @@ -4,6 +4,8 @@ import { act, render } from '@testing-library/react'; import { describe, expect, it, vi } from 'vitest'; import type { CustomWorldAgentSessionSnapshot } from '../../../packages/shared/src/contracts/customWorldAgent'; +import type { RpgCreationResultView } from '../../../packages/shared/src/contracts/rpgCreationResultView'; +import type { CustomWorldProfileRecord } from '../../../packages/shared/src/contracts/runtime'; import { type CustomWorldProfile, WorldType } from '../../types'; import { useRpgCreationEnterWorld } from './useRpgCreationEnterWorld'; @@ -69,7 +71,9 @@ function buildProfile(params: { }; } -function buildSession(): CustomWorldAgentSessionSnapshot { +function buildSession( + stage: CustomWorldAgentSessionSnapshot['stage'] = 'ready_to_publish', +): CustomWorldAgentSessionSnapshot { return { sessionId: 'session-1', currentTurn: 1, @@ -85,7 +89,7 @@ function buildSession(): CustomWorldAgentSessionSnapshot { }, progressPercent: 100, lastAssistantReply: '', - stage: 'ready_to_publish', + stage, focusCardId: null, creatorIntent: null, creatorIntentReadiness: { @@ -113,6 +117,31 @@ function buildSession(): CustomWorldAgentSessionSnapshot { }; } +function buildResultView(params: { + stage?: CustomWorldAgentSessionSnapshot['stage']; + profile: CustomWorldProfile | null; + canEnterWorld?: boolean; +}): RpgCreationResultView { + const stage = params.stage ?? 'ready_to_publish'; + const profileRecord = params.profile + ? (structuredClone(params.profile) as unknown as CustomWorldProfileRecord) + : null; + return { + session: buildSession(stage), + profile: profileRecord, + profileSource: profileRecord ? 'result_preview' : 'none', + targetStage: 'custom-world-result', + generationViewSource: null, + resultViewSource: profileRecord ? 'agent-draft' : null, + canAutosaveLibrary: true, + canSyncResultProfile: stage !== 'published', + publishReady: true, + canEnterWorld: params.canEnterWorld ?? stage === 'published', + blockerCount: 0, + recoveryAction: 'open_result', + }; +} + describe('useRpgCreationEnterWorld', () => { it('Agent 草稿测试进入游戏时优先使用结果页当前 profile,而不是回退到会话快照', async () => { const resultProfile = buildProfile({ @@ -167,4 +196,148 @@ describe('useRpgCreationEnterWorld', () => { handleCustomWorldSelect.mock.calls[0]?.[0].playableNpcs[0]?.imageSrc, ).toBe('/generated-characters/draft-role/portrait.png'); }); + + it('Agent 草稿发布时先保存当前结果页 profile,再发送 publish_world 并回读结果页', async () => { + const resultProfile = buildProfile({ + id: 'draft-profile', + name: '发布前填写内容', + imageSrc: '/generated-characters/draft-role/portrait.png', + }); + const syncedProfile = buildProfile({ + id: 'draft-profile', + name: '已保存的填写内容', + imageSrc: '/generated-characters/draft-role/synced.png', + }); + const publishedProfile = buildProfile({ + id: 'draft-profile', + name: '已发布世界', + imageSrc: '/generated-characters/draft-role/published.png', + }); + const callOrder: string[] = []; + const handleCustomWorldSelect = vi.fn(); + const setGeneratedCustomWorldProfile = vi.fn(); + const syncAgentDraftResultProfile = vi.fn(async () => { + callOrder.push('save'); + return { + profile: syncedProfile, + view: buildResultView({ + stage: 'ready_to_publish', + profile: syncedProfile, + canEnterWorld: false, + }), + }; + }); + const executePublishWorld = vi.fn(async () => { + callOrder.push('publish'); + return buildSession('published'); + }); + const syncAgentCreationResultView = vi.fn(async () => { + callOrder.push('reload'); + return buildResultView({ + stage: 'published', + profile: publishedProfile, + canEnterWorld: true, + }); + }); + + function Harness() { + const { publishCurrentResult } = useRpgCreationEnterWorld({ + isAgentDraftResultView: true, + activeAgentSessionId: 'session-1', + currentAgentSessionStage: 'ready_to_publish', + generatedCustomWorldProfile: resultProfile, + handleCustomWorldSelect, + syncAgentDraftResultProfile, + executePublishWorld, + syncAgentCreationResultView, + setGeneratedCustomWorldProfile, + }); + + return ( + + ); + } + + const { getByText } = render(); + await act(async () => { + getByText('发布').click(); + }); + + expect(callOrder).toEqual(['save', 'publish', 'reload']); + expect(syncAgentDraftResultProfile).toHaveBeenCalledWith(resultProfile); + expect(executePublishWorld).toHaveBeenCalledTimes(1); + expect(syncAgentCreationResultView).toHaveBeenCalledWith('session-1'); + expect(setGeneratedCustomWorldProfile).toHaveBeenCalledWith(syncedProfile); + expect( + setGeneratedCustomWorldProfile.mock.calls.at(-1)?.[0]?.id, + ).toBe('draft-profile'); + expect( + setGeneratedCustomWorldProfile.mock.calls.at(-1)?.[0]?.playableNpcs[0] + ?.imageSrc, + ).toBe('/generated-characters/draft-role/published.png'); + expect(handleCustomWorldSelect).not.toHaveBeenCalled(); + }); + + it('Agent 会话已发布后点击进入世界不再重复发送 publish_world', async () => { + const resultProfile = buildProfile({ + id: 'published-profile', + name: '已发布世界', + imageSrc: '/generated-characters/published-role/portrait.png', + }); + const publishedView = buildResultView({ + stage: 'published', + profile: resultProfile, + canEnterWorld: true, + }); + const handleCustomWorldSelect = vi.fn(); + const setGeneratedCustomWorldProfile = vi.fn(); + const executePublishWorld = vi.fn(async () => buildSession('published')); + const syncAgentCreationResultView = vi.fn(async () => publishedView); + const syncAgentDraftResultProfile = vi.fn(async () => ({ + profile: resultProfile, + view: null, + })); + + function Harness() { + const { enterWorldFromCurrentResult } = useRpgCreationEnterWorld({ + isAgentDraftResultView: true, + activeAgentSessionId: 'session-1', + currentAgentSessionStage: 'published', + generatedCustomWorldProfile: resultProfile, + handleCustomWorldSelect, + syncAgentDraftResultProfile, + executePublishWorld, + syncAgentCreationResultView, + setGeneratedCustomWorldProfile, + }); + + return ( + + ); + } + + const { getByText } = render(); + await act(async () => { + getByText('进入世界').click(); + }); + + expect(syncAgentDraftResultProfile).not.toHaveBeenCalled(); + expect(executePublishWorld).not.toHaveBeenCalled(); + expect(syncAgentCreationResultView).toHaveBeenCalledWith('session-1'); + expect(setGeneratedCustomWorldProfile).toHaveBeenCalledTimes(1); + expect(setGeneratedCustomWorldProfile.mock.calls[0]?.[0]?.id).toBe( + 'published-profile', + ); + expect(handleCustomWorldSelect).toHaveBeenCalledTimes(1); + expect(handleCustomWorldSelect.mock.calls[0]?.[0]?.id).toBe( + 'published-profile', + ); + }); }); diff --git a/src/components/rpg-entry/useRpgCreationEnterWorld.ts b/src/components/rpg-entry/useRpgCreationEnterWorld.ts index a3c22707..89f6cee1 100644 --- a/src/components/rpg-entry/useRpgCreationEnterWorld.ts +++ b/src/components/rpg-entry/useRpgCreationEnterWorld.ts @@ -1,6 +1,7 @@ import { useCallback } from 'react'; import type { RpgCreationResultView } from '../../../packages/shared/src/contracts/rpgCreationResultView'; +import type { CustomWorldAgentSessionSnapshot } from '../../../packages/shared/src/contracts/customWorldAgent'; import type { CustomWorldRuntimeLaunchOptions } from '../platform-entry/platformEntryTypes'; import { rpgCreationPreviewAdapter } from '../../services/rpg-creation/rpgCreationPreviewAdapter'; import type { CustomWorldProfile } from '../../types'; @@ -8,6 +9,7 @@ import type { CustomWorldProfile } from '../../types'; type UseRpgCreationEnterWorldParams = { isAgentDraftResultView: boolean; activeAgentSessionId: string | null; + currentAgentSessionStage?: CustomWorldAgentSessionSnapshot['stage'] | null; generatedCustomWorldProfile: CustomWorldProfile | null; handleCustomWorldSelect: ( customWorldProfile: CustomWorldProfile, @@ -33,6 +35,7 @@ export function useRpgCreationEnterWorld( const { isAgentDraftResultView, activeAgentSessionId, + currentAgentSessionStage, generatedCustomWorldProfile, handleCustomWorldSelect, syncAgentDraftResultProfile, @@ -77,6 +80,17 @@ export function useRpgCreationEnterWorld( return generatedCustomWorldProfile; } + if (currentAgentSessionStage === 'published') { + const latestView = await syncAgentCreationResultView(activeAgentSessionId); + const publishedProfile = + rpgCreationPreviewAdapter.buildPreviewFromResultView(latestView) ?? + generatedCustomWorldProfile; + // 中文注释:已发布会话的“进入世界”只读取后端结果页真相, + // 不能再同步草稿或重复发送 publish_world,否则会被发布阶段门槛拒绝。 + setGeneratedCustomWorldProfile(publishedProfile); + return publishedProfile; + } + const syncedResult = await syncAgentDraftResultProfile( generatedCustomWorldProfile, ); @@ -112,6 +126,7 @@ export function useRpgCreationEnterWorld( return publishedProfile; }, [ activeAgentSessionId, + currentAgentSessionStage, executePublishWorld, generatedCustomWorldProfile, isAgentDraftResultView, diff --git a/src/components/rpg-runtime-shell/RpgRuntimeShell.tsx b/src/components/rpg-runtime-shell/RpgRuntimeShell.tsx index d4d5e592..300068a7 100644 --- a/src/components/rpg-runtime-shell/RpgRuntimeShell.tsx +++ b/src/components/rpg-runtime-shell/RpgRuntimeShell.tsx @@ -25,6 +25,16 @@ const RpgRuntimeOverlayHost = lazy(async () => { }; }); +function RuntimeLayerLoadingFallback({ label }: { label: string }) { + return ( +
+
+ {label} +
+
+ ); +} + /** * RPG 运行态总外壳。 * 这里承接运行时主布局、画布舞台、主阶段路由和 overlay host, @@ -167,7 +177,7 @@ export function RpgRuntimeShell({ }} > {gameState.worldType ? ( - + }> {gameState.worldType ? ( - + }> { ); }); + it('normalizes detail profiles before runtime launch consumes them', async () => { + requestJsonMock.mockResolvedValueOnce({ + entry: { + ownerUserId: 'owner-1', + profileId: 'profile-1', + publicWorkCode: 'CW-1', + authorPublicUserCode: 'U-1', + profile: { + id: 'profile-1', + name: '旧数据世界', + summary: '只有摘要字段的旧 profile。', + }, + visibility: 'published', + publishedAt: '2026-05-21T00:00:00.000Z', + updatedAt: '2026-05-21T00:00:00.000Z', + authorDisplayName: '作者', + worldName: '旧数据世界', + subtitle: '旧数据', + summaryText: '只有摘要字段的旧 profile。', + coverImageSrc: null, + themeMode: 'martial', + playableNpcCount: 0, + landmarkCount: 0, + }, + }); + + const entry = await getRpgEntryWorldGalleryDetail('owner-1', 'profile-1'); + + expect(Array.isArray(entry.profile.playableNpcs)).toBe(true); + expect(Array.isArray(entry.profile.storyNpcs)).toBe(true); + expect(Array.isArray(entry.profile.landmarks)).toBe(true); + expect(entry.profile.attributeSchema.schemaVersion).toBe(1); + }); + + it('falls back to entry summary when old detail profile cannot be normalized', async () => { + requestJsonMock.mockResolvedValueOnce({ + entry: { + ownerUserId: 'owner-1', + profileId: 'profile-1', + publicWorkCode: 'CW-1', + authorPublicUserCode: 'U-1', + profile: { + id: 'profile-1', + summary: '缺少 name 的旧 profile。', + }, + visibility: 'published', + publishedAt: '2026-05-21T00:00:00.000Z', + updatedAt: '2026-05-21T00:00:00.000Z', + authorDisplayName: '作者', + worldName: '摘要兜底世界', + subtitle: '旧数据', + summaryText: '缺少 name 的旧 profile。', + coverImageSrc: null, + themeMode: 'martial', + playableNpcCount: 0, + landmarkCount: 0, + }, + }); + + const entry = await getRpgEntryWorldGalleryDetail('owner-1', 'profile-1'); + + expect(entry.profile.id).toBe('profile-1'); + expect(entry.profile.name).toBe('摘要兜底世界'); + expect(Array.isArray(entry.profile.playableNpcs)).toBe(true); + }); + it('reads owned library detail from the runtime entry route', async () => { requestJsonMock.mockResolvedValueOnce({ entry: { diff --git a/src/services/rpg-entry/rpgEntryLibraryClient.ts b/src/services/rpg-entry/rpgEntryLibraryClient.ts index 7a58a116..1e6bc052 100644 --- a/src/services/rpg-entry/rpgEntryLibraryClient.ts +++ b/src/services/rpg-entry/rpgEntryLibraryClient.ts @@ -7,13 +7,62 @@ import { import type { CustomWorldGalleryDetailResponse, CustomWorldGalleryResponse, + CustomWorldLibraryEntry, CustomWorldLibraryMutationResponse, CustomWorldLibraryResponse, } from '../../../packages/shared/src/contracts/runtime'; +import { normalizeCustomWorldProfileRecord } from '../../data/customWorldLibrary'; import type { CustomWorldProfile } from '../../types'; export type { RuntimeRequestOptions }; +type RpgEntryWorldEntry = CustomWorldLibraryEntry; +type RpgEntryWorldMutationResponse = + CustomWorldLibraryMutationResponse; + +function normalizeRpgEntryWorldProfile(entry: RpgEntryWorldEntry) { + const rawProfile = + entry.profile && typeof entry.profile === 'object' ? entry.profile : {}; + const fallbackProfile = { + id: entry.profileId, + name: entry.worldName, + subtitle: entry.subtitle, + summary: entry.summaryText, + settingText: entry.summaryText || entry.worldName, + playableNpcs: [], + storyNpcs: [], + items: [], + landmarks: [], + }; + const normalizedProfile = + normalizeCustomWorldProfileRecord({ + ...fallbackProfile, + ...rawProfile, + }) ?? normalizeCustomWorldProfileRecord(fallbackProfile); + + return { + ...entry, + profile: normalizedProfile ?? entry.profile, + } as RpgEntryWorldEntry; +} + +function normalizeRpgEntryWorldEntries( + entries: RpgEntryWorldEntry[] | null | undefined, +) { + return Array.isArray(entries) + ? entries.map((entry) => normalizeRpgEntryWorldProfile(entry)) + : []; +} + +function normalizeRpgEntryWorldMutationResponse( + response: RpgEntryWorldMutationResponse, +) { + return { + entry: normalizeRpgEntryWorldProfile(response.entry), + entries: normalizeRpgEntryWorldEntries(response.entries), + }; +} + /** * RPG 入口世界库 client 的真实实现。 * 第三批收口后,平台首页/详情页开始游戏链直接走 rpg-entry 域请求,不再反向穿旧 storageService 兼容层。 @@ -33,7 +82,7 @@ export async function listRpgEntryWorldLibrary( }, ); - return Array.isArray(response?.entries) ? response.entries : []; + return normalizeRpgEntryWorldEntries(response?.entries); } export async function listRpgEntryWorldGallery( @@ -63,7 +112,7 @@ export async function getRpgEntryWorldGalleryDetail( options, ); - return response.entry; + return normalizeRpgEntryWorldProfile(response.entry); } export async function getRpgEntryWorldGalleryDetailByCode( @@ -79,7 +128,7 @@ export async function getRpgEntryWorldGalleryDetailByCode( options, ); - return response.entry; + return normalizeRpgEntryWorldProfile(response.entry); } export async function remixRpgEntryWorldGallery( @@ -96,10 +145,7 @@ export async function remixRpgEntryWorldGallery( options, ); - return { - entry: response.entry, - entries: Array.isArray(response?.entries) ? response.entries : [], - }; + return normalizeRpgEntryWorldMutationResponse(response); } export async function recordRpgEntryWorldGalleryPlay( @@ -116,7 +162,7 @@ export async function recordRpgEntryWorldGalleryPlay( options, ); - return response.entry; + return normalizeRpgEntryWorldProfile(response.entry); } export async function likeRpgEntryWorldGallery( @@ -133,7 +179,7 @@ export async function likeRpgEntryWorldGallery( options, ); - return response.entry; + return normalizeRpgEntryWorldProfile(response.entry); } export async function getRpgEntryWorldLibraryDetail( @@ -149,7 +195,7 @@ export async function getRpgEntryWorldLibraryDetail( options, ); - return response.entry; + return normalizeRpgEntryWorldProfile(response.entry); } export async function upsertRpgEntryWorldProfile( @@ -171,10 +217,7 @@ export async function upsertRpgEntryWorldProfile( options, ); - return { - entry: response.entry, - entries: Array.isArray(response?.entries) ? response.entries : [], - }; + return normalizeRpgEntryWorldMutationResponse(response); } export async function deleteRpgEntryWorldProfile( @@ -190,7 +233,7 @@ export async function deleteRpgEntryWorldProfile( options, ); - return Array.isArray(response?.entries) ? response.entries : []; + return normalizeRpgEntryWorldEntries(response?.entries); } export async function publishRpgEntryWorldProfile( @@ -206,10 +249,7 @@ export async function publishRpgEntryWorldProfile( options, ); - return { - entry: response.entry, - entries: Array.isArray(response?.entries) ? response.entries : [], - }; + return normalizeRpgEntryWorldMutationResponse(response); } export async function unpublishRpgEntryWorldProfile( @@ -225,10 +265,7 @@ export async function unpublishRpgEntryWorldProfile( options, ); - return { - entry: response.entry, - entries: Array.isArray(response?.entries) ? response.entries : [], - }; + return normalizeRpgEntryWorldMutationResponse(response); } export const rpgEntryLibraryClient = {