From 41075e41a210cb5e2c43ba78cc9c757c2674fb75 Mon Sep 17 00:00:00 2001 From: kdletters <61648117+kdletters@users.noreply.github.com> Date: Thu, 21 May 2026 17:21:38 +0800 Subject: [PATCH 1/4] fix: stabilize rpg creation entry and opening cg --- .gitignore | 1 + .hermes/shared-memory/decision-log.md | 16 ++ .hermes/shared-memory/pitfalls.md | 24 ++ ...„】server-rs与SpacetimeDBæ•°æ®å¥‘约-2026-05-15.md | 5 + ...å‘è¿ç»´ã€‘本地开å‘验è¯ä¸Žç”Ÿäº§è¿ç»´-2026-05-15.md | 4 + ...玩法创作】平å°å…¥å£ä¸ŽçŽ©æ³•é“¾è·¯-2026-05-15.md | 18 ++ server-rs/crates/api-server/src/app.rs | 25 ++ server-rs/crates/api-server/src/config.rs | 23 +- .../api-server/src/creation_agent_llm_turn.rs | 119 +++++++++- .../api-server/src/creation_entry_config.rs | 28 +++ .../crates/api-server/src/custom_world_ai.rs | 90 +++++++- server-rs/crates/api-server/src/match3d.rs | 5 +- .../api-server/src/match3d/item_assets.rs | 4 +- .../crates/api-server/src/match3d/tests.rs | 4 +- .../api-server/src/openai_image_generation.rs | 214 ++++++++++++++++-- .../module-custom-world/src/application.rs | 110 +++++++++ .../crates/module-runtime/src/application.rs | 4 +- server-rs/crates/module-runtime/src/lib.rs | 17 ++ .../spacetime-module/src/custom_world.rs | 20 +- .../src/runtime/creation_entry_config.rs | 31 +++ src/components/CustomWorldResultView.test.tsx | 69 ++++++ ...ustomWorldCreationHub.interaction.test.tsx | 17 +- .../CustomWorldCreationHub.test.tsx | 14 +- ...gEntryFlowShell.agent.interaction.test.tsx | 15 +- src/data/customWorldLibrary.test.ts | 31 +++ src/data/customWorldLibrary.ts | 5 + 26 files changed, 866 insertions(+), 47 deletions(-) diff --git a/.gitignore b/.gitignore index 610ac88f3..c90efe5c6 100644 --- a/.gitignore +++ b/.gitignore @@ -36,6 +36,7 @@ temp*build*/ /.codex-temp /target/ /logs +/server-rs/crates/*/logs/ .worktrees/ .env.secrets.local spacetime.local.json diff --git a/.hermes/shared-memory/decision-log.md b/.hermes/shared-memory/decision-log.md index 783fabbdf..8aef4e705 100644 --- a/.hermes/shared-memory/decision-log.md +++ b/.hermes/shared-memory/decision-log.md @@ -113,6 +113,14 @@ - éªŒè¯æ–¹å¼ï¼šæ–°å¢žçŽ©æ³• PRD 必须显å¼å£°æ˜Žå•图资产槽ä½å’Œç³»åˆ—ç´ ææ§½ä½ï¼›æ–°å¢žå·¥ä½œå°æµ‹è¯•确认没有默认èŠå¤©å¼ Agent 输入;skill 通过 `quick_validate.py`。 - å…³è”æ–‡æ¡£ï¼š`docs/ã€çŽ©æ³•åˆ›ä½œã€‘å¹³å°å…¥å£ä¸ŽçŽ©æ³•é“¾è·¯-2026-05-15.md`ã€`.codex/skills/genarrative-play-type-integration/SKILL.md`ã€`.hermes/skills/genarrative-play-type-integration/SKILL.md`。 +## 2026-05-21 RPG publish_world 设定文本以åŽç«¯è‰ç¨¿çœŸç›¸æ´¾ç”Ÿ + +- 背景:RPG 结果页å‘布动作åªä¿è¯æäº¤ `{ action: 'publish_world' }`;旧 agent 会è¯å¯èƒ½æ²¡æœ‰ `seed_text`,但 `draft_profile_json` å·²ç»é€šè¿‡ `publish_gate` å¹¶å¯å‘布。 +- 决策:å‘布正å¼ä¸–界时,`spacetime-module` ä¸å†æŠŠ `session.seed_text` 当作唯一 `setting_text` 兜底,而是调用 `module-custom-world::resolve_custom_world_publish_setting_text(...)` 从 payloadã€å½“å‰è‰ç¨¿ profile å’Œ seed 便¬¡æ´¾ç”Ÿã€‚ +- å½±å“范围:RPG / custom-world agent å‘布链路ã€`custom_world_profile` 编译入库ã€å…¬å¼€ gallery 投影。 +- éªŒè¯æ–¹å¼ï¼š`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`;本地 api-server é‡å¯åŽæ£€æŸ¥ `/healthz`。 +- å…³è”æ–‡æ¡£ï¼š`docs/ã€çŽ©æ³•åˆ›ä½œã€‘å¹³å°å…¥å£ä¸ŽçŽ©æ³•é“¾è·¯-2026-05-15.md`ã€`docs/ã€åŽç«¯æž¶æž„】server-rs与SpacetimeDBæ•°æ®å¥‘约-2026-05-15.md`ã€`.hermes/shared-memory/pitfalls.md`。 + ## 2026-05-19 系列素æ n*n 图集抽为 api-server é€šç”¨æ¨¡å— - èƒŒæ™¯ï¼šæŠ“å¤§é¹…ç‰©å“ sheet å·²åŒ…å« prompt 组装ã€å›ºå®šç½‘格切图ã€ç»¿å¹• / è¿‘ç™½åº•é€æ˜ŽåŒ–ã€åˆ‡ç‰‡ PNG æŒä¹…化和 prompt 追踪;继续留在 Match3D ç§æœ‰æ¨¡å—会让跳一跳ã€åŽç»­åœ°å— / é“具类玩法é‡å¤å¤åˆ¶åŒä¸€å¥—算法和 OSS 元数æ®å£å¾„。 @@ -349,6 +357,14 @@ - éªŒè¯æ–¹å¼ï¼šæ‰§è¡Œå…¥å£é…ç½®ã€åˆ›ä½œ Hubã€å¹³å°å…¥å£äº¤äº’å’Œ api-server è·¯ç”±ç†”æ–­å®šå‘æµ‹è¯•,确认“视觉å°è¯´â€ä¸å‡ºçŽ°åœ¨åˆ›ä½œé¡µä¸” `/api/creation/visual-novel/*` 默认被熔断。 - å…³è”æ–‡æ¡£ï¼š`docs/design/PLATFORM_CREATE_TAB_CREATIVE_AGENT_HOME_2026-05-05.md`ã€`docs/technical/ADMIN_CREATION_ENTRY_SWITCH_CONFIG_2026-05-11.md`。 +## 2026-05-20 RPG 创作入å£å¼€æ”¾ + +- 背景:RPG 文字冒险能力已ç»å…·å¤‡åŽ†å² custom-world 创作和è¿è¡Œé—­çŽ¯ï¼Œä½†å…¥å£é»˜è®¤ç§å­ä» `visible=false`,创作页ä¸å±•示。 +- 决策:SpacetimeDB `creation_entry_type_config` 默认ç§å­ä¸­ `rpg.visible=true` 且 `open=true`,旧默认éšè—é…ç½®åªåœ¨æ ‡é¢˜ã€subtitleã€badgeã€å›¾ç‰‡ã€æŽ’åºå’Œå¼€å…³å®Œå…¨åŒ¹é…æ—¶è¿ç§»ä¸ºå¯è§å¯åˆ›å»ºã€‚`airp` ä»ä¿æŒ AI RPG å ä½ï¼Œä¸æŽ¥ç®¡å½“å‰ RPG 链路。结构化创作 / RPG JSON 链路默认关闭 Responses `web_search`,需è¦è”网增强时æ‰é€šè¿‡ `GENARRATIVE_RPG_LLM_WEB_SEARCH_ENABLED=true` 或 `GENARRATIVE_CREATION_AGENT_LLM_WEB_SEARCH_ENABLED=true` 显å¼å¯ç”¨ï¼›æœªå¼€é€šå·¥å…·çš„上游会返回 `ToolNotOpen`,ä¸èƒ½æŠŠè¿™ç±»å¤±è´¥æš´éœ²æˆâ€œæ¨¡åž‹è¿”回结果解æžå¤±è´¥â€ã€‚ +- å½±å“范围:创作入å£é»˜è®¤ç§å­ã€æ—§åº“å…¥å£çº åã€`api-server` å…¥å£ç†”æ–­ã€åˆ›ä½œé¡µæ¨¡æ¿ Tabã€åˆ›ä½œ Hub 测试ã€çŽ©æ³•é“¾è·¯æ–‡æ¡£å’ŒåŽç«¯è·¯ç”±æ–‡æ¡£ã€‚ +- éªŒè¯æ–¹å¼ï¼šæ‰§è¡Œå…¥å£é…ç½®ã€api-server 路由熔断ã€åˆ›ä½œ Hub 和平å°å…¥å£äº¤äº’å®šå‘æµ‹è¯•,确认“文字冒险â€å‡ºçŽ°åœ¨åˆ›ä½œå…¥å£ï¼Œ`/api/runtime/custom-world*`ã€`/api/story/*`ã€`/api/runtime/chat/*` 都按 `rpg` å…¥å£å¼€å…³ç†”断。 +- å…³è”æ–‡æ¡£ï¼š`docs/ã€çŽ©æ³•åˆ›ä½œã€‘å¹³å°å…¥å£ä¸ŽçŽ©æ³•é“¾è·¯-2026-05-15.md`ã€`docs/ã€åŽç«¯æž¶æž„】server-rs与SpacetimeDBæ•°æ®å¥‘约-2026-05-15.md`。 + ## 2026-05-10 è¿è¡Œæ€è¾“入设备抽象层全项目通用化 - 背景:拼图è¿è¡Œæ€æŽ¥å…¥ mocap åŽï¼Œé¼ æ ‡/触控和 mocap å„自维护输入逻辑会导致åˆå¹¶å¤§å—ã€æ‹–æ‹½è¯­ä¹‰å’Œå–æ¶ˆä¼šè¯è¡Œä¸ºä¸ä¸€è‡´ï¼›åŽç»­å…¶ä»–玩法也需è¦å¤ç”¨ä½“æ„Ÿã€æ‘‡æ†ã€é”®ç›˜ç­‰è®¾å¤‡è¾“入。 diff --git a/.hermes/shared-memory/pitfalls.md b/.hermes/shared-memory/pitfalls.md index 0399e186d..88b413fb1 100644 --- a/.hermes/shared-memory/pitfalls.md +++ b/.hermes/shared-memory/pitfalls.md @@ -22,6 +22,14 @@ - 验è¯ï¼šæ‹¼å›¾å…¥å£æµ‹è¯•ä»å¯é€šè¿‡ï¼Œä¸”新组件å¯é€šè¿‡ä¸åŒé¡µé¢å¤ç”¨è€Œä¸éœ€è¦å¤åˆ¶ä¸Šä¼ å¡å®žçŽ°ã€‚ - å…³è”:`src/components/common/CreativeImageInputPanel.tsx`ã€`src/components/puzzle-agent/PuzzleAgentWorkspace.tsx`。 +## RPG å‘布ä¸èƒ½åªä¾èµ– agent session seed_text + +- 现象:RPG 结果页 `publish_world` 返回 `UPSTREAM_ERROR`,details 为 `custom_world.setting_text ä¸èƒ½ä¸ºç©º`ï¼›åŒä¸€ session çš„ `result-view` 日志显示 `publish_ready=true`。 +- 原因:å‰ç«¯å‘å¸ƒåŠ¨ä½œåªæäº¤ `{ action: 'publish_world' }`,旧 agent 会è¯çš„ `seed_text` å¯èƒ½ä¸ºç©ºï¼›å¦‚æžœåŽç«¯åªä»Ž action payload 或 `seed_text` å– `setting_text`,就会在最终 compile / publish 校验阶段失败。 +- 处ç†ï¼š`module-custom-world::resolve_custom_world_publish_setting_text(...)` ä»¥å½“å‰ `draft_profile_json` 为è‰ç¨¿çœŸç›¸ï¼Œä¼˜å…ˆè¯»å– `settingText`ã€`creatorIntent.rawSettingText`ã€`creatorIntent.worldHook`ã€`worldHook`ã€`anchorContent.worldPromise(.hook)`ã€`summary`ã€`name/title`ï¼Œæœ€åŽæ‰å›žé€€ `seed_text`。 +- 验è¯ï¼š`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`。 + ## 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 大文件。 @@ -374,6 +382,22 @@ - 验è¯ï¼šè¿è¡Œ `npm run test -- src/services/creation-agent/creationAgentClientFactory.test.ts src/services/apiClient.test.ts`ã€`cargo test -p api-server puzzle_vector_engine --manifest-path server-rs/Cargo.toml`,真实è”è°ƒé‡å¯ `npm run dev:api-server` åŽæ£€æŸ¥ `/healthz`。 - å…³è”:`src/services/creation-agent/creationAgentClientFactory.ts`ã€`server-rs/crates/api-server/src/puzzle.rs`ã€`docs/technical/API_SERVER_EXTERNAL_SERVICE_ENV_CONFIG_2026-05-07.md`。 +## 开局 CG 故事æ¿ç”Ÿå›¾å¤±è´¥å…ˆæŸ¥ VectorEngine 请求预算和旧进程 + +- 现象:RPG 结果页点击开局 CG åŽï¼Œ`POST /api/runtime/custom-world/opening-cg` 在较长等待åŽè¿”回“开局 CG 故事æ¿ç”Ÿæˆå¤±è´¥ï¼šåˆ›å»ºå›¾ç‰‡ç”Ÿæˆä»»åŠ¡å¤±è´¥ï¼šerror sending request for url (https://api.vectorengine.ai/v1/images/generations)â€ã€‚ +- 原因:该故事æ¿ä¼šæŠŠè§’色图和首幕背景图作为å‚考图一起传给 VectorEngine `gpt-image-2-all`,请求体和上游生æˆè€—时都比普通å•图更大;若è¿è¡Œä¸­çš„ `api-server` 仿²¿ç”¨æ—§ `VECTOR_ENGINE_IMAGE_REQUEST_TIMEOUT_MS`,或者å‚考图过大,会在请求å‘é€/等待阶段被 reqwest 截断。日志里 `timeout=false connect=false request=true body=false source=client error (SendRequest)` 表示还没拿到上游 HTTP å“应,通常优先怀疑大 JSON 请求体ã€ä¸Šæ¸¸ç½‘关中断或 HTTP åè®®å…¼å®¹ï¼Œè€Œä¸æ˜¯ä¸šåŠ¡å“应解æžå¤±è´¥ã€‚直接请求 VectorEngine 若无效 token å¯å¿«é€Ÿè¿”回 401,ä¸èƒ½æ®æ­¤åˆ¤æ–­çœŸå®žç”Ÿå›¾ä¸ä¼šè¶…时。 +- 处ç†ï¼šå¼€å±€ CG å‚考图入å‚先压到å•è¾¹ 768 çš„ JPEGï¼›`/v1/images/generations` ä¿æŒ reqwest 默认 HTTP åå•†ï¼Œåªæœ‰ multipart `/v1/images/edits` å•独强制 HTTP/1.1。åŽç«¯å›¾ç‰‡ helper å°† `request_body_bytes`ã€æ¯å¼ å‚考图 Data URL 长度ã€`timeout/connect/body/source/rootSource/sourceChain/endpoint` 分类写入日志和 `error.details`,å‰ç«¯ä¼˜å…ˆå±•示 `details.reason`。修改 `.env.secrets.local` åŽå¿…é¡»é‡å¯ `api-server`,`npm run dev` 终端用 `rs api-server`,å¦åˆ™æ—§è¿›ç¨‹ä»æŒ‰æ—§è¶…æ—¶è¿è¡Œã€‚ +- 验è¯ï¼šåˆ†åˆ«è¿è¡Œ `cargo test -p api-server custom_world_ai --manifest-path server-rs/Cargo.toml` å’Œ `cargo test -p api-server openai_image_generation --manifest-path server-rs/Cargo.toml`;真实è”è°ƒé‡å¯åŽå†è§¦å‘开局 CG,若ä»å¤±è´¥çœ‹è¿”回的 `details.reason/source/rootSource/sourceChain/timeout/connect/body/endpoint` å’Œ `logs/api-server/` åŒä¸€ request_id。 +- å…³è”:`server-rs/crates/api-server/src/custom_world_ai.rs`ã€`server-rs/crates/api-server/src/custom_world_ai/opening_cg.rs`ã€`server-rs/crates/api-server/src/openai_image_generation.rs`ã€`docs/ã€å¼€å‘è¿ç»´ã€‘本地开å‘验è¯ä¸Žç”Ÿäº§è¿ç»´-2026-05-15.md`。 + +## 开局 CG æˆåŠŸåŽåˆå˜ç©ºç™½è¦ä¿ç•™ profile.openingCg + +- 现象:RPG 结果页里的开局 CG æˆåŠŸæ˜¾ç¤ºä¸€çž¬åŽï¼Œçª—å£åˆé€€å›žç©ºç™½å ä½ã€‚ +- 原因:`openingCg` åªå­˜åœ¨äºŽç»“果页 profile æ§½ä½ï¼Œå¦‚果父层在 `onProfileChange` åŽé‡æ–°åŒæ­¥äº† profile,å´ç»è¿‡ `normalizeCustomWorldProfileRecord` 或作å“库写回时丢掉 `openingCg`,预览就会从视频 / 故事æ¿å›žé€€ä¸ºç©ºç™½ã€‚ +- 处ç†ï¼š`src/data/customWorldLibrary.ts` çš„ profile 归一化必须é€ä¼  `openingCg`;结果页和父层åŽç»­åŒæ­¥éƒ½åº”æŠŠå®ƒå½“ä½œå—æŽ§èµ„äº§æ§½ä½ï¼Œè€Œä¸æ˜¯ä¸´æ—¶ UI 状æ€ã€‚ +- 验è¯ï¼š`npm run test -- src/data/customWorldLibrary.test.ts src/components/CustomWorldResultView.test.tsx`,确认生æˆåŽå³ä½¿çˆ¶å±‚åšä¸€æ¬¡å½’一化回写,开局 CG ä»ç»§ç»­æ˜¾ç¤ºã€‚ +- å…³è”:`src/data/customWorldLibrary.ts`ã€`src/components/rpg-creation-result/RpgCreationResultViewImpl.tsx`ã€`src/components/CustomWorldEntityCatalog.tsx`。 + ## 本地脚本调 VectorEngine 生图å¡ä½å…ˆåŒºåˆ† fetch 首部超时 - 现象:用 Node `fetch` 直接请求 `POST /v1/images/generations`,已ç»è®¾ç½®è¾ƒé•¿çš„ AbortController 超时,但ä»åœ¨çº¦ 180 到 300 ç§’åŽæŠ› `AbortError`ã€`TypeError: fetch failed` 或 `UND_ERR_HEADERS_TIMEOUT`ï¼›åŒä¸€ prompt 改用原生 `https.request` å¯ä»¥åœ¨è¾ƒçŸ­æ—¶é—´å†…æˆåŠŸè¿”å›žå›¾ç‰‡ã€‚ diff --git a/docs/ã€åŽç«¯æž¶æž„】server-rs与SpacetimeDBæ•°æ®å¥‘约-2026-05-15.md b/docs/ã€åŽç«¯æž¶æž„】server-rs与SpacetimeDBæ•°æ®å¥‘约-2026-05-15.md index 125fd0d0e..0986e5dba 100644 --- a/docs/ã€åŽç«¯æž¶æž„】server-rs与SpacetimeDBæ•°æ®å¥‘约-2026-05-15.md +++ b/docs/ã€åŽç«¯æž¶æž„】server-rs与SpacetimeDBæ•°æ®å¥‘约-2026-05-15.md @@ -325,6 +325,7 @@ npm run check:server-rs-ddd - Rust 结构体:`CustomWorldAgentSession` - æºç ï¼š`server-rs/crates/spacetime-module/src/custom_world.rs` +- å‘布约æŸï¼š`publish_world` çš„ action payload ä¸è¦æ±‚æºå¸¦ `settingText`ï¼›`spacetime-module` 调用 `module-custom-world::resolve_custom_world_publish_setting_text(...)`ï¼Œä¼˜å…ˆä»Žå½“å‰ `draft_profile_json` è‰ç¨¿çœŸç›¸æ´¾ç”Ÿæ­£å¼ `setting_text`,é¿å…æ—§ä¼šè¯ `seed_text` 为空时在最终 compile / publish é˜¶æ®µè§¦å‘ `custom_world.setting_text ä¸èƒ½ä¸ºç©º`。 ### `custom_world_draft_card` @@ -597,6 +598,10 @@ npm run check:server-rs-ddd `GET /api/creation-entry/config` 和入å£ç†”断优先从订阅 cache 读å–创作入å£é…置;cache 缺失时使用最近一次æˆåŠŸè¯»å–的内存快照,å†å…œåº•调用 `get_creation_entry_config` procedure 完æˆç©ºåº“ç§å­æˆ–旧库兼容。 +RPG 创作入å£çš„é…ç½® ID 是 `rpg`ï¼Œå½“å‰ `visible=true`ã€`open=true`ï¼›åŽ†å² `custom-world` è·¯ç”±ä»æ˜¯ RPG 的工程域与è¿è¡Œæ€æºç±»åž‹ã€‚å…¥å£ç†”断把 `/api/runtime/custom-world*`ã€`/api/story/*` å’Œ `/api/runtime/chat/*` 统一映射到 `rpg`,ä¸è¦æ–°å¢žå¹³è¡Œ `airp` 路由或用 `airp` æŽ¥ç®¡å½“å‰æ–‡å­—冒险链路。 + +结构化创作和 RPG çš„ LLM JSON 链路默认ä¸å¯ç”¨ Responses `web_search`ï¼›åªæœ‰åœ¨æ˜Žç¡®éœ€è¦è”网增强时,æ‰é€šè¿‡ `GENARRATIVE_RPG_LLM_WEB_SEARCH_ENABLED` 或 `GENARRATIVE_CREATION_AGENT_LLM_WEB_SEARCH_ENABLED` æ˜¾å¼æ‰“开。å¦åˆ™æœªå¼€é€šå·¥å…·çš„上游会先å自然语言å†è¿”回 `ToolNotOpen`ï¼Œè¿™ç±»å¤±è´¥è¦æŒ‰ä¸Šæ¸¸å·¥å…·ä¸å¯ç”¨å¤„ç†ï¼Œä¸è¦è¯¯åˆ¤æˆæ¨¡åž‹è¿”回结果解æžå¤±è´¥ã€‚ + 未æ¥å¯é€‰ï¼šè‹¥å‘çŽ°é¡µã€æŽ¨èæµå’Œå„玩法广场需è¦ç»Ÿä¸€ç»™æµè§ˆå™¨å‰ç«¯ç›´æŽ¥è®¢é˜…公开作å“åˆ—è¡¨ï¼Œåªæ–°å¢ž / 统一专用 public read model,例如 `public_work_gallery_entry`。该 read model 必须是åŽç«¯æŠ•å½±åŽçš„公开作å“å¡ç‰‡å¥‘约,覆盖作å“类型ã€å…¬å¼€ä½œå“å·ã€æ ‡é¢˜ã€æ‘˜è¦ã€å°é¢ã€ä½œè€…展示åã€æŽ’åºé”®ã€å…¬å¼€ç»Ÿè®¡å’Œå…¥å£å¼€å…³åŽçš„å¯è§æ€§ï¼Œä¸æš´éœ²çŽ©æ³•é¢†åŸŸæºè¡¨ row shape。å‰ç«¯å¯é€‰æ‹©è®¢é˜…这个稳定投影æ¥å‡å°‘ HTTP 拉å–,但ä¸èƒ½è®¢é˜… `puzzle_work_profile`ã€`custom_world_profile` ç­‰æºè¡¨åŽè‡ªè¡Œæ‹¼è£…列表;BFF ä»ä¿ç•™é¦–å±ã€SEO / åˆ†äº«ã€æ—§å®¢æˆ·ç«¯ã€è®¢é˜…失败和ç°åº¦æœŸé—´çš„ HTTP fallback。 ### `quest_log` diff --git a/docs/ã€å¼€å‘è¿ç»´ã€‘本地开å‘验è¯ä¸Žç”Ÿäº§è¿ç»´-2026-05-15.md b/docs/ã€å¼€å‘è¿ç»´ã€‘本地开å‘验è¯ä¸Žç”Ÿäº§è¿ç»´-2026-05-15.md index 425272305..460c13385 100644 --- a/docs/ã€å¼€å‘è¿ç»´ã€‘本地开å‘验è¯ä¸Žç”Ÿäº§è¿ç»´-2026-05-15.md +++ b/docs/ã€å¼€å‘è¿ç»´ã€‘本地开å‘验è¯ä¸Žç”Ÿäº§è¿ç»´-2026-05-15.md @@ -45,6 +45,8 @@ npm run dev:api-server åŽç«¯æ—¥å¿—默认写入 `logs/api-server/`。åŽç«¯ API smoke 使用 `npm run dev:api-server` 并检查 `/healthz`ï¼›ä¸è¦ä½¿ç”¨æ—§ `api-server:maincloud` 或任何 `GENARRATIVE_SPACETIME_MAINCLOUD_*` å£å¾„。 +本地 `.env`ã€`.env.local` 或 `.env.secrets.local` 修改åŽå¿…é¡»é‡å¯ `api-server` æ‰ä¼šç”Ÿæ•ˆï¼›è‹¥å·²ç»é€šè¿‡ `npm run dev` å¯åŠ¨å®Œæ•´è”调,å¯åœ¨è¯¥ç»ˆç«¯è¾“å…¥ `rs api-server`。排查 RPG / 拼图 / 抓大鹅等 VectorEngine 生图链路时,确认 `VECTOR_ENGINE_BASE_URL`ã€`VECTOR_ENGINE_API_KEY` å’Œ `VECTOR_ENGINE_IMAGE_REQUEST_TIMEOUT_MS` åªåœ¨æœ¬åœ°æˆ–æœåŠ¡å™¨å¯†é’¥æ–‡ä»¶ä¸­é…置,ä¸èƒ½å†™å…¥ Git。开局 CG 故事æ¿ã€é¦–图ã€èƒŒæ™¯å’Œå›¾é›†éƒ½å±žäºŽé•¿è€—时图片请求;åŽç«¯é»˜è®¤ä¼šæŠŠ `VECTOR_ENGINE_IMAGE_REQUEST_TIMEOUT_MS` 䏋陿”¶å£åˆ° `1000000`,旧进程ä»å¯èƒ½æ²¿ç”¨é‡å¯å‰çš„短超时。若开局 CG 故事æ¿åœ¨ `send()` 阶段失败且日志显示 `SendRequest`,先看åŒä¸€ request_id çš„ `request_body_bytes`ã€`reference_data_url_bytes`ã€`sourceChain` å’Œ `rootSource`;当å‰å¼€å±€ CG 会把角色图与首幕背景图压到å•è¾¹ 768 çš„ JPEG åŽå†ä½œä¸º generations `image` 数组å‘é€ï¼Œ`/v1/images/generations` 使用默认 HTTP åå•†ï¼Œåªæœ‰ multipart `/v1/images/edits` å•独强制 HTTP/1.1。 + 查看本地 Rust / SpacetimeDB 日志: ```bash @@ -213,6 +215,8 @@ OpenTelemetry çŽ°é˜¶æ®µé»˜è®¤å¼€å¯ OTLP traces / metrics / logs,但本地日 - `WECHAT_*` - `ALIYUN_OSS_*` +结构化创作 / RPG çš„ Responses JSON é“¾è·¯é»˜è®¤ä¸æ‰“å¼€ `web_search`;本地和生产如需è”网增强,必须显å¼é…ç½® `GENARRATIVE_RPG_LLM_WEB_SEARCH_ENABLED=true` 或 `GENARRATIVE_CREATION_AGENT_LLM_WEB_SEARCH_ENABLED=true`。如果上游未开通工具,Responses å¯èƒ½å…ˆå自然语言å†è¿”回 `ToolNotOpen`,这类报错应按工具ä¸å¯ç”¨æŽ’查,ä¸è¦å…ˆå½“æˆ JSON è§£æž bug。 + ### 手机验è¯ç çŸ­ä¿¡ 手机验è¯ç å‘é€èµ°é˜¿é‡Œäº‘普通短信 `SendSms`,验è¯ç ç”± `module-auth` åœ¨å½“å‰ `api-server` 进程内生æˆã€å“ˆå¸Œå­˜å‚¨å’Œæ ¡éªŒï¼Œä¸å†è°ƒç”¨é˜¿é‡Œäº‘托管验è¯ç çš„ `SendSmsVerifyCode` / `CheckSmsVerifyCode`。因此 `api-server` é‡å¯åŽï¼Œå·²å‘é€ä½†æœªæ ¡éªŒçš„验è¯ç ä¼šå¤±æ•ˆã€‚ diff --git a/docs/ã€çŽ©æ³•åˆ›ä½œã€‘å¹³å°å…¥å£ä¸ŽçŽ©æ³•é“¾è·¯-2026-05-15.md b/docs/ã€çŽ©æ³•åˆ›ä½œã€‘å¹³å°å…¥å£ä¸ŽçŽ©æ³•é“¾è·¯-2026-05-15.md index f078760ed..eb07fc540 100644 --- a/docs/ã€çŽ©æ³•åˆ›ä½œã€‘å¹³å°å…¥å£ä¸ŽçŽ©æ³•é“¾è·¯-2026-05-15.md +++ b/docs/ã€çŽ©æ³•åˆ›ä½œã€‘å¹³å°å…¥å£ä¸ŽçŽ©æ³•é“¾è·¯-2026-05-15.md @@ -34,6 +34,24 @@ 6. 点击 `generationStatus=generating` çš„è‰ç¨¿å¡å¿…é¡»æ¢å¤å¯¹åº”玩法的生æˆè¿›åº¦é¡µï¼Œä¸èƒ½è¿›å…¥ç©ºç™½ç»“果页或普通工作区;æ¢å¤ç”Ÿæˆé¡µçš„ `startedAtMs` ä½¿ç”¨ä½œå“æ‘˜è¦ `updatedAt` 推导。 7. ç§æœ‰ generated 图片必须通过 `ResolvedAssetImage` / `/api/assets/read-url` æ¢ç­¾è¯»å–。 +## RPG / 自定义世界 + +å½“å‰ RPG 创作入å£ä½¿ç”¨ `playId = rpg`,工程域和è¿è¡Œæ€æºç±»åž‹æ²¿ç”¨åŽ†å² `custom-world`。默认入å£çжæ€ä¸º `visible=true`ã€`open=true`,对外展示为“文字冒险â€ï¼›`airp` 仿˜¯ç‹¬ç«‹çš„“AI RPGâ€å ä½å…¥å£ï¼Œä¿æŒ `open=false`,ä¸è¦æŠŠå®ƒå½“ä½œå½“å‰ RPG 创作链路开放。 + +当å‰é“¾è·¯ä¸ºï¼š + +```text +åˆ›ä½œå…¥å£ -> RPG Agent å…±åˆ›å·¥ä½œå° -> 生æˆè¿‡ç¨‹é¡µ -> 结果页 -> 进入世界/试玩 -> å‘布 -> RPG è¿è¡Œæ€ +``` + +RPG æ˜¯åŽ†å²æ—¢æœ‰é“¾è·¯ä¾‹å¤–:当å‰ä»ä½¿ç”¨å¯¹è¯å¼ Agent 共创工作å°å’Œ RPG 资产编辑器体系,ä¸ä½œä¸ºæ–°å¢žçŽ©æ³•é»˜è®¤æ¨¡æ¿å¤åˆ¶ã€‚新增玩法继续éµå¾ªæœ¬æ–‡é»˜è®¤çš„表å•/图片输入工作å°ã€`CreativeImageInputPanel` å•图槽ä½å’Œé€šç”¨ç³»åˆ—ç´ æå›¾é›†ç”Ÿæˆæµç¨‹ï¼›å¦‚æžœè¦æŠŠ RPG 逿­¥è¿å›žé»˜è®¤æ¨¡å¼ï¼Œåº”先补 PRD å’Œè¿ç§»æ–¹æ¡ˆï¼Œå†æ”¹ä»£ç ã€‚ + +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 结果页开局 CG 是 `profile.openingCg` 资产槽ä½ï¼š`api-server` è´Ÿè´£ VectorEngine / OSS 副作用并返回故事æ¿å’Œè§†é¢‘引用,å‰ç«¯åªæŠŠç»“æžœå†™å›žå½“å‰ profileï¼›`sync_result_profile`ã€ä½œå“库ä¿å­˜å’Œ `normalizeCustomWorldProfileRecord` 都必须ä¿ç•™è¯¥æ§½ä½ã€‚è‹¥ç”ŸæˆæˆåŠŸåŽç”»é¢çŸ­æš‚显示åˆå˜å›žç©ºç™½ï¼Œä¼˜å…ˆæ£€æŸ¥çˆ¶å±‚釿–°åŒæ­¥æˆ– profile å½’ä¸€åŒ–æ˜¯å¦æŠŠ `openingCg` ä¸¢æŽ‰ï¼Œè€Œä¸æ˜¯å…ˆæ€€ç–‘已生æˆèµ„æºæœ¬èº«å¤±æ•ˆã€‚ + ## 拼图 当剿‹¼å›¾é“¾è·¯ï¼š diff --git a/server-rs/crates/api-server/src/app.rs b/server-rs/crates/api-server/src/app.rs index d540f4183..6560dd056 100644 --- a/server-rs/crates/api-server/src/app.rs +++ b/server-rs/crates/api-server/src/app.rs @@ -619,6 +619,31 @@ mod tests { assert_eq!(body["error"]["details"]["creationTypeId"], "visual-novel"); } + #[tokio::test] + async fn disabled_rpg_route_returns_service_unavailable() { + let state = AppState::new(AppConfig::default()).expect("state should build"); + state.set_test_creation_entry_route_enabled("rpg", false); + let app = build_router(state); + + let response = app + .oneshot( + Request::builder() + .uri("/api/runtime/custom-world/agent/sessions") + .body(Body::empty()) + .expect("request should build"), + ) + .await + .expect("request should succeed"); + + assert_eq!(response.status(), StatusCode::SERVICE_UNAVAILABLE); + let body = read_json_response(response).await; + assert_eq!( + body["error"]["details"]["reason"], + "creation_entry_disabled" + ); + assert_eq!(body["error"]["details"]["creationTypeId"], "rpg"); + } + #[tokio::test] async fn healthz_returns_standard_envelope_when_requested() { let app = build_router(AppState::new(AppConfig::default()).expect("state should build")); diff --git a/server-rs/crates/api-server/src/config.rs b/server-rs/crates/api-server/src/config.rs index 0398c9487..079f7b5a2 100644 --- a/server-rs/crates/api-server/src/config.rs +++ b/server-rs/crates/api-server/src/config.rs @@ -260,8 +260,11 @@ impl Default for AppConfig { llm_request_timeout_ms: DEFAULT_REQUEST_TIMEOUT_MS, llm_max_retries: DEFAULT_MAX_RETRIES, llm_retry_backoff_ms: DEFAULT_RETRY_BACKOFF_MS, - rpg_llm_web_search_enabled: true, - creation_agent_llm_web_search_enabled: true, + // 中文注释:创作/RPG 的结构化 JSON 链路默认ä¸å¯ç”¨ Responses web_search。 + // 未开通工具的上游会先å自然语言å†è¿”回 ToolNotOpen,容易污染严格 JSON 结果; + // 需è¦è”ç½‘å¢žå¼ºæ—¶ç”±éƒ¨ç½²çŽ¯å¢ƒæ˜¾å¼æ‰“开对应开关。 + rpg_llm_web_search_enabled: false, + creation_agent_llm_web_search_enabled: false, dashscope_base_url: "https://dashscope.aliyuncs.com/api/v1".to_string(), dashscope_api_key: None, dashscope_scene_image_model: String::new(), @@ -1467,6 +1470,14 @@ mod tests { } } + #[test] + fn default_keeps_structured_llm_web_search_disabled() { + let config = AppConfig::default(); + + assert!(!config.rpg_llm_web_search_enabled); + assert!(!config.creation_agent_llm_web_search_enabled); + } + #[test] fn from_env_reads_rpg_llm_web_search_switch() { let _guard = ENV_LOCK @@ -1476,11 +1487,11 @@ mod tests { unsafe { std::env::remove_var("GENARRATIVE_RPG_LLM_WEB_SEARCH_ENABLED"); - std::env::set_var("GENARRATIVE_RPG_LLM_WEB_SEARCH_ENABLED", "false"); + std::env::set_var("GENARRATIVE_RPG_LLM_WEB_SEARCH_ENABLED", "true"); } let config = AppConfig::from_env(); - assert!(!config.rpg_llm_web_search_enabled); + assert!(config.rpg_llm_web_search_enabled); unsafe { std::env::remove_var("GENARRATIVE_RPG_LLM_WEB_SEARCH_ENABLED"); @@ -1496,11 +1507,11 @@ mod tests { unsafe { std::env::remove_var("GENARRATIVE_CREATION_AGENT_LLM_WEB_SEARCH_ENABLED"); - std::env::set_var("GENARRATIVE_CREATION_AGENT_LLM_WEB_SEARCH_ENABLED", "false"); + std::env::set_var("GENARRATIVE_CREATION_AGENT_LLM_WEB_SEARCH_ENABLED", "true"); } let config = AppConfig::from_env(); - assert!(!config.creation_agent_llm_web_search_enabled); + assert!(config.creation_agent_llm_web_search_enabled); unsafe { std::env::remove_var("GENARRATIVE_CREATION_AGENT_LLM_WEB_SEARCH_ENABLED"); diff --git a/server-rs/crates/api-server/src/creation_agent_llm_turn.rs b/server-rs/crates/api-server/src/creation_agent_llm_turn.rs index 3a2cf8254..86bbfa93a 100644 --- a/server-rs/crates/api-server/src/creation_agent_llm_turn.rs +++ b/server-rs/crates/api-server/src/creation_agent_llm_turn.rs @@ -93,15 +93,74 @@ where F: FnMut(&str), { let mut latest_reply_text = String::new(); + let turn_output = match request_stream_creation_agent_json_turn_once( + llm_client, + system_prompt.clone(), + user_prompt.clone(), + enable_web_search, + on_reply_update, + &mut latest_reply_text, + !enable_web_search, + ) + .await + { + Ok(turn_output) => turn_output, + Err(CreationAgentJsonTurnFailure::Stream(error)) + if enable_web_search && is_web_search_tool_unavailable(&error) => + { + tracing::warn!( + error = %error, + "创作 Agent æµå¼è”网æœç´¢æ’ä»¶ä¸å¯ç”¨ï¼Œè‡ªåЍé™çº§ä¸ºæ— è”网æœç´¢é‡è¯•" + ); + latest_reply_text.clear(); + request_stream_creation_agent_json_turn_once( + llm_client, + system_prompt, + user_prompt, + false, + on_reply_update, + &mut latest_reply_text, + true, + ) + .await? + } + Err(error) => return Err(error), + }; + + let reply_text = read_reply_text(&turn_output.parsed); + if let Some(reply_text) = reply_text.as_deref() + && reply_text != latest_reply_text + { + on_reply_update(reply_text); + } + + Ok(turn_output) +} + +async fn request_stream_creation_agent_json_turn_once( + llm_client: &LlmClient, + system_prompt: String, + user_prompt: String, + enable_web_search: bool, + on_reply_update: &mut F, + latest_reply_text: &mut String, + emit_reply_updates: bool, +) -> Result +where + F: FnMut(&str), +{ let response = llm_client .stream_text( build_creation_agent_llm_request(system_prompt, user_prompt, enable_web_search), |delta: &LlmStreamDelta| { + if !emit_reply_updates { + return; + } if let Some(reply_progress) = extract_reply_text_from_partial_json(delta.accumulated_text.as_str()) - && reply_progress != latest_reply_text + && reply_progress != *latest_reply_text { - latest_reply_text = reply_progress.clone(); + *latest_reply_text = reply_progress.clone(); on_reply_update(reply_progress.as_str()); } }, @@ -110,12 +169,6 @@ where .map_err(CreationAgentJsonTurnFailure::Stream)?; let parsed = parse_json_response_text(response.content.as_str()) .map_err(|_| CreationAgentJsonTurnFailure::Parse)?; - let reply_text = read_reply_text(&parsed); - if let Some(reply_text) = reply_text.as_deref() - && reply_text != latest_reply_text - { - on_reply_update(reply_text); - } Ok(CreationAgentJsonTurnOutput { parsed }) } @@ -327,6 +380,7 @@ mod tests { let server = spawn_capturing_mock_server(vec![ MockResponse { body: concat!( + "data: {\"type\":\"response.output_text.delta\",\"delta\":\"我需è¦å…ˆæœç´¢çŽ©å…·çŽ‹å›½èµ„æ–™ã€‚\"}\n\n", "data: {\"type\":\"error\",\"code\":\"ToolNotOpen\",\"message\":\"Your account has not activated web search.\"}\n\n", "data: [DONE]\n\n" ) @@ -391,6 +445,55 @@ mod tests { } } + #[tokio::test] + async fn stream_turn_keeps_partial_updates_when_web_search_is_disabled() { + let server = spawn_capturing_mock_server(vec![MockResponse { + body: concat!( + "data: {\"type\":\"response.output_text.delta\",\"delta\":\"{\\\"replyText\\\":\\\"我先\"}\n\n", + "data: {\"type\":\"response.output_text.delta\",\"delta\":\"把玩具王国定ä½ã€‚\\\",\\\"progressPercent\\\":12}\"}\n\n", + "data: {\"type\":\"response.completed\"}\n\n", + ) + .to_string(), + }]); + let config = LlmConfig::new( + LlmProvider::Ark, + server.base_url, + "test-key".to_string(), + "test-model".to_string(), + 30_000, + 0, + 1, + ) + .expect("LLM config should build"); + let llm_client = platform_llm::LlmClient::new(config).expect("LLM client should build"); + let mut visible_replies = Vec::new(); + + let output = stream_creation_agent_json_turn( + Some(&llm_client), + "系统æç¤º".to_string(), + "用户æç¤º", + false, + CreationAgentLlmTurnErrorMessages { + model_unavailable: "模型ä¸å¯ç”¨", + generation_failed: "生æˆå¤±è´¥", + parse_failed: "è§£æžå¤±è´¥", + }, + |text| visible_replies.push(text.to_string()), + |message| message, + ) + .await + .expect("stream without web search should succeed"); + + assert_eq!( + output.parsed["replyText"].as_str(), + Some("我先把玩具王国定ä½ã€‚") + ); + assert_eq!( + visible_replies, + vec!["我先".to_string(), "我先把玩具王国定ä½ã€‚".to_string()] + ); + } + struct MockResponse { body: String, } diff --git a/server-rs/crates/api-server/src/creation_entry_config.rs b/server-rs/crates/api-server/src/creation_entry_config.rs index c470beba3..24e471d42 100644 --- a/server-rs/crates/api-server/src/creation_entry_config.rs +++ b/server-rs/crates/api-server/src/creation_entry_config.rs @@ -96,6 +96,14 @@ pub fn resolve_creation_entry_route_id(path: &str) -> Option<&'static str> { if normalized.starts_with("/api/runtime/big-fish") { return Some("big-fish"); } + if normalized.starts_with("/api/runtime/custom-world") + || normalized.starts_with("/api/runtime/custom-world-library") + || normalized.starts_with("/api/runtime/custom-world-gallery") + || normalized.starts_with("/api/runtime/chat") + || normalized.starts_with("/api/story") + { + return Some("rpg"); + } if normalized.starts_with("/api/runtime/visual-novel") { return Some("visual-novel"); } @@ -161,6 +169,26 @@ mod tests { resolve_creation_entry_route_id("/api/creation/visual-novel/sessions"), Some("visual-novel"), ); + assert_eq!( + resolve_creation_entry_route_id("/api/runtime/custom-world/agent/sessions"), + Some("rpg"), + ); + assert_eq!( + resolve_creation_entry_route_id("/api/runtime/custom-world-library/profile-1"), + Some("rpg"), + ); + assert_eq!( + resolve_creation_entry_route_id("/api/runtime/custom-world-gallery/user-1/profile-1"), + Some("rpg"), + ); + assert_eq!( + resolve_creation_entry_route_id("/api/story/sessions/runtime"), + Some("rpg"), + ); + assert_eq!( + resolve_creation_entry_route_id("/api/runtime/chat/npc/turn/stream"), + Some("rpg"), + ); assert_eq!( resolve_creation_entry_route_id("/api/runtime/bark-battle/works/work-1/config"), Some("bark-battle"), diff --git a/server-rs/crates/api-server/src/custom_world_ai.rs b/server-rs/crates/api-server/src/custom_world_ai.rs index 122804395..649999dd6 100644 --- a/server-rs/crates/api-server/src/custom_world_ai.rs +++ b/server-rs/crates/api-server/src/custom_world_ai.rs @@ -10,7 +10,9 @@ use axum::{ response::Response, }; use base64::{Engine as _, engine::general_purpose::STANDARD as BASE64_STANDARD}; -use image::{DynamicImage, GenericImageView, imageops::FilterType}; +use image::{ + DynamicImage, GenericImageView, ImageFormat, codecs::jpeg::JpegEncoder, imageops::FilterType, +}; use module_assets::{ AssetObjectAccessPolicy, AssetObjectFieldError, build_asset_entity_binding_input, build_asset_object_upsert_input, generate_asset_binding_id, generate_asset_object_id, @@ -375,6 +377,8 @@ const OPENING_CG_ENTITY_KIND: &str = "custom_world_profile"; const OPENING_CG_STORYBOARD_SLOT: &str = "opening_cg_storyboard"; const OPENING_CG_VIDEO_SLOT: &str = "opening_cg_video"; const ARK_VIDEO_TASK_POLL_INTERVAL_MS: u64 = 5_000; +const OPENING_CG_REFERENCE_MAX_EDGE: u32 = 768; +const OPENING_CG_REFERENCE_JPEG_QUALITY: u8 = 82; struct CoverPromptContext { opening_act_title: String, @@ -1025,6 +1029,16 @@ pub async fn generate_custom_world_opening_cg( "openingSceneImageSrc", ) .await?; + let player_role_reference = resize_image_reference_data_url( + player_role_reference, + OPENING_CG_REFERENCE_MAX_EDGE, + OPENING_CG_REFERENCE_JPEG_QUALITY, + )?; + let opening_scene_reference = resize_image_reference_data_url( + opening_scene_reference, + OPENING_CG_REFERENCE_MAX_EDGE, + OPENING_CG_REFERENCE_JPEG_QUALITY, + )?; let storyboard = generate_opening_cg_storyboard( &state, &owner_user_id, @@ -1617,6 +1631,52 @@ async fn resolve_reference_image_as_data_url( )) } +fn resize_image_reference_data_url( + data_url: String, + max_edge: u32, + jpeg_quality: u8, +) -> Result { + if max_edge == 0 { + return Ok(data_url); + } + let Some(parsed) = parse_image_data_url(data_url.as_str()) else { + return Ok(data_url); + }; + let image = image::load_from_memory(parsed.bytes.as_slice()).map_err(|error| { + AppError::from_status(StatusCode::BAD_REQUEST).with_details(json!({ + "provider": "custom-world-ai", + "message": format!("无法解æžå‚考图:{error}"), + })) + })?; + let (width, height) = image.dimensions(); + let already_within_budget = width <= max_edge && height <= max_edge; + if already_within_budget && parsed.mime_type == "image/jpeg" { + return Ok(data_url); + } + + // 中文注释:开局 CG 故事æ¿ä¼šåŒæ—¶å¸¦è§’色和场景两张å‚è€ƒå›¾ï¼›å…ˆåŽ‹åˆ°è¾ƒå° JPEG,é¿å…大图 PNG Data URL 让 VectorEngine 网关在请求å‘é€é˜¶æ®µä¸­æ–­ã€‚ + let resized = if already_within_budget { + image + } else { + image.resize(max_edge, max_edge, FilterType::Triangle) + }; + let encoded_image = DynamicImage::ImageRgb8(resized.to_rgb8()); + let mut encoded = Vec::new(); + JpegEncoder::new_with_quality(&mut encoded, jpeg_quality) + .encode_image(&encoded_image) + .map_err(|error| { + AppError::from_status(StatusCode::INTERNAL_SERVER_ERROR).with_details(json!({ + "provider": "custom-world-ai", + "message": format!("压缩å‚考图失败:{error}"), + })) + })?; + + Ok(format!( + "data:image/jpeg;base64,{}", + BASE64_STANDARD.encode(encoded) + )) +} + async fn create_text_to_image_generation( http_client: &reqwest::Client, settings: &DashScopeSettings, @@ -3065,6 +3125,34 @@ mod tests { assert_eq!(parsed.bytes, b"hello".to_vec()); } + #[test] + fn opening_cg_reference_data_url_is_resized_to_request_budget() { + let image = DynamicImage::ImageRgb8(image::RgbImage::new(2048, 1152)); + let mut cursor = std::io::Cursor::new(Vec::new()); + image + .write_to(&mut cursor, ImageFormat::Png) + .expect("test image should encode"); + let data_url = format!( + "data:image/png;base64,{}", + BASE64_STANDARD.encode(cursor.into_inner()) + ); + + let resized = resize_image_reference_data_url( + data_url, + OPENING_CG_REFERENCE_MAX_EDGE, + OPENING_CG_REFERENCE_JPEG_QUALITY, + ) + .expect("reference should resize"); + let parsed = parse_image_data_url(resized.as_str()).expect("resized data url should parse"); + let resized_image = + image::load_from_memory(parsed.bytes.as_slice()).expect("resized image should decode"); + let (width, height) = resized_image.dimensions(); + + assert!(width <= OPENING_CG_REFERENCE_MAX_EDGE); + assert!(height <= OPENING_CG_REFERENCE_MAX_EDGE); + assert_eq!(parsed.mime_type, "image/jpeg"); + } + #[test] fn push_cover_reference_source_keeps_full_data_url() { let mut sources = Vec::new(); diff --git a/server-rs/crates/api-server/src/match3d.rs b/server-rs/crates/api-server/src/match3d.rs index 75f731b3d..3a5994225 100644 --- a/server-rs/crates/api-server/src/match3d.rs +++ b/server-rs/crates/api-server/src/match3d.rs @@ -109,9 +109,8 @@ const MATCH3D_WORK_METADATA_LLM_MODEL: &str = "gpt-4o"; const MATCH3D_ITEM_SIZE_LARGE: &str = "大"; const MATCH3D_ITEM_SIZE_MEDIUM: &str = "中"; const MATCH3D_ITEM_SIZE_SMALL: &str = "å°"; -const MATCH3D_CONTAINER_REFERENCE_IMAGE_BYTES: &[u8] = include_bytes!( - "../../../../public/match3d-background-references/pot-fused-reference.png" -); +const MATCH3D_CONTAINER_REFERENCE_IMAGE_BYTES: &[u8] = + include_bytes!("../../../../public/match3d-background-references/pot-fused-reference.png"); const MATCH3D_PUBLIC_REFERENCE_IMAGE_PATH_PREFIX: &str = "public/"; const MATCH3D_QUESTION_THEME: &str = "你想创作什么题æ"; const MATCH3D_QUESTION_CLEAR_COUNT: &str = "éœ€è¦æ¶ˆé™¤å¤šå°‘次æ‰èƒ½é€šå…³"; diff --git a/server-rs/crates/api-server/src/match3d/item_assets.rs b/server-rs/crates/api-server/src/match3d/item_assets.rs index 87f86542a..51c270ed2 100644 --- a/server-rs/crates/api-server/src/match3d/item_assets.rs +++ b/server-rs/crates/api-server/src/match3d/item_assets.rs @@ -1,12 +1,12 @@ use super::*; +#[cfg(test)] +use crate::generated_asset_sheets::crop_generated_asset_sheet_view_edge_matte; use crate::generated_asset_sheets::{ GeneratedAssetSheetPersistInput, GeneratedAssetSheetPersistPrompt, GeneratedAssetSheetPromptInput, GeneratedAssetSheetSliceImage, build_generated_asset_sheet_prompt, persist_generated_asset_sheet_bytes, slice_generated_asset_sheet, }; -#[cfg(test)] -use crate::generated_asset_sheets::crop_generated_asset_sheet_view_edge_matte; pub(super) async fn generate_match3d_item_assets( state: &AppState, diff --git a/server-rs/crates/api-server/src/match3d/tests.rs b/server-rs/crates/api-server/src/match3d/tests.rs index 04b93cbc9..744172019 100644 --- a/server-rs/crates/api-server/src/match3d/tests.rs +++ b/server-rs/crates/api-server/src/match3d/tests.rs @@ -1164,7 +1164,9 @@ fn match3d_container_reference_image_is_embedded_for_api_only_deploy() { assert_eq!(reference.mime_type, "image/png"); assert_eq!(reference.file_name, "match3d-container-reference.png"); assert!( - reference.bytes.starts_with(&[137, 80, 78, 71, 13, 10, 26, 10]), + reference + .bytes + .starts_with(&[137, 80, 78, 71, 13, 10, 26, 10]), "container reference image should be PNG bytes" ); } diff --git a/server-rs/crates/api-server/src/openai_image_generation.rs b/server-rs/crates/api-server/src/openai_image_generation.rs index 555547019..11b754f68 100644 --- a/server-rs/crates/api-server/src/openai_image_generation.rs +++ b/server-rs/crates/api-server/src/openai_image_generation.rs @@ -1,4 +1,4 @@ -use std::time::Duration; +use std::{error::Error as _, time::Duration}; use axum::http::StatusCode; use base64::{Engine as _, engine::general_purpose::STANDARD as BASE64_STANDARD}; @@ -82,8 +82,6 @@ pub(crate) fn build_openai_image_http_client( ) -> Result { reqwest::Client::builder() .timeout(Duration::from_millis(settings.request_timeout_ms)) - // 中文注释:åŒä¸€å®¢æˆ·ç«¯ä¹Ÿä¼šæ‰¿è½½ `/v1/images/edits` multipart 图生图请求,强制 HTTP/1.1 å¯é¿å¼€éƒ¨åˆ†ç½‘关对 HTTP/2 multipart æµçš„兼容问题。 - .http1_only() .build() .map_err(|error| { AppError::from_status(StatusCode::INTERNAL_SERVER_ERROR).with_details(json!({ @@ -110,21 +108,46 @@ pub(crate) async fn create_openai_image_generation( candidate_count, reference_images, ); + let request_body_bytes = serde_json::to_vec(&request_body).map_err(|error| { + map_openai_image_request_error(format!( + "{failure_context}:åºåˆ—化图片生æˆè¯·æ±‚失败:{error}" + )) + })?; + let normalized_size = request_body + .get("size") + .and_then(Value::as_str) + .unwrap_or(size); + let reference_lengths = summarize_reference_data_url_lengths(reference_images); + let request_url = vector_engine_images_generation_url(settings); + tracing::info!( + provider = VECTOR_ENGINE_PROVIDER, + endpoint = %request_url, + model = VECTOR_ENGINE_GPT_IMAGE_2_MODEL, + prompt_chars = prompt.chars().count(), + size = normalized_size, + candidate_count = candidate_count.clamp(1, 4), + reference_count = reference_images.len(), + reference_data_url_bytes = %reference_lengths, + request_body_bytes = request_body_bytes.len(), + "VectorEngine 图片生æˆè¯·æ±‚已准备" + ); let response = http_client - .post(vector_engine_images_generation_url(settings)) + .post(request_url.as_str()) .header( header::AUTHORIZATION, format!("Bearer {}", settings.api_key), ) .header(header::ACCEPT, "application/json") .header(header::CONTENT_TYPE, "application/json") - .json(&request_body) + .body(request_body_bytes) .send() .await .map_err(|error| { - map_openai_image_request_error(format!( - "{failure_context}:创建图片生æˆä»»åŠ¡å¤±è´¥ï¼š{error}" - )) + map_openai_image_reqwest_error( + format!("{failure_context}:创建图片生æˆä»»åŠ¡å¤±è´¥").as_str(), + request_url.as_str(), + error, + ) })?; let response_status = response.status(); let response_text = response.text().await.map_err(|error| { @@ -191,8 +214,11 @@ pub(crate) async fn create_openai_image_edit( ) .text("n", "1") .text("size", normalize_image_size(size)); - let response = http_client - .post(vector_engine_images_edit_url(settings).as_str()) + let request_url = vector_engine_images_edit_url(settings); + // 中文注释:åªå¯¹ multipart `/v1/images/edits` å•独强制 HTTP/1.1;大 JSON generations ä¿æŒé»˜è®¤å商,é¿å… HTTP/1.1 网关在å‘é€å¤§è¯·æ±‚体时中断连接。 + let edit_http_client = build_openai_image_edit_http_client(settings)?; + let response = edit_http_client + .post(request_url.as_str()) .header( header::AUTHORIZATION, format!("Bearer {}", settings.api_key), @@ -202,9 +228,11 @@ pub(crate) async fn create_openai_image_edit( .send() .await .map_err(|error| { - map_openai_image_request_error(format!( - "{failure_context}:创建图片编辑任务失败:{error}" - )) + map_openai_image_reqwest_error( + format!("{failure_context}:创建图片编辑任务失败").as_str(), + request_url.as_str(), + error, + ) })?; let response_status = response.status(); let response_text = response.text().await.map_err(|error| { @@ -242,6 +270,21 @@ pub(crate) async fn create_openai_image_edit( ) } +fn build_openai_image_edit_http_client( + settings: &OpenAiImageSettings, +) -> Result { + reqwest::Client::builder() + .timeout(Duration::from_millis(settings.request_timeout_ms)) + .http1_only() + .build() + .map_err(|error| { + AppError::from_status(StatusCode::INTERNAL_SERVER_ERROR).with_details(json!({ + "provider": VECTOR_ENGINE_PROVIDER, + "message": format!("构造 VectorEngine 图片编辑 HTTP 客户端失败:{error}"), + })) + }) +} + pub(crate) fn build_openai_image_request_body( prompt: &str, negative_prompt: Option<&str>, @@ -402,6 +445,130 @@ fn map_openai_image_request_error(message: String) -> AppError { })) } +fn map_openai_image_reqwest_error( + context: &str, + request_url: &str, + error: reqwest::Error, +) -> AppError { + let message = format!( + "{context}:{}", + normalize_openai_reqwest_error_message(&error) + ); + let source_chain = reqwest_error_source_chain(&error); + let root_source = source_chain.last().cloned().unwrap_or_default(); + let source_chain_text = source_chain.join(" | "); + let is_timeout = error.is_timeout() + || is_openai_image_timeout_message(message.as_str()) + || is_openai_image_timeout_message(source_chain_text.as_str()); + let is_connect = error.is_connect(); + let status = if is_timeout { + StatusCode::GATEWAY_TIMEOUT + } else { + StatusCode::BAD_GATEWAY + }; + let source = source_chain.first().cloned().unwrap_or_default(); + let reason = resolve_openai_image_request_failure_reason(&error, source_chain.as_slice()); + + tracing::warn!( + provider = VECTOR_ENGINE_PROVIDER, + endpoint = %request_url, + timeout = is_timeout, + connect = is_connect, + request = error.is_request(), + body = error.is_body(), + source = %source, + root_source = %root_source, + source_chain = ?source_chain, + message = %message, + "VectorEngine 图片请求å‘é€å¤±è´¥" + ); + + AppError::from_status(status).with_details(json!({ + "provider": VECTOR_ENGINE_PROVIDER, + "message": message, + "reason": reason, + "endpoint": request_url, + "timeout": is_timeout, + "connect": is_connect, + "request": error.is_request(), + "body": error.is_body(), + "source": source, + "rootSource": root_source, + "sourceChain": source_chain, + })) +} + +fn normalize_openai_reqwest_error_message(error: &reqwest::Error) -> String { + error + .to_string() + .split_whitespace() + .collect::>() + .join(" ") +} + +fn reqwest_error_source_chain(error: &reqwest::Error) -> Vec { + let mut chain = Vec::new(); + let mut current = error.source(); + while let Some(source) = current { + chain.push(source.to_string()); + current = source.source(); + if chain.len() >= 8 { + break; + } + } + chain +} + +fn resolve_openai_image_request_failure_reason( + error: &reqwest::Error, + source_chain: &[String], +) -> &'static str { + let combined = std::iter::once(error.to_string()) + .chain(source_chain.iter().cloned()) + .collect::>() + .join(" | "); + if error.is_timeout() || is_openai_image_timeout_message(combined.as_str()) { + return "VectorEngine 图片生æˆè¯·æ±‚超时,请ç¨åŽé‡è¯•;如果多次å¤çŽ°ï¼Œæ£€æŸ¥ä¸Šæ¸¸è€—æ—¶å¹¶è°ƒå¤§ VECTOR_ENGINE_IMAGE_REQUEST_TIMEOUT_MS"; + } + if error.is_connect() { + return "无法连接 VectorEngine å›¾ç‰‡ç”ŸæˆæŽ¥å£ï¼Œè¯·æ£€æŸ¥æœåŠ¡å™¨ç½‘ç»œã€DNSã€é˜²ç«å¢™æˆ–代ç†é…ç½®"; + } + if error.is_body() { + return "å‘é€ VectorEngine 图片生æˆè¯·æ±‚体失败,请é‡è¯•并检查å‚考图大å°"; + } + if is_openai_image_send_request_interrupted(combined.as_str()) { + return "VectorEngine 在接收图片生æˆè¯·æ±‚时中断连接;请é‡è¯•,若æŒç»­å¤çŽ°ä¼˜å…ˆæ£€æŸ¥å‚考图体积ã€ä¸Šæ¸¸ç½‘关和 HTTP å议兼容"; + } + "VectorEngine 图片生æˆè¯·æ±‚å‘é€å¤±è´¥ï¼Œè¯·æŸ¥çœ‹ source 字段中的底层网络错误" +} + +fn is_openai_image_send_request_interrupted(message: &str) -> bool { + let lower = message.to_ascii_lowercase(); + lower.contains("sendrequest") + || lower.contains("connection closed") + || lower.contains("connection reset") + || lower.contains("broken pipe") + || lower.contains("unexpected eof") + || lower.contains("stream error") + || lower.contains("body write aborted") +} + +fn is_openai_image_timeout_message(message: &str) -> bool { + let lower = message.to_ascii_lowercase(); + lower.contains("timed out") + || lower.contains("timeout") + || lower.contains("operation timed out") + || lower.contains("deadline has elapsed") +} + +fn summarize_reference_data_url_lengths(reference_images: &[String]) -> String { + reference_images + .iter() + .map(|value| value.len().to_string()) + .collect::>() + .join(",") +} + fn map_openai_image_upstream_error( upstream_status: u16, raw_text: &str, @@ -646,6 +813,27 @@ mod tests { ); } + #[test] + fn vector_engine_reqwest_error_exposes_actionable_reason() { + let error = match reqwest::Client::new().get("http://[::1").build() { + Ok(_) => panic!("invalid url should fail request build"), + Err(error) => error, + }; + let app_error = map_openai_image_reqwest_error( + "开局 CG 故事æ¿ç”Ÿæˆå¤±è´¥ï¼šåˆ›å»ºå›¾ç‰‡ç”Ÿæˆä»»åŠ¡å¤±è´¥", + "https://api.vectorengine.ai/v1/images/generations", + error, + ); + + assert_eq!(app_error.status_code(), StatusCode::BAD_GATEWAY); + assert!( + app_error + .body_text() + .contains("开局 CG 故事æ¿ç”Ÿæˆå¤±è´¥ï¼šåˆ›å»ºå›¾ç‰‡ç”Ÿæˆä»»åŠ¡å¤±è´¥") + ); + assert!(format!("{app_error:?}").contains("sourceChain")); + } + #[test] fn b64_json_response_decodes_png_image() { let images = images_from_base64( diff --git a/server-rs/crates/module-custom-world/src/application.rs b/server-rs/crates/module-custom-world/src/application.rs index 0cf077d72..a518b3d16 100644 --- a/server-rs/crates/module-custom-world/src/application.rs +++ b/server-rs/crates/module-custom-world/src/application.rs @@ -599,6 +599,37 @@ pub fn canonicalize_custom_world_profile_before_save(profile: &mut Value) -> boo true } +pub fn resolve_custom_world_publish_setting_text( + payload: &Map, + draft_profile: &Map, + seed_text: &str, +) -> String { + // 中文注释:å‘布按钮的å‰ç«¯å¥‘约åªä¿è¯æäº¤åŠ¨ä½œåï¼›æ­£å¼ settingText 必须从è‰ç¨¿çœŸç›¸è¡¥é½ï¼Œ + // é¿å…æ—§ä¼šè¯ seed_text 为空时通过 publish gate,å´åœ¨æœ€ç»ˆ compile/publish 阶段失败。 + read_nested_text_field(payload, &["settingText"]) + .or_else(|| { + read_nested_text_field( + draft_profile, + &[ + "settingText", + "creatorIntent.rawSettingText", + "creatorIntent.worldHook", + "worldHook", + "anchorContent.worldPromise", + "anchorContent.worldPromise.hook", + "summary", + "name", + "title", + ], + ) + }) + .or_else(|| { + let seed = seed_text.trim(); + (!seed.is_empty()).then(|| seed.to_string()) + }) + .unwrap_or_default() +} + pub fn empty_agent_anchor_content_json() -> String { r#"{"worldPromise":null,"playerFantasy":null,"themeBoundary":null,"playerEntryPoint":null,"coreConflict":null,"keyRelationships":null,"hiddenLines":null,"iconicElements":null}"#.to_string() } @@ -804,6 +835,32 @@ fn read_text(object: &Map, key: &str) -> Option { .map(ToOwned::to_owned) } +fn read_nested_text_field(object: &Map, keys: &[&str]) -> Option { + for key in keys { + let mut current = Value::Object(object.clone()); + let mut found = true; + for segment in key.split('.') { + if let Some(next) = current.get(segment) { + current = next.clone(); + } else { + found = false; + break; + } + } + if found { + if let Some(value) = current + .as_str() + .map(str::trim) + .filter(|value| !value.is_empty()) + { + return Some(value.to_string()); + } + } + } + + None +} + fn read_string_list(object: &Map, key: &str) -> Vec { object .get(key) @@ -955,3 +1012,56 @@ fn build_compiled_profile_payload_json( serde_json::to_string(&Value::Object(payload)) .map_err(|_| CustomWorldFieldError::InvalidDraftProfileJson) } + +#[cfg(test)] +mod tests { + use super::*; + use serde_json::json; + + #[test] + fn publish_setting_text_falls_back_to_draft_profile_when_seed_is_empty() { + let payload = Map::new(); + let draft_profile = json!({ + "settingText": "æµ·é›¾ä¼šåžæŽ‰è®°é”™èˆªçº¿çš„äººã€‚", + "worldHook": "在失真的海图上追查一场被篡改的沉船事故。", + "summary": "守ç¯äººä¸Žç¾¤å²›è®®ä¼šå›´ç»•沉船旧案对峙。" + }) + .as_object() + .cloned() + .expect("draft profile should be object"); + + let setting_text = + resolve_custom_world_publish_setting_text(&payload, &draft_profile, ""); + + assert_eq!(setting_text, "æµ·é›¾ä¼šåžæŽ‰è®°é”™èˆªçº¿çš„äººã€‚"); + } + + #[test] + fn publish_setting_text_prefers_payload_then_draft_then_seed() { + let mut payload = Map::new(); + payload.insert( + "settingText".to_string(), + Value::String("å‘布载è·è®¾å®š".to_string()), + ); + let draft_profile = json!({ + "worldHook": "è‰ç¨¿ä¸–界一å¥è¯", + "summary": "è‰ç¨¿æ‘˜è¦" + }) + .as_object() + .cloned() + .expect("draft profile should be object"); + + assert_eq!( + resolve_custom_world_publish_setting_text(&payload, &draft_profile, "用户原始设定"), + "å‘布载è·è®¾å®š" + ); + assert_eq!( + resolve_custom_world_publish_setting_text(&Map::new(), &draft_profile, "用户原始设定"), + "è‰ç¨¿ä¸–界一å¥è¯" + ); + assert_eq!( + resolve_custom_world_publish_setting_text(&Map::new(), &Map::new(), "用户原始设定"), + "用户原始设定" + ); + } +} diff --git a/server-rs/crates/module-runtime/src/application.rs b/server-rs/crates/module-runtime/src/application.rs index c3ebf645f..87002c8a5 100644 --- a/server-rs/crates/module-runtime/src/application.rs +++ b/server-rs/crates/module-runtime/src/application.rs @@ -54,9 +54,9 @@ pub fn default_creation_entry_type_snapshots( "rpg", "文字冒险", "ç»å…¸ RPG 体验", - "内测", + "å¯åˆ›å»º", "/creation-type-references/rpg.webp", - false, + true, true, 10, updated_at_micros, diff --git a/server-rs/crates/module-runtime/src/lib.rs b/server-rs/crates/module-runtime/src/lib.rs index 3d1824267..490a2defc 100644 --- a/server-rs/crates/module-runtime/src/lib.rs +++ b/server-rs/crates/module-runtime/src/lib.rs @@ -236,6 +236,23 @@ mod tests { ); } + #[test] + fn default_creation_entry_types_open_rpg_entry() { + let configs = default_creation_entry_type_snapshots(1); + let rpg = configs + .iter() + .find(|item| item.id == "rpg") + .expect("rpg creation entry should be seeded"); + + assert_eq!(rpg.title, "文字冒险"); + assert_eq!(rpg.subtitle, "ç»å…¸ RPG 体验"); + assert!(rpg.visible); + assert!(rpg.open); + assert_eq!(rpg.badge, "å¯åˆ›å»º"); + assert_eq!(rpg.sort_order, 10); + assert_eq!(rpg.image_src, "/creation-type-references/rpg.webp"); + } + #[test] fn default_creation_entry_types_include_bark_battle() { let configs = default_creation_entry_type_snapshots(1); diff --git a/server-rs/crates/spacetime-module/src/custom_world.rs b/server-rs/crates/spacetime-module/src/custom_world.rs index 36228bfec..c7d8eeca2 100644 --- a/server-rs/crates/spacetime-module/src/custom_world.rs +++ b/server-rs/crates/spacetime-module/src/custom_world.rs @@ -2562,6 +2562,18 @@ fn upsert_nested_result_profile_id( } } +fn resolve_publish_world_setting_text( + payload: &JsonMap, + draft_profile: &JsonMap, + session: &CustomWorldAgentSession, +) -> String { + module_custom_world::resolve_custom_world_publish_setting_text( + payload, + draft_profile, + &session.seed_text, + ) +} + fn is_same_agent_draft_profile_candidate( row: &CustomWorldProfile, owner_user_id: &str, @@ -2608,13 +2620,7 @@ fn execute_publish_world_action( .filter(|value| !value.is_empty()) .map(ToOwned::to_owned) .unwrap_or_else(|| gate.profile_id.clone()); - let setting_text = payload - .get("settingText") - .and_then(JsonValue::as_str) - .map(str::trim) - .filter(|value| !value.is_empty()) - .map(ToOwned::to_owned) - .unwrap_or_else(|| session.seed_text.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) diff --git a/server-rs/crates/spacetime-module/src/runtime/creation_entry_config.rs b/server-rs/crates/spacetime-module/src/runtime/creation_entry_config.rs index 05c9db500..c0b47b2ec 100644 --- a/server-rs/crates/spacetime-module/src/runtime/creation_entry_config.rs +++ b/server-rs/crates/spacetime-module/src/runtime/creation_entry_config.rs @@ -179,6 +179,7 @@ fn seed_creation_entry_config_if_missing(ctx: &ReducerContext) { } } + migrate_rpg_entry_from_old_hidden_default(ctx, now); migrate_visual_novel_entry_from_old_visible_default(ctx, now); migrate_coming_soon_entry_from_old_open_default( ctx, @@ -204,6 +205,36 @@ fn seed_creation_entry_config_if_missing(ctx: &ReducerContext) { ); } +fn migrate_rpg_entry_from_old_hidden_default(ctx: &ReducerContext, now: Timestamp) { + let id = "rpg".to_string(); + let Some(row) = ctx.db.creation_entry_type_config().id().find(&id) else { + return; + }; + + // 中文注释:åªå¼€æ”¾åކå²é»˜è®¤éšè—çš„ RPG å…¥å£ï¼Œä¸è¦†ç›–åŽå°å…¥å£å¼€å…³åŽç»­æ‰‹åЍé…置。 + let still_old_hidden_default = row.title == "文字冒险" + && row.subtitle == "ç»å…¸ RPG 体验" + && row.badge == "内测" + && row.image_src == "/creation-type-references/rpg.webp" + && !row.visible + && row.open + && row.sort_order == 10; + if !still_old_hidden_default { + return; + } + + ctx.db + .creation_entry_type_config() + .id() + .update(CreationEntryTypeConfig { + badge: "å¯åˆ›å»º".to_string(), + visible: true, + open: true, + updated_at: now, + ..row + }); +} + fn migrate_visual_novel_entry_from_old_visible_default(ctx: &ReducerContext, now: Timestamp) { let id = "visual-novel".to_string(); let Some(row) = ctx.db.creation_entry_type_config().id().find(&id) else { diff --git a/src/components/CustomWorldResultView.test.tsx b/src/components/CustomWorldResultView.test.tsx index 2142efe80..cf381a5f5 100644 --- a/src/components/CustomWorldResultView.test.tsx +++ b/src/components/CustomWorldResultView.test.tsx @@ -5,6 +5,7 @@ import userEvent from '@testing-library/user-event'; import { useState } from 'react'; import { expect, test, vi } from 'vitest'; +import { normalizeCustomWorldProfileRecord } from '../data/customWorldLibrary'; import * as rpgCreationAssetClient from '../services/rpg-creation/rpgCreationAssetClient'; import type { CustomWorldPlayableNpc, CustomWorldProfile } from '../types'; import { RpgCreationResultView } from './rpg-creation-result/RpgCreationResultView'; @@ -286,6 +287,40 @@ function ResultViewHarness() { ); } +function ResultViewRehydratingHarness() { + const [profile, setProfile] = useState(baseProfile); + const [rehydrated, setRehydrated] = useState(false); + + return ( +
+
{rehydrated ? 'yes' : 'no'}
+ {}} + onProfileChange={(nextProfile) => { + setProfile(nextProfile); + if (!nextProfile.openingCg) { + return; + } + + window.setTimeout(() => { + const normalized = normalizeCustomWorldProfileRecord(nextProfile); + if (normalized) { + setProfile(normalized); + } + setRehydrated(true); + }, 0); + }} + /> +
+ ); +} + test('clickingæ–°å¢žå¯æ‰®æ¼”角色 shows pending item, disables button, and marks result as new', async () => { const user = userEvent.setup(); @@ -385,6 +420,40 @@ test('world tab generates opening cg only after manual click and writes it back }); }); +test('world tab keeps opening cg visible after parent rehydrates normalized profile', async () => { + const user = userEvent.setup(); + mockedRpgCreationAssetClient.generateOpeningCg.mockResolvedValue({ + id: 'opening-cg-1', + status: 'ready', + storyboardImageSrc: '/generated-custom-world-scenes/world/opening/storyboard.png', + storyboardAssetId: 'storyboard-1', + videoSrc: '/generated-custom-world-scenes/world/opening/opening.mp4', + videoAssetId: 'video-1', + 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-03T00:00:00Z', + }); + + render(); + + await user.click(screen.getByRole('button', { name: '生æˆ' })); + + await waitFor(() => { + expect(screen.getByTestId('rehydrated').textContent).toBe('yes'); + }); + expect( + document.querySelector( + 'video[src="/generated-custom-world-scenes/world/opening/opening.mp4"]', + ), + ).toBeTruthy(); +}); + test('playable tab prefers generated portrait over runtime preview placeholder', async () => { const user = userEvent.setup(); const profile = { diff --git a/src/components/custom-world-home/CustomWorldCreationHub.interaction.test.tsx b/src/components/custom-world-home/CustomWorldCreationHub.interaction.test.tsx index 9c2d722e5..dbd41fc32 100644 --- a/src/components/custom-world-home/CustomWorldCreationHub.interaction.test.tsx +++ b/src/components/custom-world-home/CustomWorldCreationHub.interaction.test.tsx @@ -25,6 +25,17 @@ const testEntryConfig = { description: '先选玩法类型,å†è¿›å…¥å¯¹åº”创作工作å°ã€‚', }, creationTypes: [ + { + id: 'rpg', + title: '文字冒险', + subtitle: 'ç»å…¸ RPG 体验', + badge: 'å¯åˆ›å»º', + imageSrc: '/creation-type-references/rpg.webp', + visible: true, + open: true, + sortOrder: 10, + updatedAtMicros: 1, + }, { id: 'puzzle', title: '拼图', @@ -253,14 +264,18 @@ test('creation hub reflects updated draft title summary and counts after rerende const match3dButton = screen.getByRole('button', { name: /抓大鹅.*3D 消除关å¡/u, }); + const rpgButton = screen.getByRole('button', { + name: /文字冒险.*ç»å…¸ RPG 体验/u, + }); expect(puzzleButton).toBeTruthy(); expect(match3dButton).toBeTruthy(); + expect(rpgButton).toBeTruthy(); expect((puzzleButton as HTMLButtonElement).disabled).toBe(false); expect((match3dButton as HTMLButtonElement).disabled).toBe(false); + expect((rpgButton as HTMLButtonElement).disabled).toBe(false); expect(screen.queryByRole('button', { name: /方洞挑战/u })).toBeNull(); expect(screen.queryByText('å直觉形状分拣')).toBeNull(); expect(screen.queryByRole('button', { name: /智能创作/u })).toBeNull(); - expect(screen.queryByRole('button', { name: /文字冒险/u })).toBeNull(); expect(screen.queryByRole('button', { name: /大鱼åƒå°é±¼/u })).toBeNull(); await user.click(match3dButton); diff --git a/src/components/custom-world-home/CustomWorldCreationHub.test.tsx b/src/components/custom-world-home/CustomWorldCreationHub.test.tsx index 8ece1b8b0..ced0e82c4 100644 --- a/src/components/custom-world-home/CustomWorldCreationHub.test.tsx +++ b/src/components/custom-world-home/CustomWorldCreationHub.test.tsx @@ -19,6 +19,17 @@ const testEntryConfig = { description: '先选玩法类型,å†è¿›å…¥å¯¹åº”创作工作å°ã€‚', }, creationTypes: [ + { + id: 'rpg', + title: '文字冒险', + subtitle: 'ç»å…¸ RPG 体验', + badge: 'å¯åˆ›å»º', + imageSrc: '/creation-type-references/rpg.webp', + visible: true, + open: true, + sortOrder: 10, + updatedAtMicros: 1, + }, { id: 'puzzle', title: '拼图', @@ -124,7 +135,8 @@ test('creation hub draft card renders compiled work summary fields', () => { expect(html).toContain('拼图关å¡åˆ›ä½œ'); expect(html).toContain('抓大鹅'); expect(html).toContain('3D 消除关å¡'); - expect(html).not.toContain('文字冒险'); + expect(html).toContain('文字冒险'); + expect(html).toContain('ç»å…¸ RPG 体验'); expect(html).not.toContain('大鱼åƒå°é±¼'); }); diff --git a/src/components/rpg-entry/RpgEntryFlowShell.agent.interaction.test.tsx b/src/components/rpg-entry/RpgEntryFlowShell.agent.interaction.test.tsx index 732dc6f66..053e09bb2 100644 --- a/src/components/rpg-entry/RpgEntryFlowShell.agent.interaction.test.tsx +++ b/src/components/rpg-entry/RpgEntryFlowShell.agent.interaction.test.tsx @@ -271,6 +271,17 @@ const testCreationEntryConfig = { description: '先选玩法类型,å†è¿›å…¥å¯¹åº”创作工作å°ã€‚', }, creationTypes: [ + { + id: 'rpg', + title: '文字冒险', + subtitle: 'ç»å…¸ RPG 体验', + badge: 'å¯åˆ›å»º', + imageSrc: '/creation-type-references/rpg.webp', + visible: true, + open: true, + sortOrder: 10, + updatedAtMicros: 1, + }, { id: 'puzzle', title: '拼图', @@ -3205,8 +3216,8 @@ test('create tab shows template tabs and embeds puzzle form by default', async ( screen.getByRole('tab', { name: '拼图' }).querySelector('img')?.src, ).toContain('/creation-type-references/puzzle.webp'); expect( - screen.getByRole('tab', { name: 'AIRP' }).querySelector('img')?.src, - ).toContain('/creation-type-references/airp.webp'); + screen.getByRole('tab', { name: '文字冒险' }).querySelector('img')?.src, + ).toContain('/creation-type-references/rpg.webp'); expect( screen.getByRole('tab', { name: '抓大鹅' }).querySelector('img')?.src, ).toContain('/creation-type-references/match3d.webp'); diff --git a/src/data/customWorldLibrary.test.ts b/src/data/customWorldLibrary.test.ts index e07f9e627..6643c7b2a 100644 --- a/src/data/customWorldLibrary.test.ts +++ b/src/data/customWorldLibrary.test.ts @@ -165,4 +165,35 @@ describe('normalizeCustomWorldProfileRecord role asset descriptions', () => { '/generated-characters/story-yizhang/portrait.png', ); }); + + it('ä¿ç•™ç»“果页生æˆçš„开局 CG æ§½ä½', () => { + const profile = normalizeCustomWorldProfileRecord({ + name: '雾港归航', + settingText: '海雾旧案', + openingCg: { + id: 'opening-cg-1', + status: 'ready', + storyboardImageSrc: '/generated-custom-world-scenes/opening/storyboard.png', + storyboardAssetId: 'storyboard-1', + videoSrc: '/generated-custom-world-scenes/opening/opening.mp4', + videoAssetId: 'video-1', + 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', + }, + }); + + expect(profile?.openingCg?.videoSrc).toBe( + '/generated-custom-world-scenes/opening/opening.mp4', + ); + expect(profile?.openingCg?.storyboardImageSrc).toBe( + '/generated-custom-world-scenes/opening/storyboard.png', + ); + }); }); diff --git a/src/data/customWorldLibrary.ts b/src/data/customWorldLibrary.ts index e075867d2..8b53f3e86 100644 --- a/src/data/customWorldLibrary.ts +++ b/src/data/customWorldLibrary.ts @@ -27,6 +27,7 @@ import { CustomWorldNpcVisualGear, CustomWorldNpcVisualGearType, CustomWorldNpcVisualRace, + CustomWorldOpeningCgProfile, CustomWorldPlayableNpc, CustomWorldProfile, CustomWorldRoleInitialItem, @@ -1155,6 +1156,9 @@ function normalizeProfile(value: unknown): CustomWorldProfile | null { .map((entry, index) => normalizePlayableNpc(entry, index)) .filter((entry): entry is CustomWorldPlayableNpc => Boolean(entry)) : []; + const openingCg = preserveStructuredRecord( + value.openingCg, + ); const normalizedProfile = { id: toText(value.id, `saved-custom-world-${Date.now().toString(36)}`), settingText, @@ -1180,6 +1184,7 @@ function normalizeProfile(value: unknown): CustomWorldProfile | null { .map((entry, index) => normalizeItem(entry, index)) .filter((entry): entry is CustomWorldItem => Boolean(entry)) : [], + openingCg, camp, landmarks: normalizeCustomWorldLandmarks({ landmarks: landmarkDrafts, From 224a26d318b38cebeae69aca5f3c38b0bae15c8b Mon Sep 17 00:00:00 2001 From: kdletters <61648117+kdletters@users.noreply.github.com> Date: Thu, 21 May 2026 17:49:07 +0800 Subject: [PATCH 2/4] fix: tolerate null legacy custom-world profile --- .hermes/shared-memory/pitfalls.md | 8 +++ ...玩法创作】平å°å…¥å£ä¸ŽçŽ©æ³•é“¾è·¯-2026-05-15.md | 2 + .../module-custom-world/src/application.rs | 51 ++++++++++++++++++- 3 files changed, 60 insertions(+), 1 deletion(-) diff --git a/.hermes/shared-memory/pitfalls.md b/.hermes/shared-memory/pitfalls.md index 88b413fb1..9c47745c9 100644 --- a/.hermes/shared-memory/pitfalls.md +++ b/.hermes/shared-memory/pitfalls.md @@ -398,6 +398,14 @@ - 验è¯ï¼š`npm run test -- src/data/customWorldLibrary.test.ts src/components/CustomWorldResultView.test.tsx`,确认生æˆåŽå³ä½¿çˆ¶å±‚åšä¸€æ¬¡å½’一化回写,开局 CG ä»ç»§ç»­æ˜¾ç¤ºã€‚ - å…³è”:`src/data/customWorldLibrary.ts`ã€`src/components/rpg-creation-result/RpgCreationResultViewImpl.tsx`ã€`src/components/CustomWorldEntityCatalog.tsx`。 +## RPG å‘布报 legacy_result_profile_json éžæ³•先查 null 兼容 + +- 现象:RPG 结果页å‘布动作返回 `UPSTREAM_ERROR`,SpacetimeDB details 里是 `custom_world.compile.legacy_result_profile_json 䏿˜¯åˆæ³• JSON object`。 +- 原因:`publish_world` å‰ç«¯å¥‘约åªè¦æ±‚ `{ action: 'publish_world' }`ï¼›`ExecuteCustomWorldAgentActionRequest.legacy_result_profile` 是å¯é€‰å­—æ®µï¼Œç» HTTP / serde / SpacetimeDB payload 传递时å¯èƒ½æ˜¾å¼æˆä¸º JSON `null`ã€‚æ—§çš„ç¼–è¯‘å™¨åªæŽ¥å— object 或缺çœï¼ŒæŠŠ `Some("null")` 当æˆéžæ³• legacy JSON。 +- 处ç†ï¼š`module-custom-world` çš„ optional JSON object è§£æžè¦æŠŠ `null` 视为未æä¾›ï¼Œä»æ‹’ç»æ•°ç»„ã€å­—ç¬¦ä¸²ã€æ•°å­—å’Œå JSON;正å¼å‘布继续以 session `draft_profile_json` 为è‰ç¨¿çœŸç›¸ã€‚ +- 验è¯ï¼š`cargo test -p module-custom-world published_profile_compile --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`。 + ## 本地脚本调 VectorEngine 生图å¡ä½å…ˆåŒºåˆ† fetch 首部超时 - 现象:用 Node `fetch` 直接请求 `POST /v1/images/generations`,已ç»è®¾ç½®è¾ƒé•¿çš„ AbortController 超时,但ä»åœ¨çº¦ 180 到 300 ç§’åŽæŠ› `AbortError`ã€`TypeError: fetch failed` 或 `UND_ERR_HEADERS_TIMEOUT`ï¼›åŒä¸€ prompt 改用原生 `https.request` å¯ä»¥åœ¨è¾ƒçŸ­æ—¶é—´å†…æˆåŠŸè¿”å›žå›¾ç‰‡ã€‚ diff --git a/docs/ã€çŽ©æ³•åˆ›ä½œã€‘å¹³å°å…¥å£ä¸ŽçŽ©æ³•é“¾è·¯-2026-05-15.md b/docs/ã€çŽ©æ³•åˆ›ä½œã€‘å¹³å°å…¥å£ä¸ŽçŽ©æ³•é“¾è·¯-2026-05-15.md index eb07fc540..2584be6b7 100644 --- a/docs/ã€çŽ©æ³•åˆ›ä½œã€‘å¹³å°å…¥å£ä¸ŽçŽ©æ³•é“¾è·¯-2026-05-15.md +++ b/docs/ã€çŽ©æ³•åˆ›ä½œã€‘å¹³å°å…¥å£ä¸ŽçŽ©æ³•é“¾è·¯-2026-05-15.md @@ -50,6 +50,8 @@ RPG API 仿²¿ç”¨åކå²å‘½å空间:`/api/runtime/custom-world*`ã€`/api/story 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` å½“ä½œå”¯ä¸€è®¾å®šæ¥æºï¼Œæ—§ä¼šè¯å¯èƒ½ä¸ºç©ºã€‚ +`legacyResultProfile` åªä½œä¸ºåކå²ç»“果页 profile 兼容兜底;`publish_world` è¯·æ±‚ç¼ºçœæˆ–显å¼ä¸º `null` 时等价于未æä¾›ï¼Œç¼–è¯‘æ­£å¼ profile æ—¶ä¸å¾—因此报 `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` ä¸¢æŽ‰ï¼Œè€Œä¸æ˜¯å…ˆæ€€ç–‘已生æˆèµ„æºæœ¬èº«å¤±æ•ˆã€‚ ## 拼图 diff --git a/server-rs/crates/module-custom-world/src/application.rs b/server-rs/crates/module-custom-world/src/application.rs index a518b3d16..57f8f41c0 100644 --- a/server-rs/crates/module-custom-world/src/application.rs +++ b/server-rs/crates/module-custom-world/src/application.rs @@ -694,7 +694,13 @@ fn parse_optional_json_object( error: CustomWorldFieldError, ) -> Result, CustomWorldFieldError> { match normalize_optional_json_slice(value) { - Some(value) => parse_required_json_object(&value, error), + Some(value) => match serde_json::from_str::(&value) { + Ok(Value::Object(object)) => Ok(object), + // 中文注释:跨层å¯é€‰å­—æ®µç» serde 结构体åºåˆ—化åŽå¯èƒ½æ˜¾å¼è½æˆ nullï¼› + // 对 optional JSON object 而言 null 等价于未æä¾›ï¼Œä¸èƒ½é˜»æ–­å‘布链路。 + Ok(Value::Null) => Ok(Map::new()), + _ => Err(error), + }, None => Ok(Map::new()), } } @@ -1018,6 +1024,49 @@ mod tests { use super::*; use serde_json::json; + fn build_test_compile_input( + legacy_result_profile_json: Option, + ) -> CustomWorldPublishedProfileCompileInput { + CustomWorldPublishedProfileCompileInput { + session_id: "session-1".to_string(), + profile_id: "cwprof_001".to_string(), + owner_user_id: "user-1".to_string(), + draft_profile_json: json!({ + "name": "潮雾列岛", + "summary": "群岛与旧ç¯å¡”之间的沉船疑案。", + "playableNpcs": [], + "storyNpcs": [], + "landmarks": [] + }) + .to_string(), + legacy_result_profile_json, + setting_text: "海图会在åˆå¤œæ”¹å†™ç¾¤å²›èˆªè·¯ã€‚".to_string(), + author_display_name: "创作者".to_string(), + updated_at_micros: 1, + } + } + + #[test] + fn published_profile_compile_treats_null_legacy_result_profile_as_absent() { + let snapshot = build_custom_world_published_profile_compile_snapshot( + build_test_compile_input(Some("null".to_string())), + ) + .expect("null legacy result profile should be treated as absent"); + + assert_eq!(snapshot.profile_id, "cwprof_001"); + assert_eq!(snapshot.world_name, "潮雾列岛"); + } + + #[test] + fn published_profile_compile_rejects_non_object_legacy_result_profile() { + let error = build_custom_world_published_profile_compile_snapshot( + build_test_compile_input(Some("[]".to_string())), + ) + .expect_err("array legacy result profile should still be invalid"); + + assert_eq!(error, CustomWorldFieldError::InvalidLegacyResultProfileJson); + } + #[test] fn publish_setting_text_falls_back_to_draft_profile_when_seed_is_empty() { let payload = Map::new(); 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 3/4] 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 9c47745c9..89753df6d 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 2584be6b7..ffb0af2e8 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 d5fcb4032..8695904b0 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 57f8f41c0..510a977cc 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 c7d8eeca2..a3249fa3c 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 5ede559e5..e16ff02fb 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 834740ae8..7b9287881 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 d394f8785..72ee4ceae 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 c7a45f6d1..8f3be02f6 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 e048fd659..8a8cb6cab 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 a3c22707e..89f6cee16 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 d4d5e5920..300068a7e 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 7a58a116a..1e6bc052d 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 = { From d74457faa2e08fc64db884e35e6eeb1f66125afa Mon Sep 17 00:00:00 2001 From: kdletters <61648117+kdletters@users.noreply.github.com> Date: Fri, 22 May 2026 03:14:11 +0800 Subject: [PATCH 4/4] fix: preserve rpg custom world detail profiles --- .hermes/shared-memory/pitfalls.md | 36 +- ...玩法创作】平å°å…¥å£ä¸ŽçŽ©æ³•é“¾è·¯-2026-05-15.md | 8 +- ...项目基线】当å‰äº§å“与工程约æŸ-2026-05-15.md | 1 + .../module-runtime-story/src/battle_tests.rs | 320 +++++++- .../crates/module-runtime-story/src/lib.rs | 4 +- .../module-runtime-story/src/post_battle.rs | 290 +++++++- .../src/session_action.rs | 122 +++- .../GameCanvasEntityLayer.test.tsx | 20 +- .../game-canvas/GameCanvasEntityLayer.tsx | 54 +- ...gEntryFlowShell.agent.interaction.test.tsx | 688 +++++++++++++++++- src/components/rpg-entry/RpgEntryHomeView.tsx | 98 ++- .../rpg-entry/rpgProfileCompleteness.ts | 157 ++++ .../useRpgCreationEnterWorld.test.tsx | 132 ++++ .../rpg-entry/useRpgCreationEnterWorld.ts | 21 +- .../useRpgEntryAgentDraftRestore.test.tsx | 383 +++++++++- .../rpg-entry/useRpgEntryLibraryDetail.ts | 49 ++ src/data/customWorldLibrary.test.ts | 283 +++++++ src/data/customWorldLibrary.ts | 49 +- src/hooks/useGameFlow.customWorld.test.tsx | 120 +++ 19 files changed, 2726 insertions(+), 109 deletions(-) create mode 100644 src/components/rpg-entry/rpgProfileCompleteness.ts diff --git a/.hermes/shared-memory/pitfalls.md b/.hermes/shared-memory/pitfalls.md index 89753df6d..cc541fc38 100644 --- a/.hermes/shared-memory/pitfalls.md +++ b/.hermes/shared-memory/pitfalls.md @@ -38,13 +38,29 @@ - 验è¯ï¼š`npm run test -- src/components/rpg-entry/useRpgCreationEnterWorld.test.tsx`;确认已å‘布场景下 `syncAgentDraftResultProfile` 与 `executePublishWorld` 凿œªè¢«è°ƒç”¨ã€‚ - å…³è”:`src/components/rpg-entry/useRpgCreationEnterWorld.ts`ã€`src/components/platform-entry/PlatformEntryFlowShellImpl.tsx`ã€`docs/ã€çŽ©æ³•åˆ›ä½œã€‘å¹³å°å…¥å£ä¸ŽçŽ©æ³•é“¾è·¯-2026-05-15.md`。 -## RPG 点击å¯åЍ黑å±å…ˆæŸ¥ profile 归一化和角色选择兜底 +## RPG 点击å¯åŠ¨é»‘å± / 默认 profile 先查 profile 归一化和摘è¦è¦†ç›– -- 现象:作å“详情点击“å¯åЍâ€åŽé¡µé¢åˆ‡åˆ° RPG runtime,但用户åªçœ‹åˆ°é»‘屿ˆ–空白;DevTools 里å¯èƒ½åŒæ—¶çœ‹åˆ°æ—§è‡ªåŠ¨å­˜æ¡£ `/api/runtime/save/snapshot` 被主动 cancel。 -- 原因:`/custom-world-library` / `/custom-world-gallery` 详情接å£å¯èƒ½è¿”å›žåŽ†å²æˆ–摘è¦å¼ `profile`,缺少 `playableNpcs`ã€`storyNpcs`ã€`landmarks`ã€`attributeSchema` ç­‰è¿è¡Œæ€å­—段;å‰ç«¯ client 若直接把该对象传给 runtime,角色选择首å±ä¼šåœ¨ `buildCustomWorldPlayableCharacters(profile)` 或åŽç»­å±žæ€§è§£æžå¤„抛错。`save/snapshot (canceled)` 通常是切 runtime 或å¸è½½æ—¶ `AbortController` å–æ¶ˆæ—§è‡ªåŠ¨å­˜æ¡£ï¼Œä¸æ˜¯é»‘屿 ¹å› ã€‚ -- 处ç†ï¼šRPG å…¥å£ä½œå“库 client 在所有返回 `CustomWorldLibraryEntry` 的接å£è¾¹ç•Œç»Ÿä¸€è°ƒç”¨ `normalizeCustomWorldProfileRecord`,并用 `profileId/worldName/subtitle/summaryText` è¡¥é½æ—§æ•°æ®ç¼ºå­—段;角色选择页对角色生æˆå¼‚常或空数组回退默认角色,并ä¿ç•™è¿”回按钮/è½»é‡ç©ºæ€ï¼›é¡¶å±‚ runtime 懒加载 fallback ä¸ä½¿ç”¨çº¯ `null`。 -- 验è¯ï¼š`npm run test -- src/services/rpg-entry/rpgEntryLibraryClient.test.ts`ã€`npm run test -- src/components/rpg-entry/RpgEntryCharacterSelectView.test.tsx`ã€`npm run typecheck`。 -- å…³è”:`src/services/rpg-entry/rpgEntryLibraryClient.ts`ã€`src/components/rpg-entry/RpgEntryCharacterSelectView.tsx`ã€`src/App.tsx`ã€`src/components/rpg-runtime-shell/RpgRuntimeShell.tsx`ã€`docs/ã€çŽ©æ³•åˆ›ä½œã€‘å¹³å°å…¥å£ä¸ŽçŽ©æ³•é“¾è·¯-2026-05-15.md`。 +- 现象:作å“详情点击“å¯åЍâ€åŽé¡µé¢åˆ‡åˆ° RPG runtime,但用户åªçœ‹åˆ°é»‘å±ã€ç©ºç™½ï¼Œæˆ–进入默认角色 / 默认 profile;从作å“详情点“作å“编辑â€åŽå¼€å±€ CGã€å°é¢ã€è§’è‰²å›¾ã€æŠ€èƒ½åŠ¨ä½œé¢„è§ˆã€åˆå§‹ç‰©å“图标或场景背景图丢失;DevTools 里å¯èƒ½åŒæ—¶çœ‹åˆ°æ—§è‡ªåŠ¨å­˜æ¡£ `/api/runtime/save/snapshot` 被主动 cancel。 +- 原因:`/custom-world-library` / `/custom-world-gallery` 详情接å£å¯èƒ½è¿”å›žåŽ†å²æˆ–摘è¦å¼ `profile`,缺少 `playableNpcs`ã€`storyNpcs`ã€`landmarks`ã€`attributeSchema` ç­‰è¿è¡Œæ€å­—段;å‰ç«¯ client 若直接把该对象传给 runtime,角色选择首å±ä¼šåœ¨ `buildCustomWorldPlayableCharacters(profile)` 或åŽç»­å±žæ€§è§£æžå¤„抛错。å¦ä¸€ç±»å¸¸è§åŽŸå› æ˜¯è¯¦æƒ…æŽ¥å£å·²å›žè¯»å®Œæ•´ profile åŽï¼Œ`savedCustomWorldEntries` 里的列表摘è¦åˆæŠŠ `selectedDetailEntry` 覆盖回空 profile,导致å¯åŠ¨æˆ–ç¼–è¾‘æ—¶åªå‰©å¡ç‰‡æ‘˜è¦ã€‚å‘布 / 回读 result-view 若返回字段更少的旧视图,也å¯èƒ½æŠŠå½“å‰ç»“果页已编辑资产é™çº§æŽ‰ã€‚`save/snapshot (canceled)` 通常是切 runtime 或å¸è½½æ—¶ `AbortController` å–æ¶ˆæ—§è‡ªåŠ¨å­˜æ¡£ï¼Œä¸æ˜¯é»‘屿 ¹å› ã€‚ +- 处ç†ï¼šRPG å…¥å£ä½œå“库 client 在所有返回 `CustomWorldLibraryEntry` 的接å£è¾¹ç•Œç»Ÿä¸€è°ƒç”¨ `normalizeCustomWorldProfileRecord`,并用 `profileId/worldName/subtitle/summaryText` è¡¥é½æ—§æ•°æ®ç¼ºå­—段;详情页已拿到è¿è¡Œæ€å­—æ®µæˆ–èµ„äº§æ§½ä½æ›´å¤šçš„完整 profile 时,ä¸å…许列表摘è¦è¦†ç›–当å‰è¯¦æƒ…ï¼›åŒä¸€ `profile.id` 下,正å¼è¿›å…¥ä¸–界å‘布 / 回读ä¸å¾—用字段更少的åŽç«¯æ—§è§†å›¾é™çº§å½“å‰ç»“果页 profile。`normalizeCustomWorldProfileRecord` 必须近似无æŸä¿ç•™ `cover`ã€`openingCg`ã€`camp.narrativeResidues`ã€`landmark.visualDescription/narrativeResidues`ã€`skills[].actionPreviewConfig`ã€`initialItems[].iconSrc`ã€`attributeSchema`ã€è§’色 `attributeProfile` å’Œ `sceneChapterBlueprints[].acts[]` çš„èƒŒæ™¯ä¸Žç»“æž„å­—æ®µï¼›åªæœ‰èƒŒæ™¯èµ„产的 act 也ä¸èƒ½è¢«è¿‡æ»¤ã€‚角色选择页对角色生æˆå¼‚常或空数组回退默认角色,并ä¿ç•™è¿”回按钮/è½»é‡ç©ºæ€ï¼›é¡¶å±‚ runtime 懒加载 fallback ä¸ä½¿ç”¨çº¯ `null`。 +- 验è¯ï¼š`npm run test -- src/components/rpg-entry/RpgEntryFlowShell.agent.interaction.test.tsx -t "creation hub published work start uses loaded detail profile instead of library summary|creation hub published work edit keeps loaded detail profile assets instead of library summary"`ï¼›`npm run test -- src/data/customWorldLibrary.test.ts -t "ä¿ç•™ç»“果页å°é¢å’Œå…³é”®å›¾ç‰‡èµ„产槽ä½|近似无æŸä¿ç•™ç¼–辑æ€å’Œè¿è¡Œæ€ç»“构字段|ä¿ç•™åªæœ‰èƒŒæ™¯èµ„产的场景幕"`ï¼›`npm run test -- src/components/rpg-entry/useRpgEntryAgentDraftRestore.test.tsx -t "默认å°é¢å’Œè§’色编辑结构差异也ä¸èƒ½è¢«åˆ—表摘è¦è¦†ç›–"`ï¼›`npm run test -- src/components/rpg-entry/useRpgCreationEnterWorld.test.tsx -t "æ­£å¼è¿›å…¥ä¸–界回读结果页字段更少时ä¸é™çº§å½“å‰å®Œæ•´ profile"`ï¼›`npm run typecheck`。 +- å…³è”:`src/components/rpg-entry/useRpgEntryLibraryDetail.ts`ã€`src/components/rpg-entry/useRpgCreationEnterWorld.ts`ã€`src/data/customWorldLibrary.ts`ã€`src/services/rpg-entry/rpgEntryLibraryClient.ts`ã€`src/components/rpg-entry/RpgEntryCharacterSelectView.tsx`ã€`src/App.tsx`ã€`src/components/rpg-runtime-shell/RpgRuntimeShell.tsx`ã€`docs/ã€çŽ©æ³•åˆ›ä½œã€‘å¹³å°å…¥å£ä¸ŽçŽ©æ³•é“¾è·¯-2026-05-15.md`。 + +## RPG 战åŽä¸€è½®æˆ˜æ–—åŽå¡åœ¨è§‚察/试探/è°ƒæ¯å…ˆæŸ¥ post-battle finalization + +- 现象:RPG 一轮战斗胜利åŽï¼Œè¿è¡Œæ€åªæ˜¾ç¤ºé»˜è®¤ `观察周围迹象 / 主动出声试探 / 原地调æ¯`ï¼Œè¿™äº›æŒ‰é’®åªæœ‰æ–‡å­—å馈;点“继续冒险â€åŽåˆå›žåˆ°åŒæ ·é€‰é¡¹ï¼Œç‚¹æŽ¢ç´¢åªæ’­é€€åœº/è¿›åœºåŠ¨ç”»ï¼Œåœºæ™¯å’Œå‰§æƒ…ä¸æŽ¨è¿›ã€‚ +- 原因:终局战斗 action 如果åªèµ°é€šç”¨ `resolve_story_runtime_action` fallback,而没有在åŽç«¯è°ƒç”¨ `finalize_post_battle_resolution(...)`,就ä¸ä¼šæŒä¹…写入 `story_continue_adventure`ã€`deferredOptions` 和下一幕 `currentSceneActState`。å¦å¤–æ—§ bootstrap å¿«ç…§å¯èƒ½åªæœ‰ `connectedSceneIds` / `forwardSceneId`ã€æ²¡æœ‰ `connections`,战åŽé€‰é¡¹ç”Ÿæˆè‹¥åªè¯» `connections` 也会退回 `idle_explore_forward` 循环。 +- 处ç†ï¼š`module-runtime-story` 在 story action 投影åŽç»Ÿä¸€è°ƒç”¨ post-battle finalizationï¼›`idle_explore_forward` æ¸…ç†æˆ˜æ–—æ€å¹¶ç”Ÿæˆä¸‹ä¸€æ®µé­é‡é¢„览;`idle_travel_next_scene` / `camp_travel_home_scene` ç”±åŽç«¯å†™å…¥æ–° `currentScenePreset`ã€åœºæ™¯ act 状æ€ã€é­é‡é¢„览和 `runtimeStats.scenesTraveled`。å‰ç«¯åªè´Ÿè´£æ’­æ”¾ç»§ç»­ã€æŽ¢ç´¢å’Œåˆ‡åœºæ™¯åŠ¨ç”»ï¼Œä¸æ‰¿æŽ¥æ­£å¼å‰§æƒ…推进真相。 +- 验è¯ï¼š`cargo test -p module-runtime-story --manifest-path server-rs\Cargo.toml battle_tests -- --nocapture` 应覆盖战斗终局æŒä¹…化 `story_continue_adventure`ã€`deferredOptions`ã€ä¸‹ä¸€å¹• actï¼Œä»¥åŠ `idle_travel_next_scene` 真正切æ¢åœºæ™¯ã€‚ +- å…³è”:`server-rs/crates/module-runtime-story/src/session_action.rs`ã€`server-rs/crates/module-runtime-story/src/post_battle.rs`ã€`server-rs/crates/module-runtime-story/src/battle_tests.rs`ã€`docs/ã€çŽ©æ³•åˆ›ä½œã€‘å¹³å°å…¥å£ä¸ŽçŽ©æ³•é“¾è·¯-2026-05-15.md`。 + +## RPG 战斗飘字ä¸è¦åªé ä½Žå¯¹æ¯”红绿文字 + +- 现象:暗色或棕黑噪声背景下,战斗伤害飘字看起æ¥åƒèƒŒæ™¯çº¹ç†ï¼Œå°¤å…¶æ˜¯è¿œç«¯æ•Œäººå¤´é¡¶çš„å°å·çº¢å­—几乎ä¸å¯è¯»ã€‚ +- 原因:旧 `CombatFloatingNumber` 主è¦ä¾èµ– `text-rose-200` / `text-emerald-200` å’Œ 8px åŒè‰² glowï¼›åœ¨æš—çº¢ã€æ£•黑ã€åƒç´ å™ªå£°èƒŒæ™¯ä¸Šï¼Œé¢œè‰²ä¸ŽèƒŒæ™¯æ··åœ¨ä¸€èµ·ï¼Œ1px 深色æè¾¹ä¹Ÿä¸è¶³ä»¥å½¢æˆè½®å»“。 +- 处ç†ï¼šé£˜å­—本体使用高亮近白文字ã€å°é¢ç§¯åŠé€æ˜Žæ·±è‰²åº•ã€æ˜Žæ˜¾æ·±è‰²æè¾¹å’Œå¤šå±‚黑色阴影;åªå¢žå¼ºçž¬æ—¶åé¦ˆï¼Œä¸æ–°å¢žè¯´æ˜Žé¢æ¿ï¼Œä¸é®æŒ¡ä¸»è¦æˆ˜æ–—ç”»é¢ã€‚ +- 验è¯ï¼š`npm run test -- src/components/game-canvas/GameCanvasEntityLayer.test.tsx` 覆盖伤害/治疗飘字样å¼ç­–略;è¿è¡Œæ€æˆªå›¾ä¸­æ•Œæ–¹å¤´é¡¶ä¼¤å®³æ•°å­—应能在暗场景上辨认。 +- å…³è”:`src/components/game-canvas/GameCanvasEntityLayer.tsx`ã€`docs/ã€é¡¹ç›®åŸºçº¿ã€‘当å‰äº§å“与工程约æŸ-2026-05-15.md`。 ## Windows provision ä¸‹è½½æˆªæ–­è¦æ–­ç‚¹ç»­ä¼ è€Œä¸æ˜¯å›žé€€ç›®æ ‡æœºä¸‹è½½ @@ -1119,3 +1135,11 @@ - 处ç†ï¼šæ‰“å¼€è‰ç¨¿æ—¶æŠŠæŒä¹…化 `generationStatus=generating` ç­‰åŒäºŽç”Ÿæˆä¸­ notice,æ¢å¤å¯¹åº”玩法生æˆè¿›åº¦é¡µï¼›æ¢å¤è®¡æ—¶ä½¿ç”¨ä½œå“æ‘˜è¦ `updatedAt` 推导 `startedAtMs`。 - 验è¯ï¼š`npm test -- src/components/rpg-entry/RpgEntryFlowShell.agent.interaction.test.tsx -t "persisted generating"`。 - å…³è”:`src/components/platform-entry/PlatformEntryFlowShellImpl.tsx`ã€`src/components/rpg-entry/RpgEntryFlowShell.agent.interaction.test.tsx`ã€`docs/ã€çŽ©æ³•åˆ›ä½œã€‘å¹³å°å…¥å£ä¸ŽçŽ©æ³•é“¾è·¯-2026-05-15.md`。 + +## 存档选择入å£ä¸è¦åªè—在“玩过â€å¼¹çª—里 + +- 现象:用户有 RPG / 拼图è¿è¡Œæ€å­˜æ¡£ï¼Œä½†å¹³å°åº•部 `è‰ç¨¿` Tab åªå±•ç¤ºä½œå“æž¶ï¼Œä¸ªäººä¸­å¿ƒåªæœ‰ç‚¹å‡» `玩过` åŽæ‰å¯èƒ½çœ‹åˆ°â€œå¯ç»§ç»­â€ï¼Œå¯¼è‡´çœ‹èµ·æ¥æ²¡æœ‰å­˜æ¡£é€‰æ‹©å…¥å£ã€‚ +- 原因:`/api/profile/save-archives` å·²åœ¨å…¥å£ bootstrap 加载,但å‰ç«¯åªæŠŠ `saveEntries` 注入 `ProfilePlayedWorksModal`;没有独立的存档入å£ã€‚ +- 处ç†ï¼šä¸ªäººä¸­å¿ƒ `常用功能` å¿…é¡»ä¿ç•™ `存档` å¿«æ·å…¥å£ï¼Œç‚¹å‡»åŽæ‰“开独立存档选择弹窗并å¤ç”¨ `SaveArchiveCard`ï¼›æ¢å¤ä»èµ° `/api/profile/save-archives/{worldKey}`,拼图存档继续走拼图 resume 分支,RPG èµ° `handleContinueGame(snapshot)`。 +- 验è¯ï¼š`npm run test -- src/components/rpg-entry/RpgEntryFlowShell.agent.interaction.test.tsx -t "profile page exposes save archive picker"`。 +- å…³è”:`src/components/rpg-entry/RpgEntryHomeView.tsx`ã€`src/components/platform-entry/PlatformEntryFlowShellImpl.tsx`ã€`src/components/rpg-entry/useRpgEntryBootstrap.ts`ã€`docs/ã€çŽ©æ³•åˆ›ä½œã€‘å¹³å°å…¥å£ä¸ŽçŽ©æ³•é“¾è·¯-2026-05-15.md`。 diff --git a/docs/ã€çŽ©æ³•åˆ›ä½œã€‘å¹³å°å…¥å£ä¸ŽçŽ©æ³•é“¾è·¯-2026-05-15.md b/docs/ã€çŽ©æ³•åˆ›ä½œã€‘å¹³å°å…¥å£ä¸ŽçŽ©æ³•é“¾è·¯-2026-05-15.md index ffb0af2e8..1d9bcfd50 100644 --- a/docs/ã€çŽ©æ³•åˆ›ä½œã€‘å¹³å°å…¥å£ä¸ŽçŽ©æ³•é“¾è·¯-2026-05-15.md +++ b/docs/ã€çŽ©æ³•åˆ›ä½œã€‘å¹³å°å…¥å£ä¸ŽçŽ©æ³•é“¾è·¯-2026-05-15.md @@ -54,9 +54,13 @@ Agent session 已进入 `published` åŽï¼Œç»“果页按钮åªèƒ½æ‰§è¡Œâ€œè¿›å…¥ `legacyResultProfile` åªä½œä¸ºåކå²ç»“果页 profile å…¼å®¹å…œåº•ï¼›ç¼–è¯‘æ­£å¼ profile 时,session è‰ç¨¿å†…å·²ä¿å­˜å­—段优先于 legacy 字段,legacy åªèƒ½è¡¥ç¼ºå¤±å­—段。`publish_world` ä¸å†æŽ¥å—å‰ç«¯ä¸´æ—¶ä¼ å…¥çš„ legacy è½½è·ï¼›åކå²å…¼å®¹è·¯å¾„中 legacy ç¼ºçœæˆ–显å¼ä¸º `null` 时等价于未æä¾›ï¼Œä¸å¾—因此报 `custom_world.compile.legacy_result_profile_json 䏿˜¯åˆæ³• JSON object`。真正的数组ã€å­—ç¬¦ä¸²ã€æ•°å­—ç­‰éž object legacy è½½è·ä»åº”æ‹’ç»ã€‚ -RPG 结果页开局 CG 是 `profile.openingCg` 资产槽ä½ï¼š`api-server` è´Ÿè´£ VectorEngine / OSS 副作用并返回故事æ¿å’Œè§†é¢‘引用,å‰ç«¯åªæŠŠç»“æžœå†™å›žå½“å‰ profileï¼›`sync_result_profile`ã€ä½œå“库ä¿å­˜å’Œ `normalizeCustomWorldProfileRecord` 都必须ä¿ç•™è¯¥æ§½ä½ã€‚è‹¥ç”ŸæˆæˆåŠŸåŽç”»é¢çŸ­æš‚显示åˆå˜å›žç©ºç™½ï¼Œä¼˜å…ˆæ£€æŸ¥çˆ¶å±‚釿–°åŒæ­¥æˆ– profile å½’ä¸€åŒ–æ˜¯å¦æŠŠ `openingCg` ä¸¢æŽ‰ï¼Œè€Œä¸æ˜¯å…ˆæ€€ç–‘已生æˆèµ„æºæœ¬èº«å¤±æ•ˆã€‚ +RPG 结果页开局 CG 是 `profile.openingCg` 资产槽ä½ï¼š`api-server` è´Ÿè´£ VectorEngine / OSS 副作用并返回故事æ¿å’Œè§†é¢‘引用,å‰ç«¯åªæŠŠç»“æžœå†™å›žå½“å‰ profileï¼›`sync_result_profile`ã€ä½œå“库ä¿å­˜å’Œ `normalizeCustomWorldProfileRecord` 都必须ä¿ç•™è¯¥æ§½ä½ã€‚å°é¢æ˜¯ `profile.cover` 资产槽ä½ï¼Œé»˜è®¤å°é¢ä¹Ÿè¦ä¿ç•™ `sourceType='default'` å’Œ `characterRoleIds`,ä¸èƒ½å› ä¸ºæ²¡æœ‰ `imageSrc` 就当作空å°é¢ã€‚è‹¥ç”ŸæˆæˆåŠŸåŽç”»é¢çŸ­æš‚显示åˆå˜å›žç©ºç™½ï¼Œä¼˜å…ˆæ£€æŸ¥çˆ¶å±‚釿–°åŒæ­¥æˆ– profile å½’ä¸€åŒ–æ˜¯å¦æŠŠ `openingCg` / `cover` ä¸¢æŽ‰ï¼Œè€Œä¸æ˜¯å…ˆæ€€ç–‘已生æˆèµ„æºæœ¬èº«å¤±æ•ˆã€‚ -RPG ä»Žä½œå“æž¶ã€å¹¿åœºè¯¦æƒ…或作å“å·æœç´¢ç‚¹å‡»â€œå¯åЍâ€å‰ï¼Œå…¥å£ client 必须把åŽç«¯è¿”回的完整 `profile` å…ˆç»è¿‡ `normalizeCustomWorldProfileRecord`ï¼Œå¹¶ç”¨ä½œå“æ¡ç›®çš„ `profileId/worldName/subtitle/summaryText` è¡¥é½æ—§æ•°æ®ç¼ºå¤±å­—段;è¿è¡Œæ€å’Œè¯¦æƒ…页ä¸å¾—直接消费未归一化的旧 profile。角色选择页还需è¦åœ¨è§’色数组异常或为空时回退默认角色,并显示å¯è¿”回的轻é‡ç©ºæ€ï¼Œä¸èƒ½ `return null` 造æˆé»‘å±ã€‚è¿è¡Œæ€æ‡’加载 fallback å¿…é¡»å¯è§ï¼Œä¸èƒ½ç”¨çº¯ `null` 让用户误判为黑å±ã€‚ +RPG ä»Žä½œå“æž¶ã€å¹¿åœºè¯¦æƒ…或作å“å·æœç´¢ç‚¹å‡»â€œå¯åЍâ€å‰ï¼Œå…¥å£ client 必须把åŽç«¯è¿”回的完整 `profile` å…ˆç»è¿‡ `normalizeCustomWorldProfileRecord`ï¼Œå¹¶ç”¨ä½œå“æ¡ç›®çš„ `profileId/worldName/subtitle/summaryText` è¡¥é½æ—§æ•°æ®ç¼ºå¤±å­—段;è¿è¡Œæ€å’Œè¯¦æƒ…页ä¸å¾—直接消费未归一化的旧 profileã€‚ä½œå“æž¶åˆ—表或 `savedCustomWorldEntries` ä¸­çš„æ‘˜è¦ profile åªå¯ç”¨äºŽå¡ç‰‡å±•示,ä¸å¯åœ¨è¯¦æƒ…接å£å·²å›žè¯»å®Œæ•´ profile åŽè¦†ç›– `selectedDetailEntry`;若摘è¦ç¼ºå°‘ `playableNpcs`ã€`storyNpcs`ã€`landmarks`ã€`items`ã€`sceneChapterBlueprints`ã€`cover`ã€`openingCg`ã€`skills[].actionPreviewConfig`ã€`initialItems[].iconSrc`ã€`attributeSchema`ã€è§’色 `attributeProfile`ã€åœºæ™¯æ®‹ç•™æˆ–场景幕背景资产,å¯åŠ¨å’Œç¼–è¾‘å¿…é¡»ç»§ç»­ä½¿ç”¨è¯¦æƒ… profile,å¦åˆ™ä¼šè¿›å…¥é»˜è®¤è§’色 / 默认 profile,或在编辑页丢 CGã€å°é¢ã€æŠ€èƒ½é¢„览和åˆå§‹ç‰©å“图标。正å¼â€œè¿›å…¥ä¸–界â€å‘布 / 回读结果页时,åŒä¸€ `profile.id` 下也ä¸å¾—用字段更少的åŽç«¯æ—§è§†å›¾é™çº§å½“å‰ç»“果页完整 profile。角色选择页还需è¦åœ¨è§’色数组异常或为空时回退默认角色,并显示å¯è¿”回的轻é‡ç©ºæ€ï¼Œä¸èƒ½ `return null` 造æˆé»‘å±ã€‚è¿è¡Œæ€æ‡’加载 fallback å¿…é¡»å¯è§ï¼Œä¸èƒ½ç”¨çº¯ `null` 让用户误判为黑å±ã€‚ + +RPG è¿è¡Œæ€çš„æˆ˜æ–—终局ã€ç»§ç»­å†’险ã€ç»§ç»­æŽ¢ç´¢å’Œåˆ‡åœºæ™¯éƒ½å±žäºŽæœåŠ¡ç«¯ runtime 快照真相:`module-runtime-story` 必须在终局战斗 action åŽè°ƒç”¨ post-battle finalization,æŒä¹…写入 `story_continue_adventure`ã€`deferredOptions`ã€`deferredRuntimeState.storyEngineMemory.currentSceneActState` 和清ç†åŽçš„æˆ˜æ–—状æ€ï¼›`idle_travel_next_scene` / `camp_travel_home_scene` 必须由åŽç«¯å†™å…¥æ–°çš„ `currentScenePreset`ã€`currentSceneActState`ã€`currentEncounter` å’Œ `runtimeStats.scenesTraveled`。å‰ç«¯åªæ’­æ”¾é€€åœºã€è¿›åœºå’Œç»§ç»­æŒ‰é’®è¡¨çŽ°ï¼Œä¸èƒ½ç”¨é»˜è®¤ `观察/试探/è°ƒæ¯` fallback 或本地动画å‡è£…推进剧情。旧 bootstrap å¿«ç…§å¯èƒ½åªæœ‰ `connectedSceneIds` / `forwardSceneId` 而没有 `connections`,åŽç«¯ç”Ÿæˆæˆ˜åŽæ—…行选项时必须兼容这些字段。 + +RPG / 拼图等è¿è¡Œæ€å­˜æ¡£é€‰æ‹©å…¥å£ç»Ÿä¸€åœ¨ä¸ªäººä¸­å¿ƒ `常用功能 > 存档` 暴露为独立弹窗;“玩过â€å¼¹çª—å¯ä»¥ç»§ç»­åˆå¹¶å±•示å¯ç»§ç»­å­˜æ¡£ï¼Œä½†ä¸èƒ½æˆä¸ºå”¯ä¸€å…¥å£ã€‚å‰ç«¯åªå±•示 `/api/profile/save-archives` 返回的列表并在用户选择åŽè°ƒç”¨å¯¹åº”æ¢å¤æŽ¥å£ï¼Œä¸èƒ½æœ¬åœ°æ‹¼è£…或筛选正å¼å­˜æ¡£çœŸç›¸ã€‚ ## 拼图 diff --git a/docs/ã€é¡¹ç›®åŸºçº¿ã€‘当å‰äº§å“与工程约æŸ-2026-05-15.md b/docs/ã€é¡¹ç›®åŸºçº¿ã€‘当å‰äº§å“与工程约æŸ-2026-05-15.md index 7b539e814..c8e6db7fd 100644 --- a/docs/ã€é¡¹ç›®åŸºçº¿ã€‘当å‰äº§å“与工程约æŸ-2026-05-15.md +++ b/docs/ã€é¡¹ç›®åŸºçº¿ã€‘当å‰äº§å“与工程约æŸ-2026-05-15.md @@ -94,6 +94,7 @@ server-rs + Axum + SpacetimeDB 8. 图åƒè¾“入通用 UI 统一走 `src/components/common/CreativeImageInputPanel.tsx`ã€‚å¤–å±‚é¡µé¢æŒæœ‰ä¸šåŠ¡çŠ¶æ€ï¼Œç»„ä»¶åªæ‰¿æ‹…上传å¡ã€é¢„览ã€å‚考图缩略图ã€AI é‡ç»˜å¼€å…³ã€é”™è¯¯å±•示和æäº¤æŒ‰é’®ã€‚ 9. å‘现页 `分类` å­é¢‘é“的筛选必须打开独立 dialog / drawer / modal,至少支æŒçŽ©æ³•ç±»åž‹è¿‡æ»¤ä¸ŽæŽ’åºåˆ‡æ¢ï¼›ç­›é€‰ç»“果为空时显示空状æ€ï¼Œä¸æŠŠç­›é€‰å†…容展开在当å‰åˆ—表下方。 10. “我的â€é¡µæ³¥ç‚¹ã€æ¸¸æˆæ—¶é•¿ã€çŽ©è¿‡ä¸‰å¼ ç»Ÿè®¡å¡åªå±•示å„è‡ªæ ‡ç­¾å’Œå€¼ï¼Œå†…å®¹å±…ä¸­ä¸”ä¸æ¢è¡Œï¼Œä¸åœ¨ç»Ÿè®¡åŒºåº•éƒ¨å±•ç¤ºâ€œæ›´æ–°äºŽâ€æ—¶é—´ã€‚ +11. RPG ç­‰è¿è¡Œæ€çš„æˆ˜æ–—飘字ã€è¡€é‡å˜åŒ–å’Œå³æ—¶å馈必须在暗色ã€å™ªå£°é«˜çš„åœºæ™¯èƒŒæ™¯ä¸Šä¿æŒå¯è¯»ï¼šä½¿ç”¨é«˜äº®æ–‡å­—ã€æ·±è‰²æè¾¹ã€å¼ºé˜´å½±æˆ–å°é¢ç§¯åŠé€æ˜Žåº•,ä¸åªä¾èµ–红/绿文字本身表达伤害或治疗。 ## æ–‡æ¡ˆä¸Žç¼–ç  diff --git a/server-rs/crates/module-runtime-story/src/battle_tests.rs b/server-rs/crates/module-runtime-story/src/battle_tests.rs index a7d414138..51b9c3fe8 100644 --- a/server-rs/crates/module-runtime-story/src/battle_tests.rs +++ b/server-rs/crates/module-runtime-story/src/battle_tests.rs @@ -5,8 +5,8 @@ use shared_contracts::runtime_story::{ }; use crate::{ - battle::resolve_battle_action, build_status_patch, read_bool_field, read_i32_field, - read_optional_string_field, + StoryRuntimeActionResolveInput, battle::resolve_battle_action, build_status_patch, + read_bool_field, read_i32_field, read_optional_string_field, resolve_story_runtime_action, }; fn build_battle_fixture() -> serde_json::Value { @@ -61,6 +61,115 @@ fn build_request(function_id: &str, option_text: &str) -> RuntimeStoryActionRequ } } +fn build_runtime_action_request( + function_id: &str, + action_text: &str, + payload: Option, +) -> shared_contracts::story::ResolveStoryRuntimeActionRequest { + shared_contracts::story::ResolveStoryRuntimeActionRequest { + story_session_id: "storysess-1".to_string(), + client_version: Some(1), + function_id: function_id.to_string(), + action_text: action_text.to_string(), + target_id: None, + payload, + } +} + +fn build_custom_world_profile_with_two_landmarks() -> serde_json::Value { + json!({ + "id": "profile-1", + "name": "雾桥旧约", + "summary": "雾桥边的旧约正在å¤è‹ã€‚", + "camp": { + "id": "camp-1", + "name": "雾桥è¥åœ°", + "description": "è¥ç«åŽ‹ç€é›¾æ°”。", + "connections": [ + { + "targetLandmarkId": "landmark-1", + "relativePosition": "forward", + "summary": "沿桥é¢ç»§ç»­å‰è¿›" + }, + { + "targetLandmarkId": "landmark-2", + "relativePosition": "right", + "summary": "转入雾中支路" + } + ] + }, + "landmarks": [ + { + "id": "landmark-1", + "name": "æ–­æ¡¥å£", + "description": "æ¡¥å£æŒ‚ç€æ—§ç¯ã€‚" + }, + { + "id": "landmark-2", + "name": "雾中渡", + "description": "渡å£åªæœ‰æ½®å£°ã€‚" + } + ], + "storyNpcs": [ + { + "id": "npc-bridge", + "name": "桥影", + "description": "桥下逼æ¥çš„æ•Œå½±", + "initialAffinity": -20 + }, + { + "id": "npc-ferryman", + "name": "摆渡人", + "description": "守ç€é›¾ä¸­æ¸¡çš„人", + "initialAffinity": 0 + } + ], + "sceneChapterBlueprints": [ + { + "id": "chapter-camp", + "sceneId": "camp-1", + "linkedLandmarkIds": ["camp-1"], + "acts": [ + { + "id": "act-camp-1", + "sceneId": "camp-1", + "oppositeNpcId": "npc-bridge" + }, + { + "id": "act-camp-2", + "sceneId": "camp-1", + "oppositeNpcId": "npc-ferryman" + } + ] + }, + { + "id": "chapter-landmark-1", + "sceneId": "landmark-1", + "linkedLandmarkIds": ["landmark-1"], + "acts": [ + { + "id": "act-landmark-1", + "sceneId": "landmark-1", + "oppositeNpcId": "npc-ferryman" + } + ] + } + ] + }) +} + +fn build_story_runtime_snapshot( + game_state: serde_json::Value, + current_story: Option, +) -> shared_contracts::story::StoryRuntimeSnapshotPayload { + shared_contracts::story::StoryRuntimeSnapshotPayload { + saved_at: None, + bottom_tab: "adventure".to_string(), + game_state, + current_story, + } +} + #[test] fn battle_resolution_prefers_player_defeat_when_both_sides_fall_in_same_turn() { let request = build_request("battle_all_in_crush", "全力压制"); @@ -89,3 +198,210 @@ fn battle_resolution_prefers_player_defeat_when_both_sides_fall_in_same_turn() { Some("defeat".to_string()) ); } + +#[test] +fn terminal_battle_action_persists_post_battle_continue_story() { + let mut game_state = build_battle_fixture(); + game_state["runtimeSessionId"] = json!("runtime-1"); + game_state["currentScene"] = json!("Story"); + game_state["worldType"] = json!("CUSTOM"); + game_state["playerHp"] = json!(30); + game_state["customWorldProfile"] = build_custom_world_profile_with_two_landmarks(); + game_state["currentScenePreset"] = json!({ + "id": "custom-scene-camp", + "name": "雾桥è¥åœ°", + "description": "è¥ç«åŽ‹ç€é›¾æ°”。", + "connectedSceneIds": ["custom-scene-landmark-1", "custom-scene-landmark-2"], + "forwardSceneId": "custom-scene-landmark-1", + "treasureHints": [], + "npcs": [] + }); + game_state["storyEngineMemory"] = json!({ + "currentSceneActState": { + "sceneId": "camp-1", + "chapterId": "chapter-camp", + "currentActId": "act-camp-1", + "currentActIndex": 0, + "completedActIds": [], + "visitedActIds": ["act-camp-1"] + } + }); + + let output = resolve_story_runtime_action(StoryRuntimeActionResolveInput { + story_session_id: "storysess-1".to_string(), + runtime_session_id: "runtime-1".to_string(), + snapshot: build_story_runtime_snapshot(game_state, None), + request: build_runtime_action_request("battle_all_in_crush", "全力压制", None), + }) + .expect("terminal battle should resolve"); + + assert_eq!( + output.presentation.battle.unwrap().outcome.as_deref(), + Some("victory") + ); + assert_eq!( + output.presentation.options[0].function_id, + "story_continue_adventure" + ); + assert_eq!( + output.snapshot.current_story.as_ref().unwrap()["options"][0]["functionId"], + json!("story_continue_adventure") + ); + assert!( + output.snapshot.current_story.as_ref().unwrap()["deferredOptions"] + .as_array() + .is_some_and(|items| { + items + .iter() + .any(|item| item["functionId"] == json!("idle_travel_next_scene")) + }) + ); + assert_eq!( + output.snapshot.current_story.as_ref().unwrap()["deferredRuntimeState"]["storyEngineMemory"] + ["currentSceneActState"]["currentActId"], + json!("act-camp-2") + ); + assert_eq!( + output.snapshot.game_state["storyEngineMemory"]["currentSceneActState"]["currentActId"], + json!("act-camp-2") + ); +} + +#[test] +fn idle_travel_next_scene_changes_scene_from_target_payload() { + let game_state = json!({ + "runtimeSessionId": "runtime-1", + "runtimeActionVersion": 1, + "currentScene": "Story", + "worldType": "CUSTOM", + "customWorldProfile": build_custom_world_profile_with_two_landmarks(), + "playerHp": 30, + "playerMaxHp": 40, + "playerMana": 10, + "playerMaxMana": 20, + "playerCurrency": 0, + "playerInventory": [], + "playerEquipment": { "weapon": null, "armor": null, "relic": null }, + "runtimeStats": { + "hostileNpcsDefeated": 0, + "itemsUsed": 0, + "questsAccepted": 0, + "scenesTraveled": 0, + "playTimeMs": 0, + "lastPlayTickAt": null + }, + "currentScenePreset": { + "id": "custom-scene-camp", + "name": "雾桥è¥åœ°", + "description": "è¥ç«åŽ‹ç€é›¾æ°”。", + "connectedSceneIds": ["custom-scene-landmark-1", "custom-scene-landmark-2"], + "connections": [ + { + "sceneId": "custom-scene-landmark-1", + "relativePosition": "forward", + "summary": "沿桥é¢ç»§ç»­å‰è¿›" + } + ], + "forwardSceneId": "custom-scene-landmark-1", + "treasureHints": [], + "npcs": [] + }, + "currentEncounter": null, + "npcInteractionActive": false, + "sceneHostileNpcs": [], + "inBattle": false, + "storyHistory": [], + "storyEngineMemory": {} + }); + + let output = resolve_story_runtime_action(StoryRuntimeActionResolveInput { + story_session_id: "storysess-1".to_string(), + runtime_session_id: "runtime-1".to_string(), + snapshot: build_story_runtime_snapshot(game_state, None), + request: build_runtime_action_request( + "idle_travel_next_scene", + "å‘å‰èµ°ï¼Œå‰å¾€æ–­æ¡¥å£", + Some(json!({ "targetSceneId": "custom-scene-landmark-1" })), + ), + }) + .expect("travel action should resolve"); + + assert_eq!( + output.snapshot.game_state["currentScenePreset"]["id"], + json!("custom-scene-landmark-1") + ); + assert_eq!( + output.snapshot.game_state["runtimeStats"]["scenesTraveled"], + json!(1) + ); + assert_eq!( + output.snapshot.game_state["currentEncounter"]["id"], + json!("npc-ferryman") + ); + assert_eq!( + output.snapshot.game_state["storyEngineMemory"]["currentSceneActState"]["currentActId"], + json!("act-landmark-1") + ); + assert!(output.presentation.options.iter().any(|option| { + option.function_id == "idle_travel_next_scene" + || option.function_id == "idle_explore_forward" + })); +} + +#[test] +fn idle_travel_next_scene_normalizes_custom_landmark_id_payload() { + let game_state = json!({ + "runtimeSessionId": "runtime-1", + "runtimeActionVersion": 1, + "currentScene": "Story", + "worldType": "CUSTOM", + "customWorldProfile": build_custom_world_profile_with_two_landmarks(), + "playerHp": 30, + "playerMaxHp": 40, + "playerMana": 10, + "playerMaxMana": 20, + "playerCurrency": 0, + "playerInventory": [], + "playerEquipment": { "weapon": null, "armor": null, "relic": null }, + "runtimeStats": { + "hostileNpcsDefeated": 0, + "itemsUsed": 0, + "questsAccepted": 0, + "scenesTraveled": 0, + "playTimeMs": 0, + "lastPlayTickAt": null + }, + "currentScenePreset": { + "id": "custom-scene-camp", + "name": "雾桥è¥åœ°", + "description": "è¥ç«åŽ‹ç€é›¾æ°”。", + "connectedSceneIds": ["landmark-1", "landmark-2"], + "forwardSceneId": "landmark-2", + "treasureHints": [], + "npcs": [] + }, + "currentEncounter": null, + "npcInteractionActive": false, + "sceneHostileNpcs": [], + "inBattle": false, + "storyHistory": [], + "storyEngineMemory": {} + }); + + let output = resolve_story_runtime_action(StoryRuntimeActionResolveInput { + story_session_id: "storysess-1".to_string(), + runtime_session_id: "runtime-1".to_string(), + snapshot: build_story_runtime_snapshot(game_state, None), + request: build_runtime_action_request( + "idle_travel_next_scene", + "å‰å¾€é›¾ä¸­æ¸¡", + Some(json!({ "targetSceneId": "landmark-2" })), + ), + }) + .expect("raw custom landmark id should resolve"); + + assert_eq!( + output.snapshot.game_state["currentScenePreset"]["id"], + json!("custom-scene-landmark-2") + ); +} diff --git a/server-rs/crates/module-runtime-story/src/lib.rs b/server-rs/crates/module-runtime-story/src/lib.rs index 53e06300a..485377f1c 100644 --- a/server-rs/crates/module-runtime-story/src/lib.rs +++ b/server-rs/crates/module-runtime-story/src/lib.rs @@ -76,7 +76,9 @@ pub use options::{ build_static_runtime_story_option, build_story_option_from_runtime_option, infer_option_scope, }; pub use post_battle::{ - finalize_post_battle_resolution, is_terminal_battle_outcome, resolve_post_battle_story_options, + clear_post_battle_state, ensure_scene_act_state, ensure_scene_encounter_preview, + finalize_post_battle_resolution, is_terminal_battle_outcome, resolve_forward_scene_id, + resolve_post_battle_story_options, resolve_runtime_scene_preset, }; pub use projection::{StoryRuntimeProjectionSource, build_story_runtime_projection}; pub use prompt_context::{RuntimeStoryPromptContextExtras, build_runtime_story_prompt_context}; diff --git a/server-rs/crates/module-runtime-story/src/post_battle.rs b/server-rs/crates/module-runtime-story/src/post_battle.rs index f804bfa4d..efeabe8f9 100644 --- a/server-rs/crates/module-runtime-story/src/post_battle.rs +++ b/server-rs/crates/module-runtime-story/src/post_battle.rs @@ -2,10 +2,11 @@ use serde_json::{Value, json}; use shared_contracts::runtime_story::RuntimeStoryOptionView; use crate::{ - CONTINUE_ADVENTURE_FUNCTION_ID, build_static_runtime_story_option, + CONTINUE_ADVENTURE_FUNCTION_ID, build_custom_scene_preset, build_static_runtime_story_option, build_story_option_from_runtime_option, ensure_json_object, read_array_field, read_bool_field, - read_field, read_i32_field, read_object_field, read_optional_string_field, write_bool_field, - write_i32_field, write_null_field, write_string_field, + read_field, read_i32_field, read_object_field, read_optional_string_field, + resolve_custom_runtime_scene_id, write_bool_field, write_i32_field, write_null_field, + write_string_field, }; const WUXIA_FIRST_SCENE_ID: &str = "wuxia-bamboo-road"; @@ -36,6 +37,8 @@ pub fn finalize_post_battle_resolution( return None; } + let original_scene_act_state = current_scene_act_state(game_state); + if outcome == "defeat" { return Some(finalize_defeat_revive(game_state, fallback_options)); } @@ -45,6 +48,7 @@ pub fn finalize_post_battle_resolution( game_state, result_text, fallback_options, + original_scene_act_state, )); } @@ -64,13 +68,14 @@ fn finalize_victory_or_spar( game_state: &mut Value, result_text: &str, fallback_options: Vec, + original_scene_act_state: Option, ) -> PostBattleFinalization { clear_post_battle_state(game_state); let is_last_act = is_current_scene_act_last(game_state); let next_act_state = if is_last_act { None } else { - resolve_next_scene_act_runtime_state(game_state) + resolve_next_scene_act_runtime_state(game_state, original_scene_act_state.as_ref()) }; if let Some(next_act_state) = next_act_state { write_current_scene_act_state(game_state, next_act_state); @@ -141,7 +146,7 @@ fn finalize_defeat_revive( { write_current_scene_act_state(game_state, first_act_state); } - ensure_first_scene_encounter_preview(game_state); + ensure_scene_encounter_preview(game_state); let story_text = if first_scene.name.is_empty() { "你在战斗中倒下,éšåŽé‡æ–°é†’æ¥ã€‚".to_string() @@ -160,7 +165,7 @@ fn finalize_defeat_revive( } } -fn clear_post_battle_state(game_state: &mut Value) { +pub fn clear_post_battle_state(game_state: &mut Value) { write_null_field(game_state, "currentEncounter"); write_bool_field(game_state, "npcInteractionActive", false); ensure_json_object(game_state).insert("sceneHostileNpcs".to_string(), Value::Array(Vec::new())); @@ -421,7 +426,7 @@ fn write_first_scene(game_state: &mut Value, scene: &RuntimeScene) { ); } -fn ensure_first_scene_encounter_preview(game_state: &mut Value) { +pub fn ensure_scene_encounter_preview(game_state: &mut Value) { if read_bool_field(game_state, "inBattle").unwrap_or(false) { return; } @@ -436,7 +441,13 @@ fn ensure_first_scene_encounter_preview(game_state: &mut Value) { }; let scene_id = read_object_field(game_state, "currentScenePreset") .and_then(|scene| read_optional_string_field(scene, "id")); - let focus_npc_id = resolve_active_scene_act_focus_npc_id(profile, scene_id.as_deref()); + let current_act_id = current_scene_act_state(game_state) + .and_then(|state| read_optional_string_field(&state, "currentActId")); + let focus_npc_id = resolve_active_scene_act_focus_npc_id( + profile, + scene_id.as_deref(), + current_act_id.as_deref(), + ); let Some(focus_npc_id) = focus_npc_id else { return; }; @@ -450,6 +461,22 @@ fn ensure_first_scene_encounter_preview(game_state: &mut Value) { ); } +pub fn ensure_scene_act_state(game_state: &mut Value) { + if read_bool_field(game_state, "inBattle").unwrap_or(false) { + return; + } + let Some(scene_id) = read_object_field(game_state, "currentScenePreset") + .and_then(|scene| read_optional_string_field(scene, "id")) + else { + return; + }; + let Some(act_state) = build_initial_scene_act_runtime_state(game_state, scene_id.as_str()) + else { + return; + }; + write_current_scene_act_state(game_state, act_state); +} + fn build_scene_travel_options(game_state: &Value) -> Vec { let Some(current_scene) = read_object_field(game_state, "currentScenePreset") else { return vec![build_static_runtime_story_option( @@ -459,32 +486,53 @@ fn build_scene_travel_options(game_state: &Value) -> Vec )]; }; let current_scene_id = read_optional_string_field(current_scene, "id"); - let mut options = read_array_field(current_scene, "connections") + let forward_scene_id = read_optional_string_field(current_scene, "forwardSceneId"); + let mut option_scene_ids = Vec::new(); + let mut options = Vec::new(); + + for connection in read_array_field(current_scene, "connections") { + let Some(scene_id) = read_optional_string_field(connection, "sceneId") else { + continue; + }; + if current_scene_id.as_deref() == Some(scene_id.as_str()) + || option_scene_ids.iter().any(|id| id == scene_id.as_str()) + { + continue; + } + let relative_position = read_optional_string_field(connection, "relativePosition") + .unwrap_or_else(|| "forward".to_string()); + options.push(build_scene_travel_option( + game_state, + scene_id.as_str(), + relative_position.as_str(), + )); + option_scene_ids.push(scene_id); + } + + for scene_id in read_array_field(current_scene, "connectedSceneIds") .into_iter() - .filter_map(|connection| { - let scene_id = read_optional_string_field(connection, "sceneId")?; - if current_scene_id.as_deref() == Some(scene_id.as_str()) { - return None; - } - let relative_position = read_optional_string_field(connection, "relativePosition") - .unwrap_or_else(|| "forward".to_string()); - let scene_name = resolve_scene_name(game_state, scene_id.as_str()) - .unwrap_or_else(|| scene_id.clone()); - Some(RuntimeStoryOptionView { - payload: Some(json!({ "targetSceneId": scene_id })), - ..build_static_runtime_story_option( - "idle_travel_next_scene", - format!( - "{},å‰å¾€{}", - direction_text(relative_position.as_str()), - scene_name - ) - .as_str(), - "story", - ) - }) - }) - .collect::>(); + .filter_map(|scene_id| scene_id.as_str().map(str::to_string)) + .chain(forward_scene_id.clone()) + { + // 中文注释:bootstrap 生æˆçš„æ—§å¿«ç…§å¸¸åªæœ‰ connectedSceneIds / forwardSceneId, + // 没有展开 connections;这里也è¦ç”Ÿæˆæ—…行 action,é¿å…战åŽåªå‰©é»˜è®¤ idle 选项循环。 + if current_scene_id.as_deref() == Some(scene_id.as_str()) + || option_scene_ids.iter().any(|id| id == scene_id.as_str()) + { + continue; + } + let relative_position = if forward_scene_id.as_deref() == Some(scene_id.as_str()) { + "forward" + } else { + "portal" + }; + options.push(build_scene_travel_option( + game_state, + scene_id.as_str(), + relative_position, + )); + option_scene_ids.push(scene_id); + } if options.is_empty() { options.push(build_static_runtime_story_option( @@ -497,6 +545,163 @@ fn build_scene_travel_options(game_state: &Value) -> Vec options } +fn build_scene_travel_option( + game_state: &Value, + scene_id: &str, + relative_position: &str, +) -> RuntimeStoryOptionView { + let scene_name = + resolve_scene_name(game_state, scene_id).unwrap_or_else(|| scene_id.to_string()); + RuntimeStoryOptionView { + payload: Some(json!({ "targetSceneId": scene_id })), + ..build_static_runtime_story_option( + "idle_travel_next_scene", + format!("{},å‰å¾€{}", direction_text(relative_position), scene_name).as_str(), + "story", + ) + } +} + +pub fn resolve_runtime_scene_preset(game_state: &Value, scene_id: &str) -> Option { + let normalized_scene_id = scene_id.trim(); + if normalized_scene_id.is_empty() { + return None; + } + + if let Some(profile) = read_object_field(game_state, "customWorldProfile") + && let Some(scene) = build_custom_scene_preset( + profile, + resolve_custom_runtime_scene_id(profile, normalized_scene_id).as_str(), + ) + { + return Some(scene); + } + + resolve_builtin_runtime_scene_preset(game_state, normalized_scene_id) +} + +pub fn resolve_forward_scene_id(game_state: &Value) -> Option { + read_object_field(game_state, "currentScenePreset").and_then(|scene| { + read_optional_string_field(scene, "forwardSceneId") + .or_else(|| { + read_array_field(scene, "connections") + .into_iter() + .find_map(|connection| read_optional_string_field(connection, "sceneId")) + }) + .or_else(|| { + read_array_field(scene, "connectedSceneIds") + .into_iter() + .find_map(|scene_id| scene_id.as_str().map(str::to_string)) + }) + }) +} + +fn resolve_builtin_runtime_scene_preset(game_state: &Value, scene_id: &str) -> Option { + let template = builtin_runtime_scene_template(scene_id)?; + Some(json!({ + "id": template.id, + "name": template.name, + "description": template.description, + "imageSrc": read_object_field(game_state, "currentScenePreset") + .and_then(|scene| read_optional_string_field(scene, "imageSrc")) + .unwrap_or_default(), + "connectedSceneIds": template.connected_scene_ids, + "connections": template.connections, + "forwardSceneId": template.forward_scene_id, + "treasureHints": template.treasure_hints, + "npcs": [], + })) +} + +fn builtin_runtime_scene_template(scene_id: &str) -> Option { + let is_xianxia = matches!( + scene_id, + "xianxia-cloud-gate" + | "xianxia-floating-isle" + | "xianxia-celestial-corridor" + | "xianxia-star-vessel" + ); + if is_xianxia { + return Some(RuntimeScene { + id: scene_id.to_string(), + name: match scene_id { + "xianxia-floating-isle" => "浮空çµå²›", + "xianxia-celestial-corridor" => "天门长廊", + "xianxia-star-vessel" => "星槎泊å°", + _ => XIANXIA_FIRST_SCENE_NAME, + } + .to_string(), + description: match scene_id { + "xianxia-floating-isle" => "浮岛边缘çµé›¾ç¿»æ¶Œï¼Œè¿œå¤„有阵纹一明一暗。", + "xianxia-celestial-corridor" => "长廊悬在云海上方,符光沿石柱缓慢游走。", + "xianxia-star-vessel" => "æ˜Ÿæ§Žæ³Šåœ¨äº‘æµ·è¾¹ç¼˜ï¼Œèˆ¹èº«ä»æœ‰æ˜Ÿç ‚微光。", + _ => XIANXIA_FIRST_SCENE_DESCRIPTION, + } + .to_string(), + image_src: String::new(), + connected_scene_ids: vec![ + "xianxia-cloud-gate".to_string(), + "xianxia-floating-isle".to_string(), + "xianxia-celestial-corridor".to_string(), + ] + .into_iter() + .filter(|id| id != scene_id) + .collect(), + connections: vec![json!({ + "sceneId": if scene_id == "xianxia-cloud-gate" { "xianxia-celestial-corridor" } else { "xianxia-cloud-gate" }, + "relativePosition": if scene_id == "xianxia-cloud-gate" { "forward" } else { "back" }, + "summary": "沿主路继续移动" + })], + forward_scene_id: Some(if scene_id == "xianxia-cloud-gate" { + "xianxia-celestial-corridor".to_string() + } else { + "xianxia-cloud-gate".to_string() + }), + treasure_hints: vec!["云阶边缘的çµå…‰æ®‹ç—•".to_string()], + npcs: Vec::new(), + }); + } + + Some(RuntimeScene { + id: scene_id.to_string(), + name: match scene_id { + "wuxia-mountain-gate" => "山门石阶", + "wuxia-mist-woods" => "迷雾竹林", + "wuxia-ferry-bridge" => "æ¸¡å£æ–­æ¡¥", + _ => WUXIA_FIRST_SCENE_NAME, + } + .to_string(), + description: match scene_id { + "wuxia-mountain-gate" => "山门石阶覆ç€è‹”痕,旧旗在风里压得很低。", + "wuxia-mist-woods" => "迷雾在竹林间翻å·ï¼Œè„šä¸‹æ³¥å°å¾ˆå¿«åˆè¢«é›¾æ°´æŠ¹å¹³ã€‚", + "wuxia-ferry-bridge" => "æ¸¡å£æ–­æ¡¥æ¨ªåœ¨å†·æ°´ä¸Šï¼Œæ¡¥è¾¹ç¯ç¬¼åªå‰©åŠæˆªæ®‹å…‰ã€‚", + _ => WUXIA_FIRST_SCENE_DESCRIPTION, + } + .to_string(), + image_src: String::new(), + connected_scene_ids: vec![ + "wuxia-bamboo-road".to_string(), + "wuxia-mountain-gate".to_string(), + "wuxia-mist-woods".to_string(), + ] + .into_iter() + .filter(|id| id != scene_id) + .collect(), + connections: vec![json!({ + "sceneId": if scene_id == "wuxia-bamboo-road" { "wuxia-mountain-gate" } else { "wuxia-bamboo-road" }, + "relativePosition": if scene_id == "wuxia-bamboo-road" { "forward" } else { "back" }, + "summary": "沿主路继续移动" + })], + forward_scene_id: Some(if scene_id == "wuxia-bamboo-road" { + "wuxia-mountain-gate".to_string() + } else { + "wuxia-bamboo-road".to_string() + }), + treasure_hints: vec!["路边åŠåŸ‹çš„æ—§ç‰©".to_string()], + npcs: Vec::new(), + }) +} + fn resolve_scene_name(game_state: &Value, scene_id: &str) -> Option { if read_object_field(game_state, "currentScenePreset") .and_then(|scene| read_optional_string_field(scene, "id")) @@ -553,7 +758,10 @@ fn direction_text(relative_position: &str) -> &'static str { } } -fn resolve_next_scene_act_runtime_state(game_state: &Value) -> Option { +fn resolve_next_scene_act_runtime_state( + game_state: &Value, + current_act_state_override: Option<&Value>, +) -> Option { let profile = read_object_field(game_state, "customWorldProfile")?; let scene_id = read_object_field(game_state, "currentScenePreset") .and_then(|scene| read_optional_string_field(scene, "id")); @@ -563,7 +771,9 @@ fn resolve_next_scene_act_runtime_state(game_state: &Value) -> Option { if acts.is_empty() { return None; } - let runtime_state = build_initial_scene_act_runtime_state(game_state, scene_id_text)?; + let runtime_state = current_act_state_override + .cloned() + .or_else(|| build_initial_scene_act_runtime_state(game_state, scene_id_text))?; let current_act_id = read_optional_string_field(&runtime_state, "currentActId"); let current_index = acts .iter() @@ -762,9 +972,17 @@ fn resolve_scene_aliases(profile: &Value, scene_id: &str) -> Vec { fn resolve_active_scene_act_focus_npc_id( profile: &Value, scene_id: Option<&str>, + current_act_id: Option<&str>, ) -> Option { let chapter = resolve_scene_chapter_blueprint(profile, scene_id)?; - let act_state = read_array_field(chapter, "acts").first().copied()?; + let acts = read_array_field(chapter, "acts"); + let act_state = current_act_id + .and_then(|act_id| { + acts.iter() + .copied() + .find(|act| read_optional_string_field(act, "id").as_deref() == Some(act_id)) + }) + .or_else(|| acts.first().copied())?; read_optional_string_field(act_state, "oppositeNpcId") .or_else(|| read_optional_string_field(act_state, "primaryNpcId")) .or_else(|| { diff --git a/server-rs/crates/module-runtime-story/src/session_action.rs b/server-rs/crates/module-runtime-story/src/session_action.rs index c7ad6421b..93d9b25a2 100644 --- a/server-rs/crates/module-runtime-story/src/session_action.rs +++ b/server-rs/crates/module-runtime-story/src/session_action.rs @@ -14,16 +14,18 @@ use crate::{ build_current_build_toast, build_npc_gift_result_text, build_runtime_story_option_from_story_option, build_runtime_story_view_model, build_static_runtime_story_option, build_status_patch, build_story_option_from_runtime_option, - clear_encounter_state, clone_inventory_item_with_quantity, current_encounter_name, - ensure_json_object, find_player_inventory_entry, normalize_equipment_slot_id, - normalize_required_string, npc_buyback_price, npc_purchase_price, + clear_encounter_state, clear_post_battle_state, clone_inventory_item_with_quantity, + current_encounter_name, ensure_json_object, ensure_scene_act_state, + ensure_scene_encounter_preview, finalize_post_battle_resolution, find_player_inventory_entry, + normalize_equipment_slot_id, normalize_required_string, npc_buyback_price, npc_purchase_price, project_story_engine_after_action, read_array_field, read_bool_field, read_field, read_i32_field, read_inventory_item_name, read_object_field, read_optional_string_field, read_player_equipment_item, read_player_inventory_values, read_runtime_session_id, read_u32_field, recruit_companion_to_party, remove_inventory_item_from_list, resolve_action_text, resolve_battle_action, resolve_equipment_slot_for_item, resolve_forge_craft_action, resolve_forge_dismantle_action, resolve_forge_reforge_action, - resolve_npc_gift_affinity_gain, restore_player_resource, simple_story_resolution, + resolve_forward_scene_id, resolve_npc_gift_affinity_gain, resolve_post_battle_story_options, + resolve_runtime_scene_preset, restore_player_resource, simple_story_resolution, write_bool_field, write_i32_field, write_null_field, write_player_equipment_item, write_player_inventory_values, write_runtime_npc_interaction_view, write_string_field, write_u32_field, @@ -97,23 +99,7 @@ pub fn resolve_story_runtime_action( requested_runtime_session_id.as_str(), ); - let mut options = resolution - .presentation_options - .take() - .unwrap_or_else(|| build_fallback_runtime_story_options(&game_state)); - if options.is_empty() { - options = build_fallback_runtime_story_options(&game_state); - } - - let story_text = resolution - .story_text - .clone() - .unwrap_or_else(|| resolution.result_text.clone()); let history_result_text = resolution.result_text.clone(); - let saved_current_story = resolution - .saved_current_story - .take() - .unwrap_or_else(|| build_current_story(story_text.as_str(), &options)); append_story_history( &mut game_state, @@ -132,6 +118,37 @@ pub fn resolve_story_runtime_action( .and_then(|battle| battle.outcome.as_deref()), ); + if let Some(post_battle) = finalize_post_battle_resolution( + &mut game_state, + history_result_text.as_str(), + resolution + .battle + .as_ref() + .and_then(|battle| battle.outcome.as_deref()), + Vec::new(), + ) { + resolution.story_text = Some(post_battle.story_text); + resolution.presentation_options = Some(post_battle.presentation_options); + resolution.saved_current_story = Some(post_battle.saved_current_story); + } + + let mut options = resolution + .presentation_options + .take() + .unwrap_or_else(|| build_fallback_runtime_story_options(&game_state)); + if options.is_empty() { + options = build_fallback_runtime_story_options(&game_state); + } + + let story_text = resolution + .story_text + .clone() + .unwrap_or_else(|| resolution.result_text.clone()); + let saved_current_story = resolution + .saved_current_story + .take() + .unwrap_or_else(|| build_current_story(story_text.as_str(), &options)); + let mut patches = vec![RuntimeStoryPatch::StoryHistoryAppend { action_text: resolution.action_text.clone(), result_text: history_result_text.clone(), @@ -212,11 +229,10 @@ fn resolve_runtime_story_choice_action( resolve_action_text("主动出声试探", request), "ä½ çš„å–Šè¯æ‰“破了当å‰é™åœºï¼Œå‘¨å›´æ½œç€çš„动é™ä¹Ÿæ›´éš¾ç»§ç»­è—ä½ã€‚", )), - "idle_explore_forward" => Ok(simple_story_resolution( - game_state, - resolve_action_text("ç»§ç»­å‘å‰æŽ¢ç´¢", request), - "你没有åœåœ¨åŽŸåœ°ï¼Œè€Œæ˜¯ç»§ç»­å‘å‰åŽ‹ï¼ŒæŠŠä¸‹ä¸€æ®µé­é‡ä¸»åŠ¨æŽ¨åˆ°è‡ªå·±é¢å‰ã€‚", - )), + "idle_explore_forward" => resolve_idle_explore_forward_action(game_state, request), + "idle_travel_next_scene" | "camp_travel_home_scene" => { + resolve_idle_travel_next_scene_action(game_state, request) + } "idle_observe_signs" => Ok(simple_story_resolution( game_state, resolve_action_text("观察周围迹象", request), @@ -309,6 +325,62 @@ fn resolve_continue_adventure_action( }) } +fn resolve_idle_explore_forward_action( + game_state: &mut Value, + request: &RuntimeStoryActionRequest, +) -> Result { + // 中文注释:探索å‰è¿›æ˜¯æˆ˜åŽç»§ç»­é“¾è·¯çš„一环,必须在åŽç«¯æ¸…掉战斗æ€å¹¶ç”Ÿæˆä¸‹ä¸€æ®µé­é‡é¢„览。 + // å‰ç«¯åªæ’­æ”¾è¡¨çŽ°åŠ¨ç”»ï¼Œä¸èƒ½åªé æœ¬åœ°çŠ¶æ€æŠŠåŒä¸€ç»„ idle 选项釿–°å±•示一é。 + clear_post_battle_state(game_state); + ensure_scene_encounter_preview(game_state); + Ok(StoryResolution { + action_text: resolve_action_text("ç»§ç»­å‘å‰æŽ¢ç´¢", request), + result_text: "你没有åœåœ¨åŽŸåœ°ï¼Œè€Œæ˜¯ç»§ç»­å‘å‰åŽ‹ï¼ŒæŠŠä¸‹ä¸€æ®µé­é‡ä¸»åŠ¨æŽ¨åˆ°è‡ªå·±é¢å‰ã€‚".to_string(), + story_text: None, + presentation_options: Some(resolve_post_battle_story_options(game_state)), + saved_current_story: None, + patches: vec![build_status_patch(game_state)], + battle: None, + toast: None, + }) +} + +fn resolve_idle_travel_next_scene_action( + game_state: &mut Value, + request: &RuntimeStoryActionRequest, +) -> Result { + // ä¸­æ–‡æ³¨é‡Šï¼šåˆ‡åœºæ™¯ä¼šæ”¹å˜ currentScenePresetã€ç« èŠ‚ act 状æ€å’Œè¿è¡Œç»Ÿè®¡ï¼Œ + // 这些都是 runtime 快照真相,ä¸èƒ½åªåœ¨å‰ç«¯æ’­æ”¾é€€åœº/进场动画。 + let payload = request.action.payload.as_ref(); + let target_scene_id = payload + .and_then(|payload| read_optional_string_field(payload, "targetSceneId")) + .or_else(|| resolve_forward_scene_id(game_state)) + .ok_or_else(|| "idle_travel_next_scene 缺少 targetSceneId".to_string())?; + let next_scene = resolve_runtime_scene_preset(game_state, target_scene_id.as_str()) + .ok_or_else(|| format!("未找到目标场景:{target_scene_id}"))?; + let next_scene_name = + read_optional_string_field(&next_scene, "name").unwrap_or_else(|| target_scene_id.clone()); + + clear_post_battle_state(game_state); + ensure_json_object(game_state).insert("currentScenePreset".to_string(), next_scene); + write_i32_field(game_state, "playerX", 0); + write_string_field(game_state, "playerFacing", "right"); + ensure_scene_act_state(game_state); + ensure_scene_encounter_preview(game_state); + increment_runtime_stat_local(game_state, "scenesTraveled", 1); + + Ok(StoryResolution { + action_text: resolve_action_text(&format!("å‰å¾€{next_scene_name}"), request), + result_text: format!("你离开当å‰åŒºåŸŸï¼ŒæŠµè¾¾äº†{next_scene_name}。"), + story_text: None, + presentation_options: Some(resolve_post_battle_story_options(game_state)), + saved_current_story: None, + patches: vec![build_status_patch(game_state)], + battle: None, + toast: None, + }) +} + fn resolve_npc_preview_talk_action( game_state: &mut Value, request: &RuntimeStoryActionRequest, diff --git a/src/components/game-canvas/GameCanvasEntityLayer.test.tsx b/src/components/game-canvas/GameCanvasEntityLayer.test.tsx index 6b26af15d..86a1f3139 100644 --- a/src/components/game-canvas/GameCanvasEntityLayer.test.tsx +++ b/src/components/game-canvas/GameCanvasEntityLayer.test.tsx @@ -7,7 +7,10 @@ import { type Encounter, type SceneHostileNpc, } from '../../types'; -import { GameCanvasEntityLayer } from './GameCanvasEntityLayer'; +import { + GameCanvasEntityLayer, + getCombatFloatingNumberPresentation, +} from './GameCanvasEntityLayer'; import { CHARACTER_COMBAT_HP_TOP_PX, ENTITY_CONTAINER_REM, @@ -125,6 +128,21 @@ function renderEntityLayer(effectNpcId: string | null) { } describe('GameCanvasEntityLayer', () => { + it('keeps combat floating numbers readable on dark noisy battle backgrounds', () => { + const damage = getCombatFloatingNumberPresentation(false); + const healing = getCombatFloatingNumberPresentation(true); + + expect(damage.toneClass).toContain('bg-rose-950/72'); + expect(damage.toneClass).toContain('text-rose-50'); + expect(damage.textStyle.WebkitTextStroke).toContain('rgba(127, 29, 29'); + expect(damage.textStyle.textShadow).toContain('rgba(0, 0, 0'); + + expect(healing.toneClass).toContain('bg-emerald-950/70'); + expect(healing.toneClass).toContain('text-emerald-50'); + expect(healing.textStyle.WebkitTextStroke).toContain('rgba(6, 78, 59'); + expect(healing.textStyle.textShadow).toContain('rgba(0, 0, 0'); + }); + it('uses mirrored stage anchors for player and opponent containers', () => { expect(getMirroredStageEntityLeft('15%', 'player')).toBe('15%'); expect(getMirroredStageEntityLeft('15%', 'opponent')).toBe(`calc(100% - 15% - ${ENTITY_CONTAINER_REM}rem)`); diff --git a/src/components/game-canvas/GameCanvasEntityLayer.tsx b/src/components/game-canvas/GameCanvasEntityLayer.tsx index f374e938b..19928c939 100644 --- a/src/components/game-canvas/GameCanvasEntityLayer.tsx +++ b/src/components/game-canvas/GameCanvasEntityLayer.tsx @@ -1,5 +1,5 @@ import {motion} from 'motion/react'; -import {type ReactNode, useEffect, useMemo, useRef, useState} from 'react'; +import {type CSSProperties, type ReactNode, useEffect, useMemo, useRef, useState} from 'react'; import {getCharacterById} from '../../data/characterPresets'; import {getFacingTowardPlayer, MONSTERS_BY_WORLD} from '../../data/hostileNpcs'; @@ -130,6 +130,45 @@ function getSceneTransitionMotionConfig( }; } +export function getCombatFloatingNumberPresentation(isHealing: boolean): { + toneClass: string; + textStyle: CSSProperties; +} { + const textShadow = [ + '0 1px 0 rgba(0, 0, 0, 0.98)', + '0 0 8px rgba(0, 0, 0, 0.92)', + '0 0 16px rgba(0, 0, 0, 0.72)', + ].join(', '); + + if (isHealing) { + return { + toneClass: [ + 'border-emerald-100/70', + 'bg-emerald-950/70', + 'text-emerald-50', + 'shadow-[0_0_18px_rgba(52,211,153,0.55)]', + ].join(' '), + textStyle: { + WebkitTextStroke: '1.45px rgba(6, 78, 59, 0.95)', + textShadow, + }, + }; + } + + return { + toneClass: [ + 'border-rose-100/75', + 'bg-rose-950/72', + 'text-rose-50', + 'shadow-[0_0_20px_rgba(248,113,113,0.68)]', + ].join(' '), + textStyle: { + WebkitTextStroke: '1.55px rgba(127, 29, 29, 0.98)', + textShadow, + }, + }; +} + function CombatFloatingNumber({ event, onDone, @@ -139,23 +178,20 @@ function CombatFloatingNumber({ }) { const isHealing = event.delta > 0; const deltaText = `${isHealing ? '+' : ''}${event.delta}`; - const colorClass = isHealing ? 'text-emerald-200' : 'text-rose-200'; - const glowClass = isHealing - ? 'drop-shadow-[0_0_8px_rgba(52,211,153,0.9)]' - : 'drop-shadow-[0_0_8px_rgba(248,113,113,0.9)]'; + const presentation = getCombatFloatingNumberPresentation(isHealing); return ( onDone(event.id)} - className={`pointer-events-none absolute -top-16 left-1/2 z-[14] -translate-x-1/2 text-lg font-black leading-none ${colorClass} ${glowClass}`} + className={`pointer-events-none absolute -top-[4.65rem] left-1/2 z-[38] flex min-w-[2.4rem] -translate-x-1/2 select-none items-center justify-center rounded-full border px-1.5 py-0.5 text-[1.45rem] font-black leading-none tracking-[-0.04em] sm:text-[1.6rem] ${presentation.toneClass}`} data-testid={`combat-feedback-${event.targetKey}`} aria-label={`战斗数值 ${deltaText}`} > - + {deltaText} diff --git a/src/components/rpg-entry/RpgEntryFlowShell.agent.interaction.test.tsx b/src/components/rpg-entry/RpgEntryFlowShell.agent.interaction.test.tsx index 053e09bb2..79b25eef1 100644 --- a/src/components/rpg-entry/RpgEntryFlowShell.agent.interaction.test.tsx +++ b/src/components/rpg-entry/RpgEntryFlowShell.agent.interaction.test.tsx @@ -35,6 +35,7 @@ import type { CustomWorldLibraryEntry, } from '../../../packages/shared/src/contracts/runtime'; import type { HydratedSavedGameSnapshot } from '../../persistence/runtimeSnapshotTypes'; +import { normalizeCustomWorldProfileRecord } from '../../data/customWorldLibrary'; import { readPublicWorkCodeFromLocationSearch, resolveSelectionStageFromPath, @@ -1921,6 +1922,14 @@ const compiledAgentDraftSession: CustomWorldAgentSessionSnapshot = { }, }; +const compiledAgentResultPreview = normalizeCustomWorldProfileRecord( + compiledAgentDraftSession.resultPreview?.preview, +); + +if (!compiledAgentResultPreview) { + throw new Error('failed to normalize compiled agent result preview'); +} + function buildResultViewForSession( session: CustomWorldAgentSessionSnapshot, ): RpgCreationResultView { @@ -8011,6 +8020,288 @@ test('agent draft result test button enters current draft without publish gate', ).toBe(false); }, 10_000); +test('agent draft result test button enters the opened draft profile instead of a previous session', async () => { + const user = userEvent.setup(); + const handleCustomWorldSelect = vi.fn(); + + const previousDraftSession = { + ...compiledAgentDraftSession, + sessionId: 'custom-world-agent-session-1', + resultPreview: { + ...compiledAgentDraftSession.resultPreview!, + publishReady: false, + canEnterWorld: true, + preview: { + ...compiledAgentResultPreview, + id: 'agent-draft-custom-world-agent-session-1', + name: '潮雾列岛', + summary: '上一份è‰ç¨¿å†…容,ä¸èƒ½è¢«æœ¬æ¬¡å¯åЍå¤ç”¨ã€‚', + playableNpcs: [ + { + ...compiledAgentResultPreview.playableNpcs[0]!, + id: 'playable-previous-1', + name: '沈砺', + }, + ], + sessionId: 'custom-world-agent-session-1', + }, + }, + } satisfies CustomWorldAgentSessionSnapshot; + const openedDraftSession = { + ...compiledAgentDraftSession, + sessionId: 'custom-world-agent-session-2', + resultPreview: { + ...compiledAgentDraftSession.resultPreview!, + publishReady: false, + canEnterWorld: true, + preview: { + ...compiledAgentResultPreview, + id: 'agent-draft-custom-world-agent-session-2', + name: '星砂废都', + subtitle: 'å æ˜Ÿæ²™æµ·ä¸ŽåºŸéƒ½é’Ÿæ¥¼', + summary: '本次从è‰ç¨¿æž¶æ‰“开的目标è‰ç¨¿å†…容。', + playerGoal: '找到废都钟楼下被星砂掩埋的旧约。', + playableNpcs: [ + { + ...compiledAgentResultPreview.playableNpcs[0]!, + id: 'playable-opened-1', + name: '砂眠', + title: '废都引路人', + }, + ], + storyNpcs: [], + landmarks: [ + { + ...compiledAgentResultPreview.landmarks[0]!, + id: 'landmark-opened-1', + name: 'å æ˜Ÿé’Ÿæ¥¼', + }, + ], + sessionId: 'custom-world-agent-session-2', + }, + }, + } satisfies CustomWorldAgentSessionSnapshot; + const sessionsById = new Map([ + [previousDraftSession.sessionId, previousDraftSession], + [openedDraftSession.sessionId, openedDraftSession], + ]); + + vi.mocked(getRpgCreationOperation).mockResolvedValue({ + operationId: 'operation-draft-foundation-1', + type: 'draft_foundation', + status: 'completed', + phaseLabel: '世界底稿已生æˆ', + phaseDetail: '第一版世界底稿和è‰ç¨¿å¡å·²ç»æ•´ç†å®Œæˆã€‚', + progress: 100, + error: null, + }); + vi.mocked(getRpgCreationSession).mockImplementation(async (sessionId) => { + const session = sessionsById.get(sessionId); + if (!session) { + throw new Error(`Missing test session: ${sessionId}`); + } + return session; + }); + vi.mocked(getRpgCreationResultView).mockImplementation(async (sessionId) => { + const session = sessionsById.get(sessionId); + if (!session) { + throw new Error(`Missing test result view: ${sessionId}`); + } + return buildResultViewForSession(session); + }); + vi.mocked(listRpgCreationWorks).mockResolvedValue([ + buildExistingRpgDraftWork({ + workId: 'draft:custom-world-agent-session-1', + title: '潮雾列岛', + summary: '上一份è‰ç¨¿å†…容,ä¸èƒ½è¢«æœ¬æ¬¡å¯åЍå¤ç”¨ã€‚', + sessionId: 'custom-world-agent-session-1', + playableNpcCount: 1, + landmarkCount: 1, + }), + buildExistingRpgDraftWork({ + workId: 'draft:custom-world-agent-session-2', + title: '星砂废都', + subtitle: '待完善è‰ç¨¿', + summary: '本次从è‰ç¨¿æž¶æ‰“开的目标è‰ç¨¿å†…容。', + sessionId: 'custom-world-agent-session-2', + playableNpcCount: 1, + landmarkCount: 1, + }), + ]); + + render(); + + await openDraftHub(user); + const draftPanel = getPlatformTabPanel('saves'); + await user.click( + await within(draftPanel).findByRole('button', { + name: /继续完善《星砂废都》/u, + }), + ); + expect(await screen.findByText('世界档案', {}, { timeout: 5000 })).toBeTruthy(); + expect(screen.getByText('星砂废都')).toBeTruthy(); + + await user.click( + await screen.findByRole('button', { name: 'ä½œå“æµ‹è¯•' }, { timeout: 5000 }), + ); + + await waitFor(() => { + expect(handleCustomWorldSelect).toHaveBeenCalledWith( + expect.objectContaining({ + id: 'agent-draft-custom-world-agent-session-2', + name: '星砂废都', + summary: '本次从è‰ç¨¿æž¶æ‰“开的目标è‰ç¨¿å†…容。', + playableNpcs: [ + expect.objectContaining({ + id: 'playable-opened-1', + name: '砂眠', + }), + ], + }), + expect.objectContaining({ + mode: 'play', + disablePersistence: true, + returnStage: 'custom-world-result', + }), + ); + }); + expect( + vi + .mocked(executeRpgCreationAction) + .mock.calls.some(([, payload]) => payload?.action === 'publish_world'), + ).toBe(false); +}, 10_000); + +test('agent draft result start button enters the opened published draft profile instead of a previous session', async () => { + const user = userEvent.setup(); + const handleCustomWorldSelect = vi.fn(); + + const previousDraftSession = { + ...compiledAgentDraftSession, + sessionId: 'custom-world-agent-session-1', + stage: 'published', + resultPreview: { + ...compiledAgentDraftSession.resultPreview!, + publishReady: true, + canEnterWorld: true, + preview: { + ...compiledAgentResultPreview, + id: 'agent-draft-custom-world-agent-session-1', + name: '潮雾列岛', + summary: '上一份已å‘布è‰ç¨¿å†…容,ä¸èƒ½è¢«æœ¬æ¬¡å¯åЍå¤ç”¨ã€‚', + sessionId: 'custom-world-agent-session-1', + }, + }, + } satisfies CustomWorldAgentSessionSnapshot; + const openedPublishedDraftSession = { + ...compiledAgentDraftSession, + sessionId: 'custom-world-agent-session-2', + stage: 'published', + resultPreview: { + ...compiledAgentDraftSession.resultPreview!, + publishReady: true, + canEnterWorld: true, + preview: { + ...compiledAgentResultPreview, + id: 'agent-draft-custom-world-agent-session-2', + name: '星砂废都', + subtitle: 'å æ˜Ÿæ²™æµ·ä¸ŽåºŸéƒ½é’Ÿæ¥¼', + summary: '本次从è‰ç¨¿æž¶æ‰“开且已å‘布的目标è‰ç¨¿å†…容。', + playableNpcs: [ + { + ...compiledAgentResultPreview.playableNpcs[0]!, + id: 'playable-opened-1', + name: '砂眠', + title: '废都引路人', + }, + ], + sessionId: 'custom-world-agent-session-2', + }, + }, + } satisfies CustomWorldAgentSessionSnapshot; + const sessionsById = new Map([ + [previousDraftSession.sessionId, previousDraftSession], + [openedPublishedDraftSession.sessionId, openedPublishedDraftSession], + ]); + + vi.mocked(getRpgCreationSession).mockImplementation(async (sessionId) => { + const session = sessionsById.get(sessionId); + if (!session) { + throw new Error(`Missing test session: ${sessionId}`); + } + return session; + }); + vi.mocked(getRpgCreationResultView).mockImplementation(async (sessionId) => { + const session = sessionsById.get(sessionId); + if (!session) { + throw new Error(`Missing test result view: ${sessionId}`); + } + return buildResultViewForSession(session); + }); + vi.mocked(listRpgCreationWorks).mockResolvedValue([ + buildExistingRpgDraftWork({ + workId: 'draft:custom-world-agent-session-1', + title: '潮雾列岛', + summary: '上一份已å‘布è‰ç¨¿å†…容,ä¸èƒ½è¢«æœ¬æ¬¡å¯åЍå¤ç”¨ã€‚', + stage: 'published', + stageLabel: 'å·²å‘布', + sessionId: 'custom-world-agent-session-1', + playableNpcCount: 1, + landmarkCount: 1, + canEnterWorld: true, + }), + buildExistingRpgDraftWork({ + workId: 'draft:custom-world-agent-session-2', + title: '星砂废都', + subtitle: 'å·²å‘布è‰ç¨¿', + summary: '本次从è‰ç¨¿æž¶æ‰“开且已å‘布的目标è‰ç¨¿å†…容。', + stage: 'published', + stageLabel: 'å·²å‘布', + sessionId: 'custom-world-agent-session-2', + playableNpcCount: 1, + landmarkCount: 1, + canEnterWorld: true, + }), + ]); + + render(); + + await openDraftHub(user); + const draftPanel = getPlatformTabPanel('saves'); + await user.click( + await within(draftPanel).findByRole('button', { + name: /继续完善《星砂废都》/u, + }), + ); + expect(await screen.findByText('世界档案', {}, { timeout: 5000 })).toBeTruthy(); + expect(screen.getByText('星砂废都')).toBeTruthy(); + + await user.click( + await screen.findByRole('button', { name: '进入世界' }, { timeout: 5000 }), + ); + + await waitFor(() => { + expect(handleCustomWorldSelect).toHaveBeenCalledWith( + expect.objectContaining({ + id: 'agent-draft-custom-world-agent-session-2', + name: '星砂废都', + summary: '本次从è‰ç¨¿æž¶æ‰“开且已å‘布的目标è‰ç¨¿å†…容。', + playableNpcs: [ + expect.objectContaining({ + id: 'playable-opened-1', + name: '砂眠', + }), + ], + }), + ); + }); + expect( + vi + .mocked(executeRpgCreationAction) + .mock.calls.some(([, payload]) => payload?.action === 'publish_world'), + ).toBe(false); +}, 10_000); + test('agent result view does not keep legacy publish blockers when preview uses anchorContent and sceneChapterBlueprints', async () => { const user = userEvent.setup(); @@ -8057,7 +8348,7 @@ test('agent result view does not keep legacy publish blockers when preview uses publishReady: true, blockers: [], preview: { - ...compiledAgentDraftSession.resultPreview!.preview, + ...compiledAgentResultPreview, settingText: 'è¢«æµ·é›¾åžæ²¡çš„æ—§èˆªè·¯ç¾¤å²›', anchorContent: { worldPromise: @@ -8695,6 +8986,65 @@ test('save tab can resume a selected archive directly into the game', async () = }); }); +test('profile page exposes save archive picker as a direct entry', async () => { + const user = userEvent.setup(); + const handleContinueGame = vi.fn(); + + vi.mocked(listProfileSaveArchives).mockResolvedValue([ + { + worldKey: 'custom:world-1', + ownerUserId: null, + profileId: 'world-1', + worldType: 'CUSTOM', + worldName: '潮雾列岛', + subtitle: 'æ—§ç¯å¡”与失控航路', + summaryText: '回到旧ç¯å¡”继续推进调查。', + coverImageSrc: null, + lastPlayedAt: '2026-04-19T12:00:00.000Z', + }, + ]); + vi.mocked(resumeProfileSaveArchive).mockResolvedValue({ + entry: { + worldKey: 'custom:world-1', + ownerUserId: null, + profileId: 'world-1', + worldType: 'CUSTOM', + worldName: '潮雾列岛', + subtitle: 'æ—§ç¯å¡”与失控航路', + summaryText: '回到旧ç¯å¡”继续推进调查。', + coverImageSrc: null, + lastPlayedAt: '2026-04-19T12:00:00.000Z', + }, + snapshot: { + version: 2, + savedAt: '2026-04-19T12:00:00.000Z', + bottomTab: 'adventure', + currentStory: null, + gameState: { + worldType: 'CUSTOM', + }, + } as HydratedSavedGameSnapshot, + }); + + render(); + + await clickFirstButtonByName(user, '我的'); + const shortcutRegion = await screen.findByRole('region', { name: '常用功能' }); + await user.click(within(shortcutRegion).getByRole('button', { name: /存档/u })); + + const closeButton = await screen.findByLabelText('关闭存档'); + const modal = closeButton.closest('.fixed') as HTMLElement; + expect(modal).toBeTruthy(); + expect(within(modal).getByText('SAVES')).toBeTruthy(); + + await user.click(within(modal).getByRole('button', { name: /潮雾列岛/u })); + + await waitFor(() => { + expect(resumeProfileSaveArchive).toHaveBeenCalledWith('custom:world-1'); + expect(handleContinueGame).toHaveBeenCalledTimes(1); + }); +}); + test('creation hub published work can open detail view before deleting from detail page', async () => { const user = userEvent.setup(); @@ -8928,6 +9278,342 @@ test('creation hub published work experience button enters world directly', asyn expect(handleCustomWorldSelect).toHaveBeenCalledTimes(1); }); +test('creation hub published work start uses loaded detail profile instead of library summary', async () => { + const user = userEvent.setup(); + const handleCustomWorldSelect = vi.fn(); + const workProfileId = 'world-detail-launch-1'; + const summaryEntry = buildMockRpgGalleryDetail({ + ownerUserId: mockAuthUser.id, + profileId: workProfileId, + publicWorkCode: 'work-detail-launch-1', + authorPublicUserCode: mockAuthUser.publicUserCode, + visibility: 'published', + publishedAt: '2026-04-20T10:00:00.000Z', + updatedAt: '2026-04-20T10:00:00.000Z', + authorDisplayName: mockAuthUser.displayName, + worldName: '星砂废都', + subtitle: 'å æ˜Ÿæ²™æµ·ä¸ŽåºŸéƒ½é’Ÿæ¥¼', + summaryText: '列表摘è¦åªæä¾›å¡ç‰‡ä¿¡æ¯ï¼Œä¸èƒ½ä½œä¸ºè¿è¡Œæ€ profile。', + coverImageSrc: null, + themeMode: 'tide', + playableNpcCount: 1, + landmarkCount: 1, + likeCount: 0, + }); + summaryEntry.profile = { + ...summaryEntry.profile, + name: '默认档案', + summary: '列表摘è¦ä¸å«è¿è¡Œæ€è§’色。', + playableNpcs: [], + storyNpcs: [], + landmarks: [], + }; + const detailEntry = buildMockRpgGalleryDetail({ + ...summaryEntry, + summaryText: '详情接å£è¿”回完整è‰ç¨¿å†…容。', + }); + detailEntry.profile = { + ...detailEntry.profile, + name: '星砂废都', + subtitle: 'å æ˜Ÿæ²™æµ·ä¸ŽåºŸéƒ½é’Ÿæ¥¼', + summary: '详情接å£è¿”回完整è‰ç¨¿å†…容。', + playableNpcs: [ + { + ...compiledAgentResultPreview.playableNpcs[0]!, + id: 'playable-stardust-1', + name: '砂眠', + title: '废都引路人', + }, + ], + landmarks: [ + { + ...compiledAgentResultPreview.landmarks[0]!, + id: 'landmark-stardust-1', + name: 'å æ˜Ÿé’Ÿæ¥¼', + }, + ], + }; + + vi.mocked(listRpgCreationWorks).mockResolvedValue([ + { + workId: `published:${workProfileId}`, + sourceType: 'published_profile', + status: 'published', + title: '星砂废都', + subtitle: 'å æ˜Ÿæ²™æµ·ä¸ŽåºŸéƒ½é’Ÿæ¥¼', + summary: '详情接å£è¿”回完整è‰ç¨¿å†…容。', + coverImageSrc: null, + coverRenderMode: 'image', + coverCharacterImageSrcs: [], + updatedAt: '2026-04-20T10:00:00.000Z', + publishedAt: '2026-04-20T10:00:00.000Z', + stage: null, + stageLabel: 'å·²å‘布', + playableNpcCount: 1, + landmarkCount: 1, + roleVisualReadyCount: 1, + roleAnimationReadyCount: 0, + roleAssetSummaryLabel: null, + sessionId: null, + profileId: workProfileId, + canResume: false, + canEnterWorld: true, + }, + ]); + vi.mocked(listRpgEntryWorldLibrary).mockResolvedValue([summaryEntry]); + vi.mocked( + rpgEntryLibraryServiceMocks.getRpgEntryWorldLibraryDetail, + ).mockResolvedValue(detailEntry); + + render(); + + await openDraftHub(user); + await user.click(await screen.findByRole('button', { name: /查看详情/u })); + await waitFor(() => { + expect( + rpgEntryLibraryServiceMocks.getRpgEntryWorldLibraryDetail, + ).toHaveBeenCalledWith(workProfileId); + }); + await user.click(await screen.findByRole('button', { name: 'å¯åЍ' })); + + await waitFor(() => { + expect(handleCustomWorldSelect).toHaveBeenCalledWith( + expect.objectContaining({ + id: workProfileId, + name: '星砂废都', + summary: '详情接å£è¿”回完整è‰ç¨¿å†…容。', + playableNpcs: [ + expect.objectContaining({ + id: 'playable-stardust-1', + name: '砂眠', + }), + ], + landmarks: [ + expect.objectContaining({ + id: 'landmark-stardust-1', + name: 'å æ˜Ÿé’Ÿæ¥¼', + }), + ], + }), + ); + }); + expect(handleCustomWorldSelect).toHaveBeenCalledTimes(1); +}); + +test('creation hub published work edit keeps loaded detail profile assets instead of library summary', async () => { + const user = userEvent.setup(); + const workProfileId = 'world-detail-edit-assets-1'; + const summaryEntry = buildMockRpgGalleryDetail({ + ownerUserId: mockAuthUser.id, + profileId: workProfileId, + publicWorkCode: 'work-detail-edit-assets-1', + authorPublicUserCode: mockAuthUser.publicUserCode, + visibility: 'published', + publishedAt: '2026-04-20T10:00:00.000Z', + updatedAt: '2026-04-20T10:00:00.000Z', + authorDisplayName: mockAuthUser.displayName, + worldName: '星砂废都', + subtitle: 'å æ˜Ÿæ²™æµ·ä¸ŽåºŸéƒ½é’Ÿæ¥¼', + summaryText: '列表摘è¦å­—段é½å…¨ä½†ä¸å«è¯¦æƒ…资产。', + coverImageSrc: null, + themeMode: 'tide', + playableNpcCount: 1, + landmarkCount: 1, + likeCount: 0, + }); + summaryEntry.profile = { + ...summaryEntry.profile, + name: '星砂废都', + summary: '列表摘è¦å­—段é½å…¨ä½†ä¸å«è¯¦æƒ…资产。', + playableNpcs: [ + { + ...compiledAgentResultPreview.playableNpcs[0]!, + id: 'playable-stardust-1', + name: '砂眠', + imageSrc: undefined, + }, + ], + storyNpcs: [ + { + ...compiledAgentResultPreview.storyNpcs[0]!, + id: 'story-clock-keeper-1', + name: '钟守', + imageSrc: undefined, + }, + ], + landmarks: [ + { + ...compiledAgentResultPreview.landmarks[0]!, + id: 'landmark-stardust-1', + name: 'å æ˜Ÿé’Ÿæ¥¼', + imageSrc: undefined, + }, + ], + sceneChapterBlueprints: [ + { + id: 'scene-chapter-stardust-1', + sceneId: 'landmark-stardust-1', + title: 'å æ˜Ÿé’Ÿæ¥¼', + summary: '星砂覆盖钟楼入å£ï¼Œé’Ÿå®ˆç­‰å¾…第一ä½è®¿å®¢ã€‚', + sceneTaskDescription: '调查钟楼旧铃自鸣的原因。', + linkedThreadIds: [], + linkedLandmarkIds: ['landmark-stardust-1'], + acts: [ + { + id: 'act-stardust-opening-1', + sceneId: 'landmark-stardust-1', + title: '第一幕', + summary: 'ç ‚çœ å¸¦çŽ©å®¶è¿›å…¥å æ˜Ÿé’Ÿæ¥¼ã€‚', + stageCoverage: ['opening'], + backgroundImageSrc: undefined, + encounterNpcIds: ['playable-stardust-1'], + primaryNpcId: 'playable-stardust-1', + oppositeNpcId: 'story-clock-keeper-1', + eventDescription: '钟楼旧铃忽然自鸣。', + linkedThreadIds: [], + advanceRule: 'after_primary_contact', + actGoal: '进入钟楼。', + transitionHook: '星砂开始倒æµã€‚', + }, + ], + }, + ], + cover: null, + openingCg: null, + }; + const detailEntry = buildMockRpgGalleryDetail({ + ...summaryEntry, + summaryText: '详情接å£è¿”回完整è‰ç¨¿å†…容。', + }); + detailEntry.profile = { + ...summaryEntry.profile, + summary: '详情接å£è¿”回完整è‰ç¨¿å†…容。', + cover: { + sourceType: 'generated', + imageSrc: '/assets/custom-world/star-waste-cover.png', + characterRoleIds: ['playable-stardust-1'], + }, + openingCg: { + id: 'opening-cg-stardust-1', + status: 'ready', + storyboardImageSrc: '/assets/custom-world/opening-storyboard.png', + videoSrc: '/assets/custom-world/opening.mp4', + imageModel: 'gpt-image-2', + videoModel: 'doubao-seedance-2-0-fast-260128', + aspectRatio: '16:9', + imageSize: '2k', + videoResolution: '480p', + durationSeconds: 15, + pointCost: 80, + estimatedWaitMinutes: 10, + updatedAt: '2026-05-21T00:00:00.000Z', + }, + camp: { + id: 'camp-stardust-1', + name: '废都è¥åœ°', + description: '钟楼阴影下的临时è¥åœ°ã€‚', + imageSrc: '/assets/custom-world/star-waste-camp.png', + sceneNpcIds: ['playable-stardust-1'], + connections: [], + }, + playableNpcs: [ + { + ...summaryEntry.profile.playableNpcs[0]!, + imageSrc: '/assets/custom-world/playable-stardust-1.png', + }, + ], + storyNpcs: [ + { + ...summaryEntry.profile.storyNpcs[0]!, + imageSrc: '/assets/custom-world/story-clock-keeper-1.png', + }, + ], + landmarks: [ + { + ...summaryEntry.profile.landmarks[0]!, + imageSrc: '/assets/custom-world/landmark-stardust-1.png', + }, + ], + sceneChapterBlueprints: [ + { + ...summaryEntry.profile.sceneChapterBlueprints![0]!, + acts: [ + { + ...summaryEntry.profile.sceneChapterBlueprints![0]!.acts[0]!, + backgroundImageSrc: + '/assets/custom-world/act-stardust-opening-1.png', + }, + ], + }, + ], + }; + + vi.mocked(listRpgCreationWorks).mockResolvedValue([ + { + workId: `published:${workProfileId}`, + sourceType: 'published_profile', + status: 'published', + title: '星砂废都', + subtitle: 'å æ˜Ÿæ²™æµ·ä¸ŽåºŸéƒ½é’Ÿæ¥¼', + summary: '列表摘è¦å­—段é½å…¨ä½†ä¸å«è¯¦æƒ…资产。', + coverImageSrc: null, + coverRenderMode: 'image', + coverCharacterImageSrcs: [], + updatedAt: '2026-04-20T10:00:00.000Z', + publishedAt: '2026-04-20T10:00:00.000Z', + stage: null, + stageLabel: 'å·²å‘布', + playableNpcCount: 1, + landmarkCount: 1, + roleVisualReadyCount: 0, + roleAnimationReadyCount: 0, + roleAssetSummaryLabel: null, + sessionId: null, + profileId: workProfileId, + canResume: false, + canEnterWorld: true, + }, + ]); + vi.mocked(listRpgEntryWorldLibrary).mockResolvedValue([summaryEntry]); + vi.mocked( + rpgEntryLibraryServiceMocks.getRpgEntryWorldLibraryDetail, + ).mockResolvedValue(detailEntry); + + render(); + + await openDraftHub(user); + await user.click(await screen.findByRole('button', { name: /查看详情/u })); + await waitFor(() => { + expect( + rpgEntryLibraryServiceMocks.getRpgEntryWorldLibraryDetail, + ).toHaveBeenCalledWith(workProfileId); + }); + await user.click(await screen.findByRole('button', { name: '作å“编辑' })); + + expect(await screen.findByText('世界档案', {}, { timeout: 5000 })).toBeTruthy(); + expect( + document.querySelector('video[src="/assets/custom-world/opening.mp4"]'), + ).toBeTruthy(); + + await user.click(screen.getByRole('button', { name: /场景\s+2/u })); + expect((await screen.findByAltText('废都è¥åœ°')).getAttribute('src')).toBe( + '/assets/custom-world/star-waste-camp.png', + ); + expect(screen.getByAltText('å æ˜Ÿé’Ÿæ¥¼-第一幕').getAttribute('src')).toBe( + '/assets/custom-world/act-stardust-opening-1.png', + ); + + await user.click(screen.getByRole('button', { name: /坿‰®æ¼”角色\s+1/u })); + expect((await screen.findByAltText('砂眠')).getAttribute('src')).toBe( + '/assets/custom-world/playable-stardust-1.png', + ); + + await user.click(screen.getByRole('button', { name: /场景角色\s+1/u })); + expect((await screen.findByAltText('钟守')).getAttribute('src')).toBe( + '/assets/custom-world/story-clock-keeper-1.png', + ); +}); + test('creation hub published work card reveals delete action after card action reveal', async () => { const user = userEvent.setup(); diff --git a/src/components/rpg-entry/RpgEntryHomeView.tsx b/src/components/rpg-entry/RpgEntryHomeView.tsx index c3a0f9913..e6c441045 100644 --- a/src/components/rpg-entry/RpgEntryHomeView.tsx +++ b/src/components/rpg-entry/RpgEntryHomeView.tsx @@ -1,5 +1,6 @@ import { AlertCircle, + Archive, ArrowRight, BookOpen, Camera, @@ -233,7 +234,8 @@ const WECHAT_JS_SDK_URL = 'https://res.wx.qq.com/open/js/jweixin-1.6.0.js'; const WECHAT_PAY_CONFIRM_RETRY_DELAYS_MS = [800, 1600, 3000] as const; const WECHAT_NATIVE_PAY_QR_IMAGE_SIZE = 180; -type ProfilePopupPanel = 'invite' | 'redeem' | 'community'; +type ProfileReferralPanel = 'invite' | 'redeem' | 'community'; +type ProfilePopupPanel = ProfileReferralPanel | 'saveArchives'; type RechargeTab = 'points' | 'membership'; type WechatMiniProgramPaymentStatus = 'success' | 'fail' | 'cancel'; type WechatPayResult = { @@ -3306,7 +3308,7 @@ function ProfileReferralModal({ onRedeemCodeChange, onSubmitRedeemCode, }: { - panel: ProfilePopupPanel; + panel: ProfileReferralPanel; center: ProfileReferralInviteCenterResponse | null; isLoading: boolean; isSubmittingRedeem: boolean; @@ -3477,6 +3479,66 @@ function ProfileReferralModal({ ); } +function ProfileSaveArchivesModal({ + saveEntries, + saveError, + isResumingSaveWorldKey, + onClose, + onResumeSave, +}: { + saveEntries: ProfileSaveArchiveSummary[]; + saveError: string | null; + isResumingSaveWorldKey: string | null; + onClose: () => void; + onResumeSave: (entry: ProfileSaveArchiveSummary) => void; +}) { + return ( +
+
+ +
+
+
+ SAVES +
+
存档
+
+ + {saveError ? ( +
+ {saveError} +
+ ) : null} + + {saveEntries.length > 0 ? ( +
+ {saveEntries.map((entry) => ( + onResumeSave(entry)} + /> + ))} +
+ ) : ( +
+ 暂无存档 +
+ )} +
+
+
+ ); +} + function ProfilePlayedWorksModal({ stats, isLoading, @@ -4504,7 +4566,7 @@ export function RpgEntryHomeView({ loadReferralCenter(); }, [activeTab, authUi?.user?.createdAt, isAuthenticated, loadReferralCenter]); - const openProfilePopupPanel = (panel: ProfilePopupPanel) => { + const openProfilePopupPanel = (panel: ProfileReferralPanel) => { setProfilePopupPanel(panel); setReferralError(null); setReferralSuccess(null); @@ -5842,6 +5904,16 @@ export function RpgEntryHomeView({ icon={showRechargeEntry ? Coins : Ticket} onClick={openRechargeOrRewardCodeModal} /> + 0 + ? `${saveEntries.length}个å¯ç»§ç»­` + : '继续游玩' + } + icon={Archive} + onClick={() => setProfilePopupPanel('saveArchives')} + /> {showRechargeEntry ? ( - {profilePopupPanel ? ( + {profilePopupPanel === 'saveArchives' ? ( + setProfilePopupPanel(null)} + onResumeSave={onResumeSave} + /> + ) : profilePopupPanel ? ( ) : null} - {profilePopupPanel ? ( + {profilePopupPanel === 'saveArchives' ? ( + setProfilePopupPanel(null)} + onResumeSave={onResumeSave} + /> + ) : profilePopupPanel ? ( | null | undefined, +) { + if (!profile) { + return 0; + } + + return ( + (profile.playableNpcs?.length ?? 0) + + (profile.storyNpcs?.length ?? 0) + + (profile.items?.length ?? 0) + + (profile.landmarks?.length ?? 0) + + (profile.sceneChapterBlueprints?.length ?? 0) + ); +} + +export function countCustomWorldProfileAssetSlots( + profile: Partial | null | undefined, +) { + if (!profile) { + return 0; + } + + return [ + profile.cover?.imageSrc, + profile.openingCg?.storyboardImageSrc, + profile.openingCg?.videoSrc, + profile.openingCg?.posterImageSrc, + profile.camp?.imageSrc, + ...(profile.playableNpcs ?? []).flatMap((role) => [ + role.imageSrc, + role.generatedVisualAssetId, + role.generatedAnimationSetId, + ...(role.skills ?? []).flatMap((skill) => [ + skill.actionPreviewConfig?.basePath, + skill.actionPreviewConfig?.previewVideoPath, + skill.actionPreviewConfig?.file, + ]), + ...role.initialItems.flatMap((item) => [item.iconSrc]), + ...Object.values(role.animationMap ?? {}).flatMap((config) => [ + config?.basePath, + config?.previewVideoPath, + config?.file, + ]), + ]), + ...(profile.storyNpcs ?? []).flatMap((npc) => [ + npc.imageSrc, + npc.generatedVisualAssetId, + npc.generatedAnimationSetId, + ...(npc.skills ?? []).flatMap((skill) => [ + skill.actionPreviewConfig?.basePath, + skill.actionPreviewConfig?.previewVideoPath, + skill.actionPreviewConfig?.file, + ]), + ...npc.initialItems.flatMap((item) => [item.iconSrc]), + ...Object.values(npc.animationMap ?? {}).flatMap((config) => [ + config?.basePath, + config?.previewVideoPath, + config?.file, + ]), + ]), + ...(profile.items ?? []).flatMap((item) => [item.iconSrc, item.sourcePath]), + ...(profile.landmarks ?? []).map((landmark) => landmark.imageSrc), + ...(profile.sceneChapterBlueprints ?? []).flatMap((chapter) => + chapter.acts.flatMap((act) => [ + act.backgroundImageSrc, + act.backgroundAssetId, + ]), + ), + ].filter((value) => value?.trim()).length; +} + +export function countCustomWorldProfileStructuredSlots( + profile: Partial | null | undefined, +) { + if (!profile) { + return 0; + } + + return [ + profile.cover, + profile.attributeSchema, + profile.themePack, + profile.storyGraph, + profile.knowledgeFacts?.length ? profile.knowledgeFacts : null, + profile.threadContracts?.length ? profile.threadContracts : null, + profile.anchorContent, + profile.creatorIntent, + profile.anchorPack, + profile.lockState, + profile.ownedSettingLayers, + profile.generationMode, + profile.generationStatus, + profile.scenarioPackId, + profile.campaignPackId, + ...(profile.playableNpcs ?? []).flatMap((role) => [ + role.attributeProfile, + ...(role.skills ?? []).map((skill) => skill.actionPreviewConfig), + ...role.initialItems, + ]), + ...(profile.storyNpcs ?? []).flatMap((npc) => [ + npc.attributeProfile, + ...(npc.skills ?? []).map((skill) => skill.actionPreviewConfig), + ...npc.initialItems, + ]), + ...(profile.landmarks ?? []).flatMap((landmark) => [ + landmark.visualDescription, + landmark.narrativeResidues?.length ? landmark.narrativeResidues : null, + ]), + ...((profile.camp?.narrativeResidues ?? []).length + ? [profile.camp?.narrativeResidues] + : []), + ...(profile.sceneChapterBlueprints ?? []).flatMap((chapter) => [ + chapter.sceneTaskDescription, + chapter.linkedThreadIds.length ? chapter.linkedThreadIds : null, + chapter.linkedLandmarkIds.length ? chapter.linkedLandmarkIds : null, + ...chapter.acts.flatMap((act) => [ + act.eventDescription, + act.linkedThreadIds.length ? act.linkedThreadIds : null, + act.actGoal, + act.transitionHook, + ]), + ]), + ].filter(Boolean).length; +} + +export function getCustomWorldProfileCompletenessScore( + profile: Partial | null | undefined, +) { + return ( + countCustomWorldProfileDetailSlots(profile) + + countCustomWorldProfileAssetSlots(profile) + + countCustomWorldProfileStructuredSlots(profile) + ); +} + +export function chooseMoreCompleteCustomWorldProfile( + fallbackProfile: CustomWorldProfile, + candidateProfile: CustomWorldProfile | null | undefined, +) { + if (!candidateProfile) { + return fallbackProfile; + } + + if (candidateProfile.id !== fallbackProfile.id) { + return candidateProfile; + } + + // 中文注释:å‘布 / 回读å¯èƒ½åªè¿”å›žåˆ—è¡¨æ‘˜è¦æˆ–旧快照。 + // åŒä¸€ä¸ª profileId 下,进入世界ä¸èƒ½æŠŠå½“å‰ç»“果页的å°é¢ã€CGã€è§’色资产é™çº§æŽ‰ã€‚ + return getCustomWorldProfileCompletenessScore(candidateProfile) >= + getCustomWorldProfileCompletenessScore(fallbackProfile) + ? candidateProfile + : fallbackProfile; +} diff --git a/src/components/rpg-entry/useRpgCreationEnterWorld.test.tsx b/src/components/rpg-entry/useRpgCreationEnterWorld.test.tsx index 8a8cb6cab..f90ca87dd 100644 --- a/src/components/rpg-entry/useRpgCreationEnterWorld.test.tsx +++ b/src/components/rpg-entry/useRpgCreationEnterWorld.test.tsx @@ -340,4 +340,136 @@ describe('useRpgCreationEnterWorld', () => { 'published-profile', ); }); + + + it('æ­£å¼è¿›å…¥ä¸–界回读结果页字段更少时ä¸é™çº§å½“å‰å®Œæ•´ profile', async () => { + const resultProfile = { + ...buildProfile({ + id: 'draft-profile-rich-assets', + name: '星砂废都', + imageSrc: '/generated-characters/draft-role/portrait.png', + }), + cover: { + sourceType: 'generated' as const, + imageSrc: '/generated-custom-world-covers/star-waste/cover.webp', + characterRoleIds: ['draft-profile-rich-assets-role'], + }, + openingCg: { + id: 'opening-cg-stardust', + status: 'ready' as const, + storyboardImageSrc: '/generated-custom-world-scenes/opening/storyboard.png', + videoSrc: '/generated-custom-world-scenes/opening/opening.mp4', + imageModel: 'gpt-image-2' as const, + videoModel: 'doubao-seedance-2-0-fast-260128', + aspectRatio: '16:9' as const, + imageSize: '2k' as const, + videoResolution: '480p' as const, + durationSeconds: 15 as const, + pointCost: 80 as const, + estimatedWaitMinutes: 10 as const, + updatedAt: '2026-05-21T00:00:00.000Z', + }, + sceneChapterBlueprints: [ + { + id: 'scene-chapter-stardust', + sceneId: 'landmark-stardust', + title: '钟楼第一夜', + summary: '钟楼第一夜。', + sceneTaskDescription: '进入钟楼。', + linkedThreadIds: [], + linkedLandmarkIds: ['landmark-stardust'], + acts: [ + { + id: 'act-stardust-opening', + sceneId: 'landmark-stardust', + title: '第一幕', + summary: 'ç ‚çœ å¸¦çŽ©å®¶è¿›å…¥å æ˜Ÿé’Ÿæ¥¼ã€‚', + stageCoverage: ['opening' as const], + backgroundImageSrc: + '/assets/custom-world/act-stardust-opening.png', + backgroundAssetId: 'asset-act-stardust-opening', + encounterNpcIds: ['draft-profile-rich-assets-role'], + primaryNpcId: 'draft-profile-rich-assets-role', + oppositeNpcId: 'draft-profile-rich-assets-role', + eventDescription: '钟楼旧铃忽然自鸣。', + linkedThreadIds: [], + advanceRule: 'after_primary_contact' as const, + actGoal: '进入钟楼。', + transitionHook: '星砂开始倒æµã€‚', + }, + ], + }, + ], + } satisfies CustomWorldProfile; + const stalePublishedProfile = { + ...resultProfile, + name: '星砂废都', + cover: null, + openingCg: null, + playableNpcs: [], + sceneChapterBlueprints: null, + } satisfies CustomWorldProfile; + const handleCustomWorldSelect = vi.fn(); + const setGeneratedCustomWorldProfile = vi.fn(); + const syncAgentDraftResultProfile = vi.fn(async () => ({ + profile: resultProfile, + view: buildResultView({ + stage: 'ready_to_publish', + profile: resultProfile, + canEnterWorld: false, + }), + })); + const executePublishWorld = vi.fn(async () => buildSession('published')); + const syncAgentCreationResultView = vi.fn(async () => + buildResultView({ + stage: 'published', + profile: stalePublishedProfile, + canEnterWorld: true, + }), + ); + + function Harness() { + const { enterWorldFromCurrentResult } = useRpgCreationEnterWorld({ + isAgentDraftResultView: true, + activeAgentSessionId: 'session-1', + currentAgentSessionStage: 'ready_to_publish', + generatedCustomWorldProfile: resultProfile, + handleCustomWorldSelect, + syncAgentDraftResultProfile, + executePublishWorld, + syncAgentCreationResultView, + setGeneratedCustomWorldProfile, + }); + + return ( + + ); + } + + const { getByText } = render(); + await act(async () => { + getByText('进入世界').click(); + }); + + const launchedProfile = handleCustomWorldSelect.mock.calls[0]?.[0]; + expect(launchedProfile?.id).toBe('draft-profile-rich-assets'); + expect(launchedProfile?.cover?.imageSrc).toBe( + '/generated-custom-world-covers/star-waste/cover.webp', + ); + expect(launchedProfile?.openingCg?.videoSrc).toBe( + '/generated-custom-world-scenes/opening/opening.mp4', + ); + expect(launchedProfile?.playableNpcs[0]?.imageSrc).toBe( + '/generated-characters/draft-role/portrait.png', + ); + expect( + launchedProfile?.sceneChapterBlueprints?.[0]?.acts[0] + ?.backgroundImageSrc, + ).toBe('/assets/custom-world/act-stardust-opening.png'); + }); }); diff --git a/src/components/rpg-entry/useRpgCreationEnterWorld.ts b/src/components/rpg-entry/useRpgCreationEnterWorld.ts index 89f6cee16..b05b079f0 100644 --- a/src/components/rpg-entry/useRpgCreationEnterWorld.ts +++ b/src/components/rpg-entry/useRpgCreationEnterWorld.ts @@ -5,6 +5,7 @@ import type { CustomWorldAgentSessionSnapshot } from '../../../packages/shared/s import type { CustomWorldRuntimeLaunchOptions } from '../platform-entry/platformEntryTypes'; import { rpgCreationPreviewAdapter } from '../../services/rpg-creation/rpgCreationPreviewAdapter'; import type { CustomWorldProfile } from '../../types'; +import { chooseMoreCompleteCustomWorldProfile } from './rpgProfileCompleteness'; type UseRpgCreationEnterWorldParams = { isAgentDraftResultView: boolean; @@ -82,9 +83,10 @@ export function useRpgCreationEnterWorld( if (currentAgentSessionStage === 'published') { const latestView = await syncAgentCreationResultView(activeAgentSessionId); - const publishedProfile = - rpgCreationPreviewAdapter.buildPreviewFromResultView(latestView) ?? - generatedCustomWorldProfile; + const publishedProfile = chooseMoreCompleteCustomWorldProfile( + generatedCustomWorldProfile, + rpgCreationPreviewAdapter.buildPreviewFromResultView(latestView), + ); // 中文注释:已å‘布会è¯çš„“进入世界â€åªè¯»å–åŽç«¯ç»“果页真相, // ä¸èƒ½å†åŒæ­¥è‰ç¨¿æˆ–é‡å¤å‘é€ publish_world,å¦åˆ™ä¼šè¢«å‘布阶段门槛拒ç»ã€‚ setGeneratedCustomWorldProfile(publishedProfile); @@ -110,17 +112,18 @@ export function useRpgCreationEnterWorld( if (canEnterPublishedWorld) { const latestView = await syncAgentCreationResultView(activeAgentSessionId); - return ( - rpgCreationPreviewAdapter.buildPreviewFromResultView(latestView) ?? - latestProfile + return chooseMoreCompleteCustomWorldProfile( + latestProfile, + rpgCreationPreviewAdapter.buildPreviewFromResultView(latestView), ); } await executePublishWorld(); const latestView = await syncAgentCreationResultView(activeAgentSessionId); - const publishedProfile = - rpgCreationPreviewAdapter.buildPreviewFromResultView(latestView) ?? - latestProfile; + const publishedProfile = chooseMoreCompleteCustomWorldProfile( + latestProfile, + rpgCreationPreviewAdapter.buildPreviewFromResultView(latestView), + ); setGeneratedCustomWorldProfile(publishedProfile); return publishedProfile; diff --git a/src/components/rpg-entry/useRpgEntryAgentDraftRestore.test.tsx b/src/components/rpg-entry/useRpgEntryAgentDraftRestore.test.tsx index c8e34e28d..aae04d9a5 100644 --- a/src/components/rpg-entry/useRpgEntryAgentDraftRestore.test.tsx +++ b/src/components/rpg-entry/useRpgEntryAgentDraftRestore.test.tsx @@ -1,17 +1,19 @@ /** @vitest-environment jsdom */ import { act, render } from '@testing-library/react'; +import { useEffect, useState } from 'react'; import { beforeEach, describe, expect, it, vi } from 'vitest'; import type { CustomWorldAgentSessionSnapshot } from '../../../packages/shared/src/contracts/customWorldAgent'; import type { CustomWorldWorkSummary } from '../../../packages/shared/src/contracts/customWorldWorkSummary'; import type { RpgCreationResultView } from '../../../packages/shared/src/contracts/rpgCreationResultView'; +import type { CustomWorldLibraryEntry } from '../../../packages/shared/src/contracts/runtime'; import { executeRpgCreationAction, getRpgCreationOperation, upsertRpgWorldProfile, } from '../../services/rpg-creation'; -import { type CustomWorldProfile,WorldType } from '../../types'; +import { type CustomWorldProfile, WorldType } from '../../types'; import { useRpgCreationResultAutosave } from './useRpgCreationResultAutosave'; import { useRpgEntryLibraryDetail } from './useRpgEntryLibraryDetail'; @@ -64,6 +66,30 @@ function buildProfile(name: string): CustomWorldProfile { }; } +function buildLibraryEntry( + profile: CustomWorldProfile, +): CustomWorldLibraryEntry { + return { + ownerUserId: 'user-1', + profileId: profile.id, + publicWorkCode: null, + authorPublicUserCode: null, + profile, + visibility: 'published' as const, + publishedAt: '2026-04-25T00:00:00.000Z', + updatedAt: '2026-04-25T00:00:00.000Z', + authorDisplayName: '测试玩家', + worldName: profile.name, + subtitle: profile.subtitle, + summaryText: profile.summary, + coverImageSrc: null, + themeMode: 'tide' as const, + playableNpcCount: profile.playableNpcs.length, + landmarkCount: profile.landmarks.length, + likeCount: 0, + }; +} + function buildSession( overrides: Partial = {}, ): CustomWorldAgentSessionSnapshot { @@ -221,6 +247,361 @@ describe('RPG Agent è‰ç¨¿æ¢å¤', () => { expect(setSelectionStage).not.toHaveBeenCalledWith('custom-world-result'); }); + it('作å“详情已加载完整编辑资产时列表摘è¦ä¸èƒ½è¦†ç›– selectedDetailEntry', async () => { + const fullProfile: CustomWorldProfile = { + ...buildProfile('星砂废都'), + id: 'profile-stardust', + cover: { + sourceType: 'default', + imageSrc: null, + characterRoleIds: ['playable-shamian'], + }, + playableNpcs: [ + { + id: 'playable-shamian', + name: '砂眠', + title: '废都引路人', + role: '主角代ç†', + description: '追查旧约的人。', + backstory: '从星砂潮æ±é‡Œé†’æ¥ã€‚', + personality: '冷é™ã€‚', + motivation: '找到旧约。', + combatStyle: 'è¸ç ‚çªè¿›ã€‚', + initialAffinity: 45, + relationshipHooks: [], + tags: [], + relations: [], + backstoryReveal: { + publicSummary: '废都引路人。', + privateChatUnlockAffinity: 60, + chapters: [], + }, + skills: [ + { + id: 'skill-star-step', + name: '星砂步', + summary: 'è¸ç ‚çªè¿›ã€‚', + style: 'mobility', + actionPreviewConfig: { + folder: 'characters/shamian', + prefix: 'skill_', + frames: 8, + basePath: '/assets/custom-world/shamian/skill', + previewVideoPath: '/assets/custom-world/shamian/skill.mp4', + }, + }, + ], + initialItems: [ + { + id: 'item-sand-compass', + name: '星砂罗盘', + category: '专属物å“', + quantity: 1, + rarity: 'rare', + description: 'èƒ½æŒ‡å‡ºæ—§çº¦åŸ‹è—æ–¹å‘。', + tags: [], + iconSrc: '/assets/custom-world/items/sand-compass.png', + }, + ], + imageSrc: '/assets/custom-world/playable-shamian.png', + attributeProfile: { + schemaId: 'schema-星砂废都', + values: { axis_a: 8 }, + topTraits: ['星砂共鸣'], + evidence: [ + { slotId: 'axis_a', reason: '能å¬è§æ˜Ÿç ‚æ½®æ±ã€‚' }, + ], + }, + }, + ], + sceneChapterBlueprints: [ + { + id: 'scene-chapter-clocktower', + sceneId: 'landmark-clocktower', + title: '钟楼第一夜', + summary: '钟楼第一夜。', + sceneTaskDescription: '进入钟楼。', + linkedThreadIds: [], + linkedLandmarkIds: ['landmark-clocktower'], + acts: [ + { + id: 'act-clocktower-opening', + sceneId: 'landmark-clocktower', + title: '第一幕', + summary: 'ç ‚çœ å¸¦çŽ©å®¶è¿›å…¥å æ˜Ÿé’Ÿæ¥¼ã€‚', + stageCoverage: ['opening'], + backgroundImageSrc: + '/assets/custom-world/act-clocktower-opening.png', + backgroundAssetId: 'asset-act-clocktower-opening', + encounterNpcIds: ['playable-shamian'], + primaryNpcId: 'playable-shamian', + oppositeNpcId: 'playable-shamian', + eventDescription: '钟楼旧铃忽然自鸣。', + linkedThreadIds: ['thread-old-vow'], + advanceRule: 'after_primary_contact', + actGoal: '进入钟楼。', + transitionHook: '星砂开始倒æµã€‚', + }, + ], + }, + ], + }; + const summaryProfile: CustomWorldProfile = { + ...fullProfile, + cover: null, + playableNpcs: [ + { + ...fullProfile.playableNpcs[0]!, + skills: [ + { + id: 'skill-star-step', + name: '星砂步', + summary: 'è¸ç ‚çªè¿›ã€‚', + style: 'mobility', + }, + ], + initialItems: [ + { + ...fullProfile.playableNpcs[0]!.initialItems[0]!, + iconSrc: undefined, + }, + ], + imageSrc: undefined, + attributeProfile: undefined, + }, + ], + sceneChapterBlueprints: [ + { + ...fullProfile.sceneChapterBlueprints![0]!, + acts: [ + { + ...fullProfile.sceneChapterBlueprints![0]!.acts[0]!, + backgroundImageSrc: undefined, + backgroundAssetId: undefined, + linkedThreadIds: [], + actGoal: '', + transitionHook: '', + }, + ], + }, + ], + }; + const detailEntry = buildLibraryEntry(fullProfile); + const summaryEntry = buildLibraryEntry(summaryProfile); + const selectedEntries: CustomWorldLibraryEntry[] = []; + + function Harness() { + const [selectedDetailEntry, setSelectedDetailEntry] = useState< + CustomWorldLibraryEntry | null + >(detailEntry); + useEffect(() => { + if (selectedDetailEntry) { + selectedEntries.push(selectedDetailEntry); + } + }, [selectedDetailEntry]); + useRpgEntryLibraryDetail({ + userId: 'user-1', + selectedDetailEntry, + setSelectedDetailEntry, + savedCustomWorldEntries: [summaryEntry], + setSavedCustomWorldEntries: vi.fn(), + setGeneratedCustomWorldProfile: vi.fn(), + setCustomWorldError: vi.fn(), + setCustomWorldAutoSaveError: vi.fn(), + setCustomWorldAutoSaveState: vi.fn(), + setCustomWorldGenerationViewSource: vi.fn(), + setCustomWorldResultViewSource: vi.fn(), + setSelectionStage: vi.fn(), + setPlatformTabToCreate: vi.fn(), + setPlatformTabToDraft: vi.fn(), + setPlatformError: vi.fn(), + appendBrowseHistoryEntry: vi.fn(async () => {}), + refreshCustomWorldWorks: vi.fn(async () => []), + refreshPublishedGallery: vi.fn(async () => []), + persistAgentUiState: vi.fn(), + syncAgentCreationResultView: vi.fn(), + buildDraftResultProfile: () => null, + suppressAgentDraftResultAutoOpen: vi.fn(), + releaseAgentDraftResultAutoOpenSuppression: vi.fn(), + resetAutoSaveTrackingToIdle: vi.fn(), + markAutoSavedProfile: vi.fn(), + }); + return null; + } + + render(); + + await act(async () => {}); + const lastSelected = selectedEntries.at(-1); + expect(lastSelected?.profile.cover?.characterRoleIds).toEqual([ + 'playable-shamian', + ]); + expect( + lastSelected?.profile.playableNpcs[0]?.skills[0]?.actionPreviewConfig + ?.previewVideoPath, + ).toBe('/assets/custom-world/shamian/skill.mp4'); + expect(lastSelected?.profile.playableNpcs[0]?.initialItems[0]?.iconSrc).toBe( + '/assets/custom-world/items/sand-compass.png', + ); + expect(lastSelected?.profile.playableNpcs[0]?.attributeProfile).toBeTruthy(); + expect( + lastSelected?.profile.sceneChapterBlueprints?.[0]?.acts[0] + ?.backgroundImageSrc, + ).toBe('/assets/custom-world/act-clocktower-opening.png'); + }); + + it('默认å°é¢å’Œè§’色编辑结构差异也ä¸èƒ½è¢«åˆ—表摘è¦è¦†ç›–', async () => { + const fullRole = { + id: 'playable-shamian', + name: '砂眠', + title: '废都引路人', + role: '主角代ç†', + description: '追查旧约的人。', + backstory: '从星砂潮æ±é‡Œé†’æ¥ã€‚', + personality: '冷é™ã€‚', + motivation: '找到旧约。', + combatStyle: 'è¸ç ‚çªè¿›ã€‚', + initialAffinity: 45, + relationshipHooks: [], + tags: [], + relations: [], + backstoryReveal: { + publicSummary: '废都引路人。', + privateChatUnlockAffinity: 60, + chapters: [], + }, + skills: [ + { + id: 'skill-star-step', + name: '星砂步', + summary: 'è¸ç ‚çªè¿›ã€‚', + style: 'mobility', + actionPreviewConfig: { + folder: 'characters/shamian', + prefix: 'skill_', + frames: 8, + basePath: '/assets/custom-world/shamian/skill', + previewVideoPath: '/assets/custom-world/shamian/skill.mp4', + }, + }, + ], + initialItems: [ + { + id: 'item-sand-compass', + name: '星砂罗盘', + category: '专属物å“', + quantity: 1, + rarity: 'rare', + description: 'èƒ½æŒ‡å‡ºæ—§çº¦åŸ‹è—æ–¹å‘。', + tags: [], + iconSrc: '/assets/custom-world/items/sand-compass.png', + }, + ], + attributeProfile: { + schemaId: 'schema-星砂废都', + values: { axis_a: 8 }, + topTraits: ['星砂共鸣'], + evidence: [ + { slotId: 'axis_a', reason: '能å¬è§æ˜Ÿç ‚æ½®æ±ã€‚' }, + ], + }, + } satisfies CustomWorldProfile['playableNpcs'][number]; + const fullProfile: CustomWorldProfile = { + ...buildProfile('星砂废都'), + id: 'profile-stardust-structure', + cover: { + sourceType: 'default', + imageSrc: null, + characterRoleIds: ['playable-shamian'], + }, + playableNpcs: [fullRole], + }; + const summaryProfile: CustomWorldProfile = { + ...fullProfile, + cover: null, + playableNpcs: [ + { + ...fullRole, + skills: [ + { + id: 'skill-star-step', + name: '星砂步', + summary: 'è¸ç ‚çªè¿›ã€‚', + style: 'mobility', + }, + ], + initialItems: [ + { + ...fullRole.initialItems[0]!, + iconSrc: undefined, + }, + ], + attributeProfile: undefined, + }, + ], + }; + const detailEntry = buildLibraryEntry(fullProfile); + const summaryEntry = buildLibraryEntry(summaryProfile); + const selectedEntries: CustomWorldLibraryEntry[] = []; + + function Harness() { + const [selectedDetailEntry, setSelectedDetailEntry] = useState< + CustomWorldLibraryEntry | null + >(detailEntry); + useEffect(() => { + if (selectedDetailEntry) { + selectedEntries.push(selectedDetailEntry); + } + }, [selectedDetailEntry]); + useRpgEntryLibraryDetail({ + userId: 'user-1', + selectedDetailEntry, + setSelectedDetailEntry, + savedCustomWorldEntries: [summaryEntry], + setSavedCustomWorldEntries: vi.fn(), + setGeneratedCustomWorldProfile: vi.fn(), + setCustomWorldError: vi.fn(), + setCustomWorldAutoSaveError: vi.fn(), + setCustomWorldAutoSaveState: vi.fn(), + setCustomWorldGenerationViewSource: vi.fn(), + setCustomWorldResultViewSource: vi.fn(), + setSelectionStage: vi.fn(), + setPlatformTabToCreate: vi.fn(), + setPlatformTabToDraft: vi.fn(), + setPlatformError: vi.fn(), + appendBrowseHistoryEntry: vi.fn(async () => {}), + refreshCustomWorldWorks: vi.fn(async () => []), + refreshPublishedGallery: vi.fn(async () => []), + persistAgentUiState: vi.fn(), + syncAgentCreationResultView: vi.fn(), + buildDraftResultProfile: () => null, + suppressAgentDraftResultAutoOpen: vi.fn(), + releaseAgentDraftResultAutoOpenSuppression: vi.fn(), + resetAutoSaveTrackingToIdle: vi.fn(), + markAutoSavedProfile: vi.fn(), + }); + return null; + } + + render(); + + await act(async () => {}); + const lastSelected = selectedEntries.at(-1); + expect(lastSelected?.profile.cover?.characterRoleIds).toEqual([ + 'playable-shamian', + ]); + expect( + lastSelected?.profile.playableNpcs[0]?.skills[0]?.actionPreviewConfig + ?.previewVideoPath, + ).toBe('/assets/custom-world/shamian/skill.mp4'); + expect(lastSelected?.profile.playableNpcs[0]?.initialItems[0]?.iconSrc).toBe( + '/assets/custom-world/items/sand-compass.png', + ); + expect(lastSelected?.profile.playableNpcs[0]?.attributeProfile?.values).toEqual( + { axis_a: 8 }, + ); + }); + it('Agent 结果页自动ä¿å­˜å…ˆå›žå†™ session,å†ä¿å­˜åŽç«¯ result-view profile', async () => { const oldProfile = buildProfile('æ—§å‰ç«¯å¿«ç…§'); const latestProfile = { diff --git a/src/components/rpg-entry/useRpgEntryLibraryDetail.ts b/src/components/rpg-entry/useRpgEntryLibraryDetail.ts index d5666566a..20d46aad3 100644 --- a/src/components/rpg-entry/useRpgEntryLibraryDetail.ts +++ b/src/components/rpg-entry/useRpgEntryLibraryDetail.ts @@ -21,6 +21,11 @@ import { unpublishRpgEntryWorldProfile, } from '../../services/rpg-entry/rpgEntryLibraryClient'; import type { CustomWorldProfile } from '../../types'; +import { + countCustomWorldProfileAssetSlots, + countCustomWorldProfileDetailSlots, + countCustomWorldProfileStructuredSlots, +} from './rpgProfileCompleteness'; import { resolveRpgEntryErrorMessage } from './rpgEntryShared'; import type { CustomWorldAutoSaveState, @@ -86,6 +91,46 @@ function isMissingRpgEntryAgentSessionError(error: unknown) { ); } +function shouldKeepSelectedDetailProfile( + selectedEntry: CustomWorldLibraryEntry, + nextOwnedEntry: CustomWorldLibraryEntry, +) { + if ( + selectedEntry.ownerUserId !== nextOwnedEntry.ownerUserId || + selectedEntry.profileId !== nextOwnedEntry.profileId + ) { + return false; + } + + const selectedDetailCount = countCustomWorldProfileDetailSlots( + selectedEntry.profile, + ); + const nextDetailCount = countCustomWorldProfileDetailSlots( + nextOwnedEntry.profile, + ); + const selectedAssetSlotCount = countCustomWorldProfileAssetSlots( + selectedEntry.profile, + ); + const nextAssetSlotCount = countCustomWorldProfileAssetSlots( + nextOwnedEntry.profile, + ); + const selectedStructuredSlotCount = + countCustomWorldProfileStructuredSlots(selectedEntry.profile); + const nextStructuredSlotCount = countCustomWorldProfileStructuredSlots( + nextOwnedEntry.profile, + ); + const expectedRuntimeCount = + nextOwnedEntry.playableNpcCount + nextOwnedEntry.landmarkCount; + + // ä½œå“æž¶åˆ—表åªä¿è¯å¡ç‰‡æ‘˜è¦ï¼Œä¸èƒ½åœ¨è¯¦æƒ…接å£å·²ç»æ‹¿åˆ°å®Œæ•´è¿è¡Œæ€å­—段åŽè¦†ç›–详情。 + return ( + (selectedDetailCount > nextDetailCount && + expectedRuntimeCount > nextDetailCount) || + selectedAssetSlotCount > nextAssetSlotCount || + selectedStructuredSlotCount > nextStructuredSlotCount + ); +} + /** * 负责平å°è¯¦æƒ…ã€åˆ›ä½œä½œå“å…¥å£å’Œç»“果页打开路径。 * å¹³å°å£³å±‚åªæ¶ˆè´¹â€œæ‰“å¼€å“ªä¸ªé¢æ¿â€çš„结果,ä¸å†è‡ªå·±æ‹¼æŽ¥æ¢å¤æµç¨‹ç»†èŠ‚ã€‚ @@ -136,6 +181,10 @@ export function useRpgEntryLibraryDetail( entry.profileId === selectedDetailEntry.profileId, ); if (nextOwnedEntry && nextOwnedEntry !== selectedDetailEntry) { + if (shouldKeepSelectedDetailProfile(selectedDetailEntry, nextOwnedEntry)) { + return; + } + setSelectedDetailEntry(nextOwnedEntry); } }, [savedCustomWorldEntries, selectedDetailEntry, setSelectedDetailEntry]); diff --git a/src/data/customWorldLibrary.test.ts b/src/data/customWorldLibrary.test.ts index 6643c7b2a..66c593ae1 100644 --- a/src/data/customWorldLibrary.test.ts +++ b/src/data/customWorldLibrary.test.ts @@ -196,4 +196,287 @@ describe('normalizeCustomWorldProfileRecord role asset descriptions', () => { '/generated-custom-world-scenes/opening/storyboard.png', ); }); + + it('ä¿ç•™ç»“果页å°é¢å’Œå…³é”®å›¾ç‰‡èµ„产槽ä½', () => { + const profile = normalizeCustomWorldProfileRecord({ + name: '星砂废都', + settingText: 'å æ˜Ÿæ²™æµ·ä¸ŽåºŸéƒ½é’Ÿæ¥¼', + cover: { + sourceType: 'generated', + imageSrc: '/generated-custom-world-covers/star-waste/cover.webp', + characterRoleIds: ['playable-shamian'], + }, + camp: { + id: 'camp-star-waste', + name: '废都è¥åœ°', + description: '钟楼阴影下的临时è¥åœ°ã€‚', + imageSrc: '/assets/custom-world/camp-star-waste.png', + sceneNpcIds: ['playable-shamian'], + connections: [], + }, + playableNpcs: [ + { + id: 'playable-shamian', + name: '砂眠', + title: '废都引路人', + role: '主角代ç†', + imageSrc: '/assets/custom-world/playable-shamian.png', + }, + ], + storyNpcs: [ + { + id: 'story-clock-keeper', + name: '钟守', + title: '钟楼守夜者', + role: '第一幕主NPC', + imageSrc: '/assets/custom-world/story-clock-keeper.png', + }, + ], + landmarks: [ + { + id: 'landmark-clocktower', + name: 'å æ˜Ÿé’Ÿæ¥¼', + description: 'åŠæˆªé’Ÿæ¥¼è¢«æ˜Ÿç ‚埋ä½ã€‚', + imageSrc: '/assets/custom-world/landmark-clocktower.png', + sceneNpcIds: ['story-clock-keeper'], + connections: [], + }, + ], + sceneChapterBlueprints: [ + { + id: 'scene-chapter-clocktower', + sceneId: 'landmark-clocktower', + title: '钟楼第一夜', + acts: [ + { + id: 'act-clocktower-opening', + sceneId: 'landmark-clocktower', + title: '第一幕', + summary: 'ç ‚çœ å¸¦çŽ©å®¶è¿›å…¥å æ˜Ÿé’Ÿæ¥¼ã€‚', + backgroundImageSrc: + '/assets/custom-world/act-clocktower-opening.png', + encounterNpcIds: ['砂眠', '钟守'], + primaryNpcId: '砂眠', + oppositeNpcId: '钟守', + }, + ], + }, + ], + }); + + expect(profile?.cover?.sourceType).toBe('generated'); + expect(profile?.cover?.imageSrc).toBe( + '/generated-custom-world-covers/star-waste/cover.webp', + ); + expect(profile?.camp?.imageSrc).toBe( + '/assets/custom-world/camp-star-waste.png', + ); + expect(profile?.playableNpcs[0]?.imageSrc).toBe( + '/assets/custom-world/playable-shamian.png', + ); + expect(profile?.storyNpcs[0]?.imageSrc).toBe( + '/assets/custom-world/story-clock-keeper.png', + ); + expect(profile?.landmarks[0]?.imageSrc).toBe( + '/assets/custom-world/landmark-clocktower.png', + ); + expect( + profile?.sceneChapterBlueprints?.[0]?.acts[0]?.backgroundImageSrc, + ).toBe('/assets/custom-world/act-clocktower-opening.png'); + }); + + it('近似无æŸä¿ç•™ç¼–辑æ€å’Œè¿è¡Œæ€ç»“构字段', () => { + const profile = normalizeCustomWorldProfileRecord({ + name: '星砂废都', + settingText: 'å æ˜Ÿæ²™æµ·ä¸ŽåºŸéƒ½é’Ÿæ¥¼', + attributeSchema: { + id: 'schema-stardust', + worldId: 'world-stardust', + schemaVersion: 1, + generatedFrom: { + worldType: 'CUSTOM', + worldName: '星砂废都', + settingSummary: 'å æ˜Ÿæ²™æµ·ä¸ŽåºŸéƒ½é’Ÿæ¥¼', + tone: 'è‹å‡‰', + conflictCore: '旧约与星砂潮æ±å†²çª', + }, + slots: [ + { slotId: 'axis_a', name: '星砂共鸣' }, + { slotId: 'axis_b', name: '废都步法' }, + { slotId: 'axis_c', name: '钟楼感知' }, + { slotId: 'axis_d', name: '旧约心ç«' }, + { slotId: 'axis_e', name: '尘缘牵引' }, + { slotId: 'axis_f', name: 'æ½®æ±çŽ„æ¯' }, + ], + }, + camp: { + id: 'camp-star-waste', + name: '废都è¥åœ°', + description: '钟楼阴影下的临时è¥åœ°ã€‚', + narrativeResidues: [ + { + id: 'residue-camp-1', + summary: 'è¥åœ°ç«ç›†é‡Œæ··ç€æ˜Ÿç ‚。', + }, + ], + }, + playableNpcs: [ + { + id: 'playable-shamian', + name: '砂眠', + title: '废都引路人', + role: '主角代ç†', + skills: [ + { + id: 'skill-star-step', + name: '星砂步', + summary: 'è¸ç ‚çªè¿›ã€‚', + style: 'mobility', + actionPreviewConfig: { + folder: 'characters/shamian', + prefix: 'skill_', + frames: 8, + basePath: '/assets/custom-world/shamian/skill', + previewVideoPath: '/assets/custom-world/shamian/skill.mp4', + file: 'skill.png', + }, + }, + ], + initialItems: [ + { + id: 'item-sand-compass', + name: '星砂罗盘', + category: '专属物å“', + quantity: 1, + rarity: 'rare', + description: 'èƒ½æŒ‡å‡ºæ—§çº¦åŸ‹è—æ–¹å‘。', + tags: ['旧约'], + iconSrc: '/assets/custom-world/items/sand-compass.png', + }, + ], + attributeProfile: { + schemaId: 'schema-stardust', + values: { axis_a: 8, axis_b: 7 }, + topTraits: ['星砂共鸣'], + evidence: [ + { slotId: 'axis_a', reason: '能å¬è§æ˜Ÿç ‚æ½®æ±ã€‚' }, + ], + }, + }, + ], + storyNpcs: [ + { + id: 'story-clock-keeper', + name: '钟守', + title: '钟楼守夜者', + role: '第一幕主NPC', + attributeProfile: { + schemaId: 'schema-stardust', + values: { axis_c: 9 }, + topTraits: ['钟楼感知'], + evidence: [ + { slotId: 'axis_c', reason: '能辨认旧铃回声。' }, + ], + }, + }, + ], + landmarks: [ + { + id: 'landmark-clocktower', + name: 'å æ˜Ÿé’Ÿæ¥¼', + description: 'åŠæˆªé’Ÿæ¥¼è¢«æ˜Ÿç ‚埋ä½ã€‚', + visualDescription: '钟楼外墙布满è“白星砂结晶。', + narrativeResidues: [ + { + id: 'residue-clocktower-1', + summary: '钟楼第å三声铃å“仿œªæ•£åŽ»ã€‚', + }, + ], + }, + ], + sceneChapterBlueprints: [ + { + id: 'scene-chapter-clocktower', + sceneId: 'landmark-clocktower', + title: '钟楼第一夜', + acts: [ + { + id: 'act-clocktower-opening', + sceneId: 'landmark-clocktower', + title: '第一幕', + summary: 'ç ‚çœ å¸¦çŽ©å®¶è¿›å…¥å æ˜Ÿé’Ÿæ¥¼ã€‚', + backgroundAssetId: 'asset-act-clocktower-opening', + backgroundImageSrc: + '/assets/custom-world/act-clocktower-opening.png', + eventDescription: '钟楼旧铃忽然自鸣。', + linkedThreadIds: ['thread-old-vow'], + advanceRule: 'after_primary_contact', + actGoal: '进入钟楼。', + transitionHook: '星砂开始倒æµã€‚', + }, + ], + }, + ], + }); + + expect(profile?.attributeSchema.id).toBe('schema-stardust'); + expect(profile?.attributeSchema.slots[0]?.name).toBe('星砂共鸣'); + expect(profile?.camp?.narrativeResidues?.[0]?.summary).toBe( + 'è¥åœ°ç«ç›†é‡Œæ··ç€æ˜Ÿç ‚。', + ); + expect( + profile?.playableNpcs[0]?.skills[0]?.actionPreviewConfig + ?.previewVideoPath, + ).toBe('/assets/custom-world/shamian/skill.mp4'); + expect(profile?.playableNpcs[0]?.initialItems[0]?.iconSrc).toBe( + '/assets/custom-world/items/sand-compass.png', + ); + expect(profile?.playableNpcs[0]?.attributeProfile?.values.axis_a).toBe(8); + expect(profile?.storyNpcs[0]?.attributeProfile?.topTraits).toContain( + '钟楼感知', + ); + expect(profile?.landmarks[0]?.visualDescription).toBe( + '钟楼外墙布满è“白星砂结晶。', + ); + expect(profile?.landmarks[0]?.narrativeResidues?.[0]?.summary).toBe( + '钟楼第å三声铃å“仿œªæ•£åŽ»ã€‚', + ); + const act = profile?.sceneChapterBlueprints?.[0]?.acts[0]; + expect(act?.backgroundAssetId).toBe('asset-act-clocktower-opening'); + expect(act?.eventDescription).toBe('钟楼旧铃忽然自鸣。'); + expect(act?.linkedThreadIds).toEqual(['thread-old-vow']); + expect(act?.actGoal).toBe('进入钟楼。'); + expect(act?.transitionHook).toBe('星砂开始倒æµã€‚'); + }); + + it('ä¿ç•™åªæœ‰èƒŒæ™¯èµ„产的场景幕,é¿å…æ¢å¤è¯¦æƒ…时丢失场景 CG', () => { + const profile = normalizeCustomWorldProfileRecord({ + name: '星砂废都', + settingText: 'å æ˜Ÿæ²™æµ·ä¸ŽåºŸéƒ½é’Ÿæ¥¼', + sceneChapterBlueprints: [ + { + id: 'scene-chapter-clocktower', + sceneId: 'landmark-clocktower', + title: '钟楼第一夜', + acts: [ + { + id: 'act-background-only', + sceneId: 'landmark-clocktower', + backgroundAssetId: 'asset-background-only', + backgroundImageSrc: '/assets/custom-world/background-only.png', + backgroundPromptText: 'å æ˜Ÿé’Ÿæ¥¼è¢«è“白星砂照亮。', + }, + ], + }, + ], + }); + + const act = profile?.sceneChapterBlueprints?.[0]?.acts[0]; + expect(act?.id).toBe('act-background-only'); + expect(act?.backgroundImageSrc).toBe( + '/assets/custom-world/background-only.png', + ); + expect(act?.backgroundAssetId).toBe('asset-background-only'); + expect(act?.backgroundPromptText).toBe('å æ˜Ÿé’Ÿæ¥¼è¢«è“白星砂照亮。'); + }); }); diff --git a/src/data/customWorldLibrary.ts b/src/data/customWorldLibrary.ts index 8b53f3e86..3b4971a73 100644 --- a/src/data/customWorldLibrary.ts +++ b/src/data/customWorldLibrary.ts @@ -28,6 +28,7 @@ import { CustomWorldNpcVisualGearType, CustomWorldNpcVisualRace, CustomWorldOpeningCgProfile, + CustomWorldCoverProfile, CustomWorldPlayableNpc, CustomWorldProfile, CustomWorldRoleInitialItem, @@ -864,6 +865,7 @@ function normalizeLandmark( id: toText(value.id, `saved-landmark-${index + 1}`), name, description: toText(value.description), + visualDescription: toText(value.visualDescription) || undefined, imageSrc: toText(value.imageSrc) || undefined, narrativeResidues: preserveStructuredRecordArray( @@ -909,7 +911,10 @@ function normalizeCampScene( summary: toText(connection.summary) || toText(connection.description), })) .filter((connection) => connection.targetLandmarkId), - narrativeResidues: null, + narrativeResidues: + preserveStructuredRecordArray( + value.narrativeResidues, + ) ?? null, }; } @@ -989,6 +994,13 @@ function normalizeSceneActBlueprint( const advanceRule = toText(value.advanceRule); const title = toText(value.title); const summary = toText(value.summary); + const backgroundImageSrc = toText(value.backgroundImageSrc); + const backgroundPromptText = toText(value.backgroundPromptText); + const backgroundAssetId = toText(value.backgroundAssetId); + const eventDescription = toText(value.eventDescription); + const linkedThreadIds = toStringArray(value.linkedThreadIds); + const actGoal = toText(value.actGoal); + const transitionHook = toText(value.transitionHook); const primaryNpcId = resolveCustomWorldRoleIdReference( profileRoles, toText(value.primaryNpcId, encounterNpcIds[0] ?? ''), @@ -998,7 +1010,18 @@ function normalizeSceneActBlueprint( toText(value.oppositeNpcId, primaryNpcId), ); - if (!title && !summary && encounterNpcIds.length === 0) { + if ( + !title && + !summary && + encounterNpcIds.length === 0 && + !backgroundImageSrc && + !backgroundPromptText && + !backgroundAssetId && + !eventDescription && + linkedThreadIds.length === 0 && + !actGoal && + !transitionHook + ) { return null; } @@ -1011,26 +1034,26 @@ function normalizeSceneActBlueprint( stageCoverage.length > 0 ? stageCoverage : index === 0 - ? ['opening'] - : ['climax', 'aftermath'], - backgroundImageSrc: toText(value.backgroundImageSrc) || undefined, - backgroundPromptText: toText(value.backgroundPromptText) || undefined, - backgroundAssetId: toText(value.backgroundAssetId) || undefined, + ? ['opening'] + : ['climax', 'aftermath'], + backgroundImageSrc: backgroundImageSrc || undefined, + backgroundPromptText: backgroundPromptText || undefined, + backgroundAssetId: backgroundAssetId || undefined, encounterNpcIds, primaryNpcId, oppositeNpcId, eventDescription: toText( - value.eventDescription, + eventDescription, oppositeNpcId ? `第 ${index + 1} 幕中,玩家与${oppositeNpcId}æ­£é¢æŽ¥è§¦ï¼ŒæŽ¨åŠ¨å½“å‰åœºæ™¯äº‹ä»¶å‡çº§ã€‚` : `第 ${index + 1} 幕中,玩家处ç†å½“å‰åœºæ™¯çš„关键事件。`, ), - linkedThreadIds: toStringArray(value.linkedThreadIds), + linkedThreadIds, advanceRule: SCENE_ACT_ADVANCE_RULES.has(advanceRule as never) ? (advanceRule as SceneActBlueprint['advanceRule']) : 'after_active_step_complete', - actGoal: toText(value.actGoal), - transitionHook: toText(value.transitionHook), + actGoal, + transitionHook, }; } @@ -1159,6 +1182,9 @@ function normalizeProfile(value: unknown): CustomWorldProfile | null { const openingCg = preserveStructuredRecord( value.openingCg, ); + const cover = preserveStructuredRecord( + value.cover, + ); const normalizedProfile = { id: toText(value.id, `saved-custom-world-${Date.now().toString(36)}`), settingText, @@ -1184,6 +1210,7 @@ function normalizeProfile(value: unknown): CustomWorldProfile | null { .map((entry, index) => normalizeItem(entry, index)) .filter((entry): entry is CustomWorldItem => Boolean(entry)) : [], + cover, openingCg, camp, landmarks: normalizeCustomWorldLandmarks({ diff --git a/src/hooks/useGameFlow.customWorld.test.tsx b/src/hooks/useGameFlow.customWorld.test.tsx index 1a91f7666..2b862281b 100644 --- a/src/hooks/useGameFlow.customWorld.test.tsx +++ b/src/hooks/useGameFlow.customWorld.test.tsx @@ -776,3 +776,123 @@ test('custom world opening act accepts runtime npc id references and still start }), ); }); + +test('switching between custom worlds sends the newly selected profile to runtime bootstrap', async () => { + const user = userEvent.setup(); + const oldProfile = buildSavedProfile(); + const openedDraftProfile = normalizeCustomWorldProfileRecord({ + ...oldProfile, + id: 'opened-draft-profile', + name: '星砂废都', + subtitle: 'å æ˜Ÿæ²™æµ·ä¸ŽåºŸéƒ½é’Ÿæ¥¼', + summary: '本次从è‰ç¨¿æž¶æ‰“开并å¯åŠ¨çš„ç›®æ ‡è‰ç¨¿ã€‚', + settingText: 'æ˜Ÿç ‚è¦†ç›–æ—§åºŸéƒ½ï¼Œé’Ÿæ¥¼ä¸‹åŸ‹ç€æ—§çº¦ã€‚', + playableNpcs: [ + { + ...oldProfile.playableNpcs[0], + id: 'opened-playable-1', + name: '砂眠', + title: '废都引路人', + }, + ], + }); + + if (!openedDraftProfile) { + throw new Error('failed to build opened draft profile'); + } + const normalizedOpenedDraftProfile = openedDraftProfile; + + function SwitchWorldHarness() { + const { + gameState, + handleCustomWorldSelect, + handleCharacterSelect, + } = useRpgSessionBootstrap(); + const openedCharacters = buildCustomWorldPlayableCharacters( + normalizedOpenedDraftProfile, + ); + const selectedCharacter = openedCharacters[0] ?? null; + + return ( +
+ + + +
+          {JSON.stringify({
+            profileId: gameState.customWorldProfile?.id ?? null,
+            profileName: gameState.customWorldProfile?.name ?? null,
+          })}
+        
+
+ ); + } + + runtimeStoryClientMocks.beginRuntimeStorySession.mockResolvedValue( + buildRuntimeStoryBootstrapSnapshot({ + profile: normalizedOpenedDraftProfile, + character: buildCustomWorldPlayableCharacters( + normalizedOpenedDraftProfile, + )[0]!, + }), + ); + + render(); + + await user.click(screen.getByRole('button', { name: '选择旧世界' })); + await waitFor(() => { + expect( + JSON.parse(screen.getByTestId('state-snapshot').textContent ?? '{}') + .profileName, + ).toBe('回潮群岛'); + }); + + await user.click(screen.getByRole('button', { name: '选择打开è‰ç¨¿' })); + await waitFor(() => { + expect( + JSON.parse(screen.getByTestId('state-snapshot').textContent ?? '{}') + .profileName, + ).toBe('星砂废都'); + }); + + await user.click(screen.getByRole('button', { name: '确认è‰ç¨¿è§’色' })); + + await waitFor(() => { + expect(runtimeStoryClientMocks.beginRuntimeStorySession).toHaveBeenCalledWith( + expect.objectContaining({ + customWorldProfile: expect.objectContaining({ + id: 'opened-draft-profile', + name: '星砂废都', + summary: '本次从è‰ç¨¿æž¶æ‰“开并å¯åŠ¨çš„ç›®æ ‡è‰ç¨¿ã€‚', + }), + character: expect.objectContaining({ + id: 'opened-playable-1', + name: '砂眠', + }), + }), + ); + }); +});