From a9d23a8a4483292d36686d4a3add7128020dfa1e Mon Sep 17 00:00:00 2001 From: kdletters <61648117+kdletters@users.noreply.github.com> Date: Thu, 21 May 2026 20:20:06 +0800 Subject: [PATCH] fix: stabilize rpg publish and launch --- .hermes/shared-memory/pitfalls.md | 16 ++ ...玩法创作】平å°å…¥å£ä¸ŽçŽ©æ³•é“¾è·¯-2026-05-15.md | 8 +- .../crates/api-server/src/custom_world.rs | 80 +++++--- .../module-custom-world/src/application.rs | 56 +++++- .../spacetime-module/src/custom_world.rs | 51 +++-- src/App.tsx | 12 +- .../PlatformEntryFlowShellImpl.tsx | 1 + .../RpgEntryCharacterSelectView.test.tsx | 89 ++++++++- .../rpg-entry/RpgEntryCharacterSelectView.tsx | 28 ++- .../useRpgCreationEnterWorld.test.tsx | 177 +++++++++++++++++- .../rpg-entry/useRpgCreationEnterWorld.ts | 15 ++ .../rpg-runtime-shell/RpgRuntimeShell.tsx | 14 +- .../rpg-entry/rpgEntryLibraryClient.test.ts | 66 +++++++ .../rpg-entry/rpgEntryLibraryClient.ts | 83 +++++--- 14 files changed, 614 insertions(+), 82 deletions(-) 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 = {