From 67ba40c6787dc9950df5be0e999eb0f488ba7d21 Mon Sep 17 00:00:00 2001 From: kdletters Date: Wed, 3 Jun 2026 00:57:24 +0800 Subject: [PATCH] Refine play type integration flow and docs --- .hermes/shared-memory/decision-log.md | 19 +- .hermes/shared-memory/pitfalls.md | 30 +- ...„】server-rs与SpacetimeDBæ•°æ®å¥‘约-2026-05-15.md | 4 +- ...玩法创作】平å°å…¥å£ä¸ŽçŽ©æ³•é“¾è·¯-2026-05-15.md | 17 +- ...玩法创作】拼图生æˆé¡µè¿›åº¦å£å¾„-2026-05-23.md | 17 +- ...玩法创作】生æˆé¡µåœ†çŽ¯å¸ƒå±€å£å¾„-2026-05-23.md | 4 +- .../shared/src/contracts/puzzleAgentDraft.ts | 4 +- .../crates/api-server/src/password_entry.rs | 18 +- .../api-server/src/password_management.rs | 14 +- server-rs/crates/api-server/src/phone_auth.rs | 28 +- .../crates/api-server/src/puzzle/draft.rs | 8 +- .../crates/api-server/src/puzzle/handlers.rs | 4 +- .../crates/api-server/src/puzzle/tests.rs | 22 + .../crates/api-server/src/refresh_session.rs | 14 +- server-rs/crates/api-server/src/state.rs | 6 +- .../crates/api-server/src/wechat_auth.rs | 42 +- server-rs/crates/module-auth/src/lib.rs | 117 +- .../spacetime-module/src/auth/procedures.rs | 17 +- .../spacetime-module/src/custom_world.rs | 1 + .../spacetime-module/src/wooden_fish.rs | 1 + ...ustomWorldCreationHub.interaction.test.tsx | 89 +- .../CustomWorldCreationHub.tsx | 7 +- .../custom-world-home/CustomWorldWorkCard.tsx | 71 +- .../creationWorkShelf.test.ts | 92 +- .../custom-world-home/creationWorkShelf.ts | 59 +- .../PlatformEntryFlowShellImpl.tsx | 1383 ++++++++++++++--- ...gEntryFlowShell.agent.interaction.test.tsx | 399 ++++- src/components/rpg-entry/RpgEntryHomeView.tsx | 6 +- ...ch3DCreationWorkspace.interaction.test.tsx | 4 +- .../workspaces/Match3DCreationWorkspace.tsx | 8 +- ...zzleCreationWorkspace.interaction.test.tsx | 9 + .../workspaces/PuzzleCreationWorkspace.tsx | 3 + src/index.css | 25 + .../miniGameDraftGenerationProgress.test.ts | 124 +- .../miniGameDraftGenerationProgress.ts | 179 +-- 35 files changed, 2226 insertions(+), 619 deletions(-) diff --git a/.hermes/shared-memory/decision-log.md b/.hermes/shared-memory/decision-log.md index 1299cdbe..80803c1e 100644 --- a/.hermes/shared-memory/decision-log.md +++ b/.hermes/shared-memory/decision-log.md @@ -23,6 +23,7 @@ - å½±å“范围:`src/services/payment/paymentPlatform.ts`ã€`src/components/rpg-entry/RpgEntryHomeView.tsx`ã€`miniprogram/pages/wechat-pay/`ã€`server-rs/crates/api-server/src/runtime_profile.rs`ã€`server-rs/crates/shared-contracts/src/runtime.rs`ã€`packages/shared/src/contracts/runtime.ts`ã€å¾®ä¿¡ç™»å½•æ€å­˜å‚¨ã€‚ - éªŒè¯æ–¹å¼ï¼šæ³¥ç‚¹å’Œä¼šå‘˜å•†å“在å°ç¨‹åºè¿è¡Œæ€éƒ½è¯·æ±‚ `wechat_mp_virtual`ï¼›å°ç¨‹åºé¡µèƒ½æŒ‰ payload 调用 `wx.requestVirtualPayment` / `wx.requestPayment`ï¼›`cargo check -p api-server --manifest-path server-rs/Cargo.toml` 与支付相关å‰ç«¯æµ‹è¯•通过。 - å…³è”æ–‡æ¡£ï¼š`docs/ã€æŠ€æœ¯æ–¹æ¡ˆã€‘å¾®ä¿¡è™šæ‹Ÿæ”¯ä»˜æŽ¥å…¥-2026-05-26.md`。 + ## 2026-05-30 Linux 本地 dev ç«¯å£æ®µæŒ‰ç³»ç»Ÿçº§æ³¨å†Œè¡¨åˆ†é… - 背景:åŒä¸€å° Linux 开呿œºä¸Šæœ‰å¤šä¸ªç”¨æˆ·åŒæ—¶è·‘ `npm run dev` 时,å•纯é å„自 `GENARRATIVE_DEV_PORT_RANGE` 容易撞段,且åŒä¸€ç”¨æˆ·å¹¶å‘起两个 dev ä¼šè¯æ—¶ä¹Ÿä¼šæŠŠç›¸åŒç«¯å£æ®µé‡å¤æ‹¿èµ°ã€‚ @@ -104,6 +105,7 @@ - å½±å“范围:`src/components/rpg-entry/RpgEntryHomeView.tsx`ã€`src/components/rpg-entry/RpgEntryHomeView.recharge.test.tsx`ã€`docs/ã€é¡¹ç›®åŸºçº¿ã€‘当å‰äº§å“与工程约æŸ-2026-05-15.md`。 - éªŒè¯æ–¹å¼ï¼š`npm run test -- src/components/rpg-entry/RpgEntryHomeView.recharge.test.tsx` åº”æ–­è¨€ä»»åŠ¡å¡æ˜¾ç¤º `1 / 1`ã€é¢†å–åŽæ˜¾ç¤ºå·²å®Œæˆï¼Œä¸”新用户账å·ä¹Ÿæ²¡æœ‰ `次级入å£` / `填邀请ç ` 常驻按钮;`npm run typecheck`ã€`npm run check:encoding` 通过。 - å…³è”æ–‡æ¡£ï¼š`docs/ã€é¡¹ç›®åŸºçº¿ã€‘当å‰äº§å“与工程约æŸ-2026-05-15.md`。 + ## 2026-05-26 生æˆé¡µæ€»è¿›åº¦åœ†å¼§é€†æ—¶é’ˆå›žè°ƒ 5 度 - 背景:创作生æˆé¡µçš„æ€»è¿›åº¦åœ†å¼§åœ¨ `160deg` ä½ç½®ä»éœ€è½»å¾®å‘å·¦å¾®è°ƒï¼Œç”¨æˆ·è¦æ±‚å‘左逆时针回调 `5deg`。 @@ -213,11 +215,19 @@ ## 2026-05-23 拼图生æˆé¡µæŒ‰åŽç«¯çœŸå®žè¿›åº¦æŽ¨è¿›é˜¶æ®µ - 背景:拼图生æˆé¡µåŽŸå…ˆä¼šæŒ‰æœ¬åœ°è€—æ—¶è‡ªåŠ¨æŽ¨è¿›æ­¥éª¤ï¼Œå®¹æ˜“åœ¨åŽç«¯çœŸå®žç”Ÿæˆå°šæœªå®Œæˆæ—¶è·³åˆ°åŽç»­é˜¶æ®µï¼Œå¯¼è‡´é¡µé¢çжæ€å’Œä¼šè¯è¿›åº¦è„±èŠ‚ã€‚ -- 决策:拼图生æˆé¡µçš„跨步骤推进åªè®¤åŽç«¯ä¼šè¯ `progressPercent` çš„çœŸå®žé‡Œç¨‹ç¢‘ï¼Œå½“å‰æ­¥éª¤å†…部å†ç”¨æœ¬åœ°è€—æ—¶å‡è¿›åº¦å¹³æ»‘展示;总进度åˆå§‹å¿…须为 `0%`ï¼Œä¹‹åŽæŒ‰ `0-88`ã€`88-94`ã€`94-96`ã€`96-98` 的真实里程碑区间平滑推进。åªè¦å½“剿­¥éª¤ç”Ÿæˆå†…容未完æˆï¼Œå°±å¿…é¡»åœç•™åœ¨å½“剿­¥éª¤ã€‚页é¢åªå±•ç¤ºå½“å‰æ­¥éª¤æ ‡é¢˜å’Œè¿›åº¦ï¼Œä¸å±•示步骤详细æè¿°ã€‚`ç”Ÿæˆæ‹¼å›¾é¦–图` å•独按 4 分钟估算,完整 AI é‡ç»˜è·¯å¾„约 448 秒;上传图且关闭 AI é‡ç»˜è·¯å¾„跳过首图生æˆï¼Œä»çº¦ 208 秒。 +- 决策:拼图生æˆé¡µçš„跨步骤推进åªè®¤åŽç«¯ä¼šè¯ `progressPercent` çš„çœŸå®žé‡Œç¨‹ç¢‘ï¼Œå½“å‰æ­¥éª¤å†…部å†ç”¨æœ¬åœ°è€—æ—¶å‡è¿›åº¦å¹³æ»‘展示;`88/94/96` åªåˆ‡æ¢å½“剿­¥éª¤ï¼Œä¸ç›´æŽ¥ä½œä¸ºæ€»è¿›åº¦åœ°æ¿ã€‚æ€»è¿›åº¦æŒ‰å·²å®Œæˆæ­¥éª¤æƒé‡åР当剿­¥éª¤å†…å‡è¿›åº¦æŽ¨å¯¼ï¼Œéžå®Œæˆæ€æœ€å¤šåœåœ¨ `98%`。æ¢å¤æŒä¹…化生æˆä¸­è‰ç¨¿æ—¶ï¼Œå±•ç¤ºæ€ `startedAtMs` 使用åŽç«¯ session `updatedAt` æˆ–ä½œå“æ‘˜è¦ `updatedAt`,ä¿è¯å·²è€—æ—¶ä¸å› é‡æ–°è¿›å…¥é¡µé¢æ¸…零。åªè¦å½“剿­¥éª¤ç”Ÿæˆå†…容未完æˆï¼Œå°±å¿…é¡»åœç•™åœ¨å½“剿­¥éª¤ã€‚页é¢åªå±•ç¤ºå½“å‰æ­¥éª¤æ ‡é¢˜å’Œè¿›åº¦ï¼Œä¸å±•示步骤详细æè¿°ã€‚`ç”Ÿæˆæ‹¼å›¾é¦–图` å•独按 4 分钟估算,完整 AI é‡ç»˜è·¯å¾„约 448 秒;上传图且关闭 AI é‡ç»˜è·¯å¾„跳过首图生æˆï¼Œä»çº¦ 208 秒。 - å½±å“范围:`src/services/miniGameDraftGenerationProgress.ts`ã€`src/components/platform-entry/PlatformEntryFlowShellImpl.tsx`ã€`src/components/CustomWorldGenerationView.tsx`ã€æ‹¼å›¾ç”Ÿæˆé¡µç›¸å…³æµ‹è¯•与玩法链路文档。 - éªŒè¯æ–¹å¼ï¼šæ‹¼å›¾ç”Ÿæˆé¡µæ¢å¤ã€è½®è¯¢å’Œæµ‹è¯•都应以 `puzzleProgressPercent` 驱动阶段推进;`npm run test -- src/services/miniGameDraftGenerationProgress.test.ts src/components/CustomWorldGenerationView.test.tsx`ã€`npm run typecheck`ã€`npm run check:encoding` 通过。 - å…³è”æ–‡æ¡£ï¼š`docs/ã€çŽ©æ³•åˆ›ä½œã€‘æ‹¼å›¾ç”Ÿæˆé¡µè¿›åº¦å£å¾„-2026-05-23.md`ã€`docs/ã€çŽ©æ³•åˆ›ä½œã€‘å¹³å°å…¥å£ä¸ŽçŽ©æ³•é“¾è·¯-2026-05-15.md`。 +## 2026-06-02 生æˆå¤±è´¥è‰ç¨¿å¿…é¡»ç•™åœ¨ä½œå“æž¶å¹¶è¦†ç›–生æˆä¸­æ‘˜è¦ + +- 背景:生æˆé¡µæ”¶åˆ°å¤±è´¥å›žåŒ…åŽä¼šè¿›å…¥é‡è¯•æ€ï¼Œä½†è¿”回è‰ç¨¿ Tab 时,åŽç«¯ä½œå“摘è¦å¯èƒ½ä»çŸ­æš‚ä¿æŒ `generationStatus=generating`,导致用户看到“生æˆä¸­â€ï¼›è¿žç»­è§¦å‘å¤šä¸ªæ‹¼å›¾ç”Ÿæˆæ—¶ï¼Œå¤±è´¥åŽå¦‚果清掉 pending æ¡ç›®ï¼Œè¿˜ä¼šå°‘显示新增è‰ç¨¿ã€‚åŽå°å¤±è´¥å¦‚æžœåªå†™å±€éƒ¨ç”Ÿæˆé¡µé”™è¯¯ï¼Œç”¨æˆ·ç¦»å¼€ç”Ÿæˆé¡µåŽä¹Ÿæ”¶ä¸åˆ°é€šçŸ¥ã€‚ +- 决策:平å°å£³åœ¨ç”Ÿæˆå¤±è´¥æ—¶å¿…é¡»åŒæ—¶æ ‡è®°è‰ç¨¿ notice å’Œ pending ä½œå“æž¶æ¡ç›®ä¸º `failed`,ä¸å¾—删除 pending æ¡ç›®ã€‚失败 notice è¦ä¿å­˜é”™è¯¯æ¶ˆæ¯å¹¶åœ¨ç”¨æˆ·ç¦»å¼€ç”Ÿæˆé¡µåŽè§¦å‘å¸¦æ¥æºçš„ `PlatformErrorDialog`ï¼›ä½œå“æž¶æœ¬åœ°å¤±è´¥ notice è¦è¦†ç›–æŒä¹…化生æˆä¸­æ‘˜è¦ï¼Œå¤±è´¥è‰ç¨¿ä»æ˜¾ç¤ºä¸ºè‰ç¨¿å¡ä½†ä¸æ˜¾ç¤ºâ€œç”Ÿæˆä¸­â€ã€‚点击失败è‰ç¨¿å¿…须优先æ¢å¤å¤±è´¥ / é‡è¯•页,ä¸èƒ½æŒ‰æŒä¹…化 `generating` 釿–°å¯åŠ¨ç”Ÿæˆï¼›æ‹¼å›¾å¥‘约已å…许 `generationStatus=failed`,pending 拼图和åŽç«¯å¤±è´¥å›žå†™éƒ½æŒ‰ session 独立è½å¤±è´¥æ€ï¼Œè·³ä¸€è·³ / 木鱼 / 抓大鹅等也直接映射为 `failed` 或对应失败æ€ã€‚ +- å½±å“范围:`src/components/platform-entry/PlatformEntryFlowShellImpl.tsx`ã€`src/components/custom-world-home/creationWorkShelf.ts`ã€`src/components/custom-world-home/CustomWorldCreationHub.tsx`ã€çŽ©æ³•é“¾è·¯æ–‡æ¡£å’Œå¤±è´¥æ€äº¤äº’测试。 +- éªŒè¯æ–¹å¼ï¼š`node node_modules/vitest/vitest.mjs run src/components/rpg-entry/RpgEntryFlowShell.agent.interaction.test.tsx -t "failed parallel puzzle|background match3d"`;失败åŽè¿”回è‰ç¨¿ Tab 应看到对应新增è‰ç¨¿ï¼Œä¸”没有“生æˆä¸­â€æ ‡è®°ï¼›åŽå°å¤±è´¥åº”å¼¹å‡ºé”™è¯¯æ¥æºï¼Œç‚¹å‡»å¤±è´¥è‰ç¨¿åº”进入失败 / é‡è¯•页。 +- å…³è”æ–‡æ¡£ï¼š`docs/ã€çŽ©æ³•åˆ›ä½œã€‘å¹³å°å…¥å£ä¸ŽçŽ©æ³•é“¾è·¯-2026-05-15.md`ã€`.hermes/shared-memory/pitfalls.md`。 + ## 2026-05-23 所有玩法生æˆé¡µç»Ÿä¸€åœ†çŽ¯ä¸»è§†è§‰ - 背景:多个玩法生æˆé¡µåˆ†åˆ«å±•ç¤ºæ¨ªå‘æ€»è¿›åº¦æ¡ã€æ­¥éª¤åˆ—表或三槽ä½åˆ—表,和最新å‚考图里的陶泥儿圆环等待æ€ä¸ä¸€è‡´ï¼Œä¹Ÿè®©ç§»åŠ¨ç«¯ä¿¡æ¯å¯†åº¦å高。 @@ -333,7 +343,6 @@ - éªŒè¯æ–¹å¼ï¼šæ‰§è¡Œ `cargo test -p api-server match3d --manifest-path server-rs\Cargo.toml`ã€`npm run test -- src/components/match3d-result/Match3DResultView.test.tsx src/components/match3d-runtime/Match3DRuntimeShell.test.tsx src/services/match3dSpritesheetParser.test.ts src/services/match3dGeneratedModelCache.test.ts`ã€`npm run typecheck`ã€`npm run check:encoding`。 - å…³è”æ–‡æ¡£ï¼š`docs/ã€çŽ©æ³•åˆ›ä½œã€‘å¹³å°å…¥å£ä¸ŽçŽ©æ³•é“¾è·¯-2026-05-15.md`。 - ## 2026-05-18 Rust 手写模å—å…¥å£ç»Ÿä¸€ä¸ç”¨ mod.rs - 背景:Rust 目录模å—åŒæ—¶å­˜åœ¨ `mod.rs` 与åŒå `.rs` 两ç§å…¥å£å½¢å¼ï¼Œå‰æ¬¡æ‹†åˆ†å·²è®© `spacetime-client/src/mapper.rs` 采用åŒåå…¥å£ï¼›ç»§ç»­æ–°å¢ž `mod.rs` 会让文件定ä½å’Œè¯„审å£å¾„ä¸ä¸€è‡´ã€‚ @@ -572,7 +581,7 @@ - éªŒè¯æ–¹å¼ï¼šè‰ç¨¿é¡µä½œå“å¡ä¸Žåˆ†ç±»é¡µåˆ—表视觉å£å¾„ä¿æŒä¸€è‡´ï¼›`npm run test -- src/components/custom-world-home/CustomWorldCreationHub.test.tsx src/components/custom-world-home/CustomWorldCreationHub.interaction.test.tsx`ã€`npm run typecheck`ã€`npm run check:encoding`。 - å…³è”æ–‡æ¡£ï¼š`docs/design/MOBILE_CREATION_WORK_LIST_TWO_COLUMN_LAYOUT_2026-04-29.md`ã€`docs/experience/MOBILE_UI_DEV_EXPERIENCE.md`。 -2026-05-14 补充:è‰ç¨¿é¡µä½œå“å¡ä¸å†ç”¨â€œè‰ç¨¿ / å·²å‘å¸ƒâ€æ–‡å­—标识状æ€ï¼Œæ”¹ä¸ºå›¾æ ‡åŒ– UI 状æ€ç‚¹ï¼›ä½œå“å°é¢ç›´æŽ¥é“ºåˆ°å¡ç‰‡å³åŠåŒºå¹¶ä»Žå³å‘å·¦æ¸éšï¼›å·²å‘布作å“å³ä¸Šè§’常驻分享图标;è‰ç¨¿é•¿æŒ‰å¼¹å‡ºåˆ é™¤é¢æ¿ï¼Œå·²å‘å¸ƒé•¿æŒ‰å¼¹å‡ºåˆ†äº«å’Œåˆ é™¤é¢æ¿ã€‚ +2026-05-14 补充:è‰ç¨¿é¡µä½œå“å¡ä¸å†ç”¨â€œè‰ç¨¿ / å·²å‘å¸ƒâ€æ–‡å­—标识状æ€ï¼Œæ”¹ä¸ºå›¾æ ‡åŒ– UI 状æ€ç‚¹ï¼›ä½œå“å°é¢ç›´æŽ¥é“ºåˆ°å¡ç‰‡å³åŠåŒºå¹¶ä»Žå³å‘å·¦æ¸éšï¼›å·²å‘布作å“å³ä¸Šè§’常驻分享图标;è‰ç¨¿é•¿æŒ‰å¼¹å‡ºåˆ é™¤é¢æ¿ï¼Œå·²å‘å¸ƒé•¿æŒ‰å¼¹å‡ºåˆ†äº«å’Œåˆ é™¤é¢æ¿ã€‚2026-06-02 追加:作å“å¡ç‰‡å³ä¸Šè§’ä¸å†æ”¾åˆ é™¤æŒ‰é’®ï¼›åˆ é™¤åªé€šè¿‡å·¦æ»‘ã€é”®ç›˜å±•开或长按 / å³é”®å±•开的å³ä¾§æ“作区出现,é¿å…与å¡ç‰‡ä¸»ç‚¹å‡»å’Œåˆ†äº«å…¥å£æŠ¢å æ ‡é¢˜åŒºã€‚ ## 2026-05-13 认è¯è¿è¡ŒæœŸåŒæ­¥ç›´æŽ¥å¯¼å…¥æ­£å¼è®¤è¯è¡¨ @@ -759,6 +768,7 @@ - éªŒè¯æ–¹å¼ï¼šæ‰§è¡Œ `npx vitest run src/services/useMocapInput.test.ts src/components/child-motion-demo/ChildMotionWarmupDemo.test.tsx src/components/child-motion-demo/childMotionWarmupModel.test.ts src/services/child-motion-demo/childMotionDebugInput.test.ts src/routing/appRoutes.test.ts`ã€`npx eslint ...`ã€`npm run typecheck`ã€`npm run check:encoding`,并确认 `http://127.0.0.1:8876/stream` WebSocket 坿¡æ‰‹ã€`http://127.0.0.1:3000/child-motion-demo` å¯è®¿é—®ã€‚ ## 2026-05-18 寓教于ä¹é¢‘é“è¡¥å……çƒ­èº«å…³å…¥å£ + - 背景:用户希望在å‘çŽ°é¡µçš„å¯“æ•™äºŽä¹æ¿å—里直接看到热身关入å£ï¼Œè€Œä¸æ˜¯åªä¾èµ–独立直达路由。 - 决策:`child-motion-demo` 作为寓教于ä¹é¢‘é“的独立å¡ç‰‡å±•示,点击åŽç›´æŽ¥è¿›å…¥ `/child-motion-demo`;该入å£ä¸Ž `å®è´çˆ±ç”»` 并列,ä»å¤ç”¨çŽ°æœ‰ç‹¬ç«‹çƒ­èº«å…³è·¯ç”±ï¼Œä¸æ–°å¢žæ–°çš„åˆ›ä½œæ¨¡æ¿æˆ–è¿è¡Œæ€å£³å±‚。 - å½±å“范围:`src/components/rpg-entry/RpgEntryHomeView.tsx`ã€`src/components/platform-entry/PlatformEntryFlowShellImpl.tsx`ã€`src/components/rpg-entry/RpgEntryHomeView.recharge.test.tsx`ã€`docs/ã€çŽ©æ³•åˆ›ä½œã€‘å¹³å°å…¥å£ä¸ŽçŽ©æ³•é“¾è·¯-2026-05-15.md`。 @@ -1171,13 +1181,14 @@ - å½±å“范围:`module-auth`ã€`api-server` 作å“作者解æžã€`AppState` å¯åЍåˆå§‹åŒ–ã€åކå²å­¤å„¿ä½œå“离线回填脚本与相关文档。 - éªŒè¯æ–¹å¼ï¼š`cargo test -p module-auth --manifest-path server-rs/Cargo.toml`ã€`cargo test -p api-server --manifest-path server-rs/Cargo.toml work_author`ã€`npm run test -- scripts/rebind-orphan-work-owners.test.ts`。 - å…³è”æ–‡æ¡£ï¼š`server-rs/crates/module-auth/src/domain.rs`ã€`server-rs/crates/module-auth/src/lib.rs`ã€`server-rs/crates/api-server/src/work_author.rs`ã€`scripts/rebind-orphan-work-owners.mjs`。 + ## 2026-05-26 敲木鱼å‘布åŽä½œå“æž¶ä¸ŽæŽ¨èæµåˆ·æ–°å£å¾„ - 背景:敲木鱼已具备公开广场投影,但è‰ç¨¿ Tab çš„ä½œå“æž¶æ²¡æœ‰å½“å‰ç”¨æˆ·ä½œå“列表接å£ï¼Œå¯¼è‡´å·²å‘布作å“在å‘布åŽä¸èƒ½ç«‹å³å‡ºçŽ°åœ¨â€œå·²å‘布â€ç­›é€‰å’ŒæŽ¨èæµé‡Œã€‚ - 决策:新增 `GET /api/creation/wooden-fish/works` 作为当å‰ç”¨æˆ·æœ¨é±¼ä½œå“架事实æºï¼Œè¿”回 `WoodenFishWorksResponse.items` 摘è¦ï¼›å¹³å°å£³åœ¨å‘布æˆåŠŸåŽå¿…é¡»åŒæ—¶åˆ·æ–°ä½œå“架和公开广场列表。 - å½±å“范围:`server-rs/crates/api-server/src/wooden_fish.rs`ã€`server-rs/crates/api-server/src/modules/wooden_fish.rs`ã€`src/services/wooden-fish/woodenFishClient.ts`ã€`src/components/custom-world-home/creationWorkShelf.ts`ã€`src/components/platform-entry/PlatformEntryFlowShellImpl.tsx`。 - éªŒè¯æ–¹å¼ï¼šå‘布一个木鱼作å“åŽï¼Œè‰ç¨¿ Tab 的已å‘布筛选应立刻出现 `WF-*` 作å“å¡ï¼ŒæŽ¨è / 最新æµä¹Ÿåº”ç«‹å³åˆ·æ–°å‡ºå…¬å¼€å¡ç‰‡ã€‚ - - å…³è”æ–‡æ¡£ï¼š`docs/ã€çŽ©æ³•åˆ›ä½œã€‘å¹³å°å…¥å£ä¸ŽçŽ©æ³•é“¾è·¯-2026-05-15.md`ã€`docs/prd/ã€çŽ©æ³•åˆ›ä½œã€‘æ•²æœ¨é±¼çŽ©æ³•æ¨¡æ¿PRD-2026-05-20.md`。 +- å…³è”æ–‡æ¡£ï¼š`docs/ã€çŽ©æ³•åˆ›ä½œã€‘å¹³å°å…¥å£ä¸ŽçŽ©æ³•é“¾è·¯-2026-05-15.md`ã€`docs/prd/ã€çŽ©æ³•åˆ›ä½œã€‘æ•²æœ¨é±¼çŽ©æ³•æ¨¡æ¿PRD-2026-05-20.md`。 ## 2026-05-27 认è¯å¿«ç…§å®Œå…¨åŽ»æ–‡ä»¶åŒ–å¹¶ä»…ä¿ç•™è¡Œçº§å¤‡æŸ¥ diff --git a/.hermes/shared-memory/pitfalls.md b/.hermes/shared-memory/pitfalls.md index 99c0eb8c..b92baeef 100644 --- a/.hermes/shared-memory/pitfalls.md +++ b/.hermes/shared-memory/pitfalls.md @@ -158,7 +158,6 @@ - 验è¯ï¼š`PlatformEntryFlowShellImpl.tsx` 中ä¸åº”å†å‡ºçŽ°å››ä¸ªæ—§å·¥ä½œå°çš„入壿¸²æŸ“分支,创作 Tab 与 `/creation/` ä»èƒ½æ­£å¸¸è¿›å…¥å¯¹åº”工作å°ã€‚ - å…³è”:`src/components/unified-creation/UnifiedCreationWorkspace.tsx`ã€`src/components/platform-entry/PlatformEntryFlowShellImpl.tsx`ã€`docs/ã€çŽ©æ³•åˆ›ä½œã€‘å¹³å°å…¥å£ä¸ŽçŽ©æ³•é“¾è·¯-2026-05-15.md`。 - ## Jenkinsfile 开头ä¸èƒ½å¸¦ UTF-8 BOM - 现象:`Genarrative-Stdb-Module-Publish` 在 `Pipeline script from SCM` è¯»å– `jenkins/Jenkinsfile.production-stdb-module-publish` åŽï¼Œæµæ°´çº¿è¿˜æœªè¿›å…¥ä»»ä½• stage 就失败,报 `java.lang.NoSuchMethodError: No such DSL method 'pipeline'`,堆栈ä½ç½®æ˜¯ `WorkflowScript.run(WorkflowScript:1)`。 @@ -362,7 +361,6 @@ - 验è¯ï¼š`tr '\0' '\n' < /proc/$(systemctl show genarrative-api.service -p MainPID --value)/environ | grep GENARRATIVE_TRACKING_OUTBOX_DIR` åº”æŒ‡å‘ `/var/lib/genarrative/tracking-outbox`ï¼›é‡å¯åŽå½“å‰ PID ä¸å†å‡ºçް `Permission denied (os error 13)`。 - å…³è”:`scripts/deploy/production-api-deploy.sh`ã€`scripts/jenkins-server-provision.sh`ã€`docs/ã€å¼€å‘è¿ç»´ã€‘本地开å‘验è¯ä¸Žç”Ÿäº§è¿ç»´-2026-05-15.md`。 - ## 外部 API 失败没法追溯先查 external_api_call_failure - 现象:VectorEngine å›¾ç‰‡ç”Ÿæˆ / 编辑接å£å¯¹å‰ç«¯åªè¡¨çŽ°ä¸º `502` / `504` 或“上游æœåŠ¡è¯·æ±‚å¤±è´¥â€ï¼Œä½†éš¾ä»¥åŒºåˆ†æ˜¯è¯·æ±‚å‘é€å¤±è´¥ã€ä¸Šæ¸¸ 429/5xxã€å“应解æžå¤±è´¥ã€æœªè¿”回图片,还是下载图片失败。 @@ -562,10 +560,18 @@ - 现象:用户通过“忘记密ç â€é‡è®¾å¯†ç åŽï¼ŒæŽ¥å£è¿”回æˆåŠŸæˆ–é¡µé¢è¿›å…¥ç™»å½•æ€ï¼Œä½†å†æ¬¡ä½¿ç”¨æ–°å¯†ç ç™»å½•ä»æç¤ºâ€œæ‰‹æœºå·æˆ–密ç é”™è¯¯â€ï¼›é‡å¯åŽè¿˜å¯èƒ½å‡ºçް `Bearer JWT 版本已失效`,日志里的 token version 与本地快照ä¸ä¸€è‡´ã€‚ - 原因:é‡ç½®/修改密ç ä¼šæ›´æ–° `password_hash`ã€`password_login_enabled` å’Œ `token_version`,如果 API å±‚åªæ›´æ–°æœ¬åœ° `InMemoryAuthStore`,没有调用 `sync_auth_store_snapshot_to_spacetime()`,`api-server` é‡å¯æ—¶å¯èƒ½ä»Žæ—§çš„ SpacetimeDB 表或旧快照æ¢å¤è´¦å·çжæ€ã€‚ -- 处ç†ï¼š`POST /api/auth/password/change` 与 `POST /api/auth/password/reset` æˆåŠŸåŽå¿…é¡»åŒæ­¥è®¤è¯å¿«ç…§ã€‚2026-05-27 起,å¯åЍæ¢å¤åªå…许从 SpacetimeDB æ­£å¼è®¤è¯è¡¨æ¢å¤ï¼›`auth_store_snapshot` åªä¿ç•™è¡Œçº§è®°å½•,ä¸å†å†™ `default` èšåˆå•行,也ä¸å†æŠŠæœ¬åœ°æ–‡ä»¶ `auth-store.json` / `GENARRATIVE_AUTH_STORE_PATH` 当作æ¢å¤æºã€‚è‹¥å¯åŠ¨æ—¶è¿žä¸ä¸Š SpacetimeDB,`api-server` 等待å¯åЍæ¢å¤è¶…æ—¶åŽè¿›å…¥ä¾èµ–ä¸å¯ç”¨æ¨¡å¼ï¼Œæ‰€æœ‰è¯·æ±‚返回 `503 SERVICE_UNAVAILABLE`,`details.reason = "spacetime_startup_unavailable"`。 +- 处ç†ï¼š`POST /api/auth/password/change` 与 `POST /api/auth/password/reset` æˆåŠŸåŽå¿…é¡»åŒæ­¥è®¤è¯å¿«ç…§ã€‚2026-05-27 起,å¯åЍæ¢å¤åªå…许从 SpacetimeDB æ­£å¼è®¤è¯è¡¨æ¢å¤ï¼›`auth_store_snapshot` åªä¿ç•™è¡Œçº§è®°å½•,ä¸å†å†™ `default` èšåˆå•行,也ä¸å†æŠŠæœ¬åœ°æ–‡ä»¶ `auth-store.json` / `GENARRATIVE_AUTH_STORE_PATH` 当作æ¢å¤æºã€‚认è¯åˆ›å»ºã€ç™»å½•会è¯ã€åˆ·æ–°ã€é€€å‡ºã€æ”¹å¯†ã€é‡ç½®å¯†ç ã€ç»‘å®šå’Œèµ„æ–™å˜æ›´ç­‰å†™æ“ä½œå¿…é¡»åœ¨è¿”å›žå®¢æˆ·ç«¯å‰æˆåŠŸåŒæ­¥ SpacetimeDBï¼›åŒæ­¥å¤±è´¥æ—¶æŽ¥å£è¿”回错误,ä¸å…许把åªå­˜åœ¨äºŽå½“å‰è¿›ç¨‹å†…å­˜çš„è´¦å·æˆ–会è¯å½“æˆæˆåŠŸç»“æžœã€‚æ–°ç”¨æˆ·æ³¨å†Œå¥–åŠ±ã€é‚€è¯·ç ç»‘定和登录埋点必须排在认è¯åŒæ­¥æˆåŠŸä¹‹åŽï¼Œé¿å…è®¤è¯æ²¡è½åº“时先写出钱包或邀请关系。若å¯åŠ¨æ—¶è¿žä¸ä¸Š SpacetimeDB,`api-server` 等待å¯åЍæ¢å¤è¶…æ—¶åŽè¿›å…¥ä¾èµ–ä¸å¯ç”¨æ¨¡å¼ï¼Œæ‰€æœ‰è¯·æ±‚返回 `503 SERVICE_UNAVAILABLE`,`details.reason = "spacetime_startup_unavailable"`。 - 验è¯ï¼šæ‰§è¡Œ `cargo test -p module-auth password --manifest-path server-rs/Cargo.toml` 与 `cargo test -p api-server password --manifest-path server-rs/Cargo.toml`;手测时é‡è®¾å¯†ç åŽæ—§å¯†ç åº”失败,新密ç åº”æˆåŠŸï¼Œé‡å¯åŽä»åº”ä¿æŒã€‚ - å…³è”:`server-rs/crates/api-server/src/password_management.rs`ã€`server-rs/crates/api-server/src/state.rs`ã€`docs/technical/PASSWORD_LOGIN_CHANGE_RESET_DESIGN_2026-04-24.md`。 +## 密ç ç™»å½•失败且短信登录æç¤ºæ‰‹æœºå·å·²å­˜åœ¨å…ˆæŸ¥å­¤å„¿æ‰‹æœºå·ç´¢å¼• + +- 现象:è€è´¦å·ç”¨å¯†ç ç™»å½•æç¤ºâ€œæ‰‹æœºå·æˆ–密ç é”™è¯¯â€ï¼Œæ”¹ç”¨çŸ­ä¿¡éªŒè¯ç ç™»å½•åˆæç¤ºâ€œæ‰‹æœºå·å·²å­˜åœ¨ / 已注册â€ï¼Œç”¨æˆ·å¡åœ¨æ—¢ä¸èƒ½ç™»å½•也ä¸èƒ½é‡æ–°åˆ›å»ºçš„状æ€ã€‚ +- 原因:历å²ç‰ˆæœ¬æˆ–åœæœåŠ¡æ—¶è®¤è¯åŒæ­¥ä¸å®Œæ•´ï¼Œå¯èƒ½åœ¨ SpacetimeDB `auth_identity(provider=phone)` 或 `module-auth` 快照里留下 `phone_to_user_id` 映射,但对应 `user_account` / `users_by_username` 用户行已ç»ä¸å­˜åœ¨ã€‚密ç ç™»å½•按手机å·ç´¢å¼•找ä¸åˆ°çœŸå®žç”¨æˆ·ï¼ŒçŸ­ä¿¡ç™»å½•å°è¯•创建新用户时åˆè¢«å­¤å„¿æ‰‹æœºå·ç´¢å¼•挡ä½ã€‚ +- 处ç†ï¼š`export_auth_store_snapshot_from_tables` 导出时必须过滤没有 `user_account` çš„ phone / wechat identityã€union 索引和 refresh sessionï¼›`module-auth` 从 JSON å¿«ç…§æ¢å¤æ—¶ä¹Ÿå¿…须二次丢弃指å‘ä¸å­˜åœ¨ç”¨æˆ·çš„索引。è¿è¡Œæ—¶åˆ›å»ºæ‰‹æœºå·ç”¨æˆ·å‰è‹¥å‘çŽ°æ‰‹æœºå·æ˜ å°„指å‘ä¸å­˜åœ¨çš„用户,应删除孤儿映射åŽç»§ç»­åˆ›å»ºï¼Œé¿å…æ­»é”æ€ç»§ç»­æ‰©æ•£ã€‚ +- 验è¯ï¼š`cargo test -p module-auth snapshot_json_drops_orphan_phone_index_before_phone_login --manifest-path server-rs/Cargo.toml`ã€`cargo test -p module-auth phone --manifest-path server-rs/Cargo.toml`ã€`cargo test -p spacetime-module auth --manifest-path server-rs/Cargo.toml`ã€`cargo test -p api-server phone_login_reuses_existing_user_for_same_phone_number --manifest-path server-rs/Cargo.toml`。 +- å…³è”:`server-rs/crates/module-auth/src/lib.rs`ã€`server-rs/crates/spacetime-module/src/auth/procedures.rs`ã€`docs/ã€åŽç«¯æž¶æž„】server-rs与SpacetimeDBæ•°æ®å¥‘约-2026-05-15.md`。 + ## è®¤è¯æœ¬åœ°æ–‡ä»¶å¿«ç…§å·²åºŸå¼ƒï¼Œæ—§ procedure 也已删 - 现象:有些旧代ç å’Œç”Ÿæˆ bindings 里还会残留 `get_auth_store_snapshot`ã€`upsert_auth_store_snapshot`ã€`import_auth_store_snapshot`,或者把 `auth-store.json` 误当æˆè®¤è¯æ¢å¤æºã€‚ @@ -1542,14 +1548,22 @@ - 验è¯ï¼š`npm run test -- src/components/rpg-entry/rpgEntryWorldPresentation.test.ts src/components/rpg-entry/RpgEntryHomeView.recharge.test.tsx`ã€`npm run typecheck`ã€`npm run check:encoding`。 - å…³è”:`src/index.css`ã€`src/components/rpg-entry/RpgEntryHomeView.tsx`ã€`src/components/rpg-entry/rpgEntryWorldPresentation.ts`ã€`docs/ã€çŽ©æ³•åˆ›ä½œã€‘å¹³å°å…¥å£ä¸ŽçŽ©æ³•é“¾è·¯-2026-05-15.md`。 -## 生æˆä¸­è‰ç¨¿åˆ·æ–°åŽä¸è¦å¤ç”¨æ—§ updatedAt 当展示起点 +## 生æˆä¸­è‰ç¨¿æ¢å¤è¦æŒ‰åŽç«¯æ—¶é—´æˆ³è®¡æ—¶ -- 现象:拼图或抓大鹅è‰ç¨¿ç”Ÿæˆä¸­åˆ·æ–°ç½‘页åŽï¼Œä½œå“æž¶å¡ç‰‡èƒ½æ˜¾ç¤ºç­‰å¾…é®ç½©ï¼Œä½†è¿›å…¥ç”Ÿæˆé¡µæ—¶æ€»è¿›åº¦é¦–帧直接跳到 80%+,看起æ¥åƒå·²ç»è·‘了一大åŠã€‚ -- 原因:å‰ç«¯åªæŠŠæŒä¹…化 `generationStatus=generating` 当作æ¢å¤ç”Ÿæˆé¡µçš„æ¡ä»¶ï¼Œä½†æ¢å¤å±•ç¤ºæ—¶ä»æ²¿ç”¨äº†ä½œå“æ‘˜è¦ `updatedAt` 作为伪 `startedAtMs`ï¼›åŒæ—¶æ‹¼å›¾æ€»è¿›åº¦åˆæŠŠåŽç«¯ `progressPercent` 直接当作 floor,导致 `86%` 之类未到首个里程碑的会è¯ä¸€è¿›é¡µå°±æŠ¬åˆ° 80%+。 -- 处ç†ï¼šæ¢å¤ç”Ÿæˆä¸­çš„è‰ç¨¿æ—¶ï¼Œå±•示起点改用“进入生æˆé¡µçš„当剿—¶é—´â€ï¼›`updatedAt` åªä¿ç•™ç»™ä½œå“架排åºå’Œæ‘˜è¦ï¼Œä¸å†å‚与生æˆé¡µå‡è¿›åº¦èµ·ç®—。拼图总进度还è¦å¿½ç•¥ `88` 以下的åŽç«¯è¿›åº¦ floor,拼图ä¿ç•™åŽç«¯é‡Œç¨‹ç¢‘æŽ¨è¿›ï¼ŒæŠ“å¤§é¹…ç­‰éžæ‹¼å›¾çŽ©æ³•åˆ™ä»Ž `0%` 平滑起步,é¿å…刚进页就看到 4% / 88% / 80%+。 -- 验è¯ï¼š`npm test -- src/components/rpg-entry/RpgEntryFlowShell.agent.interaction.test.tsx -t "persisted generating"`ã€`npm run test -- src/services/miniGameDraftGenerationProgress.test.ts -t "match3d draft generation starts total progress from zero"`。 +- 现象:拼图或抓大鹅è‰ç¨¿ç”Ÿæˆä¸­åˆ·æ–°ç½‘页åŽï¼Œè¿›å…¥ç”Ÿæˆé¡µçš„“已耗时â€ä»Ž `0 ç§’` 釿–°å¼€å§‹ï¼›å¦ä¸€ç±»æ—§é—®é¢˜æ˜¯åŽç«¯ `progressPercent=88` 时总进度首帧直接跳到 `88%`。 +- 原因:生æˆé¡µæ¢å¤æ›¾æŠŠå±•ç¤ºæ€ `startedAtMs` é‡ç½®ä¸ºè¿›å…¥é¡µé¢çš„当剿—¶é—´ï¼Œå¯¼è‡´è®¡æ—¶ä¸è·ŸéšåŽç«¯çœŸå®žç”Ÿæˆæ—¶åˆ»ï¼›æ‹¼å›¾æ€»è¿›åº¦ä¹Ÿæ›¾æŠŠåŽç«¯é‡Œç¨‹ç¢‘当作百分比地æ¿ï¼Œå¯¼è‡´æ­¥éª¤åˆšåˆ‡æ¢å°±æŠ¬é«˜æ€»è¿›åº¦ã€‚ +- 处ç†ï¼šæ¢å¤ç”Ÿæˆä¸­çš„è‰ç¨¿æ—¶ï¼Œå±•示起点使用åŽç«¯ session `updatedAt` æˆ–ä½œå“æ‘˜è¦ `updatedAt`ï¼›`88/94/96` åªåˆ‡æ¢å½“剿­¥éª¤ï¼Œä¸ç›´æŽ¥ä½œä¸ºæ€»è¿›åº¦åœ°æ¿ã€‚æ€»è¿›åº¦æŒ‰å·²å®Œæˆæ­¥éª¤æƒé‡åР当剿­¥éª¤å†…å‡è¿›åº¦æŽ¨å¯¼ï¼Œéžå®Œæˆæ€æœ€å¤šåœåœ¨ `98%`。 +- 验è¯ï¼š`node node_modules/vitest/vitest.mjs run src/components/rpg-entry/RpgEntryFlowShell.agent.interaction.test.tsx -t "persisted generating"`ã€`node node_modules/vitest/vitest.mjs run src/services/miniGameDraftGenerationProgress.test.ts`。 - å…³è”:`src/components/platform-entry/PlatformEntryFlowShellImpl.tsx`ã€`src/components/rpg-entry/RpgEntryFlowShell.agent.interaction.test.tsx`ã€`src/services/miniGameDraftGenerationProgress.ts`ã€`docs/ã€çŽ©æ³•åˆ›ä½œã€‘æ‹¼å›¾ç”Ÿæˆé¡µè¿›åº¦å£å¾„-2026-05-23.md`。 +## 生æˆå¤±è´¥è‰ç¨¿å›žåˆ°ä½œå“æž¶ä¸èƒ½ç»§ç»­æ˜¾ç¤ºç”Ÿæˆä¸­ + +- 现象:拼图生æˆé¡µå·²ç»æ”¶åˆ° VectorEngine 图片编辑失败并进入é‡è¯•æ€ï¼Œä½†ç”¨æˆ·è¿”回è‰ç¨¿ Tab åŽï¼ŒåŒä¸€è‰ç¨¿ä»æ˜¾ç¤ºâ€œç”Ÿæˆä¸­â€ï¼›è¿žç»­è§¦å‘å¤šä¸ªæ‹¼å›¾ç”Ÿæˆæ—¶ï¼Œå¤±è´¥åŽè¿˜å¯èƒ½åªå‰©ä¸€æ¡æ–°å¢žè‰ç¨¿ï¼Œæˆ–者åªçœ‹åˆ°æ ‡é¢˜ä¸ºâ€œç¬¬1å…³â€çš„åŠæˆå“空壳;抓大鹅åŽå°å¤±è´¥æ—¶ä¹Ÿå¯èƒ½æ²¡æœ‰ä»»ä½•通知,点击è‰ç¨¿åˆåƒé‡æ–°å¼€å§‹ç”Ÿæˆã€‚ +- 原因:å‰ç«¯å¤±è´¥ notice åªæ›´æ–°ç”Ÿæˆé¡µå±€éƒ¨çжæ€ï¼Œpending ä½œå“æž¶æ¡ç›®åœ¨å¤±è´¥æ—¶è¢«æ¸…æŽ‰æˆ–è¢«éž `generating` 状æ€è¯¯æ˜ å°„为 `ready`ï¼›åŽç«¯ä½œå“摘è¦ä¹Ÿå¯èƒ½çŸ­æš‚仿˜¯ `generationStatus=generating`ã€‚å¦‚æžœå¤±è´¥æ¶ˆæ¯æ²¡æœ‰å†™å…¥ notice,用户离开生æˆé¡µåŽä¸ä¼šå¼¹å‡º `PlatformErrorDialog`;如果打开è‰ç¨¿åªçœ‹æŒä¹…化 `generating`ï¼Œå°±ä¼šç»•è¿‡å¤±è´¥æ€æ¢å¤ã€‚ +- 处ç†ï¼šå¤±è´¥æ—¶æŒ‰ session ä¿ç•™ pending ä½œå“æž¶æ¡ç›®å¹¶æ ‡è®° `failed`,失败 notice ä¿å­˜é”™è¯¯æ¶ˆæ¯å¹¶è§¦å‘å¸¦æ¥æºçš„ `PlatformErrorDialog`;拼图契约没有 `failed` 枚举,pending 拼图映射为 `idle`ï¼ŒåŒæ—¶ç”¨æœ¬åœ°å¤±è´¥ notice 覆盖æŒä¹…化生æˆä¸­çжæ€å’Œæ—§çš„“正在生æˆâ€æ‘˜è¦ã€‚点击失败è‰ç¨¿åº”优先用 notice / åŽç«¯ session / fallback payload 组装失败生æˆé¡µï¼Œä¸èƒ½é‡æ–°ä»Ž 0 ç§’å¯åŠ¨æ–°è¿›åº¦ï¼›æ‹¼å›¾å¤±è´¥åŠæˆå“没有有效 `workTitle` æ—¶ï¼Œä½œå“æž¶æ ‡é¢˜å›žé€€ä¸ºâ€œæ‹¼å›¾è‰ç¨¿â€ã€‚ +- 验è¯ï¼š`node node_modules/vitest/vitest.mjs run src/components/rpg-entry/RpgEntryFlowShell.agent.interaction.test.tsx -t "failed parallel puzzle|background match3d"`。 +- å…³è”:`src/components/platform-entry/PlatformEntryFlowShellImpl.tsx`ã€`src/components/custom-world-home/creationWorkShelf.ts`ã€`docs/ã€çŽ©æ³•åˆ›ä½œã€‘å¹³å°å…¥å£ä¸ŽçŽ©æ³•é“¾è·¯-2026-05-15.md`。 + ## 汪汪声浪è‰ç¨¿è¯•玩ä¸è¦å†™æ­£å¼ run - 现象:如果è‰ç¨¿ç»“果页试玩和å‘å¸ƒåŽ runtime 共用åŒä¸€å†™æˆç»©è·¯å¾„,未å‘布或未确认资æºçš„è‰ç¨¿è¯•玩会污染正å¼å•å±€ã€æŽ’è¡Œæ¦œå’Œä½œå“统计。 diff --git a/docs/ã€åŽç«¯æž¶æž„】server-rs与SpacetimeDBæ•°æ®å¥‘约-2026-05-15.md b/docs/ã€åŽç«¯æž¶æž„】server-rs与SpacetimeDBæ•°æ®å¥‘约-2026-05-15.md index 0cf69286..41e555e1 100644 --- a/docs/ã€åŽç«¯æž¶æž„】server-rs与SpacetimeDBæ•°æ®å¥‘约-2026-05-15.md +++ b/docs/ã€åŽç«¯æž¶æž„】server-rs与SpacetimeDBæ•°æ®å¥‘约-2026-05-15.md @@ -242,10 +242,12 @@ npm run check:server-rs-ddd - Rust 结构体:`AuthStoreSnapshot` - æºç ï¼š`server-rs/crates/spacetime-module/src/auth/tables.rs` -è®¤è¯æ¢å¤ç­–略:`api-server` å¯åŠ¨æ—¶åªä»Ž SpacetimeDB æ­£å¼è®¤è¯è¡¨ï¼ˆ`user_account` / `auth_identity` / `refresh_session`)投影æ¢å¤è¿›ç¨‹å†…认è¯å·¥ä½œé›†ï¼›`auth_store_snapshot` åªä¿ç•™è¡Œçº§å¿«ç…§å¤‡æŸ¥ï¼Œä¸å†ä½œä¸ºå¯åŠ¨å…œåº•æ¥æºã€‚`module-auth` åªä¿ç•™å†…存工作集和 JSON 导入 / 导出能力,ä¸å†å†™æœ¬åœ°æŒä¹…化文件;`auth-store.json` / `GENARRATIVE_AUTH_STORE_PATH` ä¸å†æ˜¯å…¼å®¹æ¢å¤æºï¼Œä¹Ÿä¸å¾—在å¯åŠ¨æ—¶å›žå†™è¦†ç›– `auth_identity` / `user_account`。若å¯åЍæ¢å¤é˜¶æ®µ SpacetimeDB ä¸å¯è¿žæŽ¥æˆ–超时,`api-server` 进入ä¾èµ–ä¸å¯ç”¨æ¨¡å¼å¹¶å¯¹è¯·æ±‚返回 `503 SERVICE_UNAVAILABLE`,直到è¿ç»´æ¢å¤ SpacetimeDB å¹¶é‡å¯æœåŠ¡ã€‚ +è®¤è¯æ¢å¤ç­–略:`api-server` å¯åŠ¨æ—¶åªä»Ž SpacetimeDB æ­£å¼è®¤è¯è¡¨ï¼ˆ`user_account` / `auth_identity` / `refresh_session`)投影æ¢å¤è¿›ç¨‹å†…认è¯å·¥ä½œé›†ï¼›`auth_store_snapshot` åªä¿ç•™è¡Œçº§å¿«ç…§å¤‡æŸ¥ï¼Œä¸å†ä½œä¸ºå¯åŠ¨å…œåº•æ¥æºã€‚`module-auth` åªä¿ç•™å†…存工作集和 JSON 导入 / 导出能力,ä¸å†å†™æœ¬åœ°æŒä¹…化文件;`auth-store.json` / `GENARRATIVE_AUTH_STORE_PATH` ä¸å†æ˜¯å…¼å®¹æ¢å¤æºï¼Œä¹Ÿä¸å¾—在å¯åŠ¨æ—¶å›žå†™è¦†ç›– `auth_identity` / `user_account`。认è¯åˆ›å»ºã€ç™»å½•会è¯ã€åˆ·æ–°ã€é€€å‡ºã€æ”¹å¯†ã€é‡ç½®å¯†ç ã€ç»‘å®šå’Œèµ„æ–™å˜æ›´ç­‰å†™æ“ä½œå¿…é¡»åœ¨è¿”å›žå®¢æˆ·ç«¯å‰æˆåŠŸåŒæ­¥ SpacetimeDB æ­£å¼è®¤è¯è¡¨ï¼›åŒæ­¥å¤±è´¥æ—¶æŽ¥å£è¿”回错误,ä¸å…许把åªå­˜åœ¨äºŽå½“å‰è¿›ç¨‹å†…å­˜çš„è´¦å·æˆ–会è¯å½“æˆæˆåŠŸç»“æžœã€‚æ–°ç”¨æˆ·æ³¨å†Œå¥–åŠ±ã€é‚€è¯·ç ç»‘定和登录埋点必须排在认è¯åŒæ­¥æˆåŠŸä¹‹åŽï¼Œé¿å…è®¤è¯æ²¡è½åº“时先写出钱包或邀请关系。若å¯åЍæ¢å¤é˜¶æ®µ SpacetimeDB ä¸å¯è¿žæŽ¥æˆ–超时,`api-server` 进入ä¾èµ–ä¸å¯ç”¨æ¨¡å¼å¹¶å¯¹è¯·æ±‚返回 `503 SERVICE_UNAVAILABLE`,直到è¿ç»´æ¢å¤ SpacetimeDB å¹¶é‡å¯æœåŠ¡ã€‚ `auth_store_snapshot` ç¦æ­¢å†å†™å•行 `snapshot_id = "default"` èšåˆ JSON。认è¯åŒæ­¥å…¥å£æ”¶åˆ° `module-auth` 整份快照åŽå¿…须拆æˆè¡Œçº§è®°å½•写入åŒä¸€å¼ è¡¨ï¼Œå½“å‰è¡Œé”®å‰ç¼€åŒ…括:`meta/next_user_id`ã€`user/`ã€`phone/`ã€`session/`ã€`session_hash/`ã€`wechat/`ã€`union/`。SpacetimeDB 模å—åªä¿ç•™ `import_auth_store_snapshot_json` 与 `export_auth_store_snapshot_from_tables` 两个认è¯å¿«ç…§è¿‡ç¨‹ï¼›æ—§ `get_auth_store_snapshot`ã€`upsert_auth_store_snapshot`ã€`import_auth_store_snapshot` 兼容入å£å·²åˆ é™¤ã€‚导入正å¼è¡¨æ—¶åªæŒ‰ä¸»é”® upsert 本次快照包å«çš„用户ã€èº«ä»½å’Œä¼šè¯ï¼Œé¿å…过期快照把其他用户整表删除。 +导出认è¯å¿«ç…§æ—¶ï¼Œ`auth_identity` 与 `refresh_session` åªèƒ½å¼•用ä»å­˜åœ¨äºŽ `user_account` çš„ç”¨æˆ·ï¼›å­¤å„¿æ‰‹æœºå· identityã€å¾®ä¿¡ identityã€union 索引或 refresh session 必须被过滤,ä¸èƒ½æ¢å¤æˆ `module-auth` 内存æ€é‡Œçš„ `phone_to_user_id` 死索引。`module-auth` 从 JSON å¿«ç…§æ¢å¤æ—¶ä¹Ÿè¦äºŒæ¬¡æ¸…ç†è¿™äº›å­¤å„¿ç´¢å¼•,é¿å…历å²å快照导致密ç ç™»å½•æç¤ºé”™è¯¯ã€çŸ­ä¿¡ç™»å½•åˆæç¤ºæ‰‹æœºå·å·²å­˜åœ¨ã€‚ + ### `bark_battle_draft_config` - Rust 结构体:`BarkBattleDraftConfigRow` diff --git a/docs/ã€çŽ©æ³•åˆ›ä½œã€‘å¹³å°å…¥å£ä¸ŽçŽ©æ³•é“¾è·¯-2026-05-15.md b/docs/ã€çŽ©æ³•åˆ›ä½œã€‘å¹³å°å…¥å£ä¸ŽçŽ©æ³•é“¾è·¯-2026-05-15.md index d367bf19..cf5e893f 100644 --- a/docs/ã€çŽ©æ³•åˆ›ä½œã€‘å¹³å°å…¥å£ä¸ŽçŽ©æ³•é“¾è·¯-2026-05-15.md +++ b/docs/ã€çŽ©æ³•åˆ›ä½œã€‘å¹³å°å…¥å£ä¸ŽçŽ©æ³•é“¾è·¯-2026-05-15.md @@ -14,7 +14,7 @@ åˆ›ä½œè¡¨å•æäº¤å‰çš„æ³¥ç‚¹ä½™é¢å‰ç½®æ ¡éªŒåªå…许用独立弹窗æç¤ºå¤±è´¥åŽŸå› ï¼Œä¸å¾—æŠŠç”¨æˆ·é€€å›žåˆ›ä½œå…¥å£æˆ–玩法模æ¿åˆ—表,也ä¸å¾—清空当å‰è¡¨å•状æ€ã€‚当å‰é€‚ç”¨æ‹¼å›¾ã€æŠ“å¤§é¹…å’Œæ±ªæ±ªå£°æµªç­‰ä¼šåœ¨å‰ç«¯æäº¤å‰æ ¡éªŒæ³¥ç‚¹çš„生æˆå…¥å£ï¼›ä½™é¢ä¸è¶³ã€ä½™é¢è¯»å–失败都应åœç•™åœ¨å½“å‰å·¥ä½œå°ï¼Œç”±ç”¨æˆ·å…³é—­æç¤ºåŽç»§ç»­ç¼–辑或自行补足泥点。 -å¹³å°å…¥å£ã€ç”Ÿæˆé¡µã€ç»“果页ã€ä½œå“详情ã€ä½œå“æž¶å’Œè¿è¡Œæ€çš„è·¨æµç¨‹é”™è¯¯ç»Ÿä¸€æ”¶å£åˆ° `PlatformErrorDialog`ã€‚å¼¹çª—å¿…é¡»å¸¦æ˜Žç¡®é”™è¯¯æ¥æºï¼Œä¾‹å¦‚æŸä¸ªè‰ç¨¿ã€æŸæ¬¡ç”Ÿæˆã€ä½œå“详情或æŸä¸ªæ¸¸çŽ©å®žä¾‹ï¼Œå¹¶æä¾›å¤åˆ¶æŒ‰é’®å¤åˆ¶â€œé”™è¯¯æ¥æº + 错误内容â€ã€‚页é¢å†…ä¸å†é‡å¤æ¸²æŸ“裸错误 bannerï¼›è¡¨å•æ ¡éªŒã€å‘布确认弹窗里的局部业务错误å¯ä»¥ä¿ç•™åœ¨åŽŸå¼¹çª—å†…ã€‚ +å¹³å°å…¥å£ã€ç”Ÿæˆé¡µã€ç»“果页ã€ä½œå“详情ã€ä½œå“æž¶å’Œè¿è¡Œæ€çš„è·¨æµç¨‹é”™è¯¯ç»Ÿä¸€æ”¶å£åˆ° `PlatformErrorDialog`ã€‚å¼¹çª—å¿…é¡»å¸¦æ˜Žç¡®é”™è¯¯æ¥æºï¼Œä¾‹å¦‚æŸä¸ªè‰ç¨¿ã€æŸæ¬¡ç”Ÿæˆã€ä½œå“详情或æŸä¸ªæ¸¸çŽ©å®žä¾‹ï¼Œå¹¶æä¾›å¤åˆ¶æŒ‰é’®å¤åˆ¶â€œé”™è¯¯æ¥æº + 错误内容â€ã€‚页é¢å†…ä¸å†é‡å¤æ¸²æŸ“裸错误 bannerï¼›è¡¨å•æ ¡éªŒã€å‘布确认弹窗里的局部业务错误å¯ä»¥ä¿ç•™åœ¨åŽŸå¼¹çª—å†…ã€‚ç”Ÿæˆä»»åŠ¡åœ¨ç”¨æˆ·ç¦»å¼€ç”Ÿæˆé¡µåŽå¼‚步失败时,也必须通过åŒä¸€å¼¹çª—通知用户,并把失败消æ¯å†™å…¥è¯¥ session çš„è‰ç¨¿ notice,供è‰ç¨¿é¡µå’Œå¤±è´¥é‡è¯•页æ¢å¤ä½¿ç”¨ã€‚ 生æˆä»»åŠ¡åœ¨ç”¨æˆ·ç¦»å¼€ç”Ÿæˆé¡µåŽå¼‚æ­¥å®Œæˆæ—¶ï¼Œå¹³å°å£³å±‚必须弹出 `PlatformTaskCompletionDialog`。完æˆå¼¹çª—åŒæ ·è¦å¸¦æ¥æºï¼Œä¾‹å¦‚æŸä¸ªè‰ç¨¿æˆ–生æˆä¼šè¯ï¼Œå¹¶æä¾›å¤åˆ¶æŒ‰é’®å¤åˆ¶â€œæ¥æº + 状æ€â€ï¼›å¦‚果用户ä»åœç•™åœ¨ç”Ÿæˆé¡µå¹¶è¢«è‡ªåŠ¨å¸¦å…¥ç»“æžœé¡µæˆ–è¯•çŽ©é¡µï¼Œç”Ÿæˆé¡µ / 结果页本身å³ä¸ºå®Œæˆå馈,ä¸å†é¢å¤–å åŠ å®Œæˆå¼¹çª—。 @@ -47,10 +47,11 @@ 3. è‰ç¨¿é¡µä¸Žåº•部导航的未读æç¤ºç‚¹ç»Ÿä¸€ä½¿ç”¨å¹³å°æš–棕色点和暖棕光晕,ä¸å†ä½¿ç”¨çº¢ç‚¹æˆ–红色 glowï¼›è‰ç¨¿ Tab ä½œå“æž¶å¡ç‰‡æ— è®ºè‰ç¨¿ / å·²å‘布都ä¸å¤–露作者信æ¯ï¼›å·²å‘布作å“å¡å³ä¸Šè§’直接显示无边框分享 iconã€‚åˆ é™¤ç­‰ç ´åæ€§åŠ¨ä½œåœ¨ä½œå“å¡ä¸Šä¹Ÿè¦ç›´æŽ¥å¼€æ”¾ç‹¬ç«‹åˆ é™¤å…¥å£ï¼Œå·¦æ»‘或长按仅作为辅助æ“作层。 4. 生æˆä¸­ä½œå“在整å¡ä¸ŠåŠ ç­‰å¾…é®ç½©ï¼Œä½†ä¸ç§»é™¤ä½œå“基础信æ¯ã€‚ 5. 生æˆä¸­çжæ€ä¸èƒ½åªå­˜åœ¨å‰ç«¯å†…å­˜ notice。åŽç«¯ä½œå“摘è¦å¿…须下å‘坿¢å¤çš„ `generationStatus`ï¼›å‰ç«¯åˆ·æ–°æˆ–退出产å“åŽï¼Œä½œå“架优先用摘è¦çŠ¶æ€æ¢å¤ç­‰å¾…é®ç½©ï¼Œæœ¬è½®å†…å­˜ notice åªä½œä¸ºå³æ—¶å馈。 -6. 点击 `generationStatus=generating` çš„è‰ç¨¿å¡å¿…é¡»æ¢å¤å¯¹åº”玩法的生æˆè¿›åº¦é¡µï¼Œä¸èƒ½è¿›å…¥ç©ºç™½ç»“果页或普通工作区;æ¢å¤ç”Ÿæˆé¡µçš„ `startedAtMs` 使用进入生æˆé¡µçš„当剿—¶é—´ï¼Œä½œå“æ‘˜è¦ `updatedAt` åªç”¨äºŽæŽ’åºå’Œæ‘˜è¦å±•示,ä¸å‚与å‡è¿›åº¦èµ·ç®—。 -7. 从è‰ç¨¿ Tab ä½œå“æž¶æ‰“å¼€è‰ç¨¿å·¥ä½œåŒºã€ç”Ÿæˆé¡µæˆ–结果页时,返回按钮必须回到è‰ç¨¿ Tab çš„åŒä¸€ä½œå“架语境;从创作 Tab 新建或直接进入创作链路时æ‰å›žåˆ°åˆ›ä½œ Tab。平å°å£³å±‚éœ€è¦æ˜¾å¼è®°å½•本次创作æµçš„è¿”å›žæ¥æºï¼Œä¸èƒ½è®©ç»“果页返回动作固定跳到创作入å£ã€‚ -8. ç§æœ‰ generated 图片必须通过 `ResolvedAssetImage` / `/api/assets/read-url` æ¢ç­¾è¯»å–。 -9. æ•²æœ¨é±¼ä½œå“æž¶è¯»å–当å‰ç”¨æˆ·ä½œå“列表时走 `GET /api/creation/wooden-fish/works`ï¼›å‘布æˆåŠŸåŽå¹³å°å£³å¿…é¡»åŒæ—¶åˆ·æ–°ä½œå“架与公开广场,é¿å…作å“刚å‘布时ä»åœç•™åœ¨æ—§åˆ—表。 +6. 点击 `generationStatus=generating` çš„è‰ç¨¿å¡å¿…é¡»æ¢å¤å¯¹åº”玩法的生æˆè¿›åº¦é¡µï¼Œä¸èƒ½è¿›å…¥ç©ºç™½ç»“果页或普通工作区;æ¢å¤ç”Ÿæˆé¡µçš„ `startedAtMs` 优先使用åŽç«¯ session çš„ `updatedAt`,没有 session æ—¶å†ä½¿ç”¨ä½œå“æ‘˜è¦ `updatedAt`,ä¸å¾—因釿–°è¿›å…¥é¡µé¢ä»Ž 0 ç§’é‡æ–°è®¡æ—¶ã€‚ +7. 生æˆå¤±è´¥å¿…须按 session 独立记录,ä¸èƒ½ç”¨ä¸€ä¸ªå¤±è´¥æ‰“断或覆盖åŒçŽ©æ³•çš„å…¶å®ƒç”Ÿæˆä»»åŠ¡ã€‚å¤±è´¥ notice 需è¦ä¿å­˜é”™è¯¯æ¶ˆæ¯å¹¶è¦†ç›–ä½œå“æž¶æœ¬åœ°çжæ€ï¼šå³ä½¿åŽç«¯æ‘˜è¦æš‚æ—¶ä»æ˜¯ `generationStatus=generating` 或åªå†™å‡ºåŠæˆå“投影,è‰ç¨¿å¡ä¹Ÿä¸å¾—继续显示“生æˆä¸­â€ï¼Œç‚¹å‡»åŽå¿…须进入失败 / é‡è¯•生æˆé¡µï¼Œä¸èƒ½é‡æ–°åˆ›å»ºä¸€è½®ç”Ÿæˆï¼›æ‹¼å›¾è¿™ç±»å¤±è´¥åŠæˆå“若没有有效 `workTitle`ï¼Œä½œå“æž¶æ ‡é¢˜å›žé€€ä¸ºâ€œæ‹¼å›¾è‰ç¨¿â€ï¼Œä¸æš´éœ²â€œç¬¬1å…³â€ç©ºå£³ã€‚ +8. 从è‰ç¨¿ Tab ä½œå“æž¶æ‰“å¼€è‰ç¨¿å·¥ä½œåŒºã€ç”Ÿæˆé¡µæˆ–结果页时,返回按钮必须回到è‰ç¨¿ Tab çš„åŒä¸€ä½œå“架语境;从创作 Tab 新建或直接进入创作链路时æ‰å›žåˆ°åˆ›ä½œ Tab。平å°å£³å±‚éœ€è¦æ˜¾å¼è®°å½•本次创作æµçš„è¿”å›žæ¥æºï¼Œä¸èƒ½è®©ç»“果页返回动作固定跳到创作入å£ã€‚ +9. ç§æœ‰ generated 图片必须通过 `ResolvedAssetImage` / `/api/assets/read-url` æ¢ç­¾è¯»å–。 +10. æ•²æœ¨é±¼ä½œå“æž¶è¯»å–当å‰ç”¨æˆ·ä½œå“列表时走 `GET /api/creation/wooden-fish/works`ï¼›å‘布æˆåŠŸåŽå¹³å°å£³å¿…é¡»åŒæ—¶åˆ·æ–°ä½œå“架与公开广场,é¿å…作å“刚å‘布时ä»åœç•™åœ¨æ—§åˆ—表。 å‘现 Tabã€åˆ›ä½œ Tab 与è‰ç¨¿ Tab çš„é¡µé¢æ ¹å†…容区ä¸å†å¥— `platform-page-stage` 外层全局å¡ç‰‡å£³ï¼Œè®©åˆ—表ã€ç­›é€‰å’ŒçŽ©æ³•å¡èŽ·å¾—æ›´å®½çš„æ¨ªå‘空间;推èé¡µå’Œæˆ‘çš„é¡µä»æŒ‰å„自页é¢è®¾è®¡ä¿ç•™åŽŸæœ‰å…¨å±€å¡ç‰‡å£å¾„。移动端“我的â€é¡µä»æŒ‰é¡¶éƒ¨å¤´åƒ / 昵称 / é™¶æ³¥å·ã€ä¼šå‘˜æ¨ªå¹…ã€ä¸‰å¼ ç»Ÿè®¡å¡ã€æ¯æ—¥ä»»åŠ¡ã€äº”项常用功能宫格ã€è®¾ç½®å…¥å£å’Œæ³•律信æ¯ç»„织,ä¸ä¿ç•™æ—§çš„底部“填邀请ç â€æ¬¡çº§å…¥å£ï¼›å¸¸ç”¨åŠŸèƒ½å½“å‰åªå±•ç¤ºå››é¡¹å¸¸é©»å…¥å£æ—¶å¿…须按四列铺满整行,ä¸ä¿ç•™äº”列网格导致左对é½ç©ºä½ï¼›æ¯æ—¥ä»»åŠ¡å¡å¿…é¡»è¯»å– `/api/profile/tasks` 的当å‰ä»»åŠ¡æ‘˜è¦å¹¶åœ¨é¢†å–åŽåŒæ­¥åˆ·æ–°å¡ç‰‡è¿›åº¦ã€‚å­—å·å¿…须维æŒå¹³å°æ™®é€š UI æ¡£ä½ï¼Œä¸èƒ½å› ä¸ºçª„å±æŠŠå¡ç‰‡æ ‡é¢˜ã€åŠŸèƒ½ label æˆ–æ³•å¾‹ä¿¡æ¯æ’‘æˆå±•示级字å·ï¼›æœ€åŽä¸€å±å†…容必须能在底部 dock 上方完整滚动露出,ä¸å¾—è¢«å›ºå®šåº•éƒ¨å¯¼èˆªé®æŒ¡ã€‚ @@ -95,9 +96,9 @@ RPG / 拼图等è¿è¡Œæ€å­˜æ¡£ä»ä»¥ `/api/profile/save-archives` çš„åŽç«¯åˆ— - 图åƒè¾“å…¥å¤ç”¨ `CreativeImageInputPanel`。 - 结果页æ¯å…³ç”»é¢ç¼–辑å¤ç”¨ `CreativeImageInputPanel`;入å£é¡µå’Œå…³å¡ç”»é¢åªå…±äº«å—控 UI 模å—,ä¸å…±äº«æ•°æ®æºã€çжæ€ã€action 或存储ä½ç½®ï¼šå…¥å£é¡µç»§ç»­å†™ `formDraft` 与è‰ç¨¿ç¼–译 payload,关å¡ç”»é¢å†™ `levels[].pictureReference/pictureDescription` å¹¶è§¦å‘ `generate_puzzle_images`。结果页删除独立“素æé…ç½®â€Tab,ä¸å†æä¾›å•独 UI 背景生æˆå…¥å£ã€‚é€šç”¨å›¾ç‰‡é¢æ¿çš„展示图和 AI é‡ç»˜å‚考图能力必须分开控制:结果页正å¼å…³å¡å›¾åªä½œä¸ºé¢„览图,ä¸å› å­˜åœ¨æ­£å¼å›¾è‡ªåŠ¨æš´éœ² AI é‡ç»˜å¼€å…³ï¼›åªæœ‰æœ¬åœ°ä¸Šä¼ ã€åކå²é€‰æ‹©æˆ–å·²ä¿å­˜ `pictureReference` å¯ä½œä¸ºé‡ç»˜å‚è€ƒå›¾æ—¶ï¼Œæ‰æ˜¾ç¤º AI é‡ç»˜å¼€å…³å¹¶æŠŠçжæ€å¸¦å…¥ `generate_puzzle_images`。用户在本次编辑中上传或选择历å²å›¾åŽï¼Œè¯¥å›¾ä¼˜å…ˆå æ®ä¸»å›¾å¡ç‰‡ï¼Œå¯åˆ é™¤ã€åˆ‡æ¢ AI é‡ç»˜ï¼Œä¹Ÿå¯å…³é—­ AI é‡ç»˜ç›´ç”¨ï¼›ä»…有正å¼å›¾é¢„è§ˆæ—¶ï¼Œç”»é¢æè¿°æ¡†ä»å¯ä¸Šä¼ å¤šå¼ å‚考图。关å¡è¯¦æƒ…å¼¹çª—åº”ä½¿ç”¨åŠ å®½é¢æ¿ï¼Œå…³å¡åç§°ã€ç”»é¢å›¾å’Œç”»é¢æè¿°åˆå¹¶åœ¨åŒä¸€ä¸ªçºµå‘列表中,å称输入和画é¢ç¼–辑模å—外层ä¸å†åŒ…独立 `platform-subpanel`;画é¢å›¾å¡ä»å¿…é¡»ä¿ç•™ç¨³å®šæœ€å°é«˜åº¦ï¼Œé¿å…弹窗内 `flex-1` 布局å缩åŽåªå‰©æ ‡é¢˜ã€æè¿°è¾“入和æ“作按钮。 - 支æŒç”»é¢æè¿°ç”Ÿå›¾ã€å¤šå‚考图生图ã€ä¸Šä¼ æˆ–历å²ç”Ÿæˆä¸»å›¾åŽ AI é‡ç»˜ã€ä¸Šä¼ æˆ–历å²ç”Ÿæˆä¸»å›¾åŽä¸é‡ç»˜ï¼›ä¸»é“¾è¦æ±‚æµè§ˆå™¨å…ˆç» `/api/assets/direct-upload-tickets` ç›´ä¼  OSS 并确认 `asset_object`,创作 action åªæäº¤ `referenceImageAssetObjectId(s)`,由åŽç«¯æ ¡éªŒ owner / bucket / kind / MIME / size åŽç­¾å‘ OSS åªè¯» URL 并下载为 VectorEngine `/v1/images/edits` çš„ multipart `image` part。本地上传 Data URL ä¸ŽåŽ†å² `/generated-*` 图片路径仅ä¿ç•™ä¸ºæ—§è‰ç¨¿ã€æ—§å…¥å£æˆ–未è¿ç§»å®¢æˆ·ç«¯çš„兼容输入;关闭 AI é‡ç»˜æ—¶ï¼ŒåŽç«¯ç»Ÿä¸€è§£æžä¸ºé¦–关或当å‰å…³å¡æ­£å¼å›¾åŽå†æŒä¹…化,ä¸è°ƒç”¨ç¬¬ä¸€æ®µæ‹¼å›¾é¦–图生æˆã€‚ -- è‰ç¨¿ç”Ÿæˆä¼šå…ˆæŒä¹…化 `generationStatus=generating` çš„ä½œå“æ‘˜è¦ï¼Œç”Ÿæˆå®Œæˆå¹¶å›žå†™å…³å¡æ‹¼å›¾ç”»é¢ã€å…³å¡ç”»é¢å‚考图ã€UI spritesheet 和关å¡èƒŒæ™¯å›¾åŽå†å˜ä¸º `ready`;当å‰ä¸è‡ªåŠ¨ç”ŸæˆèƒŒæ™¯éŸ³ä¹ã€‚生æˆé¡µæ­¥éª¤æŽ¨è¿›å¿…须跟éšåŽç«¯ session `progressPercent` 的真实里程碑:`88` 表示è‰ç¨¿ç¼–译完æˆå¹¶è¿›å…¥å‡ºå›¾æ­¥éª¤ï¼Œ`94` 表示生æˆå›¾å·²ä¿å­˜å¹¶è¿›å…¥ UI / 背景步骤,`96` 表示正å¼å›¾ä¸Ž UI 背景已确认并进入写入步骤,最终 action æˆåŠŸæˆ–å‘布æ‰è¿›å…¥å®Œæˆæ€ï¼›æ¯ä¸ªæ­¥éª¤å†…部å¯ä»¥æŒ‰å®žé™…等待时间使用å‡è¿›åº¦å¹³æ»‘推进,总进度按 `0-88`ã€`88-94`ã€`94-96`ã€`96-98` çš„çœŸå®žé‡Œç¨‹ç¢‘åŒºé—´å¹³æ»‘æŽ¨è¿›ã€‚ä»»ä¸€åŒæ­¥ action 回包到达时立å³ä»¥çœŸå®žå®Œæˆ/失败结果冻结进度。 -- ä½œå“æž¶æ‹¼å›¾è‰ç¨¿çš„“生æˆä¸­â€é®ç½©åªè¡¨ç¤ºåˆå§‹è‰ç¨¿è¿˜æ²¡æœ‰å¯æŸ¥çœ‹ç»“果;åªè¦ä½œå“摘è¦ã€é¦–å…³å°é¢æˆ–任一关å¡å€™é€‰å›¾å·²ç»å¯ç”¨ï¼ŒåŽç»­ UI 背景é‡ç”Ÿæˆå’Œè¿½åŠ å…³å¡ç”Ÿå›¾éƒ½å¿…é¡»ä½œä¸ºç»“æžœé¡µå±€éƒ¨ç”Ÿæˆæ€å¤„ç†ï¼Œä¸èƒ½é˜»æ­¢æ‰“å¼€è‰ç¨¿ç»“果页。 -- 拼图è‰ç¨¿ç¼–译是长耗时 action,å‰ç«¯ action 请求默认等待 `1_800_000ms`(30 分钟)且ä¸è‡ªåЍé‡è¯•ã€‚æ¯æ¬¡å›¾ç‰‡ç”Ÿæˆè°ƒç”¨çš„预期用时按 90 秒计算,但 `ç”Ÿæˆæ‹¼å›¾é¦–图` å•独按 4 分钟展示;完整 AI é‡ç»˜è·¯å¾„为 `编译首关è‰ç¨¿` 8 ç§’ã€`生æˆå…³å¡åç§°` 10 ç§’ã€`ç”Ÿæˆæ‹¼å›¾é¦–图` 4 分钟ã€`生æˆå…³å¡ç”»é¢` 90 ç§’ã€`生æˆUI与背景` 90 ç§’ã€`写入正å¼è‰ç¨¿` 10 秒,åˆè®¡çº¦ 448 秒。上传图且关闭 AI é‡ç»˜æ—¶å¿…须跳过 `ç”Ÿæˆæ‹¼å›¾é¦–图`,直接进入 `生æˆå…³å¡ç”»é¢` å’Œ `生æˆUI与背景`,åˆè®¡çº¦ 208 秒。生æˆé¡µæ¢å¤æ—¶å¿…须使用进入生æˆé¡µçš„当剿—¶é—´ä½œä¸ºåŽŸå§‹ `startedAtMs`;失败/å®Œæˆæ€ç”¨ `finishedAtMs` 冻结耗时。未收到对应åŽç«¯é‡Œç¨‹ç¢‘å‰ï¼ŒåŽç»­æ­¥éª¤ä¿æŒå¾…处ç†ï¼›å³ä½¿å½“剿­¥éª¤é¢„计时长耗尽,也åªèƒ½è®©å½“剿­¥éª¤å†…部进度åœåœ¨ `98%` 内,ä¸èƒ½è‡ªåŠ¨å®Œæˆå½“剿­¥éª¤æˆ–跳到åŽç»­æ­¥éª¤ã€‚生æˆé¡µæ¯ä¸ªæ­¥éª¤åªå±•示标题和进度,ä¸å±•示步骤详细æè¿°ã€‚ +- è‰ç¨¿ç”Ÿæˆä¼šå…ˆæŒä¹…化 `generationStatus=generating` çš„ä½œå“æ‘˜è¦ï¼Œç”Ÿæˆå®Œæˆå¹¶å›žå†™å…³å¡æ‹¼å›¾ç”»é¢ã€å…³å¡ç”»é¢å‚考图ã€UI spritesheet 和关å¡èƒŒæ™¯å›¾åŽå†å˜ä¸º `ready`;当å‰ä¸è‡ªåŠ¨ç”ŸæˆèƒŒæ™¯éŸ³ä¹ã€‚生æˆé¡µæ­¥éª¤æŽ¨è¿›å¿…须跟éšåŽç«¯ session `progressPercent` 的真实里程碑:`88` 表示è‰ç¨¿ç¼–译完æˆå¹¶è¿›å…¥å‡ºå›¾æ­¥éª¤ï¼Œ`94` 表示生æˆå›¾å·²ä¿å­˜å¹¶è¿›å…¥ UI / 背景步骤,`96` 表示正å¼å›¾ä¸Ž UI 背景已确认并进入写入步骤,最终 action æˆåŠŸæˆ–å‘布æ‰è¿›å…¥å®Œæˆæ€ï¼›æ¯ä¸ªæ­¥éª¤å†…部å¯ä»¥æŒ‰å®žé™…等待时间使用å‡è¿›åº¦å¹³æ»‘推进。`88/94/96` åªè´Ÿè´£åˆ‡æ¢å½“剿­¥éª¤ï¼Œä¸ä½œä¸ºæ€»è¿›åº¦åœ°æ¿ï¼›æ€»è¿›åº¦æŒ‰å·²å®Œæˆæ­¥éª¤æƒé‡åР当剿­¥éª¤å†…å‡è¿›åº¦æŽ¨å¯¼ï¼Œéžå®Œæˆæ€æœ€å¤šåœåœ¨ `98%`ã€‚ä»»ä¸€åŒæ­¥ action 回包到达时立å³ä»¥çœŸå®žå®Œæˆ/失败结果冻结进度。 +- ä½œå“æž¶æ‹¼å›¾è‰ç¨¿çš„“生æˆä¸­â€é®ç½©åªè¡¨ç¤ºåˆå§‹è‰ç¨¿è¿˜æ²¡æœ‰å¯æŸ¥çœ‹ç»“果;åªè¦ä½œå“摘è¦ã€é¦–å…³å°é¢æˆ–任一关å¡å€™é€‰å›¾å·²ç»å¯ç”¨ï¼ŒåŽç»­ UI 背景é‡ç”Ÿæˆå’Œè¿½åŠ å…³å¡ç”Ÿå›¾éƒ½å¿…é¡»ä½œä¸ºç»“æžœé¡µå±€éƒ¨ç”Ÿæˆæ€å¤„ç†ï¼Œä¸èƒ½é˜»æ­¢æ‰“å¼€è‰ç¨¿ç»“果页。生æˆå¤±è´¥åŽï¼ŒåŒä¸€æµè§ˆå™¨ä¼šè¯å†…的失败 notice 必须覆盖åŽç«¯å¯èƒ½ä»çŸ­æš‚返回的 `generationStatus=generating` 摘è¦ï¼Œä½œå“æž¶ä¿ç•™å¯¹åº”è‰ç¨¿å¡ä½†ä¸å†æ˜¾ç¤ºâ€œç”Ÿæˆä¸­â€ï¼Œç‚¹å‡»åŽå›žåˆ°å¤±è´¥ / é‡è¯•状æ€ã€‚ +- 拼图è‰ç¨¿ç¼–译是长耗时 action,å‰ç«¯ action 请求默认等待 `1_800_000ms`(30 分钟)且ä¸è‡ªåЍé‡è¯•ã€‚æ¯æ¬¡å›¾ç‰‡ç”Ÿæˆè°ƒç”¨çš„预期用时按 90 秒计算,但 `ç”Ÿæˆæ‹¼å›¾é¦–图` å•独按 4 分钟展示;完整 AI é‡ç»˜è·¯å¾„为 `编译首关è‰ç¨¿` 8 ç§’ã€`生æˆå…³å¡åç§°` 10 ç§’ã€`ç”Ÿæˆæ‹¼å›¾é¦–图` 4 分钟ã€`生æˆå…³å¡ç”»é¢` 90 ç§’ã€`生æˆUI与背景` 90 ç§’ã€`写入正å¼è‰ç¨¿` 10 秒,åˆè®¡çº¦ 448 秒。上传图且关闭 AI é‡ç»˜æ—¶å¿…须跳过 `ç”Ÿæˆæ‹¼å›¾é¦–图`,直接进入 `生æˆå…³å¡ç”»é¢` å’Œ `生æˆUI与背景`,åˆè®¡çº¦ 208 秒。生æˆé¡µæ¢å¤æ—¶å¿…须使用åŽç«¯ session `updatedAt` æˆ–ä½œå“æ‘˜è¦ `updatedAt` 作为原始 `startedAtMs`;失败/å®Œæˆæ€ç”¨ `finishedAtMs` 冻结耗时。未收到对应åŽç«¯é‡Œç¨‹ç¢‘å‰ï¼ŒåŽç»­æ­¥éª¤ä¿æŒå¾…处ç†ï¼›å³ä½¿å½“剿­¥éª¤é¢„计时长耗尽,也åªèƒ½è®©å½“剿­¥éª¤å†…部进度åœåœ¨ `98%` 内,ä¸èƒ½è‡ªåŠ¨å®Œæˆå½“剿­¥éª¤æˆ–跳到åŽç»­æ­¥éª¤ã€‚生æˆé¡µæ¯ä¸ªæ­¥éª¤åªå±•示标题和进度,ä¸å±•示步骤详细æè¿°ã€‚ - å‰ç«¯åˆ›ä½œã€ç»“果页ã€ç”Ÿæˆé¡µå’Œé”™è¯¯æç¤ºä¸å±•示 GPT / Gemini 等具体模型å称;如需在内部ä¿ç•™æ¨¡åž‹è·¯ç”±ï¼ŒUI åªä½¿ç”¨â€œæ ‡å‡†æ¨¡å¼â€â€œåˆ›æ„模å¼â€ç­‰äº§å“化å称。 - è‹¥æµè§ˆå™¨é”å±ã€æ¯å±æˆ–网络切æ¢å¯¼è‡´ compile 请求失败,å‰ç«¯åœ¨æ ‡è®°å¤±è´¥å‰å¿…须先å¤è¯» `getPuzzleAgentSession(sessionId)`ï¼›åªæœ‰æœ€æ–° session ä»ç¼º `draft.coverImageSrc`ã€é¦–å…³ `coverImageSrc` 或候选图时æ‰å±•示失败,å¤è¯»åˆ°å·²ç”Ÿæˆè‰ç¨¿æ—¶æŒ‰æˆåŠŸæ”¶å°¾ã€åˆ·æ–°ä½œå“架并继续自动试玩/结果页链路。 - 拼图å‚考图 AI é‡ç»˜èµ° VectorEngine `/v1/images/edits`;无å‚考图时走 `/v1/images/generations`。两者模型都使用 `gpt-image-2`,å‚考图由åŽç«¯ä½œä¸º multipart `image` part 传入编辑接å£ã€‚ diff --git a/docs/ã€çŽ©æ³•åˆ›ä½œã€‘æ‹¼å›¾ç”Ÿæˆé¡µè¿›åº¦å£å¾„-2026-05-23.md b/docs/ã€çŽ©æ³•åˆ›ä½œã€‘æ‹¼å›¾ç”Ÿæˆé¡µè¿›åº¦å£å¾„-2026-05-23.md index bc0ae2a0..c5314d86 100644 --- a/docs/ã€çŽ©æ³•åˆ›ä½œã€‘æ‹¼å›¾ç”Ÿæˆé¡µè¿›åº¦å£å¾„-2026-05-23.md +++ b/docs/ã€çŽ©æ³•åˆ›ä½œã€‘æ‹¼å›¾ç”Ÿæˆé¡µè¿›åº¦å£å¾„-2026-05-23.md @@ -1,6 +1,6 @@ # 拼图生æˆé¡µè¿›åº¦å£å¾„ -更新时间:`2026-05-24` +更新时间:`2026-06-02` ## 目标 @@ -8,14 +8,14 @@ ## è½åœ°å£å¾„ -- æ€»è¿›åº¦å’Œå½“å‰æ­¥éª¤å†…百分比å¯ä»¥æŒ‰å·²è€—时平滑增长,但进入生æˆé¡µçš„åˆå§‹å¸§å¿…须从 `0%` 开始,éžå®Œæˆæ€æœ€å¤šåœåœ¨ `98%`。 -- 未收到首个真实里程碑å‰ï¼Œé¡µé¢ä»åœç•™åœ¨å½“剿­¥éª¤ï¼Œæ€»è¿›åº¦åœ¨ `0-88` 区间内平滑推进;收到 `88/94/96` 里程碑åŽï¼Œåˆ†åˆ«åœ¨ `88-94`ã€`94-96`ã€`96-98` 区间内推进,é¿å…步骤ä¸è·³æ—¶æ€»è¿›åº¦ä¹Ÿåœæ­»ã€‚ -- åŽç«¯ `progressPercent` 低于 `88` åªä½œä¸ºå½“å‰ä¼šè¯çжæ€è®°å½•,ä¸å¾—把生æˆé¡µé˜¶æ®µæŽ¨åˆ°é¦–个图片里程碑;低于首个里程碑时页é¢ä»æŒ‰å½“å‰è§†å›¾è¿›å…¥æ—¶é—´ä»Ž `0%` 平滑展示。 +- æ€»è¿›åº¦å’Œå½“å‰æ­¥éª¤å†…百分比å¯ä»¥æŒ‰å·²è€—时平滑增长;新å‘起的生æˆåˆå§‹å¸§ä»Ž `0%` 开始,æ¢å¤æŒä¹…化生æˆä¸­è‰ç¨¿æ—¶å¿…须按åŽç«¯ session / ä½œå“æ‘˜è¦æ—¶é—´æˆ³æŽ¨å¯¼å·²è€—时,ä¸èƒ½æ¯æ¬¡é‡æ–°ä»Ž `0 ç§’` 起算。éžå®Œæˆæ€æœ€å¤šåœåœ¨ `98%`。 +- åŽç«¯ `progressPercent` çš„ `88/94/96` åªç”¨äºŽåˆ‡æ¢å½“å‰çœŸå®žæ­¥éª¤ï¼Œä¸å¾—直接作为总进度地æ¿ï¼›æ€»è¿›åº¦åº”æŒ‰å·²å®Œæˆæ­¥éª¤æƒé‡åР当剿­¥éª¤å†…å‡è¿›åº¦æŽ¨å¯¼ï¼Œé¿å…æ¢å¤æˆ–轮询åŽçž¬é—´è·³åˆ° `88%`。 +- åŽç«¯ `progressPercent` 低于 `88` åªä½œä¸ºå½“å‰ä¼šè¯çжæ€è®°å½•,ä¸å¾—把生æˆé¡µé˜¶æ®µæŽ¨åˆ°é¦–个图片里程碑,也ä¸å¾—抬高首帧总进度。 - 步骤状æ€ä»¥çœŸå®žé˜¶æ®µä¸ºå‡†ï¼š`phase` / åŽç«¯ä¼šè¯è¿›åº¦ / æœ€ç»ˆå®Œæˆæˆ–失败回包æ‰å…许跨步。 -- 拼图生æˆé¡µæ¢å¤æŒä¹…化 `generationStatus=generating` è‰ç¨¿æ—¶ï¼Œå±•示进度使用“进入生æˆé¡µçš„当剿—¶é—´â€ä½œä¸º `startedAtMs`ï¼›ä¸å¾—å†ç”¨ä½œå“æ‘˜è¦ `updatedAt` 推导展示起点,é¿å…刷新åŽé¦–帧直接跳到 `80%+`。 -- 拼图和抓大鹅等生æˆé¡µä»Žä½œå“æž¶ / 刷新æ¢å¤è¿›å…¥æ—¶ï¼Œå‰ç«¯åº”把展示æ€ç”Ÿæˆçжæ€é‡åŸºå‡†åˆ°è¿›å…¥é¡µé¢çš„当剿—¶é—´ï¼›åŽå° session çš„ `progressPercent` 与历å²é‡Œç¨‹ç¢‘åªä¿ç•™ä¸ºçжæ€äº‹å®žï¼Œä¸å¾—直接作为首帧总进度。 +- 拼图和抓大鹅等生æˆé¡µä»Žä½œå“æž¶ / 刷新æ¢å¤è¿›å…¥æ—¶ï¼Œå‰ç«¯åº”优先使用åŽç«¯ session `updatedAt` æˆ–ä½œå“æ‘˜è¦ `updatedAt` ä½œä¸ºå±•ç¤ºæ€ `startedAtMs`,ä¿è¯å·²è€—时与åŽç«¯ç”Ÿæˆæ—¶é—´å¯¹é½ï¼›åŽå° session çš„ `progressPercent` åªè´Ÿè´£çœŸå®žæ­¥éª¤æŽ¨è¿›ï¼Œä¸ç›´æŽ¥å†³å®šæ€»è¿›åº¦ç™¾åˆ†æ¯”。 +- 生æˆå¤±è´¥æ—¶ï¼Œç”Ÿæˆé¡µå†»ç»“为失败 / é‡è¯•状æ€ï¼›åŒä¸€æµè§ˆå™¨ä¼šè¯å†…返回è‰ç¨¿ Tab 时,失败è‰ç¨¿å¿…é¡»ç»§ç»­å‡ºçŽ°åœ¨ä½œå“æž¶ï¼Œä¸”本地失败 notice è¦è¦†ç›–åŽç«¯ä»å¯èƒ½çŸ­æš‚返回的 `generationStatus=generating` 摘è¦ï¼Œä¸èƒ½ç»§ç»­æ˜¾ç¤ºâ€œç”Ÿæˆä¸­â€ã€‚ - 当剿­¥éª¤æœªå®Œæˆæ—¶ï¼ŒåŽç»­æ­¥éª¤ä¿æŒå¾…处ç†ï¼›å³ä½¿é¢„计时间耗尽,也åªèƒ½è®©å½“剿­¥éª¤å†…部进度接近或达到上é™ï¼Œä¸èƒ½è‡ªåŠ¨å®ŒæˆåŽç»­æ­¥éª¤ã€‚ -- æŠ“å¤§é¹…ç­‰éžæ‹¼å›¾å°æ¸¸æˆçš„生æˆé¡µä¹Ÿéµå®ˆåˆå§‹å¸§ `0%`:没有åŽç«¯èµ„äº§è®¡æ•°æ—¶ï¼Œå½“å‰æ­¥éª¤å†…å‡è¿›åº¦æŒ‰çŽ©æ³•é¢„è®¡ç­‰å¾…æ€»æ—¶é•¿ä»Ž `0` 平滑推进,ä¸ä½¿ç”¨å›ºå®š `0.5` 这类常é‡èµ·æ­¥ã€‚ +- æŠ“å¤§é¹…ç­‰éžæ‹¼å›¾å°æ¸¸æˆçš„生æˆé¡µä¹Ÿéµå®ˆåŒä¸€æ¢å¤å£å¾„:没有åŽç«¯èµ„äº§è®¡æ•°æ—¶ï¼Œå½“å‰æ­¥éª¤å†…å‡è¿›åº¦æŒ‰çŽ©æ³•é¢„è®¡ç­‰å¾…æ€»æ—¶é•¿å¹³æ»‘æŽ¨è¿›ï¼Œä¸ä½¿ç”¨å›ºå®š `0.5` 这类常é‡èµ·æ­¥ï¼Œä¹Ÿä¸åœ¨æœªå®Œæˆæ—¶æ˜¾ç¤º `100%`。 - 步骤å¡ç‰‡åªå±•示标题和进度,ä¸å±•示详细æè¿°ã€‚ - ç”Ÿæˆæ‹¼å›¾é¦–图步骤按 4 分钟预估;完整 AI é‡ç»˜è·¯å¾„总预计时长为 448 秒,上传图且关闭 AI é‡ç»˜æ—¶è·³è¿‡é¦–图生æˆï¼Œä»ä¸º 208 秒。 @@ -25,5 +25,6 @@ - `src/services/miniGameDraftGenerationProgress.test.ts` 覆盖åŽç«¯ `progressPercent < 88` æ—¶ä¸ä¼šæŠ¬é«˜è¿›å…¥ç”Ÿæˆé¡µçš„åˆå§‹æ€»è¿›åº¦ã€‚ - `src/services/miniGameDraftGenerationProgress.test.ts` è¦†ç›–æŠ“å¤§é¹…ç­‰éžæ‹¼å›¾ç”Ÿæˆé¡µåˆå§‹æ€»è¿›åº¦ä¸º `0%`。 - `src/components/CustomWorldGenerationView.test.tsx` 覆盖步骤详情ä¸åœ¨ç”Ÿæˆé¡µæ¸²æŸ“。 -- `src/components/rpg-entry/RpgEntryFlowShell.agent.interaction.test.tsx -t "persisted generating"` 覆盖刷新åŽç»§ç»­ç”Ÿæˆä¸­æ‹¼å›¾ / 抓大鹅è‰ç¨¿ä¸ä¼šç»§æ‰¿æ—§ `updatedAt` 导致总进度首帧过高。 +- `src/components/rpg-entry/RpgEntryFlowShell.agent.interaction.test.tsx -t "persisted generating"` 覆盖刷新åŽç»§ç»­ç”Ÿæˆä¸­æ‹¼å›¾ / 抓大鹅è‰ç¨¿æŒ‰åŽç«¯æ—¶é—´æˆ³æ¢å¤ï¼Œä¸”ä¸ä¼šå› åŽç«¯é‡Œç¨‹ç¢‘直接跳到 `88%` 或 `100%`。 +- `src/components/rpg-entry/RpgEntryFlowShell.agent.interaction.test.tsx -t "failed parallel puzzle generations"` 覆盖失败åŽçš„ pending 拼图è‰ç¨¿ä»ç•™åœ¨ä½œå“架,并且ä¸å†æ˜¾ç¤ºâ€œç”Ÿæˆä¸­â€ã€‚ - æ–‡æ¡£ä¸»å›¾è°±çš„æ‹¼å›¾ç« èŠ‚åŒæ­¥ä¿ç•™è¯¥å£å¾„。 diff --git a/docs/ã€çŽ©æ³•åˆ›ä½œã€‘ç”Ÿæˆé¡µåœ†çŽ¯å¸ƒå±€å£å¾„-2026-05-23.md b/docs/ã€çŽ©æ³•åˆ›ä½œã€‘ç”Ÿæˆé¡µåœ†çŽ¯å¸ƒå±€å£å¾„-2026-05-23.md index 7236ab89..91b4980c 100644 --- a/docs/ã€çŽ©æ³•åˆ›ä½œã€‘ç”Ÿæˆé¡µåœ†çŽ¯å¸ƒå±€å£å¾„-2026-05-23.md +++ b/docs/ã€çŽ©æ³•åˆ›ä½œã€‘ç”Ÿæˆé¡µåœ†çŽ¯å¸ƒå±€å£å¾„-2026-05-23.md @@ -1,6 +1,6 @@ # 生æˆé¡µåœ†çŽ¯å¸ƒå±€å£å¾„ -更新时间:`2026-05-30` +更新时间:`2026-06-02` ## 目标 @@ -17,7 +17,7 @@ - 在窄å±ä¸‹ï¼Œé¢„计等待 / 已耗时信æ¯å¡æ”¾åˆ°åœ†çŽ¯ä¸‹æ–¹ä¸¤åˆ—æŽ’å¸ƒï¼›`sm` åŠä»¥ä¸Šè§†å£å†å›žåˆ°åœ†çޝ左峿‚¬æµ®ï¼Œé¿å…左峿‚¬æµ®å¡å’Œåœ†çޝ共åŒè¶…过视å£å®½åº¦ã€‚ - 总进度标题和百分比数字必须显å¼é«˜äºŽ SVG 圆环层级渲染,é¿å…被圆环边缘压ä½ï¼›åœ†çŽ¯æœ¬èº«åªåšèƒŒæ™¯å±‚ï¼Œä¸æŠ¢æ–‡å­—å±‚ã€‚ - æ€»è¿›åº¦æ ‡é¢˜å’Œç™¾åˆ†æ¯”æ•°å­—è¦æ¯”圆环å†ä¸Šç§»ä¸€ç‚¹ï¼Œå½“å‰å†…容区上边è·ä»¥ `pt-[2%]` 为准,桌é¢ç«¯å¯è¿›ä¸€æ­¥å¾®è°ƒåˆ° `sm:pt-[1.5%]`ï¼Œç¡®ä¿æ•°å­—ä¸ä¸Žè¿›åº¦æ¡å¼§çº¿é‡åˆã€‚ -- ä»Žä½œå“æž¶æˆ–刷新åŽçš„æŒä¹…åŒ–ç”Ÿæˆä¸­è‰ç¨¿è¿›å…¥ç”Ÿæˆé¡µæ—¶ï¼Œå‰ç«¯å¿…é¡»é‡ç½®â€œå±•ç¤ºæ€ startedAtMsâ€ä¸ºè¿›å…¥ç”Ÿæˆé¡µçš„当剿—¶é—´ï¼›åŽç«¯ `progressPercent` åªç”¨äºŽåŽç»­çœŸå®žæ­¥éª¤æŽ¨è¿›ï¼Œä¸å¾—å‚与首帧总进度展示,é¿å…æ¢å¤ç”Ÿæˆé¡µé¦–帧直接显示 `80%+`。 +- ä»Žä½œå“æž¶æˆ–刷新åŽçš„æŒä¹…åŒ–ç”Ÿæˆä¸­è‰ç¨¿è¿›å…¥ç”Ÿæˆé¡µæ—¶ï¼Œå‰ç«¯å¿…须按åŽç«¯ session `updatedAt` æˆ–ä½œå“æ‘˜è¦ `updatedAt` æ¢å¤å±•ç¤ºæ€ `startedAtMs`,ä¿è¯â€œå·²è€—æ—¶â€ä¸å› é‡æ–°è¿›å…¥é¡µé¢è€Œæ¸…é›¶ï¼›åŽç«¯ `progressPercent` åªç”¨äºŽçœŸå®žæ­¥éª¤æŽ¨è¿›ï¼Œä¸å¾—直接作为总进度地æ¿ï¼Œé¿å…æ¢å¤ç”Ÿæˆé¡µé¦–帧直接显示 `88%` 或 `100%`。 - 生æˆé¡µåªå±•示åŠé€æ˜Žâ€œå½“剿­¥éª¤â€å•å¡ï¼Œå¡ç‰‡å†…åªä¿ç•™æ­¥éª¤åç§°ã€æ­¥éª¤çжæ€ã€æ­¥éª¤è¿›åº¦æ¡å’Œè½»é‡åŠ è½½æŒ‡ç¤ºï¼›â€œå½“å‰æ­¥éª¤â€æ ‡ç­¾ä½¿ç”¨ `10px-11px`,步骤å称使用 `14px-15px`,状æ€ä½¿ç”¨ `11px-12px`,ä¸å†æ¸²æŸ“步骤列表或步骤详情。 - 当å‰ä½œå“ä¿¡æ¯æ”¾åœ¨åœ†è§’ä¿¡æ¯å¡ä¸­ï¼Œæ ‡é¢˜å›ºå®šä½¿ç”¨ `13px`;有结构化字段时以两列信æ¯å—展示,例如“题æ / ç´ ææ•°é‡â€ï¼Œæ— ç»“构化字段时æ‰å±•示纯文本设定。 - 汪汪声浪生æˆé¡µ `BarkBattleGeneratingView` 也必须对é½åŒä¸€åž‚直布局,ä¸å†ç»§ç»­å±•示三行槽ä½åˆ—表或左å³åˆ†æ æŠ¢å ä¸»è§†è§‰ã€‚ diff --git a/packages/shared/src/contracts/puzzleAgentDraft.ts b/packages/shared/src/contracts/puzzleAgentDraft.ts index 38fa22fb..3b8cdbaf 100644 --- a/packages/shared/src/contracts/puzzleAgentDraft.ts +++ b/packages/shared/src/contracts/puzzleAgentDraft.ts @@ -62,7 +62,7 @@ export interface PuzzleDraftLevel { selectedCandidateId: string | null; coverImageSrc: string | null; coverAssetId: string | null; - generationStatus: 'idle' | 'generating' | 'ready'; + generationStatus: 'idle' | 'generating' | 'ready' | 'failed'; } export interface PuzzleResultDraft { @@ -78,7 +78,7 @@ export interface PuzzleResultDraft { selectedCandidateId: string | null; coverImageSrc: string | null; coverAssetId: string | null; - generationStatus: 'idle' | 'generating' | 'ready'; + generationStatus: 'idle' | 'generating' | 'ready' | 'failed'; levels?: PuzzleDraftLevel[]; formDraft?: { workTitle?: string; diff --git a/server-rs/crates/api-server/src/password_entry.rs b/server-rs/crates/api-server/src/password_entry.rs index 816ba472..94c3781a 100644 --- a/server-rs/crates/api-server/src/password_entry.rs +++ b/server-rs/crates/api-server/src/password_entry.rs @@ -40,6 +40,15 @@ pub async fn password_entry( state.password_entry_service().execute(input).await } .map_err(map_password_entry_error)?; + let session_client = resolve_session_client_context(&headers); + let signed_session = create_password_auth_session(&state, &result.user, &session_client)?; + state + .sync_auth_store_snapshot_to_spacetime() + .await + .map_err(|error| { + AppError::from_status(StatusCode::INTERNAL_SERVER_ERROR) + .with_message(format!("åŒæ­¥è®¤è¯å¿«ç…§å¤±è´¥ï¼š{error}")) + })?; if result.created { crate::registration_reward::grant_new_user_registration_wallet_reward( &state, @@ -48,8 +57,6 @@ pub async fn password_entry( ) .await; } - let session_client = resolve_session_client_context(&headers); - let signed_session = create_password_auth_session(&state, &result.user, &session_client)?; record_daily_login_tracking_event_after_auth_success( &state, &request_context, @@ -57,13 +64,6 @@ pub async fn password_entry( AuthLoginMethod::Password, ) .await; - state - .sync_auth_store_snapshot_to_spacetime() - .await - .map_err(|error| { - AppError::from_status(StatusCode::INTERNAL_SERVER_ERROR) - .with_message(format!("åŒæ­¥è®¤è¯å¿«ç…§å¤±è´¥ï¼š{error}")) - })?; let mut headers = HeaderMap::new(); attach_set_cookie_header( diff --git a/server-rs/crates/api-server/src/password_management.rs b/server-rs/crates/api-server/src/password_management.rs index 9d305c68..94e5177e 100644 --- a/server-rs/crates/api-server/src/password_management.rs +++ b/server-rs/crates/api-server/src/password_management.rs @@ -100,13 +100,6 @@ pub async fn reset_password( &session_client, module_auth::AuthLoginMethod::Password, )?; - record_daily_login_tracking_event_after_auth_success( - &state, - &request_context, - &result.user.id, - module_auth::AuthLoginMethod::Password, - ) - .await; state .sync_auth_store_snapshot_to_spacetime() .await @@ -114,6 +107,13 @@ pub async fn reset_password( AppError::from_status(StatusCode::INTERNAL_SERVER_ERROR) .with_message(format!("åŒæ­¥è®¤è¯å¿«ç…§å¤±è´¥ï¼š{error}")) })?; + record_daily_login_tracking_event_after_auth_success( + &state, + &request_context, + &result.user.id, + module_auth::AuthLoginMethod::Password, + ) + .await; let mut headers = HeaderMap::new(); attach_set_cookie_header( diff --git a/server-rs/crates/api-server/src/phone_auth.rs b/server-rs/crates/api-server/src/phone_auth.rs index 31e64afa..1b8788ca 100644 --- a/server-rs/crates/api-server/src/phone_auth.rs +++ b/server-rs/crates/api-server/src/phone_auth.rs @@ -151,6 +151,20 @@ pub async fn phone_login( } }; let created = result.created; + let session_client = resolve_session_client_context(&headers); + let signed_session = create_auth_session( + &state, + &result.user, + &session_client, + AuthLoginMethod::Phone, + )?; + state + .sync_auth_store_snapshot_to_spacetime() + .await + .map_err(|error| { + AppError::from_status(StatusCode::INTERNAL_SERVER_ERROR) + .with_message(format!("åŒæ­¥è®¤è¯å¿«ç…§å¤±è´¥ï¼š{error}")) + })?; if created { crate::registration_reward::grant_new_user_registration_wallet_reward( &state, @@ -170,13 +184,6 @@ pub async fn phone_login( } else { None }; - let session_client = resolve_session_client_context(&headers); - let signed_session = create_auth_session( - &state, - &result.user, - &session_client, - AuthLoginMethod::Phone, - )?; record_daily_login_tracking_event_after_auth_success( &state, &request_context, @@ -184,13 +191,6 @@ pub async fn phone_login( AuthLoginMethod::Phone, ) .await; - state - .sync_auth_store_snapshot_to_spacetime() - .await - .map_err(|error| { - AppError::from_status(StatusCode::INTERNAL_SERVER_ERROR) - .with_message(format!("åŒæ­¥è®¤è¯å¿«ç…§å¤±è´¥ï¼š{error}")) - })?; let mut headers = HeaderMap::new(); attach_set_cookie_header( diff --git a/server-rs/crates/api-server/src/puzzle/draft.rs b/server-rs/crates/api-server/src/puzzle/draft.rs index 1f3b53db..276a29f5 100644 --- a/server-rs/crates/api-server/src/puzzle/draft.rs +++ b/server-rs/crates/api-server/src/puzzle/draft.rs @@ -2,8 +2,8 @@ use super::*; pub(crate) fn build_puzzle_form_seed_text(payload: &CreatePuzzleAgentSessionRequest) -> String { build_puzzle_form_seed_prompt(PuzzleFormSeedPromptParts { - title: None, - work_description: None, + title: payload.work_title.as_deref(), + work_description: payload.work_description.as_deref(), picture_description: payload .picture_description .as_deref() @@ -32,8 +32,8 @@ pub(crate) async fn save_puzzle_form_payload_before_compile( now: i64, ) -> Result { let seed_text = build_puzzle_form_seed_text_from_parts( - None, - None, + payload.work_title.as_deref(), + payload.work_description.as_deref(), payload .picture_description .as_deref() diff --git a/server-rs/crates/api-server/src/puzzle/handlers.rs b/server-rs/crates/api-server/src/puzzle/handlers.rs index ab594f07..afd6f3cf 100644 --- a/server-rs/crates/api-server/src/puzzle/handlers.rs +++ b/server-rs/crates/api-server/src/puzzle/handlers.rs @@ -725,8 +725,8 @@ pub async fn execute_puzzle_agent_action( } "save_puzzle_form_draft" => { let seed_text = build_puzzle_form_seed_text_from_parts( - None, - None, + payload.work_title.as_deref(), + payload.work_description.as_deref(), payload .picture_description .as_deref() diff --git a/server-rs/crates/api-server/src/puzzle/tests.rs b/server-rs/crates/api-server/src/puzzle/tests.rs index d4bca634..86512e7d 100644 --- a/server-rs/crates/api-server/src/puzzle/tests.rs +++ b/server-rs/crates/api-server/src/puzzle/tests.rs @@ -384,6 +384,28 @@ fn puzzle_compile_error_preserves_vector_engine_unavailable_status() { assert_eq!(response.status(), StatusCode::SERVICE_UNAVAILABLE); } +#[test] +fn puzzle_form_seed_text_includes_work_metadata() { + let payload = CreatePuzzleAgentSessionRequest { + seed_text: Some("æ—§ seed ä¼šè¢«ç”»é¢æè¿°å…œåº•è¦†ç›–ã€‚".to_string()), + work_title: Some("雨夜猫街".to_string()), + work_description: Some("123".to_string()), + picture_description: Some("一åªçŒ«åœ¨é›¨å¤œç¯ç‰Œä¸‹å›žå¤´ã€‚".to_string()), + reference_image_src: None, + reference_image_srcs: Vec::new(), + reference_image_asset_object_id: None, + reference_image_asset_object_ids: Vec::new(), + image_model: None, + ai_redraw: Some(true), + }; + + let seed_text = build_puzzle_form_seed_text(&payload); + + assert!(seed_text.contains("作å“å称:雨夜猫街")); + assert!(seed_text.contains("ä½œå“æè¿°ï¼š123")); + assert!(seed_text.contains("ç”»é¢æè¿°ï¼šä¸€åªçŒ«åœ¨é›¨å¤œç¯ç‰Œä¸‹å›žå¤´ã€‚")); +} + #[tokio::test] async fn puzzle_compile_error_normalizes_legacy_apimart_image_message() { let error = map_puzzle_compile_error(SpacetimeClientError::Runtime( diff --git a/server-rs/crates/api-server/src/refresh_session.rs b/server-rs/crates/api-server/src/refresh_session.rs index 9276ce1b..2c45a948 100644 --- a/server-rs/crates/api-server/src/refresh_session.rs +++ b/server-rs/crates/api-server/src/refresh_session.rs @@ -56,13 +56,6 @@ pub async fn refresh_session( Some(&rotated.session.issued_by_provider), Some(&rotated.session.client_info), )?; - record_daily_login_tracking_event_after_auth_success( - &state, - &request_context, - &rotated.user.id, - rotated.session.issued_by_provider.clone(), - ) - .await; state .sync_auth_store_snapshot_to_spacetime() .await @@ -70,6 +63,13 @@ pub async fn refresh_session( AppError::from_status(axum::http::StatusCode::INTERNAL_SERVER_ERROR) .with_message(format!("åŒæ­¥è®¤è¯å¿«ç…§å¤±è´¥ï¼š{error}")) })?; + record_daily_login_tracking_event_after_auth_success( + &state, + &request_context, + &rotated.user.id, + rotated.session.issued_by_provider.clone(), + ) + .await; let mut headers = HeaderMap::new(); attach_set_cookie_header( diff --git a/server-rs/crates/api-server/src/state.rs b/server-rs/crates/api-server/src/state.rs index c0c61378..89a80331 100644 --- a/server-rs/crates/api-server/src/state.rs +++ b/server-rs/crates/api-server/src/state.rs @@ -591,7 +591,7 @@ impl AppState { ) .map_err(|_| SpacetimeClientError::Runtime("认è¯å¿«ç…§æ›´æ–°æ—¶é—´è¶…出 i64 范围".to_string()))?; // 当å‰è¿›ç¨‹å†… auth_store 是认è¯è¯·æ±‚çš„å³æ—¶å·¥ä½œé›†ï¼›SpacetimeDB æ­£å¼è®¤è¯è¡¨ç”¨äºŽè·¨è¿›ç¨‹æ¢å¤ã€‚ - // 远端数æ®åº“挂起或网络异常时,åªé™çº§åŽç»­æ¢å¤èƒ½åŠ›ï¼Œä¸èƒ½è®©å·²æˆåŠŸçš„ç™»å½•/刷新/退出回滚为失败。 + // 认è¯å˜æ›´å¿…须在返回客户端å‰å†™å…¥ SpacetimeDB,é¿å…åªåœ¨æœ¬è¿›ç¨‹å†…æˆåŠŸã€é‡å¯åŽä¸¢å¤±è´¦å·æˆ–会è¯ã€‚ #[cfg(not(test))] if let Err(error) = self .spacetime_client @@ -600,9 +600,9 @@ impl AppState { { warn!( error = %error, - "认è¯å¿«ç…§å¯¼å…¥ SpacetimeDB æ­£å¼è¡¨å¤±è´¥ï¼Œå½“å‰è®¤è¯æµç¨‹ç»§ç»­" + "认è¯å¿«ç…§å¯¼å…¥ SpacetimeDB æ­£å¼è¡¨å¤±è´¥ï¼Œå½“å‰è®¤è¯æµç¨‹ä¸­æ­¢" ); - return Ok(()); + return Err(error); } #[cfg(not(test))] Ok(()) diff --git a/server-rs/crates/api-server/src/wechat_auth.rs b/server-rs/crates/api-server/src/wechat_auth.rs index d7dfc858..f2766959 100644 --- a/server-rs/crates/api-server/src/wechat_auth.rs +++ b/server-rs/crates/api-server/src/wechat_auth.rs @@ -145,13 +145,6 @@ pub async fn handle_wechat_callback( &session_client, AuthLoginMethod::Wechat, )?; - record_daily_login_tracking_event_after_auth_success( - &state, - &request_context, - &result.user.id, - AuthLoginMethod::Wechat, - ) - .await; state .sync_auth_store_snapshot_to_spacetime() .await @@ -159,6 +152,13 @@ pub async fn handle_wechat_callback( AppError::from_status(StatusCode::INTERNAL_SERVER_ERROR) .with_message(format!("åŒæ­¥è®¤è¯å¿«ç…§å¤±è´¥ï¼š{error}")) })?; + record_daily_login_tracking_event_after_auth_success( + &state, + &request_context, + &result.user.id, + AuthLoginMethod::Wechat, + ) + .await; let mut response = Redirect::to(&build_auth_result_redirect_url( &redirect_path, &[ @@ -241,6 +241,20 @@ pub async fn bind_wechat_phone( .await .map_err(map_wechat_bind_phone_error)? }; + let session_client = resolve_session_client_context(&headers); + let signed_session = create_auth_session( + &state, + &result.user, + &session_client, + AuthLoginMethod::Wechat, + )?; + state + .sync_auth_store_snapshot_to_spacetime() + .await + .map_err(|error| { + AppError::from_status(StatusCode::INTERNAL_SERVER_ERROR) + .with_message(format!("åŒæ­¥è®¤è¯å¿«ç…§å¤±è´¥ï¼š{error}")) + })?; if result.activated_new_user { crate::registration_reward::grant_new_user_registration_wallet_reward( &state, @@ -249,13 +263,6 @@ pub async fn bind_wechat_phone( ) .await; } - let session_client = resolve_session_client_context(&headers); - let signed_session = create_auth_session( - &state, - &result.user, - &session_client, - AuthLoginMethod::Wechat, - )?; record_daily_login_tracking_event_after_auth_success( &state, &request_context, @@ -263,13 +270,6 @@ pub async fn bind_wechat_phone( AuthLoginMethod::Wechat, ) .await; - state - .sync_auth_store_snapshot_to_spacetime() - .await - .map_err(|error| { - AppError::from_status(StatusCode::INTERNAL_SERVER_ERROR) - .with_message(format!("åŒæ­¥è®¤è¯å¿«ç…§å¤±è´¥ï¼š{error}")) - })?; let mut response_headers = HeaderMap::new(); attach_set_cookie_header( diff --git a/server-rs/crates/module-auth/src/lib.rs b/server-rs/crates/module-auth/src/lib.rs index 66f31450..3b0e5677 100644 --- a/server-rs/crates/module-auth/src/lib.rs +++ b/server-rs/crates/module-auth/src/lib.rs @@ -11,7 +11,7 @@ pub use errors::*; pub use events::*; use std::{ - collections::HashMap, + collections::{HashMap, HashSet}, sync::{Arc, Mutex}, }; @@ -918,16 +918,47 @@ impl Default for InMemoryAuthStoreState { impl InMemoryAuthStoreState { fn from_persistent_snapshot(snapshot: PersistentAuthStoreSnapshot) -> Self { + let existing_user_ids = snapshot + .users_by_username + .values() + .map(|stored| stored.user.id.clone()) + .collect::>(); + let phone_to_user_id = snapshot + .phone_to_user_id + .into_iter() + .filter(|(_, user_id)| existing_user_ids.contains(user_id)) + .collect(); + let sessions_by_id = snapshot + .sessions_by_id + .into_iter() + .filter(|(_, stored)| existing_user_ids.contains(&stored.session.user_id)) + .collect::>(); + let session_id_by_refresh_token_hash = snapshot + .session_id_by_refresh_token_hash + .into_iter() + .filter(|(_, session_id)| sessions_by_id.contains_key(session_id)) + .collect(); + let wechat_identity_by_provider_uid = snapshot + .wechat_identity_by_provider_uid + .into_iter() + .filter(|(_, identity)| existing_user_ids.contains(&identity.user_id)) + .collect(); + let user_id_by_provider_union_id = snapshot + .user_id_by_provider_union_id + .into_iter() + .filter(|(_, user_id)| existing_user_ids.contains(user_id)) + .collect(); + Self { next_user_id: snapshot.next_user_id, users_by_username: snapshot.users_by_username, - phone_to_user_id: snapshot.phone_to_user_id, - sessions_by_id: snapshot.sessions_by_id, - session_id_by_refresh_token_hash: snapshot.session_id_by_refresh_token_hash, + phone_to_user_id, + sessions_by_id, + session_id_by_refresh_token_hash, phone_codes_by_key: HashMap::new(), wechat_states_by_token: HashMap::new(), - wechat_identity_by_provider_uid: snapshot.wechat_identity_by_provider_uid, - user_id_by_provider_union_id: snapshot.user_id_by_provider_union_id, + wechat_identity_by_provider_uid, + user_id_by_provider_union_id, } } @@ -1159,10 +1190,17 @@ impl InMemoryAuthStore { .inner .lock() .map_err(|_| PhoneAuthError::Store("用户仓储é”已中毒".to_string()))?; - if state.phone_to_user_id.contains_key(&phone_number.e164) { - return Err(PhoneAuthError::Store( - "手机å·å·²å­˜åœ¨ï¼Œæ— æ³•é‡å¤åˆ›å»ºè´¦å·".to_string(), - )); + if let Some(existing_user_id) = state.phone_to_user_id.get(&phone_number.e164).cloned() { + let existing_user_exists = state + .users_by_username + .values() + .any(|stored_user| stored_user.user.id == existing_user_id); + if existing_user_exists { + return Err(PhoneAuthError::Store( + "手机å·å·²å­˜åœ¨ï¼Œæ— æ³•é‡å¤åˆ›å»ºè´¦å·".to_string(), + )); + } + state.phone_to_user_id.remove(&phone_number.e164); } let created_at = format_rfc3339(OffsetDateTime::now_utc()).map_err(|message| { @@ -1213,8 +1251,15 @@ impl InMemoryAuthStore { .inner .lock() .map_err(|_| PasswordEntryError::Store("用户仓储é”已中毒".to_string()))?; - if state.phone_to_user_id.contains_key(&phone_number.e164) { - return Err(PasswordEntryError::InvalidCredentials); + if let Some(existing_user_id) = state.phone_to_user_id.get(&phone_number.e164).cloned() { + let existing_user_exists = state + .users_by_username + .values() + .any(|stored_user| stored_user.user.id == existing_user_id); + if existing_user_exists { + return Err(PasswordEntryError::InvalidCredentials); + } + state.phone_to_user_id.remove(&phone_number.e164); } let created_at = format_rfc3339(OffsetDateTime::now_utc()).map_err(|message| { @@ -2629,6 +2674,54 @@ mod tests { assert_eq!(rotated.user.id, user.id); } + #[tokio::test] + async fn snapshot_json_drops_orphan_phone_index_before_phone_login() { + let snapshot = PersistentAuthStoreSnapshot { + next_user_id: 9, + users_by_username: HashMap::new(), + phone_to_user_id: HashMap::from([( + "+8613800138032".to_string(), + "user_missing_phone_owner".to_string(), + )]), + sessions_by_id: HashMap::new(), + session_id_by_refresh_token_hash: HashMap::new(), + wechat_identity_by_provider_uid: HashMap::new(), + user_id_by_provider_union_id: HashMap::new(), + }; + let snapshot_json = serde_json::to_string(&snapshot).expect("snapshot should serialize"); + let restored_store = InMemoryAuthStore::from_snapshot_json(&snapshot_json) + .expect("snapshot json should restore"); + let phone_service = build_phone_service(restored_store); + let now = OffsetDateTime::now_utc(); + + phone_service + .send_code( + SendPhoneCodeInput { + phone_number: "13800138032".to_string(), + scene: PhoneAuthScene::Login, + }, + now, + ) + .await + .expect("phone code should send"); + let result = phone_service + .login( + PhoneLoginInput { + phone_number: "13800138032".to_string(), + verify_code: DEFAULT_SMS_MOCK_VERIFY_CODE.to_string(), + }, + now + Duration::seconds(1), + ) + .await + .expect("orphan phone index should not block phone login"); + + assert!(result.created); + assert_eq!( + result.user.phone_number_masked.as_deref(), + Some("138****8032") + ); + } + #[tokio::test] async fn password_entry_rejects_email_or_username_identifier() { let service = build_password_service(build_store()); diff --git a/server-rs/crates/spacetime-module/src/auth/procedures.rs b/server-rs/crates/spacetime-module/src/auth/procedures.rs index 8ae86d70..5f93eb01 100644 --- a/server-rs/crates/spacetime-module/src/auth/procedures.rs +++ b/server-rs/crates/spacetime-module/src/auth/procedures.rs @@ -454,6 +454,10 @@ fn export_auth_store_snapshot_from_tables_tx( .meta_id() .find(&AUTH_STORE_PROJECTION_META_ID.to_string()) .map(|row| row.updated_at.to_micros_since_unix_epoch()); + let valid_user_ids = users + .iter() + .map(|user| user.user_id.clone()) + .collect::>(); let mut phone_identity_by_user_id = std::collections::HashMap::new(); let mut phone_to_user_id = std::collections::HashMap::new(); @@ -461,6 +465,10 @@ fn export_auth_store_snapshot_from_tables_tx( let mut user_id_by_provider_union_id = std::collections::HashMap::new(); for identity in identities { + if !valid_user_ids.contains(&identity.user_id) { + continue; + } + match identity.provider.as_str() { "phone" => { let phone_number = identity @@ -529,6 +537,10 @@ fn export_auth_store_snapshot_from_tables_tx( let mut sessions_by_id = std::collections::HashMap::new(); let mut session_id_by_refresh_token_hash = std::collections::HashMap::new(); for session in sessions { + if !valid_user_ids.contains(&session.user_id) { + continue; + } + let client_info = serde_json::from_str::(&session.client_info_json) .map_err(|error| format!("refresh session å®¢æˆ·ç«¯ä¿¡æ¯ JSON è§£æžå¤±è´¥ï¼š{error}"))?; session_id_by_refresh_token_hash.insert( @@ -693,10 +705,9 @@ mod tests { #[test] fn auth_store_snapshot_user_row_key_is_stable_after_username_change() { - let mut before = sample_snapshot(); + let before = sample_snapshot(); let mut after = sample_snapshot(); - after.users_by_username.clear(); - let mut renamed_user = before + let mut renamed_user = after .users_by_username .remove("phone_42") .expect("sample user exists"); diff --git a/server-rs/crates/spacetime-module/src/custom_world.rs b/server-rs/crates/spacetime-module/src/custom_world.rs index d17c6910..6e88121e 100644 --- a/server-rs/crates/spacetime-module/src/custom_world.rs +++ b/server-rs/crates/spacetime-module/src/custom_world.rs @@ -5521,6 +5521,7 @@ mod tests { deleted_at: None, created_at: Timestamp::from_micros_since_unix_epoch(1), updated_at: Timestamp::from_micros_since_unix_epoch(1), + visible: true, } } diff --git a/server-rs/crates/spacetime-module/src/wooden_fish.rs b/server-rs/crates/spacetime-module/src/wooden_fish.rs index d4dc56de..dbfc0693 100644 --- a/server-rs/crates/spacetime-module/src/wooden_fish.rs +++ b/server-rs/crates/spacetime-module/src/wooden_fish.rs @@ -1415,6 +1415,7 @@ mod tests { height: 1536, })), back_button_asset_json: None, + visible: true, } } } diff --git a/src/components/custom-world-home/CustomWorldCreationHub.interaction.test.tsx b/src/components/custom-world-home/CustomWorldCreationHub.interaction.test.tsx index 1d0d9680..94a559da 100644 --- a/src/components/custom-world-home/CustomWorldCreationHub.interaction.test.tsx +++ b/src/components/custom-world-home/CustomWorldCreationHub.interaction.test.tsx @@ -560,7 +560,7 @@ test('creation hub shows RPG public work code from published library entry', () expect(screen.queryByText('CW-00000001')).toBeNull(); }); -test('creation hub exposes persisted draft delete action directly on the card', () => { +test('creation hub keeps persisted draft delete action off the card header', () => { const { container } = render( { @@ -641,6 +641,75 @@ test('creation hub reveals persisted draft delete action from keyboard', async ( expect(screen.queryByRole('button', { name: '分享' })).toBeNull(); }); +test('creation hub reveals persisted draft delete action from long press menu', () => { + const { container } = render( + {}} + onCreateType={noopCreateType} + onOpenDraft={() => {}} + onEnterPublished={() => {}} + onDeletePublished={() => {}} + entryConfig={testEntryConfig} + creationTypes={testCreationTypes} + />, + ); + + const card = screen.getByRole('button', { name: /继续完善《潮雾列岛》/u }); + fireEvent.contextMenu(card); + + expect( + container.querySelector('.creation-work-card-shell--actions-visible'), + ).toBeTruthy(); + expect(screen.getByRole('button', { name: '删除' })).toBeTruthy(); +}); + +test('creation hub gives every deletable work card a side delete action', () => { + const { container } = render( + {}} + onCreateType={noopCreateType} + onOpenDraft={() => {}} + onEnterPublished={() => {}} + onDeletePublished={() => {}} + onDeleteBabyObjectMatch={() => {}} + onDeletePuzzle={() => {}} + entryConfig={testEntryConfig} + creationTypes={testCreationTypes} + />, + ); + + expect(screen.queryByRole('button', { name: '删除' })).toBeNull(); + expect( + container.querySelectorAll('.creation-work-card__swipe-underlay'), + ).toHaveLength(3); +}); + test('creation hub shows delete action for baby object match drafts', async () => { const user = userEvent.setup(); const onDeleteBabyObjectMatch = vi.fn(); @@ -719,7 +788,7 @@ test('creation hub works-only tab filters bark battle draft and published works' expect(onOpenBarkBattleDetail).toHaveBeenCalledWith(barkBattlePublishedItem); }); -test('creation hub published work delete action is directly visible', async () => { +test('creation hub published work delete action stays in revealed side actions', async () => { const user = userEvent.setup(); const onDeletePuzzle = vi.fn(); const onOpenPuzzleDetail = vi.fn(); @@ -759,9 +828,11 @@ test('creation hub published work delete action is directly visible', async () = />, ); - expect(screen.getByRole('button', { name: '删除' })).toBeTruthy(); + expect(screen.queryByRole('button', { name: '删除' })).toBeNull(); expect(screen.getByRole('button', { name: '分享' })).toBeTruthy(); + screen.getByRole('button', { name: /查看详情《待删拼图》/u }).focus(); + await user.keyboard('{ArrowLeft}'); await user.click(screen.getByRole('button', { name: '删除' })); expect(onDeletePuzzle).toHaveBeenCalledWith( @@ -770,7 +841,7 @@ test('creation hub published work delete action is directly visible', async () = expect(onOpenPuzzleDetail).not.toHaveBeenCalled(); }); -test('creation hub exposes work delete action directly on card', async () => { +test('creation hub reveals draft work delete action from keyboard', async () => { const user = userEvent.setup(); const onDeletePuzzle = vi.fn(); const onOpenPuzzleDetail = vi.fn(); @@ -810,6 +881,10 @@ test('creation hub exposes work delete action directly on card', async () => { />, ); + expect(screen.queryByRole('button', { name: '删除' })).toBeNull(); + + screen.getByRole('button', { name: /继续创作《直接删除拼图》/u }).focus(); + await user.keyboard('{ArrowLeft}'); await user.click(screen.getByRole('button', { name: '删除' })); expect(onDeletePuzzle).toHaveBeenCalledWith( @@ -858,7 +933,9 @@ test('creation hub keeps swipe delete action available', async () => { />, ); - const card = screen.getByRole('button', { name: /查看详情《左滑删除拼图》/u }); + const card = screen.getByRole('button', { + name: /查看详情《左滑删除拼图》/u, + }); fireEvent.touchStart(card, { touches: [{ clientX: 180, clientY: 20 }], }); diff --git a/src/components/custom-world-home/CustomWorldCreationHub.tsx b/src/components/custom-world-home/CustomWorldCreationHub.tsx index 857f9f48..106934ab 100644 --- a/src/components/custom-world-home/CustomWorldCreationHub.tsx +++ b/src/components/custom-world-home/CustomWorldCreationHub.tsx @@ -22,6 +22,7 @@ import { buildCreationWorkShelfItems, type CreationWorkShelfItem, type CreationWorkShelfMetricId, + type CreationWorkShelfRuntimeState, } from './creationWorkShelf'; import { CustomWorldCreationStartCard } from './CustomWorldCreationStartCard'; import { CustomWorldWorkCard } from './CustomWorldWorkCard'; @@ -66,7 +67,9 @@ type CustomWorldCreationHubProps = { onOpenJumpHopDetail?: (item: JumpHopWorkSummaryResponse) => void; onDeleteJumpHop?: ((item: JumpHopWorkSummaryResponse) => void) | null; woodenFishItems?: WoodenFishWorkSummaryResponse[]; - onOpenWoodenFishDetail?: ((item: WoodenFishWorkSummaryResponse) => void) | null; + onOpenWoodenFishDetail?: + | ((item: WoodenFishWorkSummaryResponse) => void) + | null; onDeleteWoodenFish?: ((item: WoodenFishWorkSummaryResponse) => void) | null; puzzleItems?: PuzzleWorkSummary[]; onOpenPuzzleDetail?: (item: PuzzleWorkSummary) => void; @@ -84,7 +87,7 @@ type CustomWorldCreationHubProps = { onDeleteVisualNovel?: ((item: VisualNovelWorkSummary) => void) | null; getWorkState?: ( item: CreationWorkShelfItem, - ) => { isGenerating?: boolean; hasUnreadUpdate?: boolean } | null; + ) => CreationWorkShelfRuntimeState | null; onOpenShelfItem?: (item: CreationWorkShelfItem) => void; mode?: 'full' | 'start-only' | 'works-only'; }; diff --git a/src/components/custom-world-home/CustomWorldWorkCard.tsx b/src/components/custom-world-home/CustomWorldWorkCard.tsx index 00f38323..2f3cc588 100644 --- a/src/components/custom-world-home/CustomWorldWorkCard.tsx +++ b/src/components/custom-world-home/CustomWorldWorkCard.tsx @@ -1,5 +1,6 @@ import { BadgeCheck, + CircleAlert, Clock3, Loader2, Share2, @@ -439,11 +440,8 @@ export function CustomWorldWorkCard({ return; } - updateSwipeOffset( - gesture, - event.clientX, - event.clientY, - () => event.preventDefault(), + updateSwipeOffset(gesture, event.clientX, event.clientY, () => + event.preventDefault(), ); }; @@ -473,9 +471,7 @@ export function CustomWorldWorkCard({ } }; - const beginTouchSwipeGesture = ( - event: ReactTouchEvent, - ) => { + const beginTouchSwipeGesture = (event: ReactTouchEvent) => { if (swipeRevealWidth <= 0) { return; } @@ -494,20 +490,15 @@ export function CustomWorldWorkCard({ }; }; - const updateTouchSwipeGesture = ( - event: ReactTouchEvent, - ) => { + const updateTouchSwipeGesture = (event: ReactTouchEvent) => { const gesture = swipeGestureRef.current; const touch = event.touches[0]; if (!gesture || gesture.pointerId !== -1 || !touch) { return; } - updateSwipeOffset( - gesture, - touch.clientX, - touch.clientY, - () => event.preventDefault(), + updateSwipeOffset(gesture, touch.clientX, touch.clientY, () => + event.preventDefault(), ); }; @@ -676,8 +667,8 @@ export function CustomWorldWorkCard({ {displayTitle} -
- {canUseShareAction ? ( + {canUseShareAction ? ( +
- ) : null} - {onDelete ? ( - - ) : null} -
+
+ ) : null}
@@ -762,6 +723,16 @@ export function CustomWorldWorkCard({ {item.summary}
+ {item.hasGenerationFailure ? ( +
+
+ ) : null} + {isPublished ? (
{item.pointIncentive ? ( diff --git a/src/components/custom-world-home/creationWorkShelf.test.ts b/src/components/custom-world-home/creationWorkShelf.test.ts index 180e1e7a..e0866d6d 100644 --- a/src/components/custom-world-home/creationWorkShelf.test.ts +++ b/src/components/custom-world-home/creationWorkShelf.test.ts @@ -93,7 +93,9 @@ test('buildCreationWorkShelfItems maps wooden fish items with WF public code', ( expect(items[0]?.sharePath).toContain('/works/detail?work=WF-12345678'); expect(items[0]?.openActionLabel).toBe('查看详情'); expect(items[0]?.badges.some((badge) => badge.label === '敲木鱼')).toBe(true); - expect(items[0]?.metrics.find((metric) => metric.id === 'play-count')?.value).toBe(9); + expect( + items[0]?.metrics.find((metric) => metric.id === 'play-count')?.value, + ).toBe(9); expect(onOpenWoodenFishDetail).toHaveBeenCalledWith(woodenFishWork); }); @@ -211,9 +213,9 @@ test('buildCreationWorkShelfItems keeps separate bark battle draft and published expect(items.find((item) => item.status === 'published')?.id).toBe( 'BB-PUB00001', ); - expect(items.find((item) => item.status === 'published')?.publicWorkCode).toBe( - 'BB-PUB00001', - ); + expect( + items.find((item) => item.status === 'published')?.publicWorkCode, + ).toBe('BB-PUB00001'); }); test('buildCreationWorkShelfItems falls back to deterministic RPG public work code when library entry is missing', () => { @@ -303,10 +305,9 @@ test('buildCreationWorkShelfItems gives bark battle draft cover from character o expect(items.find((item) => item.id === 'BB-COVER001')?.coverImageSrc).toBe( '/draft-player-cover.png', ); - expect(items.find((item) => item.id === 'BB-COVER001')?.coverCharacterImageSrcs).toEqual([ - '/draft-player-cover.png', - '/draft-opponent-cover.png', - ]); + expect( + items.find((item) => item.id === 'BB-COVER001')?.coverCharacterImageSrcs, + ).toEqual(['/draft-player-cover.png', '/draft-opponent-cover.png']); expect(items.find((item) => item.id === 'BB-COVER002')?.coverImageSrc).toBe( '/creation-type-references/bark-battle.webp', ); @@ -457,14 +458,76 @@ test('buildCreationWorkShelfItems restores persisted generation state for puzzle ], }); - expect(items.find((item) => item.kind === 'puzzle')?.isGenerating).toBe( - true, - ); + expect(items.find((item) => item.kind === 'puzzle')?.isGenerating).toBe(true); expect(items.find((item) => item.kind === 'match3d')?.isGenerating).toBe( true, ); }); +test('buildCreationWorkShelfItems lets failure notice override persisted generating copy', () => { + const items = buildCreationWorkShelfItems({ + rpgItems: [], + bigFishItems: [], + puzzleItems: [ + { + workId: 'puzzle:failed-generating', + profileId: 'puzzle-profile-failed-generating', + ownerUserId: 'user-1', + sourceSessionId: 'puzzle-session-failed-generating', + authorDisplayName: '测试作者', + levelName: '失败拼图', + summary: 'æ­£åœ¨ç”Ÿæˆæ‹¼å›¾è‰ç¨¿ã€‚', + themeTags: [], + coverImageSrc: null, + publicationStatus: 'draft', + updatedAt: '2026-05-08T00:00:00.000Z', + publishedAt: null, + publishReady: false, + generationStatus: 'generating', + }, + ], + getItemState: (item) => + item.kind === 'puzzle' + ? { + isGenerating: false, + suppressPersistedGenerating: true, + summaryOverride: '拼图è‰ç¨¿ç”Ÿæˆå¤±è´¥ï¼Œå¯é‡æ–°æ‰“开处ç†ã€‚', + } + : null, + }); + + expect(items[0]?.isGenerating).toBe(false); + expect(items[0]?.summary).toBe('拼图è‰ç¨¿ç”Ÿæˆå¤±è´¥ï¼Œå¯é‡æ–°æ‰“开处ç†ã€‚'); +}); + +test('persisted failed puzzle draft is not treated as generating', () => { + const items = buildCreationWorkShelfItems({ + rpgItems: [], + bigFishItems: [], + puzzleItems: [ + { + workId: 'puzzle:failed', + profileId: 'puzzle-profile-failed', + ownerUserId: 'user-1', + sourceSessionId: 'puzzle-session-failed', + authorDisplayName: '测试作者', + levelName: '失败拼图', + summary: 'æœåŠ¡ç«¯å·²å›žå†™å¤±è´¥ã€‚', + themeTags: [], + coverImageSrc: null, + publicationStatus: 'draft', + updatedAt: '2026-05-08T00:00:00.000Z', + publishedAt: null, + publishReady: false, + generationStatus: 'failed', + }, + ], + }); + + expect(items[0]?.isGenerating).toBeFalsy(); + expect(items[0]?.summary).toBe('æœåŠ¡ç«¯å·²å›žå†™å¤±è´¥ã€‚'); +}); + test('buildCreationWorkShelfItems maps baby object match local drafts', () => { const onOpenBabyObjectMatchDetail = vi.fn(); const onDeleteBabyObjectMatch = vi.fn(); @@ -1088,7 +1151,6 @@ test('bark battle draft generating state only follows pending assets', () => { ).toBe(false); }); - test('CustomWorldWorkCard hides author on shelf draft and published cards', () => { const buildItem = ( status: CreationWorkShelfItem['status'], @@ -1110,7 +1172,11 @@ test('CustomWorldWorkCard hides author on shelf draft and published cards', () = canDelete: false, canShare: false, badges: [ - { id: 'status', label: status === 'draft' ? 'è‰ç¨¿' : 'å·²å‘布', tone: 'neutral' }, + { + id: 'status', + label: status === 'draft' ? 'è‰ç¨¿' : 'å·²å‘布', + tone: 'neutral', + }, { id: 'type', label: '汪汪', tone: 'neutral' }, ], metrics: [], diff --git a/src/components/custom-world-home/creationWorkShelf.ts b/src/components/custom-world-home/creationWorkShelf.ts index 81300a53..70c03d55 100644 --- a/src/components/custom-world-home/creationWorkShelf.ts +++ b/src/components/custom-world-home/creationWorkShelf.ts @@ -125,6 +125,8 @@ export type CreationWorkShelfItem = { kind: CreationWorkShelfKind; status: CreationWorkShelfStatus; isGenerating?: boolean; + hasGenerationFailure?: boolean; + generationFailureSummary?: string; hasUnreadUpdate?: boolean; title: string; summary: string; @@ -145,6 +147,16 @@ export type CreationWorkShelfItem = { source: CreationWorkShelfSource; }; +export type CreationWorkShelfRuntimeState = { + isGenerating?: boolean; + hasGenerationFailure?: boolean; + generationFailureSummary?: string; + hasUnreadUpdate?: boolean; + suppressPersistedGenerating?: boolean; + titleOverride?: string; + summaryOverride?: string; +}; + export function buildCreationWorkShelfItems(params: { rpgItems: CustomWorldWorkSummary[]; rpgLibraryEntries?: CustomWorldLibraryEntry[]; @@ -191,7 +203,7 @@ export function buildCreationWorkShelfItems(params: { onDeleteVisualNovel?: (item: VisualNovelWorkSummary) => void; getItemState?: ( item: CreationWorkShelfItem, - ) => { isGenerating?: boolean; hasUnreadUpdate?: boolean } | null; + ) => CreationWorkShelfRuntimeState | null; }) { const { rpgItems, @@ -307,18 +319,24 @@ export function buildCreationWorkShelfItems(params: { .map((item) => { const state = getItemState?.(item); const persistedIsGenerating = isPersistedCreationWorkGenerating(item); - return state + const isGenerating = Boolean( + state?.isGenerating || + (!state?.suppressPersistedGenerating && persistedIsGenerating), + ); + return state || isGenerating ? { ...item, - isGenerating: Boolean(state.isGenerating || persistedIsGenerating), - hasUnreadUpdate: state.hasUnreadUpdate, + title: state?.titleOverride ?? item.title, + summary: state?.summaryOverride ?? item.summary, + isGenerating, + hasGenerationFailure: + state?.hasGenerationFailure ?? item.hasGenerationFailure, + generationFailureSummary: + state?.generationFailureSummary ?? + item.generationFailureSummary, + hasUnreadUpdate: state?.hasUnreadUpdate, } - : persistedIsGenerating - ? { - ...item, - isGenerating: true, - } - : item; + : item; }) .sort( (left, right) => @@ -327,7 +345,6 @@ export function buildCreationWorkShelfItems(params: { ); } - function mergeBarkBattleShelfSourceItems( items: readonly BarkBattleWorkSummary[], ): BarkBattleWorkSummary[] { @@ -376,8 +393,8 @@ function mapRpgWorkToShelfItem( : null; const publicWorkCode = item.status === 'published' - ? (libraryEntry?.publicWorkCode?.trim() || - (item.profileId ? buildCustomWorldPublicWorkCode(item.profileId) : null)) + ? libraryEntry?.publicWorkCode?.trim() || + (item.profileId ? buildCustomWorldPublicWorkCode(item.profileId) : null) : null; const badges: CreationWorkShelfBadge[] = [ buildStatusBadge(item.status), @@ -843,7 +860,9 @@ function mapWoodenFishWorkToShelfItem( ): CreationWorkShelfItem { const status = item.publicationStatus === 'published' ? 'published' : 'draft'; const publicWorkCode = - status === 'published' ? buildWoodenFishPublicWorkCode(item.profileId) : null; + status === 'published' + ? buildWoodenFishPublicWorkCode(item.profileId) + : null; const title = item.workTitle.trim() || '敲木鱼'; const summary = item.workDescription.trim() || (status === 'draft' ? 'æœªå¡«å†™ä½œå“æè¿°' : ''); @@ -884,10 +903,7 @@ function mapWoodenFishWorkToShelfItem( }; } - -function resolveAuthorDisplayName( - ...sources: Array -) { +function resolveAuthorDisplayName(...sources: Array) { for (const source of sources) { const authorDisplayName = source && @@ -961,7 +977,8 @@ export function resolvePuzzleLevelCoverImageSrc( const fallbackCandidateImageSrc = normalizeCoverImageSrc( level.candidates[level.candidates.length - 1]?.imageSrc, ); - const candidateImageSrc = selectedCandidateImageSrc || fallbackCandidateImageSrc; + const candidateImageSrc = + selectedCandidateImageSrc || fallbackCandidateImageSrc; if ( candidateImageSrc && @@ -984,7 +1001,9 @@ function resolveMatch3DWorkCoverImageSrc(item: Match3DWorkSummary) { const topLevelContainerImageSrc = normalizeCoverImageSrc(item.generatedBackgroundAsset?.containerImageSrc) || - normalizeCoverImageSrc(item.generatedBackgroundAsset?.containerImageObjectKey); + normalizeCoverImageSrc( + item.generatedBackgroundAsset?.containerImageObjectKey, + ); if (topLevelContainerImageSrc) { return topLevelContainerImageSrc; } diff --git a/src/components/platform-entry/PlatformEntryFlowShellImpl.tsx b/src/components/platform-entry/PlatformEntryFlowShellImpl.tsx index 1df46027..ba1edf6a 100644 --- a/src/components/platform-entry/PlatformEntryFlowShellImpl.tsx +++ b/src/components/platform-entry/PlatformEntryFlowShellImpl.tsx @@ -77,6 +77,7 @@ import type { PuzzleWorkSummary } from '../../../packages/shared/src/contracts/p import type { CustomWorldGalleryCard, CustomWorldLibraryEntry, + ProfileDashboardSummary, ProfilePlayedWorkSummary, ProfilePlayStatsResponse, ProfileSaveArchiveResumeResponse, @@ -447,11 +448,12 @@ type AgentResultPublishGateView = { blockers: string[]; publishReady: boolean; }; -type DraftGenerationNoticeStatus = 'generating' | 'ready'; +type DraftGenerationNoticeStatus = 'generating' | 'ready' | 'failed'; type DraftGenerationNotice = { status: DraftGenerationNoticeStatus; seen: boolean; completedAtMs?: number; + message?: string; }; type DraftGenerationNoticeMap = Record; type CreationWorkShelfKind = CreationWorkShelfItem['kind']; @@ -459,6 +461,8 @@ type PendingDraftShelfState = { status: DraftGenerationNoticeStatus; seen: boolean; updatedAt: string; + title?: string; + summary?: string; }; type PendingDraftShelfMap = Partial< Record< @@ -1960,6 +1964,39 @@ function buildJumpHopCreationUrlState(params: { }; } +function buildJumpHopPendingSession( + item: JumpHopWorkSummaryResponse, +): JumpHopSessionSnapshotResponse { + const sessionId = + normalizeCreationUrlValue(item.sourceSessionId) ?? item.profileId; + return { + sessionId, + ownerUserId: item.ownerUserId, + status: item.generationStatus, + draft: { + templateId: 'jump-hop', + templateName: '跳一跳', + profileId: item.profileId, + workTitle: item.workTitle, + workDescription: item.workDescription, + themeTags: item.themeTags, + difficulty: item.difficulty, + stylePreset: item.stylePreset, + characterPrompt: '', + tilePrompt: '', + endMoodPrompt: null, + characterAsset: null, + tileAtlasAsset: null, + tileAssets: [], + path: null, + coverComposite: item.coverImageSrc, + generationStatus: item.generationStatus, + }, + createdAt: item.updatedAt, + updatedAt: item.updatedAt, + }; +} + function buildWoodenFishCreationUrlState(params: { session?: WoodenFishSessionSnapshotResponse | null; work?: WoodenFishWorkProfileResponse | null; @@ -2069,15 +2106,37 @@ function normalizeDraftNoticeId(id: string | null | undefined) { return id?.trim() || null; } +function normalizePendingDraftShelfLookupId( + kind: Exclude, + id: string | null | undefined, +) { + const normalizedId = normalizeDraftNoticeId(id); + if (!normalizedId) { + return null; + } + + const noticePrefix = `${kind}:`; + if (!normalizedId.startsWith(noticePrefix)) { + return normalizedId; + } + + return normalizeDraftNoticeId(normalizedId.slice(noticePrefix.length)); +} + function createPendingDraftShelfState( status: DraftGenerationNoticeStatus, seen = false, updatedAt = new Date().toISOString(), + metadata?: { title?: string | null; summary?: string | null }, ): PendingDraftShelfState { + const title = metadata?.title?.trim(); + const summary = metadata?.summary?.trim(); return { status, seen, updatedAt, + ...(title ? { title } : {}), + ...(summary ? { summary } : {}), }; } @@ -2096,6 +2155,46 @@ function formatPlatformTaskCompletionSource(label: string, id?: string | null) { return normalizedId ? `${label} ${normalizedId}` : label; } +function isBackgroundGenerationStillRunningMessage(message: string) { + return /ä»åœ¨åŽå°å¤„ç†|åŽå°ä»åœ¨å¤„ç†|ä»åœ¨ç”Ÿæˆ|åŽå°ç”Ÿæˆ/u.test(message); +} + +function buildDraftFailedShelfSummary(kind: CreationWorkShelfKind) { + switch (kind) { + case 'puzzle': + return '拼图è‰ç¨¿ç”Ÿæˆå¤±è´¥ï¼Œå¯é‡æ–°æ‰“开处ç†ã€‚'; + case 'match3d': + return '玩法素æç”Ÿæˆå¤±è´¥ï¼Œå¯é‡æ–°æ‰“开处ç†ã€‚'; + case 'big-fish': + return 'è‰ç¨¿ç”Ÿæˆå¤±è´¥ï¼Œå¯é‡æ–°æ‰“开处ç†ã€‚'; + case 'square-hole': + return '挑战素æç”Ÿæˆå¤±è´¥ï¼Œå¯é‡æ–°æ‰“开处ç†ã€‚'; + case 'jump-hop': + return '跳一跳玩法è‰ç¨¿ç”Ÿæˆå¤±è´¥ï¼Œå¯é‡æ–°æ‰“开处ç†ã€‚'; + case 'wooden-fish': + return '敲木鱼è‰ç¨¿ç”Ÿæˆå¤±è´¥ï¼Œå¯é‡æ–°æ‰“开处ç†ã€‚'; + case 'visual-novel': + return '视觉å°è¯´è‰ç¨¿ç”Ÿæˆå¤±è´¥ï¼Œå¯é‡æ–°æ‰“开处ç†ã€‚'; + case 'bark-battle': + return '声浪竞技素æç”Ÿæˆå¤±è´¥ï¼Œå¯é‡æ–°æ‰“开处ç†ã€‚'; + case 'baby-object-match': + return 'å®è´è¯†ç‰©è‰ç¨¿ç”Ÿæˆå¤±è´¥ï¼Œå¯é‡æ–°æ‰“开处ç†ã€‚'; + default: + return 'è‰ç¨¿ç”Ÿæˆå¤±è´¥ï¼Œå¯é‡æ–°æ‰“开处ç†ã€‚'; + } +} + +function isDraftShelfSummaryPlaceholder(value: string | null | undefined) { + const normalized = value?.trim(); + if (!normalized) { + return true; + } + + return /^(正在生æˆ|.*生æˆå¤±è´¥ï¼Œå¯é‡æ–°æ‰“开处ç†ã€‚$|æœªå¡«å†™ä½œå“æè¿°$)/u.test( + normalized, + ); +} + function buildPlatformErrorDialogDismissKey( error: (PlatformErrorDialogPayload & { key: string }) | null, ) { @@ -2165,32 +2264,119 @@ function buildDraftCompletionDialogSource( function createMiniGameDraftGenerationStateForRestoredDraft( kind: MiniGameDraftGenerationKind, metadata?: MiniGameDraftGenerationState['metadata'], + startedAtMs = Date.now(), ): MiniGameDraftGenerationState { return { - ...createMiniGameDraftGenerationState(kind), + ...createMiniGameDraftGenerationState(kind, startedAtMs), ...(metadata ? { metadata } : {}), }; } +function createFailedMiniGameDraftGenerationStateForRestoredDraft( + kind: MiniGameDraftGenerationKind, + updatedAt: string | null | undefined, + error: string, + metadata?: MiniGameDraftGenerationState['metadata'], +): MiniGameDraftGenerationState { + return resolveFinishedMiniGameDraftGenerationState( + createMiniGameDraftGenerationStateForRestoredDraft( + kind, + metadata, + resolveMiniGameDraftGenerationStartedAtMs(updatedAt), + ), + 'failed', + { error }, + ); +} + +function buildPuzzleFormPayloadFromWork( + item: PuzzleWorkSummary, +): CreatePuzzleAgentSessionRequest { + const pictureDescription = + item.workDescription?.trim() || + item.summary?.trim() || + item.levels?.[0]?.pictureDescription?.trim() || + item.levelName?.trim() || + item.workTitle?.trim() || + ''; + + return { + seedText: pictureDescription, + workTitle: item.workTitle?.trim() || item.levelName?.trim() || undefined, + workDescription: item.workDescription?.trim() || item.summary?.trim(), + pictureDescription, + referenceImageSrc: null, + referenceImageSrcs: [], + referenceImageAssetObjectId: null, + referenceImageAssetObjectIds: [], + imageModel: null, + aiRedraw: true, + }; +} + +function parseOptionalFiniteNumber(value: string | number | null | undefined) { + if (typeof value === 'number') { + return Number.isFinite(value) ? value : undefined; + } + + const normalizedValue = value?.trim(); + if (!normalizedValue) { + return undefined; + } + + const parsedValue = Number(normalizedValue); + return Number.isFinite(parsedValue) ? parsedValue : undefined; +} + +function buildMatch3DFormPayloadFromSession( + session: Match3DAgentSessionSnapshot, +): CreateMatch3DSessionRequest { + const themeText = + session.config?.themeText?.trim() || + session.draft?.themeText?.trim() || + session.anchorPack.theme.value.trim() || + ''; + + return { + seedText: themeText, + themeText, + referenceImageSrc: + session.config?.referenceImageSrc ?? session.draft?.referenceImageSrc ?? null, + clearCount: + session.config?.clearCount ?? + session.draft?.clearCount ?? + parseOptionalFiniteNumber(session.anchorPack.clearCount.value) ?? + undefined, + difficulty: + session.config?.difficulty ?? + session.draft?.difficulty ?? + parseOptionalFiniteNumber(session.anchorPack.difficulty.value) ?? + undefined, + assetStyleId: session.config?.assetStyleId ?? null, + assetStyleLabel: session.config?.assetStyleLabel ?? null, + assetStylePrompt: session.config?.assetStylePrompt ?? null, + generateClickSound: session.config?.generateClickSound, + }; +} + +function buildMatch3DFormPayloadFromWork( + item: Match3DWorkSummary, +): CreateMatch3DSessionRequest { + const themeText = item.themeText?.trim() || item.gameName?.trim() || ''; + return { + seedText: themeText, + themeText, + referenceImageSrc: item.referenceImageSrc ?? null, + clearCount: item.clearCount, + difficulty: item.difficulty, + }; +} + function rebaseMiniGameDraftGenerationStateForDisplay( state: MiniGameDraftGenerationState, ): MiniGameDraftGenerationState { - const rebasedStartedAtMs = Date.now(); - - if (state.kind === 'puzzle') { - const puzzleAiRedraw = state.metadata?.puzzleAiRedraw; - return { - ...state, - startedAtMs: rebasedStartedAtMs, - finishedAtMs: undefined, - metadata: - typeof puzzleAiRedraw === 'boolean' ? { puzzleAiRedraw } : undefined, - }; - } - return { ...state, - startedAtMs: rebasedStartedAtMs, finishedAtMs: undefined, }; } @@ -2458,6 +2644,11 @@ function isPersistedDraftGenerating(value: string | null | undefined) { return value?.trim() === 'generating'; } +function isPersistedDraftFailed(value: string | null | undefined) { + const normalized = value?.trim(); + return normalized === 'failed' || normalized === 'partial_failed'; +} + function resolveProfileWalletBalance( dashboard: { walletBalance?: number | null } | null | undefined, ) { @@ -2467,6 +2658,51 @@ function resolveProfileWalletBalance( : 0; } +function adjustProfileDashboardWalletBalance( + dashboard: ProfileDashboardSummary | null, + delta: number, +): ProfileDashboardSummary | null { + if (!dashboard || !Number.isFinite(delta) || delta === 0) { + return dashboard; + } + + return { + ...dashboard, + walletBalance: Math.max( + 0, + resolveProfileWalletBalance(dashboard) + Math.trunc(delta), + ), + updatedAt: new Date().toISOString(), + }; +} + +function reconcileProfileWalletLocalDeltaWithServerDashboard( + previousDashboard: ProfileDashboardSummary | null, + latestDashboard: ProfileDashboardSummary | null, + localDelta: number, +) { + if ( + !previousDashboard || + !latestDashboard || + !Number.isFinite(localDelta) || + localDelta === 0 + ) { + return Number.isFinite(localDelta) ? Math.trunc(localDelta) : 0; + } + + const previousBalance = resolveProfileWalletBalance(previousDashboard); + const latestBalance = resolveProfileWalletBalance(latestDashboard); + const normalizedDelta = Math.trunc(localDelta); + + if (normalizedDelta < 0) { + const reflectedDebit = Math.max(0, previousBalance - latestBalance); + return Math.min(0, normalizedDelta + reflectedDebit); + } + + const reflectedCredit = Math.max(0, latestBalance - previousBalance); + return Math.max(0, normalizedDelta - reflectedCredit); +} + function buildPendingBigFishWorks( pending: Record | undefined, existingItems: readonly BigFishWorkSummary[], @@ -2479,27 +2715,32 @@ function buildPendingBigFishWorks( .filter(([sessionId]) => existingItems.every((item) => item.sourceSessionId !== sessionId), ) - .map(([sessionId, state]) => ({ - workId: `big-fish-work-${sessionId}`, - sourceSessionId: sessionId, - ownerUserId: '', - authorDisplayName: '', - title: '大鱼åƒå°é±¼è‰ç¨¿', - subtitle: 'è‰ç¨¿ç”Ÿæˆä¸­', - summary: '正在生æˆçŽ©æ³•è‰ç¨¿ã€‚', - coverImageSrc: null, - status: 'draft', - updatedAt: state.updatedAt, - publishedAt: null, - publishReady: false, - levelCount: 0, - levelMainImageReadyCount: 0, - levelMotionReadyCount: 0, - backgroundReady: false, - playCount: 0, - remixCount: 0, - likeCount: 0, - })); + .map(([sessionId, state]) => { + const isFailed = state.status === 'failed'; + return { + workId: `big-fish-work-${sessionId}`, + sourceSessionId: sessionId, + ownerUserId: '', + authorDisplayName: '', + title: '大鱼åƒå°é±¼è‰ç¨¿', + subtitle: isFailed ? '生æˆå¤±è´¥å¾…é‡è¯•' : 'è‰ç¨¿ç”Ÿæˆä¸­', + summary: isFailed + ? 'è‰ç¨¿ç”Ÿæˆå¤±è´¥ï¼Œå¯é‡æ–°æ‰“开处ç†ã€‚' + : '正在生æˆçŽ©æ³•è‰ç¨¿ã€‚', + coverImageSrc: null, + status: 'draft', + updatedAt: state.updatedAt, + publishedAt: null, + publishReady: false, + levelCount: 0, + levelMainImageReadyCount: 0, + levelMotionReadyCount: 0, + backgroundReady: false, + playCount: 0, + remixCount: 0, + likeCount: 0, + }; + }); } function buildPendingJumpHopWorks( @@ -2514,25 +2755,36 @@ function buildPendingJumpHopWorks( .filter(([sessionId]) => existingItems.every((item) => item.sourceSessionId !== sessionId), ) - .map(([sessionId, state]) => ({ - runtimeKind: 'jump-hop', - workId: `jump-hop-work-${sessionId}`, - profileId: `jump-hop-profile-${sessionId}`, - ownerUserId: '', - sourceSessionId: sessionId, - workTitle: '跳一跳è‰ç¨¿', - workDescription: '正在生æˆè·³ä¸€è·³çŽ©æ³•è‰ç¨¿ã€‚', - themeTags: [], - difficulty: 'standard', - stylePreset: 'minimal-blocks', - coverImageSrc: null, - publicationStatus: 'draft', - playCount: 0, - updatedAt: state.updatedAt, - publishedAt: null, - publishReady: false, - generationStatus: state.status === 'generating' ? 'generating' : 'ready', - })); + .map(([sessionId, state]) => { + const generationStatus = + state.status === 'failed' + ? 'failed' + : state.status === 'generating' + ? 'generating' + : 'ready'; + return { + runtimeKind: 'jump-hop', + workId: `jump-hop-work-${sessionId}`, + profileId: `jump-hop-profile-${sessionId}`, + ownerUserId: '', + sourceSessionId: sessionId, + workTitle: '跳一跳è‰ç¨¿', + workDescription: + state.status === 'failed' + ? '跳一跳玩法è‰ç¨¿ç”Ÿæˆå¤±è´¥ï¼Œå¯é‡æ–°æ‰“开处ç†ã€‚' + : '正在生æˆè·³ä¸€è·³çŽ©æ³•è‰ç¨¿ã€‚', + themeTags: [], + difficulty: 'standard', + stylePreset: 'minimal-blocks', + coverImageSrc: null, + publicationStatus: 'draft', + playCount: 0, + updatedAt: state.updatedAt, + publishedAt: null, + publishReady: false, + generationStatus, + }; + }); } function buildPendingWoodenFishWorks( @@ -2547,23 +2799,34 @@ function buildPendingWoodenFishWorks( .filter(([sessionId]) => existingItems.every((item) => item.sourceSessionId !== sessionId), ) - .map(([sessionId, state]) => ({ - runtimeKind: 'wooden-fish', - workId: `wooden-fish-work-${sessionId}`, - profileId: sessionId, - ownerUserId: '', - sourceSessionId: sessionId, - workTitle: '敲木鱼è‰ç¨¿', - workDescription: 'æ­£åœ¨ç”Ÿæˆæ•²æœ¨é±¼è‰ç¨¿ã€‚', - themeTags: ['敲木鱼'], - coverImageSrc: null, - publicationStatus: 'draft', - playCount: 0, - updatedAt: state.updatedAt, - publishedAt: null, - publishReady: false, - generationStatus: state.status === 'generating' ? 'generating' : 'ready', - })); + .map(([sessionId, state]) => { + const generationStatus = + state.status === 'failed' + ? 'failed' + : state.status === 'generating' + ? 'generating' + : 'ready'; + return { + runtimeKind: 'wooden-fish', + workId: `wooden-fish-work-${sessionId}`, + profileId: sessionId, + ownerUserId: '', + sourceSessionId: sessionId, + workTitle: '敲木鱼è‰ç¨¿', + workDescription: + state.status === 'failed' + ? '敲木鱼è‰ç¨¿ç”Ÿæˆå¤±è´¥ï¼Œå¯é‡æ–°æ‰“开处ç†ã€‚' + : 'æ­£åœ¨ç”Ÿæˆæ•²æœ¨é±¼è‰ç¨¿ã€‚', + themeTags: ['敲木鱼'], + coverImageSrc: null, + publicationStatus: 'draft', + playCount: 0, + updatedAt: state.updatedAt, + publishedAt: null, + publishReady: false, + generationStatus, + }; + }); } function buildPendingMatch3DWorks( @@ -2578,27 +2841,39 @@ function buildPendingMatch3DWorks( .filter(([sessionId]) => existingItems.every((item) => item.sourceSessionId !== sessionId), ) - .map(([sessionId, state]) => ({ - workId: `match3d-work-${sessionId}`, - profileId: sessionId, - ownerUserId: '', - sourceSessionId: sessionId, - gameName: '抓大鹅è‰ç¨¿', - themeText: '', - summary: '正在生æˆçŽ©æ³•ç´ æã€‚', - tags: [], - coverImageSrc: null, - referenceImageSrc: null, - clearCount: 0, - difficulty: 0, - publicationStatus: 'draft', - playCount: 0, - updatedAt: state.updatedAt, - publishedAt: null, - publishReady: false, - generationStatus: state.status === 'generating' ? 'generating' : 'ready', - generatedItemAssets: [], - })); + .map(([sessionId, state]) => { + const themeText = state.summary?.trim() || state.title?.trim() || ''; + const fallbackSummary = + state.status === 'failed' + ? '玩法素æç”Ÿæˆå¤±è´¥ï¼Œå¯é‡æ–°æ‰“开处ç†ã€‚' + : '正在生æˆçŽ©æ³•ç´ æã€‚'; + return { + workId: `match3d-work-${sessionId}`, + profileId: sessionId, + ownerUserId: '', + sourceSessionId: sessionId, + gameName: '抓大鹅è‰ç¨¿', + themeText, + summary: themeText || fallbackSummary, + tags: [], + coverImageSrc: null, + referenceImageSrc: null, + clearCount: 0, + difficulty: 0, + publicationStatus: 'draft', + playCount: 0, + updatedAt: state.updatedAt, + publishedAt: null, + publishReady: false, + generationStatus: + state.status === 'failed' + ? 'failed' + : state.status === 'generating' + ? 'generating' + : 'ready', + generatedItemAssets: [], + }; + }); } function buildPendingSquareHoleWorks( @@ -2621,7 +2896,10 @@ function buildPendingSquareHoleWorks( gameName: '方洞挑战è‰ç¨¿', themeText: '', twistRule: '', - summary: 'æ­£åœ¨ç”ŸæˆæŒ‘战素æã€‚', + summary: + state.status === 'failed' + ? '挑战素æç”Ÿæˆå¤±è´¥ï¼Œå¯é‡æ–°æ‰“开处ç†ã€‚' + : 'æ­£åœ¨ç”ŸæˆæŒ‘战素æã€‚', tags: [], coverImageSrc: null, backgroundPrompt: '', @@ -2653,6 +2931,12 @@ function buildPendingPuzzleWorks( .map(([sessionId, state]) => { const profileId = buildPuzzleResultProfileId(sessionId) ?? `puzzle-profile-${sessionId}`; + const title = state.title?.trim() || '拼图è‰ç¨¿'; + const summary = + state.summary?.trim() || + (state.status === 'failed' + ? '拼图è‰ç¨¿ç”Ÿæˆå¤±è´¥ï¼Œå¯é‡æ–°æ‰“开处ç†ã€‚' + : 'æ­£åœ¨ç”Ÿæˆæ‹¼å›¾è‰ç¨¿ã€‚'); return { workId: buildPuzzleResultWorkId(sessionId) ?? `puzzle-work-${sessionId}`, @@ -2660,10 +2944,10 @@ function buildPendingPuzzleWorks( ownerUserId: '', sourceSessionId: sessionId, authorDisplayName: '', - workTitle: '拼图è‰ç¨¿', - workDescription: 'æ­£åœ¨ç”Ÿæˆæ‹¼å›¾è‰ç¨¿ã€‚', - levelName: '拼图è‰ç¨¿', - summary: 'æ­£åœ¨ç”Ÿæˆæ‹¼å›¾è‰ç¨¿ã€‚', + workTitle: title, + workDescription: summary, + levelName: title, + summary, themeTags: [], coverImageSrc: null, coverAssetId: null, @@ -2675,7 +2959,11 @@ function buildPendingPuzzleWorks( likeCount: 0, publishReady: false, generationStatus: - state.status === 'generating' ? 'generating' : 'ready', + state.status === 'generating' + ? 'generating' + : state.status === 'failed' + ? 'failed' + : 'ready', levels: [], }; }); @@ -2698,7 +2986,10 @@ function buildPendingVisualNovelWorks( profileId, ownerUserId: '', title: '视觉å°è¯´è‰ç¨¿', - description: '正在生æˆè§†è§‰å°è¯´è‰ç¨¿ã€‚', + description: + state.status === 'failed' + ? '视觉å°è¯´è‰ç¨¿ç”Ÿæˆå¤±è´¥ï¼Œå¯é‡æ–°æ‰“开处ç†ã€‚' + : '正在生æˆè§†è§‰å°è¯´è‰ç¨¿ã€‚', coverImageSrc: null, tags: [], publishStatus: 'draft', @@ -2727,7 +3018,10 @@ function buildPendingBarkBattleWorks( ownerUserId: '', authorDisplayName: '', title: '汪汪声浪è‰ç¨¿', - summary: '正在生æˆå£°æµªç«žæŠ€ç´ æã€‚', + summary: + state.status === 'failed' + ? '声浪竞技素æç”Ÿæˆå¤±è´¥ï¼Œå¯é‡æ–°æ‰“开处ç†ã€‚' + : '正在生æˆå£°æµªç«žæŠ€ç´ æã€‚', themeDescription: '', playerImageDescription: '', opponentImageDescription: '', @@ -2738,7 +3032,11 @@ function buildPendingBarkBattleWorks( difficultyPreset: 'normal', status: 'draft', generationStatus: - state.status === 'generating' ? 'pending_assets' : 'ready', + state.status === 'generating' + ? 'pending_assets' + : state.status === 'failed' + ? 'partial_failed' + : 'ready', publishReady: false, playCount: 0, updatedAt: state.updatedAt, @@ -2751,10 +3049,14 @@ function buildPuzzleCompileActionFromFormPayload( ): PuzzleAgentActionRequest { const pictureDescription = payload?.pictureDescription?.trim() || payload?.seedText?.trim(); + const workTitle = payload?.workTitle?.trim(); + const workDescription = payload?.workDescription?.trim() || pictureDescription; return { action: 'compile_puzzle_draft', promptText: pictureDescription, + ...(workTitle ? { workTitle } : {}), + ...(workDescription ? { workDescription } : {}), ...(pictureDescription ? { pictureDescription } : {}), referenceImageSrc: payload?.referenceImageSrc || null, referenceImageSrcs: payload?.referenceImageSrcs ?? [], @@ -2776,9 +3078,18 @@ function buildPuzzleFormPayloadFromSession( session.anchorPack.visualSubject.value.trim() || session.seedText?.trim() || ''; + const workTitle = + formDraft?.workTitle?.trim() || session.draft?.workTitle?.trim(); + const workDescription = + formDraft?.workDescription?.trim() || + session.draft?.workDescription?.trim() || + session.draft?.summary?.trim() || + pictureDescription; return { seedText: pictureDescription, + ...(workTitle ? { workTitle } : {}), + ...(workDescription ? { workDescription } : {}), pictureDescription, referenceImageSrc: null, referenceImageSrcs: [], @@ -2789,6 +3100,29 @@ function buildPuzzleFormPayloadFromSession( }; } +function buildPendingPuzzleDraftMetadata( + payload: CreatePuzzleAgentSessionRequest | null | undefined, +) { + const title = payload?.workTitle?.trim(); + const summary = + payload?.workDescription?.trim() || + payload?.pictureDescription?.trim() || + payload?.seedText?.trim(); + return { + ...(title ? { title } : {}), + ...(summary ? { summary } : {}), + }; +} + +function buildPendingMatch3DDraftMetadata( + payload: CreateMatch3DSessionRequest | null | undefined, +) { + const themeText = payload?.themeText?.trim() || payload?.seedText?.trim(); + return { + ...(themeText ? { title: themeText, summary: themeText } : {}), + }; +} + function buildPuzzleFormPayloadFromAction( payload: PuzzleAgentActionRequest, ): CreatePuzzleAgentSessionRequest | null { @@ -3531,6 +3865,9 @@ export function PlatformEntryFlowShellImpl({ useState({}); const [pendingDraftShelfItems, setPendingDraftShelfItems] = useState({}); + const profileWalletLocalDeltaRef = useRef(0); + const lastProfileDashboardSnapshotRef = + useRef(null); const [ pendingPlatformTaskCompletionDialog, setPendingPlatformTaskCompletionDialog, @@ -3541,6 +3878,16 @@ export function PlatformEntryFlowShellImpl({ }) | null >(null); + const [ + pendingPlatformTaskFailureDialog, + setPendingPlatformTaskFailureDialog, + ] = useState< + | (PlatformErrorDialogPayload & { + key: string; + failedAtMs: number; + }) + | null + >(null); const [profileTaskRefreshKey, setProfileTaskRefreshKey] = useState(0); const [initialCreationUrlState] = useState(() => readCreationUrlState()); const handledInitialCreationUrlStateRef = useRef(false); @@ -3569,6 +3916,7 @@ export function PlatformEntryFlowShellImpl({ id: string | null | undefined, status: DraftGenerationNoticeStatus, seen = false, + metadata?: { title?: string | null; summary?: string | null }, ) => { const normalizedId = normalizeDraftNoticeId(id); if (!normalizedId) { @@ -3577,10 +3925,18 @@ export function PlatformEntryFlowShellImpl({ setPendingDraftShelfItems((current) => ({ ...current, - [kind]: { - ...(current[kind] ?? {}), - [normalizedId]: createPendingDraftShelfState(status, seen), - }, + [kind]: (() => { + const currentItems = current[kind] ?? {}; + return { + ...currentItems, + [normalizedId]: createPendingDraftShelfState( + status, + seen, + new Date().toISOString(), + metadata ?? currentItems[normalizedId], + ), + }; + })(), })); }, [], @@ -3613,20 +3969,35 @@ export function PlatformEntryFlowShellImpl({ ); const updateDraftGenerationNotices = useCallback( - (keys: string[], status: DraftGenerationNoticeStatus, seen = false) => { + ( + keys: string[], + status: DraftGenerationNoticeStatus, + seen = false, + message?: string | null, + ) => { const uniqueKeys = Array.from(new Set(keys.filter(Boolean))); if (uniqueKeys.length === 0) { return; } const completedAtMs = status === 'ready' ? Date.now() : undefined; + const normalizedMessage = message?.trim(); setDraftGenerationNotices((current) => { const next = { ...current }; for (const key of uniqueKeys) { next[key] = completedAtMs === undefined - ? { status, seen } - : { status, seen, completedAtMs }; + ? { + status, + seen, + ...(normalizedMessage ? { message: normalizedMessage } : {}), + } + : { + status, + seen, + completedAtMs, + ...(normalizedMessage ? { message: normalizedMessage } : {}), + }; } return next; }); @@ -3666,15 +4037,38 @@ export function PlatformEntryFlowShellImpl({ }, [draftGenerationNotices], ); + const getPendingDraftShelfState = useCallback( + (kind: Exclude, keys: string[]) => { + const entries = pendingDraftShelfItems[kind]; + if (!entries) { + return null; + } + + for (const key of keys) { + const normalizedKey = normalizePendingDraftShelfLookupId(kind, key); + const pending = normalizedKey ? entries[normalizedKey] : null; + if (pending) { + return pending; + } + } + return null; + }, + [pendingDraftShelfItems], + ); const markDraftGenerating = useCallback( (kind: CreationWorkShelfKind, ids: Array) => { setPendingPlatformTaskCompletionDialog(null); + setPendingPlatformTaskFailureDialog(null); updateDraftGenerationNotices( collectDraftNoticeKeys(kind, ids), 'generating', ); }, - [setPendingPlatformTaskCompletionDialog, updateDraftGenerationNotices], + [ + setPendingPlatformTaskCompletionDialog, + setPendingPlatformTaskFailureDialog, + updateDraftGenerationNotices, + ], ); const markDraftReady = useCallback( ( @@ -3682,6 +4076,7 @@ export function PlatformEntryFlowShellImpl({ ids: Array, viewedImmediately: boolean, ) => { + setPendingPlatformTaskFailureDialog(null); updateDraftGenerationNotices( collectDraftNoticeKeys(kind, ids), 'ready', @@ -3696,17 +4091,54 @@ export function PlatformEntryFlowShellImpl({ completedAtMs, }); }, - [setPendingPlatformTaskCompletionDialog, updateDraftGenerationNotices], + [ + setPendingPlatformTaskCompletionDialog, + setPendingPlatformTaskFailureDialog, + updateDraftGenerationNotices, + ], + ); + const markDraftFailed = useCallback( + ( + kind: CreationWorkShelfKind, + ids: Array, + errorMessage?: string | null, + showFailureDialog = true, + ) => { + setPendingPlatformTaskCompletionDialog(null); + const noticeKeys = collectDraftNoticeKeys(kind, ids); + updateDraftGenerationNotices(noticeKeys, 'failed', false, errorMessage); + const normalizedErrorMessage = errorMessage?.trim(); + if (normalizedErrorMessage && showFailureDialog) { + const failedAtMs = Date.now(); + setPendingPlatformTaskFailureDialog({ + key: `draft-failure:${kind}:${noticeKeys.join('|')}:${failedAtMs}`, + source: buildDraftCompletionDialogSource(kind, ids), + message: normalizedErrorMessage, + failedAtMs, + }); + } + }, + [ + setPendingPlatformTaskCompletionDialog, + setPendingPlatformTaskFailureDialog, + updateDraftGenerationNotices, + ], ); const markPendingDraftGenerating = useCallback( ( kind: Exclude, id: string | null | undefined, + metadata?: { title?: string | null; summary?: string | null }, ) => { setPendingPlatformTaskCompletionDialog(null); - updatePendingDraftShelfItem(kind, id, 'generating'); + setPendingPlatformTaskFailureDialog(null); + updatePendingDraftShelfItem(kind, id, 'generating', false, metadata); }, - [setPendingPlatformTaskCompletionDialog, updatePendingDraftShelfItem], + [ + setPendingPlatformTaskCompletionDialog, + setPendingPlatformTaskFailureDialog, + updatePendingDraftShelfItem, + ], ); const markPendingDraftReady = useCallback( ( @@ -3718,6 +4150,15 @@ export function PlatformEntryFlowShellImpl({ }, [updatePendingDraftShelfItem], ); + const markPendingDraftFailed = useCallback( + ( + kind: Exclude, + id: string | null | undefined, + ) => { + updatePendingDraftShelfItem(kind, id, 'failed', false); + }, + [updatePendingDraftShelfItem], + ); const getMatch3DBackgroundCompileTask = useCallback( (sessionId: string | null | undefined) => { const normalizedSessionId = normalizeDraftNoticeId(sessionId); @@ -3758,13 +4199,60 @@ export function PlatformEntryFlowShellImpl({ }; }, []); + useEffect(() => { + profileWalletLocalDeltaRef.current = 0; + lastProfileDashboardSnapshotRef.current = null; + }, [authUi?.user?.id]); + + const getPlatformProfileDashboardWithLocalWalletDelta = useCallback( + async (options?: Parameters[0]) => { + const latestDashboard = await getPlatformProfileDashboard(options); + const reconciledDelta = + reconcileProfileWalletLocalDeltaWithServerDashboard( + lastProfileDashboardSnapshotRef.current, + latestDashboard, + profileWalletLocalDeltaRef.current, + ); + lastProfileDashboardSnapshotRef.current = latestDashboard; + profileWalletLocalDeltaRef.current = reconciledDelta; + return adjustProfileDashboardWalletBalance( + latestDashboard, + reconciledDelta, + ); + }, + [], + ); + const platformBootstrap = usePlatformEntryBootstrap({ user: authUi?.user, canAccessProtectedData: authUi?.canAccessProtectedData, - getProfileDashboard: getPlatformProfileDashboard, + getProfileDashboard: getPlatformProfileDashboardWithLocalWalletDelta, handleContinueGame, hasInitialAgentSession, }); + const { + canReadProtectedData: canRefreshPlatformDashboard, + refreshProfileDashboard, + } = platformBootstrap; + const refreshPlatformDashboardSilently = useCallback(() => { + if (!canRefreshPlatformDashboard) { + return; + } + void refreshProfileDashboard().catch(() => undefined); + }, [canRefreshPlatformDashboard, refreshProfileDashboard]); + const adjustProfileWalletBalanceLocally = useCallback( + (delta: number) => { + if (!Number.isFinite(delta) || delta === 0) { + return; + } + + profileWalletLocalDeltaRef.current += Math.trunc(delta); + platformBootstrap.setProfileDashboard((current) => + adjustProfileDashboardWalletBalance(current, delta), + ); + }, + [platformBootstrap], + ); const entryNavigation = usePlatformEntryNavigation({ setSelectionStage, setSelectedDetailEntry, @@ -3850,6 +4338,14 @@ export function PlatformEntryFlowShellImpl({ }, [draftGenerationNotices], ); + const isDraftNoticeFailed = useCallback( + (kind: CreationWorkShelfKind, ids: Array) => { + return collectDraftNoticeKeys(kind, ids).some( + (key) => draftGenerationNotices[key]?.status === 'failed', + ); + }, + [draftGenerationNotices], + ); const isDraftNoticeReadyUnread = useCallback( (kind: CreationWorkShelfKind, ids: Array) => { return collectDraftNoticeKeys(kind, ids).some((key) => { @@ -3862,7 +4358,7 @@ export function PlatformEntryFlowShellImpl({ const ensureEnoughDraftGenerationPointsFromServer = useCallback( async (pointsCost: number) => { try { - const latestDashboard = await getPlatformProfileDashboard( + const latestDashboard = await getPlatformProfileDashboardWithLocalWalletDelta( RECOMMEND_RUNTIME_BACKGROUND_AUTH_OPTIONS, ); platformBootstrap.setProfileDashboard(latestDashboard); @@ -3885,7 +4381,7 @@ export function PlatformEntryFlowShellImpl({ return false; } }, - [platformBootstrap], + [getPlatformProfileDashboardWithLocalWalletDelta, platformBootstrap], ); const resolveBigFishErrorMessage = useCallback( @@ -4700,9 +5196,52 @@ export function PlatformEntryFlowShellImpl({ ); const getCreationWorkShelfState = useCallback( (item: CreationWorkShelfItem) => { - const notice = getDraftGenerationNotice( - getGenerationNoticeShelfKeys(item), - ); + const noticeKeys = getGenerationNoticeShelfKeys(item); + const notice = getDraftGenerationNotice(noticeKeys); + if (notice?.status === 'failed') { + const failedSummary = buildDraftFailedShelfSummary(item.source.kind); + const pending = + item.source.kind === 'rpg' + ? null + : getPendingDraftShelfState(item.source.kind, noticeKeys); + const pendingSummary = pending?.summary?.trim(); + return { + isGenerating: false, + hasGenerationFailure: true, + generationFailureSummary: failedSummary, + hasUnreadUpdate: false, + suppressPersistedGenerating: true, + titleOverride: + item.source.kind === 'puzzle' && + item.status === 'draft' && + !item.source.item.workTitle?.trim() + ? '拼图è‰ç¨¿' + : undefined, + summaryOverride: isDraftShelfSummaryPlaceholder(item.summary) + ? (pendingSummary ?? failedSummary) + : undefined, + }; + } + if ( + item.source.kind === 'puzzle' && + isPersistedDraftFailed(item.source.item.generationStatus) + ) { + const failedSummary = buildDraftFailedShelfSummary('puzzle'); + return { + isGenerating: false, + hasGenerationFailure: true, + generationFailureSummary: failedSummary, + hasUnreadUpdate: false, + suppressPersistedGenerating: true, + titleOverride: + item.status === 'draft' && !item.source.item.workTitle?.trim() + ? '拼图è‰ç¨¿' + : undefined, + summaryOverride: isDraftShelfSummaryPlaceholder(item.summary) + ? failedSummary + : undefined, + }; + } const isNoticeGenerating = notice?.status === 'generating' && (item.source.kind !== 'puzzle' || @@ -4712,7 +5251,7 @@ export function PlatformEntryFlowShellImpl({ hasUnreadUpdate: notice?.status === 'ready' && !notice.seen, }; }, - [getDraftGenerationNotice], + [getDraftGenerationNotice, getPendingDraftShelfState], ); const visibleDraftNoticeKeys = useMemo( () => [ @@ -4831,9 +5370,17 @@ export function PlatformEntryFlowShellImpl({ ? puzzleGenerationState : selectionStage === 'match3d-generating' ? match3dGenerationState - : selectionStage === 'baby-object-match-generating' - ? babyObjectMatchGenerationState - : null; + : selectionStage === 'big-fish-generating' + ? bigFishGenerationState + : selectionStage === 'square-hole-generating' + ? squareHoleGenerationState + : selectionStage === 'jump-hop-generating' + ? jumpHopGenerationState + : selectionStage === 'wooden-fish-generating' + ? woodenFishGenerationState + : selectionStage === 'baby-object-match-generating' + ? babyObjectMatchGenerationState + : null; const shouldTickProgress = selectionStage === 'visual-novel-generating' ? visualNovelGenerationStartedAtMs != null && @@ -4855,11 +5402,15 @@ export function PlatformEntryFlowShellImpl({ return () => window.clearInterval(timerId); }, [ babyObjectMatchGenerationState, + bigFishGenerationState, + jumpHopGenerationState, match3dGenerationState, puzzleGenerationState, selectionStage, + squareHoleGenerationState, visualNovelGenerationPhase, visualNovelGenerationStartedAtMs, + woodenFishGenerationState, ]); const runProtectedAction = useCallback( @@ -5147,7 +5698,7 @@ export function PlatformEntryFlowShellImpl({ setSelectionStage('big-fish-generating'); setBigFishGenerationState(createMiniGameDraftGenerationState('big-fish')); }, - onActionError: ({ payload, errorMessage }) => { + onActionError: ({ payload, errorMessage, session }) => { if (payload.action !== 'big_fish_compile_draft') { return; } @@ -5158,6 +5709,13 @@ export function PlatformEntryFlowShellImpl({ }) : current, ); + markPendingDraftFailed('big-fish', session.sessionId); + markDraftFailed( + 'big-fish', + [`big-fish-work-${session.sessionId}`, session.sessionId], + errorMessage, + ); + void refreshBigFishShelf(); }, }); @@ -5299,6 +5857,29 @@ export function PlatformEntryFlowShellImpl({ if (payload.action !== 'match3d_compile_draft') { return; } + if (isBackgroundGenerationStillRunningMessage(errorMessage)) { + try { + const { session: latestSession } = + await match3dCreationClient.getSession(session.sessionId); + setSession(latestSession); + const profileId = + latestSession.draft?.profileId ?? latestSession.publishedProfileId; + if (profileId) { + const { item } = await getMatch3DWorkDetail(profileId); + setMatch3DProfile(normalizeMatch3DWorkForRuntimeUi(item)); + } + markPendingDraftGenerating('match3d', latestSession.sessionId); + markDraftGenerating('match3d', [ + latestSession.draft?.profileId, + latestSession.publishedProfileId, + latestSession.sessionId, + ]); + await refreshMatch3DShelf().catch(() => undefined); + return; + } catch { + await refreshMatch3DShelf().catch(() => undefined); + } + } setMatch3DGenerationState((current) => current ? resolveFinishedMiniGameDraftGenerationState(current, 'failed', { @@ -5306,6 +5887,12 @@ export function PlatformEntryFlowShellImpl({ }) : current, ); + markPendingDraftFailed('match3d', session.sessionId); + markDraftFailed( + 'match3d', + [session.draft?.profileId, session.publishedProfileId, session.sessionId], + errorMessage, + ); try { const { session: latestSession } = await match3dCreationClient.getSession(session.sessionId); @@ -5498,6 +6085,17 @@ export function PlatformEntryFlowShellImpl({ setSquareHoleProfile( buildSquareHoleProfileFromSession(response.session), ); + markPendingDraftFailed('square-hole', response.session.sessionId); + markDraftFailed( + 'square-hole', + [ + response.session.draft?.profileId, + response.session.publishedProfileId, + response.session.sessionId, + ], + errorMessage, + ); + void refreshSquareHoleShelf().catch(() => undefined); if (shouldOpenResult) { setSelectionStage('square-hole-result'); } @@ -5556,7 +6154,7 @@ export function PlatformEntryFlowShellImpl({ } } }, - onActionError: ({ payload, errorMessage }) => { + onActionError: ({ payload, errorMessage, session }) => { if ( payload.action === 'square_hole_compile_draft' || payload.action === 'square_hole_generate_visual_assets' @@ -5571,6 +6169,13 @@ export function PlatformEntryFlowShellImpl({ if (selectionStageRef.current === 'square-hole-generating') { setSelectionStage('square-hole-generating'); } + markPendingDraftFailed('square-hole', session.sessionId); + markDraftFailed( + 'square-hole', + [session.draft?.profileId, session.publishedProfileId, session.sessionId], + errorMessage, + ); + void refreshSquareHoleShelf().catch(() => undefined); } }, }); @@ -5630,8 +6235,12 @@ export function PlatformEntryFlowShellImpl({ if ( payload.action === 'publish_puzzle_work' || - payload.action === 'generate_puzzle_tags' + payload.action === 'generate_puzzle_tags' || + payload.action === 'compile_puzzle_draft' || + payload.action === 'generate_puzzle_images' || + payload.action === 'generate_puzzle_ui_background' ) { + refreshPlatformDashboardSilently(); await Promise.allSettled([ refreshPuzzleShelf(), refreshPuzzleGallery(), @@ -5750,7 +6359,13 @@ export function PlatformEntryFlowShellImpl({ session.publishedProfileId, buildPuzzleResultProfileId(session.sessionId), ]); - markPendingDraftGenerating('puzzle', session.sessionId); + markPendingDraftGenerating( + 'puzzle', + session.sessionId, + buildPendingPuzzleDraftMetadata( + formPayload ?? buildPuzzleFormPayloadFromSession(session), + ), + ); selectionStageRef.current = 'puzzle-generating'; activePuzzleGenerationSessionIdRef.current = session.sessionId; setSelectionStage('puzzle-generating'); @@ -5773,6 +6388,7 @@ export function PlatformEntryFlowShellImpl({ if (payload.action !== 'compile_puzzle_draft') { return; } + refreshPlatformDashboardSilently(); const formPayload = buildPuzzleFormPayloadFromAction(payload) ?? puzzleBackgroundCompileTasks[session.sessionId]?.payload ?? @@ -5804,6 +6420,18 @@ export function PlatformEntryFlowShellImpl({ error: errorMessage, }, })); + markPendingDraftFailed('puzzle', session.sessionId); + markDraftFailed( + 'puzzle', + [ + session.sessionId, + buildPuzzleResultWorkId(session.sessionId), + session.publishedProfileId, + buildPuzzleResultProfileId(session.sessionId), + ], + errorMessage, + ); + void refreshPuzzleShelf(); setPuzzleGenerationState((current) => current ? failedGenerationState : current, ); @@ -6121,6 +6749,11 @@ export function PlatformEntryFlowShellImpl({ source: string; message: string | null | undefined; }> = [ + { + key: pendingPlatformTaskFailureDialog?.key ?? 'draft-failure', + source: pendingPlatformTaskFailureDialog?.source ?? '创作è‰ç¨¿', + message: pendingPlatformTaskFailureDialog?.message, + }, { key: 'creation-entry-config', source: '创作入å£é…ç½®', @@ -6308,6 +6941,7 @@ export function PlatformEntryFlowShellImpl({ match3dGenerationViewSession?.sessionId, match3dRun?.runId, match3dSession?.sessionId, + pendingPlatformTaskFailureDialog, platformBootstrap.platformError, publicWorkDetailError, puzzleCreationError, @@ -6384,6 +7018,10 @@ export function PlatformEntryFlowShellImpl({ platformBootstrap.setPlatformError(null); return; } + if (currentPlatformErrorDialog.key.startsWith('draft-failure:')) { + setPendingPlatformTaskFailureDialog(null); + return; + } if (currentPlatformErrorDialog.key === 'rpg-creation-type') { sessionController.setCreationTypeError(null); return; @@ -6458,6 +7096,7 @@ export function PlatformEntryFlowShellImpl({ sessionController, setBigFishError, setMatch3DError, + setPendingPlatformTaskFailureDialog, setPuzzleError, setSquareHoleError, setVisualNovelError, @@ -6690,6 +7329,14 @@ export function PlatformEntryFlowShellImpl({ const openPuzzleWorkspace = useCallback(() => { markCreationFlowReturnToCreate(); + setPuzzleSession(null); + setPuzzleFormDraftPayload(null); + setPuzzleOperation(null); + setPuzzleGenerationState(null); + setPuzzleRun(null); + setSelectedPuzzleDetail(null); + setPuzzleRuntimeAuthMode('default'); + activePuzzleGenerationSessionIdRef.current = null; enterCreateTab(); setShowCreationTypeModal(false); setPuzzleCreationError(null); @@ -6697,6 +7344,7 @@ export function PlatformEntryFlowShellImpl({ setSelectionStage('puzzle-agent-workspace'); }, [ enterCreateTab, + setPuzzleSession, setPuzzleCreationError, setPuzzleError, setSelectionStage, @@ -6819,9 +7467,10 @@ export function PlatformEntryFlowShellImpl({ setPuzzleFormDraftPayload(payload); setPuzzleCreationError(null); setPuzzleError(null); + const shouldConsumePuzzleDraftPoints = payload.aiRedraw !== false; if ( - payload.aiRedraw !== false && + shouldConsumePuzzleDraftPoints && !(await preflightPuzzleDraftGeneration()) ) { return; @@ -6862,7 +7511,14 @@ export function PlatformEntryFlowShellImpl({ nextSession.publishedProfileId, buildPuzzleResultProfileId(nextSession.sessionId), ]); - markPendingDraftGenerating('puzzle', nextSession.sessionId); + markPendingDraftGenerating( + 'puzzle', + nextSession.sessionId, + buildPendingPuzzleDraftMetadata(payload), + ); + if (shouldConsumePuzzleDraftPoints) { + adjustProfileWalletBalanceLocally(-PUZZLE_DRAFT_GENERATION_POINT_COST); + } selectionStageRef.current = 'puzzle-generating'; activePuzzleGenerationSessionIdRef.current = nextSession.sessionId; setSelectionStage('puzzle-generating'); @@ -6969,12 +7625,16 @@ export function PlatformEntryFlowShellImpl({ if (recovered) { return; } + if (shouldConsumePuzzleDraftPoints) { + adjustProfileWalletBalanceLocally(PUZZLE_DRAFT_GENERATION_POINT_COST); + } const failedGenerationState = resolveFinishedMiniGameDraftGenerationState( generationState, 'failed', { error: errorMessage }, ); + const openFailure = isViewingPuzzleGeneration(nextSession.sessionId); setPuzzleBackgroundCompileTasks((current) => ({ ...current, [nextSession.sessionId]: { @@ -6984,15 +7644,33 @@ export function PlatformEntryFlowShellImpl({ error: errorMessage, }, })); - if (isViewingPuzzleGeneration(nextSession.sessionId)) { + markPendingDraftFailed('puzzle', nextSession.sessionId); + markDraftFailed( + 'puzzle', + [ + nextSession.sessionId, + buildPuzzleResultWorkId(nextSession.sessionId), + nextSession.publishedProfileId, + buildPuzzleResultProfileId(nextSession.sessionId), + ], + errorMessage, + !openFailure, + ); + void refreshPuzzleShelf(); + if (openFailure) { setPuzzleError(errorMessage); setPuzzleGenerationState(failedGenerationState); } + } finally { + refreshPlatformDashboardSilently(); } }, [ + adjustProfileWalletBalanceLocally, markDraftGenerating, + markDraftFailed, markDraftReady, + markPendingDraftFailed, markPendingDraftGenerating, markPendingDraftReady, isViewingPuzzleGeneration, @@ -7000,6 +7678,7 @@ export function PlatformEntryFlowShellImpl({ puzzleFlow, refreshPuzzleShelf, recoverCompletedPuzzleDraftGeneration, + refreshPlatformDashboardSilently, resolvePuzzleErrorMessage, setPuzzleError, setSelectionStage, @@ -7051,7 +7730,12 @@ export function PlatformEntryFlowShellImpl({ nextSession.publishedProfileId, nextSession.sessionId, ]); - markPendingDraftGenerating('match3d', nextSession.sessionId); + markPendingDraftGenerating( + 'match3d', + nextSession.sessionId, + buildPendingMatch3DDraftMetadata(payload), + ); + adjustProfileWalletBalanceLocally(-MATCH3D_DRAFT_GENERATION_POINT_COST); selectionStageRef.current = 'match3d-generating'; activeMatch3DGenerationSessionIdRef.current = nextSession.sessionId; setSelectionStage('match3d-generating'); @@ -7157,12 +7841,51 @@ export function PlatformEntryFlowShellImpl({ error, '执行抓大鹅æ“作失败。', ); + if (isBackgroundGenerationStillRunningMessage(errorMessage)) { + try { + const { session: latestSession } = + await match3dCreationClient.getSession(nextSession.sessionId); + markPendingDraftGenerating('match3d', latestSession.sessionId); + markDraftGenerating('match3d', [ + latestSession.draft?.profileId, + latestSession.publishedProfileId, + latestSession.sessionId, + ]); + setMatch3DBackgroundCompileTasks((current) => ({ + ...current, + [nextSession.sessionId]: { + session: latestSession, + payload, + generationState, + error: errorMessage, + }, + })); + if (isViewingMatch3DGeneration(nextSession.sessionId)) { + setMatch3DError(errorMessage); + setMatch3DSession(latestSession); + setMatch3DGenerationState(generationState); + const profileId = + latestSession.draft?.profileId ?? + latestSession.publishedProfileId; + if (profileId) { + const { item } = await getMatch3DWorkDetail(profileId); + setMatch3DProfile(normalizeMatch3DWorkForRuntimeUi(item)); + } + } + await refreshMatch3DShelf().catch(() => undefined); + return; + } catch { + await refreshMatch3DShelf().catch(() => undefined); + } + } + adjustProfileWalletBalanceLocally(MATCH3D_DRAFT_GENERATION_POINT_COST); const failedGenerationState = resolveFinishedMiniGameDraftGenerationState( generationState, 'failed', { error: errorMessage }, ); + const openFailure = isViewingMatch3DGeneration(nextSession.sessionId); setMatch3DBackgroundCompileTasks((current) => ({ ...current, [nextSession.sessionId]: { @@ -7172,13 +7895,34 @@ export function PlatformEntryFlowShellImpl({ error: errorMessage, }, })); - if (isViewingMatch3DGeneration(nextSession.sessionId)) { + markPendingDraftFailed('match3d', nextSession.sessionId); + markDraftFailed( + 'match3d', + [ + nextSession.draft?.profileId, + nextSession.publishedProfileId, + nextSession.sessionId, + ], + errorMessage, + !openFailure, + ); + if (openFailure) { setMatch3DError(errorMessage); setMatch3DGenerationState(failedGenerationState); } try { const { session: latestSession } = await match3dCreationClient.getSession(nextSession.sessionId); + markDraftFailed( + 'match3d', + [ + latestSession.draft?.profileId, + latestSession.publishedProfileId, + latestSession.sessionId, + ], + errorMessage, + !openFailure, + ); setMatch3DBackgroundCompileTasks((current) => ({ ...current, [nextSession.sessionId]: { @@ -7188,7 +7932,7 @@ export function PlatformEntryFlowShellImpl({ error: errorMessage, }, })); - if (isViewingMatch3DGeneration(nextSession.sessionId)) { + if (openFailure) { setMatch3DSession(latestSession); const profileId = latestSession.draft?.profileId ?? @@ -7202,17 +7946,23 @@ export function PlatformEntryFlowShellImpl({ } catch { await refreshMatch3DShelf().catch(() => undefined); } + } finally { + refreshPlatformDashboardSilently(); } }, [ + adjustProfileWalletBalanceLocally, match3dRuntimeAdapter, isViewingMatch3DGeneration, markDraftGenerating, + markDraftFailed, markDraftReady, + markPendingDraftFailed, markPendingDraftGenerating, markPendingDraftReady, preflightMatch3DDraftGeneration, refreshMatch3DShelf, + refreshPlatformDashboardSilently, resolveMatch3DErrorMessage, setIsStreamingMatch3DReply, setMatch3DError, @@ -7381,6 +8131,9 @@ export function PlatformEntryFlowShellImpl({ const response = await executePuzzleAgentAction(session.sessionId, { action: 'save_puzzle_form_draft', promptText: payload.pictureDescription ?? null, + workTitle: payload.workTitle, + workDescription: + payload.workDescription ?? payload.pictureDescription ?? '', pictureDescription: payload.pictureDescription ?? '', referenceImageSrc: payload.referenceImageSrc ?? null, referenceImageSrcs: payload.referenceImageSrcs ?? [], @@ -7502,6 +8255,7 @@ export function PlatformEntryFlowShellImpl({ setDraftGenerationNotices({}); setPendingDraftShelfItems({}); setPendingPlatformTaskCompletionDialog(null); + setPendingPlatformTaskFailureDialog(null); resetRpgSessionViewState(); setRpgGeneratedCustomWorldProfile(null); setRpgCustomWorldError(null); @@ -8699,7 +9453,10 @@ export function PlatformEntryFlowShellImpl({ created: JumpHopSessionResponse, payload?: JumpHopWorkspaceCreateRequest, ) => { - const generationState = createMiniGameDraftGenerationState('jump-hop'); + const generationState = createMiniGameDraftGenerationState( + 'jump-hop', + resolveMiniGameDraftGenerationStartedAtMs(created.session.updatedAt), + ); setJumpHopError(null); setJumpHopSession(created.session); writeCreationUrlState( @@ -8710,6 +9467,11 @@ export function PlatformEntryFlowShellImpl({ setJumpHopGenerationState(generationState); setIsJumpHopBusy(true); setSelectionStage('jump-hop-generating'); + markDraftGenerating('jump-hop', [ + created.session.sessionId, + created.session.draft?.profileId, + ]); + markPendingDraftGenerating('jump-hop', created.session.sessionId); try { const response = await jumpHopClient.executeAction( @@ -8777,10 +9539,22 @@ export function PlatformEntryFlowShellImpl({ { error: errorMessage }, ), ); + markPendingDraftFailed('jump-hop', created.session.sessionId); + markDraftFailed( + 'jump-hop', + [created.session.sessionId, created.session.draft?.profileId], + errorMessage, + ); + void refreshJumpHopShelf().catch(() => undefined); try { const latest = await jumpHopClient.getSession( created.session.sessionId, ); + markDraftFailed( + 'jump-hop', + [latest.session.sessionId, latest.session.draft?.profileId], + errorMessage, + ); setJumpHopSession(latest.session); setJumpHopWork(null); writeCreationUrlState( @@ -8797,7 +9571,15 @@ export function PlatformEntryFlowShellImpl({ setIsJumpHopBusy(false); } }, - [createReadyJumpHopGenerationState, setSelectionStage], + [ + createReadyJumpHopGenerationState, + markDraftFailed, + markDraftGenerating, + markPendingDraftFailed, + markPendingDraftGenerating, + refreshJumpHopShelf, + setSelectionStage, + ], ); const retryJumpHopDraftGeneration = useCallback(() => { @@ -9158,10 +9940,33 @@ export function PlatformEntryFlowShellImpl({ { error: errorMessage }, ), ); + markPendingDraftFailed('wooden-fish', created.session.sessionId); + markDraftFailed( + 'wooden-fish', + [created.session.sessionId, created.session.draft?.profileId], + errorMessage, + ); + setWoodenFishWorks((current) => + current.map((item) => + item.sourceSessionId === created.session.sessionId + ? { + ...item, + generationStatus: 'failed', + updatedAt: new Date().toISOString(), + } + : item, + ), + ); + void refreshWoodenFishShelf().catch(() => undefined); try { const latest = await woodenFishClient.getSession( created.session.sessionId, ); + markDraftFailed( + 'wooden-fish', + [latest.session.sessionId, latest.session.draft?.profileId], + errorMessage, + ); setWoodenFishSession(latest.session); setWoodenFishWork(null); writeCreationUrlState( @@ -9180,8 +9985,10 @@ export function PlatformEntryFlowShellImpl({ }, [ createReadyWoodenFishGenerationState, + markDraftFailed, markDraftGenerating, markDraftReady, + markPendingDraftFailed, markPendingDraftGenerating, markPendingDraftReady, refreshWoodenFishShelf, @@ -9479,9 +10286,22 @@ export function PlatformEntryFlowShellImpl({ puzzleFlow.setSession(response.session); } catch (error) { setPuzzleError(resolvePuzzleErrorMessage(error, '执行拼图æ“作失败。')); + } finally { + if ( + payload.action === 'generate_puzzle_images' || + payload.action === 'generate_puzzle_ui_background' + ) { + refreshPlatformDashboardSilently(); + } } }, - [puzzleFlow, puzzleSession, resolvePuzzleErrorMessage, setPuzzleError], + [ + puzzleFlow, + puzzleSession, + refreshPlatformDashboardSilently, + resolvePuzzleErrorMessage, + setPuzzleError, + ], ); const retryPuzzleDraftGeneration = useCallback(() => { @@ -11913,13 +12733,10 @@ export function PlatformEntryFlowShellImpl({ const openJumpHopDraft = useCallback( async (item: JumpHopWorkSummaryResponse) => { - markDraftNoticeSeen( - collectDraftNoticeKeys('jump-hop', [ - item.workId, - item.profileId, - item.sourceSessionId, - ]), - ); + const noticeIds = [item.workId, item.profileId, item.sourceSessionId]; + const hasFailedNotice = isDraftNoticeFailed('jump-hop', noticeIds); + const sessionId = normalizeCreationUrlValue(item.sourceSessionId); + markDraftNoticeSeen(collectDraftNoticeKeys('jump-hop', noticeIds)); if (item.publicationStatus === 'published') { void openJumpHopPublicWorkDetail(item.profileId); @@ -11929,6 +12746,37 @@ export function PlatformEntryFlowShellImpl({ setJumpHopError(null); setPublicWorkDetailError(null); setIsJumpHopBusy(true); + if ( + hasFailedNotice && + sessionId === jumpHopSession?.sessionId && + jumpHopGenerationState?.phase === 'failed' + ) { + enterCreateTab(); + setSelectionStage('jump-hop-generating'); + setIsJumpHopBusy(false); + return; + } + + if (item.generationStatus === 'generating' && !hasFailedNotice) { + const pendingSession = buildJumpHopPendingSession(item); + setJumpHopSession(pendingSession); + setJumpHopRun(null); + setJumpHopWork(null); + setJumpHopGenerationState( + createMiniGameDraftGenerationState( + 'jump-hop', + resolveMiniGameDraftGenerationStartedAtMs(item.updatedAt), + ), + ); + writeCreationUrlState( + buildJumpHopCreationUrlState({ session: pendingSession }), + ); + enterCreateTab(); + setSelectionStage('jump-hop-generating'); + setIsJumpHopBusy(false); + return; + } + try { const detail = await jumpHopClient.getWorkDetail(item.profileId); setJumpHopSession(null); @@ -11947,9 +12795,13 @@ export function PlatformEntryFlowShellImpl({ }, [ enterCreateTab, + isDraftNoticeFailed, + jumpHopGenerationState?.phase, + jumpHopSession?.sessionId, markDraftNoticeSeen, - openPublicWorkDetail, + openJumpHopPublicWorkDetail, setSelectionStage, + writeCreationUrlState, ], ); @@ -11977,13 +12829,11 @@ export function PlatformEntryFlowShellImpl({ const openWoodenFishDraft = useCallback( async (item: WoodenFishWorkSummaryResponse) => { - markDraftNoticeSeen( - collectDraftNoticeKeys('wooden-fish', [ - item.workId, - item.profileId, - item.sourceSessionId, - ]), - ); + const noticeIds = [item.workId, item.profileId, item.sourceSessionId]; + const hasFailedNotice = isDraftNoticeFailed('wooden-fish', noticeIds); + const sessionId = + normalizeCreationUrlValue(item.sourceSessionId) ?? item.profileId; + markDraftNoticeSeen(collectDraftNoticeKeys('wooden-fish', noticeIds)); if (item.publicationStatus === 'published') { void openWoodenFishPublicWorkDetail(item.profileId); @@ -11993,11 +12843,28 @@ export function PlatformEntryFlowShellImpl({ setWoodenFishError(null); setPublicWorkDetailError(null); setIsWoodenFishBusy(true); - if (item.generationStatus === 'generating') { + if ( + hasFailedNotice && + sessionId === woodenFishSession?.sessionId && + woodenFishGenerationState?.phase === 'failed' + ) { + enterCreateTab(); + setSelectionStage('wooden-fish-generating'); + setIsWoodenFishBusy(false); + return; + } + + if (item.generationStatus === 'generating' && !hasFailedNotice) { const pendingSession = buildWoodenFishPendingSession(item); setWoodenFishSession(pendingSession); setWoodenFishRun(null); setWoodenFishWork(null); + setWoodenFishGenerationState( + createMiniGameDraftGenerationState( + 'wooden-fish', + resolveMiniGameDraftGenerationStartedAtMs(item.updatedAt), + ), + ); writeCreationUrlState( buildWoodenFishCreationUrlState({ session: pendingSession }), ); @@ -12029,15 +12896,20 @@ export function PlatformEntryFlowShellImpl({ resolveRpgCreationErrorMessage(error, 'è¯»å–æ•²æœ¨é±¼è‰ç¨¿å¤±è´¥ã€‚'), ); enterCreateTab(); - setSelectionStage('wooden-fish-generating'); + setSelectionStage( + hasFailedNotice ? 'wooden-fish-workspace' : 'wooden-fish-generating', + ); } finally { setIsWoodenFishBusy(false); } }, [ enterCreateTab, + isDraftNoticeFailed, markDraftNoticeSeen, openWoodenFishPublicWorkDetail, + woodenFishGenerationState?.phase, + woodenFishSession?.sessionId, writeCreationUrlState, setSelectionStage, ], @@ -12173,6 +13045,8 @@ export function PlatformEntryFlowShellImpl({ buildPuzzleResultWorkId(item.sourceSessionId), buildPuzzleResultProfileId(item.sourceSessionId), ]); + const failedNotice = getDraftGenerationNotice(noticeKeys); + const isPersistedFailed = isPersistedDraftFailed(item.generationStatus); const hasGeneratingNotice = isDraftNoticeGenerating('puzzle', [ item.workId, item.profileId, @@ -12180,9 +13054,21 @@ export function PlatformEntryFlowShellImpl({ buildPuzzleResultWorkId(item.sourceSessionId), buildPuzzleResultProfileId(item.sourceSessionId), ]); + const hasFailedNotice = isDraftNoticeFailed('puzzle', [ + item.workId, + item.profileId, + item.sourceSessionId, + buildPuzzleResultWorkId(item.sourceSessionId), + buildPuzzleResultProfileId(item.sourceSessionId), + ]); + const noticeErrorMessage = + failedNotice?.status === 'failed' + ? (failedNotice.message ?? buildDraftFailedShelfSummary('puzzle')) + : buildDraftFailedShelfSummary('puzzle'); const isMarkedGenerating = - (hasGeneratingNotice && !resolvePuzzleWorkCoverImageSrc(item)) || - isPersistedPuzzleDraftGenerating(item); + !hasFailedNotice && + ((hasGeneratingNotice && !resolvePuzzleWorkCoverImageSrc(item)) || + isPersistedPuzzleDraftGenerating(item)); setPuzzleOperation(null); setPuzzleRun(null); setPuzzleRuntimeAuthMode('default'); @@ -12202,6 +13088,67 @@ export function PlatformEntryFlowShellImpl({ ); const activeGenerationState = backgroundTask?.generationState ?? puzzleGenerationViewState; + const failedGenerationState = + backgroundTask?.generationState.phase === 'failed' + ? backgroundTask.generationState + : item.sourceSessionId === puzzleSession?.sessionId && + activeGenerationState?.phase === 'failed' + ? activeGenerationState + : hasFailedNotice || isPersistedFailed + ? createFailedMiniGameDraftGenerationStateForRestoredDraft( + 'puzzle', + item.updatedAt, + noticeErrorMessage, + { puzzleAiRedraw: true }, + ) + : null; + + if ((hasFailedNotice || isPersistedFailed) && failedGenerationState) { + let failedSession = backgroundTask?.session ?? null; + let failedPayload = backgroundTask?.payload ?? null; + const failedError = + backgroundTask?.error ?? failedNotice?.message ?? noticeErrorMessage; + if (!failedSession) { + try { + const { session: latestSession } = await getPuzzleAgentSession( + item.sourceSessionId, + ); + failedSession = latestSession; + failedPayload = buildPuzzleFormPayloadFromSession(latestSession); + } catch { + failedPayload = buildPuzzleFormPayloadFromWork(item); + } + } + if (!failedPayload) { + failedPayload = failedSession + ? buildPuzzleFormPayloadFromSession(failedSession) + : buildPuzzleFormPayloadFromWork(item); + } + if (backgroundTask) { + puzzleFlow.setSession(backgroundTask.session); + } else if (failedSession) { + puzzleFlow.setSession(failedSession); + } + setPuzzleFormDraftPayload(failedPayload); + setPuzzleError(failedError); + if (failedSession) { + setPuzzleBackgroundCompileTasks((current) => ({ + ...current, + [failedSession!.sessionId]: { + session: failedSession!, + payload: failedPayload!, + generationState: failedGenerationState, + error: failedError, + }, + })); + } + enterCreateTab(); + selectionStageRef.current = 'puzzle-generating'; + activePuzzleGenerationSessionIdRef.current = item.sourceSessionId; + setPuzzleGenerationState(failedGenerationState); + setSelectionStage('puzzle-generating'); + return; + } if ( item.sourceSessionId === puzzleSession?.sessionId && @@ -12258,26 +13205,34 @@ export function PlatformEntryFlowShellImpl({ item.sourceSessionId, ); const payload = buildPuzzleFormPayloadFromSession(latestSession); - const generationState = - createMiniGameDraftGenerationStateForRestoredDraft('puzzle', { - puzzleAiRedraw: payload.aiRedraw ?? true, - puzzleProgressPercent: - latestSession.draft && !latestSession.draft.formDraft - ? latestSession.progressPercent - : undefined, - }); + const startedAtMs = resolveMiniGameDraftGenerationStartedAtMs( + latestSession.updatedAt, + ); + const baseGenerationState = + createMiniGameDraftGenerationStateForRestoredDraft( + 'puzzle', + { + puzzleAiRedraw: payload.aiRedraw ?? true, + puzzleProgressPercent: + latestSession.draft && !latestSession.draft.formDraft + ? latestSession.progressPercent + : undefined, + }, + startedAtMs, + ); + const generationState = mergePuzzleSessionProgressIntoGenerationState( + baseGenerationState, + latestSession, + ); puzzleFlow.setSession(latestSession); setPuzzleFormDraftPayload(payload); - setPuzzleGenerationState( - rebaseMiniGameDraftGenerationStateForDisplay(generationState), - ); + setPuzzleGenerationState(generationState); setPuzzleBackgroundCompileTasks((current) => ({ ...current, [latestSession.sessionId]: { session: latestSession, payload, - generationState: - rebaseMiniGameDraftGenerationStateForDisplay(generationState), + generationState, error: null, }, })); @@ -12317,6 +13272,8 @@ export function PlatformEntryFlowShellImpl({ [ enterCreateTab, getPuzzleBackgroundCompileTask, + getDraftGenerationNotice, + isDraftNoticeFailed, isDraftNoticeGenerating, markDraftNoticeSeen, openPuzzleDetail, @@ -12362,18 +13319,43 @@ export function PlatformEntryFlowShellImpl({ return; } + const failedNotice = getDraftGenerationNotice(noticeKeys); + const hasFailedNotice = isDraftNoticeFailed('match3d', [ + item.workId, + item.profileId, + item.sourceSessionId, + ]); + const noticeErrorMessage = + failedNotice?.status === 'failed' + ? (failedNotice.message ?? buildDraftFailedShelfSummary('match3d')) + : buildDraftFailedShelfSummary('match3d'); const isMarkedGenerating = - isDraftNoticeGenerating('match3d', [ + !hasFailedNotice && + (isDraftNoticeGenerating('match3d', [ item.workId, item.profileId, item.sourceSessionId, - ]) || isPersistedDraftGenerating(item.generationStatus); + ]) || + isPersistedDraftGenerating(item.generationStatus)); const backgroundTask = getMatch3DBackgroundCompileTask( item.sourceSessionId, ); const activeGenerationState = backgroundTask?.generationState ?? match3dGenerationViewState; + const failedGenerationState = + backgroundTask?.generationState.phase === 'failed' + ? backgroundTask.generationState + : item.sourceSessionId === match3dSession?.sessionId && + activeGenerationState?.phase === 'failed' + ? activeGenerationState + : hasFailedNotice + ? createFailedMiniGameDraftGenerationStateForRestoredDraft( + 'match3d', + item.updatedAt, + noticeErrorMessage, + ) + : null; if (hasUnreadReadyNotice) { try { @@ -12406,6 +13388,52 @@ export function PlatformEntryFlowShellImpl({ } } + if (failedGenerationState) { + let failedSession = backgroundTask?.session ?? null; + let failedPayload = backgroundTask?.payload ?? null; + const failedError = + backgroundTask?.error ?? failedNotice?.message ?? noticeErrorMessage; + if (!failedSession) { + try { + const { session: latestSession } = + await match3dCreationClient.getSession(item.sourceSessionId); + failedSession = latestSession; + failedPayload = buildMatch3DFormPayloadFromSession(latestSession); + } catch { + failedPayload = buildMatch3DFormPayloadFromWork(item); + } + } + if (!failedPayload) { + failedPayload = failedSession + ? buildMatch3DFormPayloadFromSession(failedSession) + : buildMatch3DFormPayloadFromWork(item); + } + if (backgroundTask) { + setMatch3DSession(backgroundTask.session); + } else if (failedSession) { + setMatch3DSession(failedSession); + } + setMatch3DFormDraftPayload(failedPayload); + setMatch3DError(failedError); + if (failedSession) { + setMatch3DBackgroundCompileTasks((current) => ({ + ...current, + [failedSession!.sessionId]: { + session: failedSession!, + payload: failedPayload!, + generationState: failedGenerationState, + error: failedError, + }, + })); + } + enterCreateTab(); + selectionStageRef.current = 'match3d-generating'; + activeMatch3DGenerationSessionIdRef.current = item.sourceSessionId; + setMatch3DGenerationState(failedGenerationState); + setSelectionStage('match3d-generating'); + return; + } + if ( item.sourceSessionId === match3dSession?.sessionId && isMiniGameDraftGenerating(activeGenerationState) @@ -12462,9 +13490,14 @@ export function PlatformEntryFlowShellImpl({ setMatch3DSession(latestSession); setMatch3DFormDraftPayload(null); setMatch3DProfile(null); - const generationState = rebaseMiniGameDraftGenerationStateForDisplay( - createMiniGameDraftGenerationStateForRestoredDraft('match3d'), - ); + const generationState = + createMiniGameDraftGenerationStateForRestoredDraft( + 'match3d', + undefined, + resolveMiniGameDraftGenerationStartedAtMs( + latestSession.updatedAt, + ), + ); setMatch3DGenerationState(generationState); enterCreateTab(); selectionStageRef.current = 'match3d-generating'; @@ -12506,6 +13539,8 @@ export function PlatformEntryFlowShellImpl({ [ enterCreateTab, getMatch3DBackgroundCompileTask, + getDraftGenerationNotice, + isDraftNoticeFailed, isDraftNoticeGenerating, isDraftNoticeReadyUnread, markDraftNoticeSeen, diff --git a/src/components/rpg-entry/RpgEntryFlowShell.agent.interaction.test.tsx b/src/components/rpg-entry/RpgEntryFlowShell.agent.interaction.test.tsx index 602d9470..1b5750f6 100644 --- a/src/components/rpg-entry/RpgEntryFlowShell.agent.interaction.test.tsx +++ b/src/components/rpg-entry/RpgEntryFlowShell.agent.interaction.test.tsx @@ -88,9 +88,7 @@ import { } from '../../services/edutainment-baby-object'; import { jumpHopClient } from '../../services/jump-hop/jumpHopClient'; import { match3dCreationClient } from '../../services/match3d-creation'; -import { - createServerMatch3DRuntimeAdapter, -} from '../../services/match3d-runtime'; +import { createServerMatch3DRuntimeAdapter } from '../../services/match3d-runtime'; import { deleteMatch3DWork, getMatch3DWorkDetail, @@ -172,6 +170,7 @@ import { } from '../../services/square-hole-works'; import { listVisualNovelGallery } from '../../services/visual-novel-runtime'; import { listVisualNovelWorks } from '../../services/visual-novel-works'; +import { woodenFishClient } from '../../services/wooden-fish/woodenFishClient'; import { type CustomWorldProfile, WorldType } from '../../types'; import { AuthUiContext, @@ -759,6 +758,22 @@ vi.mock('../../services/visual-novel-works', () => ({ updateVisualNovelWork: vi.fn(), })); +vi.mock('../../services/wooden-fish/woodenFishClient', () => ({ + woodenFishClient: { + checkpointRun: vi.fn(), + createSession: vi.fn(), + executeAction: vi.fn(), + finishRun: vi.fn(), + getGalleryDetail: vi.fn(), + getSession: vi.fn(), + getWorkDetail: vi.fn(), + listGallery: vi.fn(), + listWorks: vi.fn(), + publishWork: vi.fn(), + startRun: vi.fn(), + }, +})); + vi.mock('../../services/visual-novel-creation', () => ({ compileVisualNovelWorkProfile: vi.fn(), createVisualNovelSession: vi.fn(), @@ -2672,6 +2687,12 @@ beforeEach(() => { vi.mocked(deleteRpgEntryWorldProfile).mockResolvedValue([]); vi.mocked(listVisualNovelGallery).mockResolvedValue({ works: [] }); vi.mocked(listVisualNovelWorks).mockResolvedValue({ works: [] }); + vi.mocked(woodenFishClient.listGallery).mockResolvedValue({ + items: [], + hasMore: false, + nextCursor: null, + }); + vi.mocked(woodenFishClient.listWorks).mockResolvedValue({ items: [] }); vi.mocked(listLocalBabyObjectMatchDrafts).mockResolvedValue([]); vi.mocked(deleteLocalBabyObjectMatchDraft).mockResolvedValue([]); vi.mocked(jumpHopClient.listGallery).mockResolvedValue({ @@ -3825,9 +3846,9 @@ test('bark battle form checks mud points before creating image assets', async () ).toBeTruthy(); expect(screen.getByText('汪汪声浪é…置表å•')).toBeTruthy(); expect(screen.queryByRole('tablist', { name: '玩法模æ¿åˆ†ç±»' })).toBeNull(); - expect((screen.getByLabelText('æ±ªæ±ªä½œå“æ ‡é¢˜') as HTMLInputElement).value).toBe( - '自定义声浪æ¯', - ); + expect( + (screen.getByLabelText('æ±ªæ±ªä½œå“æ ‡é¢˜') as HTMLInputElement).value, + ).toBe('自定义声浪æ¯'); expect(createBarkBattleDraft).not.toHaveBeenCalled(); expect(generateAllBarkBattleImageAssets).not.toHaveBeenCalled(); }); @@ -3975,6 +3996,106 @@ test('running match3d form generation can return to draft tab and reopen progres }); }); +test('background match3d draft failure notifies and reopens failed retry page', async () => { + const user = userEvent.setup(); + const runningSession = buildMockMatch3DAgentSession({ + sessionId: 'match3d-background-failed-session', + draft: null, + stage: 'collecting_config', + }); + const persistedFailedWork: Match3DWorkSummary = { + workId: 'match3d-background-failed-work', + profileId: 'match3d-background-failed-profile', + ownerUserId: 'user-1', + sourceSessionId: runningSession.sessionId, + gameName: '失败中的抓鹅', + themeText: '泥塑水果摊', + summary: '正在生æˆçŽ©æ³•ç´ æã€‚', + tags: ['æ°´æžœ', '抓大鹅'], + coverImageSrc: null, + referenceImageSrc: null, + clearCount: 12, + difficulty: 4, + publicationStatus: 'draft', + playCount: 0, + updatedAt: '2026-05-18T12:05:00.000Z', + publishedAt: null, + publishReady: false, + generationStatus: 'generating', + generatedItemAssets: [], + }; + let rejectCompile!: (reason?: unknown) => void; + vi.mocked(match3dCreationClient.createSession).mockResolvedValue({ + session: runningSession, + }); + vi.mocked(match3dCreationClient.executeAction).mockReturnValue( + new Promise((_, reject) => { + rejectCompile = reject; + }), + ); + vi.mocked(match3dCreationClient.getSession).mockResolvedValue({ + session: buildMockMatch3DAgentSession({ + sessionId: runningSession.sessionId, + stage: 'collecting_config', + draft: null, + updatedAt: '2026-05-18T12:05:00.000Z', + }), + }); + + render(); + + await openCreateTemplateHub(user); + await user.click(await findCreationTypeButton('抓大鹅')); + await user.click( + await screen.findByRole('button', { name: 'ç”ŸæˆæŠ“å¤§é¹…è‰ç¨¿' }), + ); + expect( + await screen.findByRole('progressbar', { + name: '抓大鹅è‰ç¨¿ç”Ÿæˆè¿›åº¦', + }), + ).toBeTruthy(); + + await user.click(screen.getByRole('button', { name: '返回创作中心' })); + await openDraftHub(user); + await expectDraftHubGeneratingBadgeCountAtLeast(1); + vi.mocked(listMatch3DWorks).mockResolvedValue({ + items: [persistedFailedWork], + }); + + await act(async () => { + rejectCompile(new Error('æŠ“å¤§é¹…ç´ ææœåŠ¡å¤±è´¥')); + await Promise.resolve(); + }); + + const failureDialog = await screen.findByRole('dialog', { + name: 'å‘生错误', + }); + expect(within(failureDialog).getByText(/æŠ“å¤§é¹…ç´ ææœåŠ¡å¤±è´¥/u)).toBeTruthy(); + await user.click(within(failureDialog).getByRole('button', { name: '关闭' })); + + const draftPanel = getPlatformTabPanel('saves'); + const reopenButton = await within(draftPanel).findByRole('button', { + name: /继续创作《(?:失败中的抓鹅|抓大鹅è‰ç¨¿)》/u, + }); + expect(within(draftPanel).getByText('èµ›åšæ°´æžœæ‘Š')).toBeTruthy(); + await user.click(reopenButton); + + expect(await screen.findByText(/生æˆå¤±è´¥/u)).toBeTruthy(); + const reopenedFailureDialog = await screen.findByRole('dialog', { + name: 'å‘生错误', + }); + await user.click( + within(reopenedFailureDialog).getByRole('button', { name: '关闭' }), + ); + await waitFor(() => { + expect(screen.queryByRole('dialog', { name: 'å‘生错误' })).toBeNull(); + }); + expect( + await screen.findByRole('button', { name: '釿–°ç”Ÿæˆè‰ç¨¿' }), + ).toBeTruthy(); + expect(match3dCreationClient.executeAction).toHaveBeenCalledTimes(1); +}); + test('running match3d persisted draft reopens progress instead of unfinished result', async () => { const user = userEvent.setup(); const runningSession = buildMockMatch3DAgentSession({ @@ -4065,9 +4186,6 @@ test('running match3d persisted draft reopens progress instead of unfinished res }), ).toBeTruthy(); expect(screen.queryByText('抓大鹅结果页')).toBeNull(); - expect(match3dCreationClient.getSession).toHaveBeenCalledWith( - 'match3d-running-persisted-session', - ); }); test('persisted generating match3d draft opens generation progress after refresh', async () => { @@ -4135,12 +4253,14 @@ test('persisted generating match3d draft opens generation progress after refresh name: '抓大鹅è‰ç¨¿ç”Ÿæˆè¿›åº¦', }), ).toBeTruthy(); - expect( + const restoredProgressValue = Number( screen .getByRole('progressbar', { name: '抓大鹅è‰ç¨¿ç”Ÿæˆè¿›åº¦' }) .getAttribute('aria-valuenow'), - ).toBe('0'); - expect(screen.getByText('0%')).toBeTruthy(); + ); + expect(restoredProgressValue).toBeGreaterThan(0); + expect(restoredProgressValue).toBeLessThan(100); + expect(screen.getByText(`${restoredProgressValue}%`)).toBeTruthy(); expect(screen.queryByText('抓大鹅结果页')).toBeNull(); expect(getMatch3DWorkDetail).not.toHaveBeenCalledWith( 'match3d-profile-generating', @@ -4432,9 +4552,7 @@ test('running puzzle form generation creates a new puzzle draft on same template await openCreateTemplateHub(user); await user.click(await findCreationTypeButton('拼图')); - await user.click( - await screen.findByRole('button', { name: '生æˆè‰ç¨¿' }), - ); + await user.click(await screen.findByRole('button', { name: '生æˆè‰ç¨¿' })); expect( await screen.findByRole('progressbar', { name: '拼图图片生æˆè¿›åº¦', @@ -4458,7 +4576,9 @@ test('running puzzle form generation creates a new puzzle draft on same template expect((secondGenerateButton as HTMLButtonElement).disabled).toBe(false); await user.click(secondGenerateButton); - expect(createPuzzleAgentSession).toHaveBeenCalledTimes(1); + await waitFor(() => { + expect(createPuzzleAgentSession).toHaveBeenCalledTimes(2); + }); expect(executePuzzleAgentAction).toHaveBeenCalledTimes(2); expect(executePuzzleAgentAction).toHaveBeenNthCalledWith( 1, @@ -4467,7 +4587,7 @@ test('running puzzle form generation creates a new puzzle draft on same template ); expect(executePuzzleAgentAction).toHaveBeenNthCalledWith( 2, - 'puzzle-session-1', + 'puzzle-parallel-session-2', expect.objectContaining({ action: 'compile_puzzle_draft' }), ); @@ -4479,7 +4599,11 @@ test('running puzzle form generation creates a new puzzle draft on same template await user.click(screen.getByRole('button', { name: '返回创作中心' })); await openDraftHub(user); await waitFor(() => { - expect(screen.getAllByText('拼图è‰ç¨¿').length).toBeGreaterThanOrEqual(2); + expect( + within(getPlatformTabPanel('saves')).getAllByRole('button', { + name: /继续创作《[^》]+》,生æˆä¸­/u, + }).length, + ).toBeGreaterThanOrEqual(2); }); await expectDraftHubGeneratingBadgeCountAtLeast(2); @@ -4513,6 +4637,158 @@ test('running puzzle form generation creates a new puzzle draft on same template }); }); +test('failed parallel puzzle generations stay as separate non-generating drafts', async () => { + const user = userEvent.setup(); + const firstSession = buildMockPuzzleAgentSession({ + sessionId: 'puzzle-parallel-failed-session-1', + }); + const secondSession = buildMockPuzzleAgentSession({ + sessionId: 'puzzle-parallel-failed-session-2', + }); + let rejectFirstCompile!: (reason?: unknown) => void; + let rejectSecondCompile!: (reason?: unknown) => void; + vi.mocked(createPuzzleAgentSession) + .mockResolvedValueOnce({ + session: firstSession, + }) + .mockResolvedValueOnce({ + session: secondSession, + }); + vi.mocked(executePuzzleAgentAction) + .mockReturnValueOnce( + new Promise((_, reject) => { + rejectFirstCompile = reject; + }), + ) + .mockReturnValueOnce( + new Promise((_, reject) => { + rejectSecondCompile = reject; + }), + ); + + render(); + + await openCreateTemplateHub(user); + await user.click(await findCreationTypeButton('拼图')); + await user.click(await screen.findByRole('button', { name: '生æˆè‰ç¨¿' })); + expect( + await screen.findByRole('progressbar', { + name: '拼图图片生æˆè¿›åº¦', + }), + ).toBeTruthy(); + await user.click(screen.getByRole('button', { name: '返回创作中心' })); + expect(await screen.findByText('18泥点')).toBeTruthy(); + + await openCreateTemplateHub(user); + await user.click(await findCreationTypeButton('拼图')); + await user.click(await screen.findByRole('button', { name: '生æˆè‰ç¨¿' })); + await waitFor(() => { + expect(createPuzzleAgentSession).toHaveBeenCalledTimes(2); + }); + expect(executePuzzleAgentAction).toHaveBeenCalledTimes(2); + + await clickFirstButtonByName(user, '返回'); + expect(await screen.findByText('16泥点')).toBeTruthy(); + await openDraftHub(user); + const draftPanel = getPlatformTabPanel('saves'); + await waitFor(() => { + expect( + within(draftPanel).getAllByRole('button', { + name: /继续创作《[^》]+》,生æˆä¸­/u, + }).length, + ).toBeGreaterThanOrEqual(2); + }); + await expectDraftHubGeneratingBadgeCountAtLeast(2); + + vi.mocked(listPuzzleWorks).mockResolvedValue({ + items: [ + { + workId: `puzzle-work-${firstSession.sessionId}`, + profileId: `puzzle-profile-${firstSession.sessionId}`, + ownerUserId: 'user-1', + sourceSessionId: firstSession.sessionId, + authorDisplayName: '测试玩家', + workTitle: '', + workDescription: '一套雨夜猫街主题拼图。', + levelName: '第1å…³', + summary: '一套雨夜猫街主题拼图。', + themeTags: [], + coverImageSrc: null, + coverAssetId: null, + publicationStatus: 'draft', + updatedAt: '2026-05-18T12:00:00.000Z', + publishedAt: null, + playCount: 0, + remixCount: 0, + likeCount: 0, + publishReady: false, + generationStatus: 'failed', + levels: [], + }, + { + workId: `puzzle-work-${secondSession.sessionId}`, + profileId: `puzzle-profile-${secondSession.sessionId}`, + ownerUserId: 'user-1', + sourceSessionId: secondSession.sessionId, + authorDisplayName: '测试玩家', + workTitle: '', + workDescription: '一套雨夜猫街主题拼图。', + levelName: '第1å…³', + summary: '一套雨夜猫街主题拼图。', + themeTags: [], + coverImageSrc: null, + coverAssetId: null, + publicationStatus: 'draft', + updatedAt: '2026-05-18T12:00:01.000Z', + publishedAt: null, + playCount: 0, + remixCount: 0, + likeCount: 0, + publishReady: false, + generationStatus: 'failed', + levels: [], + }, + ], + }); + + await act(async () => { + rejectFirstCompile( + new Error( + '拼图 VectorEngine 图片编辑失败:创建图片编辑任务失败:error sending request for url (https://api.vectorengine.cn/v1/images/edits)', + ), + ); + rejectSecondCompile( + new Error( + '拼图 VectorEngine 图片编辑失败:创建图片编辑任务失败:error sending request for url (https://api.vectorengine.cn/v1/images/edits)', + ), + ); + await Promise.resolve(); + }); + + await waitFor(() => { + expect( + within(draftPanel).getAllByRole('button', { + name: /继续创作《[^》]+》/u, + }).length, + ).toBeGreaterThanOrEqual(2); + expect(within(draftPanel).queryAllByLabelText('生æˆä¸­')).toHaveLength(0); + }); + expect(await screen.findByText('20泥点')).toBeTruthy(); + expect(within(draftPanel).queryByText('第1å…³')).toBeNull(); + expect( + within(draftPanel).getAllByText('拼图è‰ç¨¿ç”Ÿæˆå¤±è´¥ï¼Œå¯é‡æ–°æ‰“开处ç†ã€‚') + .length, + ).toBeGreaterThanOrEqual(2); + expect( + within(draftPanel).getAllByText('一套雨夜猫街主题拼图。').length, + ).toBeGreaterThanOrEqual(2); + const failureDialog = await screen.findByRole('dialog', { + name: 'å‘生错误', + }); + expect(within(failureDialog).getByText(/拼图 VectorEngine 图片编辑失败/u)) + .toBeTruthy(); +}); + test('running puzzle draft opens generation progress from draft tab', async () => { const user = userEvent.setup(); const runningSession = buildMockPuzzleAgentSession({ @@ -4548,9 +4824,7 @@ test('running puzzle draft opens generation progress from draft tab', async () = await openCreateTemplateHub(user); await user.click(await findCreationTypeButton('拼图')); - await user.click( - await screen.findByRole('button', { name: '生æˆè‰ç¨¿' }), - ); + await user.click(await screen.findByRole('button', { name: '生æˆè‰ç¨¿' })); expect( await screen.findByRole('progressbar', { name: '拼图图片生æˆè¿›åº¦', @@ -4562,7 +4836,7 @@ test('running puzzle draft opens generation progress from draft tab', async () = await expectDraftHubGeneratingBadgeCountAtLeast(1); await user.click( - screen.getByRole('button', { name: /继续创作《拼图è‰ç¨¿ã€‹/u }), + screen.getByRole('button', { name: /继续创作《[^》]+》,生æˆä¸­/u }), ); expect( @@ -4602,9 +4876,7 @@ test('puzzle form checks mud points before creating a draft', async () => { await openCreateTemplateHub(user); await user.click(await findCreationTypeButton('拼图')); - await user.click( - await screen.findByRole('button', { name: '生æˆè‰ç¨¿' }), - ); + await user.click(await screen.findByRole('button', { name: '生æˆè‰ç¨¿' })); const noticeDialog = await screen.findByRole('dialog', { name: '泥点ä¸è¶³' }); expect( @@ -4894,7 +5166,9 @@ test('match3d result back returns to draft hub when opened from shelf', async () within(draftPanel).getByRole('tablist', { name: '作å“筛选' }), ).toBeTruthy(); expect(within(draftPanel).getByText('自动试玩抓大鹅')).toBeTruthy(); - expect(getPlatformTabPanel('create').getAttribute('aria-hidden')).toBe('true'); + expect(getPlatformTabPanel('create').getAttribute('aria-hidden')).toBe( + 'true', + ); expect(screen.queryByText('抓大鹅结果页')).toBeNull(); }); @@ -5221,13 +5495,9 @@ test('completed match3d draft notice first opens trial then reopens result', asy name: '生æˆå®Œæˆ', }); expect( - within(completionDialog).getByText( - /抓大鹅è‰ç¨¿ match3d-notice-session-1/u, - ), - ).toBeTruthy(); - expect( - within(completionDialog).getByText(/生æˆä»»åŠ¡å·²å®Œæˆ/u), + within(completionDialog).getByText(/抓大鹅è‰ç¨¿ match3d-notice-session-1/u), ).toBeTruthy(); + expect(within(completionDialog).getByText(/生æˆä»»åŠ¡å·²å®Œæˆ/u)).toBeTruthy(); expect( within(completionDialog).getByRole('button', { name: 'å¤åˆ¶å†…容' }), ).toBeTruthy(); @@ -5445,9 +5715,7 @@ test('puzzle draft generation auto starts trial and runtime back opens draft res await openCreateTemplateHub(user); await user.click(await findCreationTypeButton('拼图')); - await user.click( - await screen.findByRole('button', { name: '生æˆè‰ç¨¿' }), - ); + await user.click(await screen.findByRole('button', { name: '生æˆè‰ç¨¿' })); await waitFor(() => { expect(updatePuzzleWork).toHaveBeenCalledWith( @@ -5534,9 +5802,7 @@ test('embedded puzzle form recovers when compile request times out after backend await openCreateTemplateHub(user); await user.click(await findCreationTypeButton('拼图')); - await user.click( - await screen.findByRole('button', { name: '生æˆè‰ç¨¿' }), - ); + await user.click(await screen.findByRole('button', { name: '生æˆè‰ç¨¿' })); await waitFor(() => { expect(getPuzzleAgentSession).toHaveBeenCalledWith( @@ -6685,7 +6951,10 @@ test('home recommendation puzzle next level switches to similar work detail', as nextLevelId: 'puzzle-level-2', recommendedNextWorks: [], }; - const startedRun = buildMockPuzzleRun(entryWork.profileId, entryWork.levelName); + const startedRun = buildMockPuzzleRun( + entryWork.profileId, + entryWork.levelName, + ); const similarRun = { ...buildMockPuzzleRun(similarWork.profileId, similarWork.levelName), runId: clearedRun.runId, @@ -6719,7 +6988,9 @@ test('home recommendation puzzle next level switches to similar work detail', as vi.mocked(submitPuzzleLeaderboard).mockResolvedValue({ run: clearedRunWithSameWorkNext, }); - let resolveAdvancePuzzleNextLevel!: (value: { run: PuzzleRunSnapshot }) => void; + let resolveAdvancePuzzleNextLevel!: (value: { + run: PuzzleRunSnapshot; + }) => void; vi.mocked(advancePuzzleNextLevel).mockReturnValue( new Promise((resolve) => { resolveAdvancePuzzleNextLevel = resolve; @@ -6753,10 +7024,9 @@ test('home recommendation puzzle next level switches to similar work detail', as await user.click(within(dialog).getByRole('button', { name: '下一关' })); await waitFor(() => { - expect(advancePuzzleNextLevel).toHaveBeenCalledWith( - clearedRun.runId, - { preferSimilarWork: true }, - ); + expect(advancePuzzleNextLevel).toHaveBeenCalledWith(clearedRun.runId, { + preferSimilarWork: true, + }); }); expect(screen.getByTestId('puzzle-board')).toBeTruthy(); expect(screen.queryByText('加载中...')).toBeNull(); @@ -7457,8 +7727,8 @@ test('embedded puzzle form maps raw bearer token errors to user-facing auth copy expect(createPuzzleAgentSession).toHaveBeenCalledTimes(1); expect(createCreativeAgentSession).not.toHaveBeenCalled(); expect( - await screen.findByText('当å‰ç™»å½•状æ€å·²å¤±æ•ˆï¼Œè¯·é‡æ–°ç™»å½•åŽç»§ç»­ã€‚'), - ).toBeTruthy(); + await screen.findAllByText('当å‰ç™»å½•状æ€å·²å¤±æ•ˆï¼Œè¯·é‡æ–°ç™»å½•åŽç»§ç»­ã€‚'), + ).not.toHaveLength(0); expect(screen.queryByText('缺少 Authorization Bearer Token')).toBeNull(); }); @@ -7572,7 +7842,9 @@ test('puzzle draft result back button returns to draft hub when opened from shel within(draftPanel).getByRole('tablist', { name: '作å“筛选' }), ).toBeTruthy(); expect(within(draftPanel).getByText('雨夜猫塔')).toBeTruthy(); - expect(getPlatformTabPanel('create').getAttribute('aria-hidden')).toBe('true'); + expect(getPlatformTabPanel('create').getAttribute('aria-hidden')).toBe( + 'true', + ); expect(screen.queryByText('拼图工作区:missing-session')).toBeNull(); expect( screen.queryByText('雨夜里有一åªä¼šå‘光的猫站在é—迹å°é˜¶ä¸Šã€‚'), @@ -7635,14 +7907,14 @@ test('persisted generating puzzle draft opens generation progress after refresh' name: '拼图图片生æˆè¿›åº¦', }), ).toBeTruthy(); - expect( - Number( - screen - .getByRole('progressbar', { name: '拼图图片生æˆè¿›åº¦' }) - .getAttribute('aria-valuenow'), - ), - ).toBe(0); - expect(screen.getByText('0%')).toBeTruthy(); + const restoredProgressValue = Number( + screen + .getByRole('progressbar', { name: '拼图图片生æˆè¿›åº¦' }) + .getAttribute('aria-valuenow'), + ); + expect(restoredProgressValue).toBeGreaterThan(0); + expect(restoredProgressValue).toBeLessThan(100); + expect(screen.getByText(`${restoredProgressValue}%`)).toBeTruthy(); expect(screen.queryByText('拼图结果页')).toBeNull(); }); @@ -7736,7 +8008,9 @@ test('puzzle compile timeout shows failure dialog when reread session is still g await user.click(await screen.findByRole('button', { name: '生æˆè‰ç¨¿' })); const dialog = await screen.findByRole('dialog', { name: 'å‘生错误' }); - expect(within(dialog).getByText('拼图è‰ç¨¿ puzzle-session-timeout')).toBeTruthy(); + expect( + within(dialog).getByText('拼图è‰ç¨¿ puzzle-session-timeout'), + ).toBeTruthy(); expect( within(dialog).getByText( '拼图共创æ“作超时,请确认è¿è¡Œæ—¶åŽç«¯å·²å¯åЍåŽé‡è¯•。', @@ -9818,8 +10092,12 @@ test('agent draft result back button returns to draft hub without syncing result await waitFor(() => { expect(draftPanel.getAttribute('aria-hidden')).toBe('false'); }); - expect(within(draftPanel).getByRole('tablist', { name: '作å“筛选' })).toBeTruthy(); - expect(getPlatformTabPanel('create').getAttribute('aria-hidden')).toBe('true'); + expect( + within(draftPanel).getByRole('tablist', { name: '作å“筛选' }), + ).toBeTruthy(); + expect(getPlatformTabPanel('create').getAttribute('aria-hidden')).toBe( + 'true', + ); expect( vi @@ -10915,8 +11193,9 @@ test('creation hub published work card reveals delete action after card action r publishedCard.focus(); await user.keyboard('{ArrowLeft}'); - expect(screen.getByRole('button', { name: '删除' })).toBeTruthy(); - await user.click(screen.getByRole('button', { name: '删除' })); + const deleteButtons = screen.getAllByRole('button', { name: '删除' }); + expect(deleteButtons.length).toBeGreaterThan(0); + await user.click(deleteButtons[0]!); const dialog = await screen.findByRole('dialog', { name: '删除作å“' }); expect(dialog.parentElement?.className).toContain('platform-theme--light'); diff --git a/src/components/rpg-entry/RpgEntryHomeView.tsx b/src/components/rpg-entry/RpgEntryHomeView.tsx index 47b0e4b8..6d5a5a8d 100644 --- a/src/components/rpg-entry/RpgEntryHomeView.tsx +++ b/src/components/rpg-entry/RpgEntryHomeView.tsx @@ -7059,7 +7059,8 @@ export function RpgEntryHomeView({
- ) : isAuthenticated && activeTab === 'create' ? ( + ) : isAuthenticated && + (activeTab === 'create' || activeTab === 'saves') ? (