From a45e358e83931c6db4de155d83b32b187f986f1c Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?=E9=AB=98=E7=89=A9?= <253518756@qq.com> Date: Sat, 16 May 2026 22:59:02 +0800 Subject: [PATCH] Add generationStatus and match3d/runtime fixes Introduce persistent generationStatus to work summaries (puzzle & match3d) and propagate generation recovery rules across docs and frontend/backends so "generating" is restored from server-side work summary rather than ephemeral front-end notices. Update API server image/asset handling (improve match3d material sheet green/alpha decontamination and promote generatedItemAssets background fields) and add runtime improvements: alpha-based hotspot hit-testing, tray insertion/three-match animation behavior, and session re-read on client-side VectorEngine timeouts/lock-screen interruptions. Many docs, tests and related frontend modules updated/added to reflect these contract and behavior changes. --- .hermes/shared-memory/decision-log.md | 8 + .hermes/shared-memory/pitfalls.md | 64 +- ...玩法创作】平å°å…¥å£ä¸ŽçŽ©æ³•é“¾è·¯-2026-05-15.md | 35 +- packages/shared/src/contracts/match3dWorks.ts | 2 + .../shared/src/contracts/puzzleWorkSummary.ts | 2 + server-rs/crates/api-server/src/match3d.rs | 394 +++++++++++ .../crates/api-server/src/match3d/mappers.rs | 77 +- server-rs/crates/api-server/src/puzzle.rs | 160 ++++- .../crates/api-server/src/puzzle/mappers.rs | 22 + .../crates/module-match3d/src/application.rs | 156 +++- .../crates/module-puzzle/src/application.rs | 138 +++- .../shared-contracts/src/match3d_works.rs | 34 + .../shared-contracts/src/puzzle_works.rs | 4 + .../crates/spacetime-client/src/mapper.rs | 4 + .../spacetime-module/src/match3d/mod.rs | 38 +- .../spacetime-module/src/match3d/types.rs | 2 + .../crates/spacetime-module/src/puzzle.rs | 91 ++- .../src/runtime/creation_entry_config.rs | 3 +- .../creationWorkShelf.test.ts | 52 ++ .../custom-world-home/creationWorkShelf.ts | 21 +- .../match3d-result/Match3DResultView.test.tsx | 2 +- .../Match3DRuntimeShell.test.tsx | 329 ++++++++- .../match3d-runtime/Match3DRuntimeShell.tsx | 669 +++++++++++++++--- .../match3d-runtime/match3dHotspot.test.ts | 124 ++++ .../match3d-runtime/match3dHotspot.ts | 194 +++++ .../match3d-runtime/match3dRuntimeUiStyles.ts | 4 +- .../PlatformEntryFlowShellImpl.tsx | 539 +++++++++++--- .../puzzle-result/PuzzleResultView.test.tsx | 90 +++ .../puzzle-result/PuzzleResultView.tsx | 29 +- ...gEntryFlowShell.agent.interaction.test.tsx | 321 +++++++-- src/index.css | 118 ++- .../creationAgentClientFactory.test.ts | 1 + .../creationAgentClientFactory.ts | 4 +- .../match3d-runtime/match3dLocalRuntime.ts | 134 ++-- .../match3d-runtime/match3dTrayLayout.test.ts | 167 +++++ .../match3d-runtime/match3dTrayLayout.ts | 134 ++++ .../miniGameDraftGenerationProgress.test.ts | 16 + .../miniGameDraftGenerationProgress.ts | 7 +- .../puzzle-runtime/puzzleLocalRuntime.test.ts | 47 ++ .../puzzle-runtime/puzzleLocalRuntime.ts | 39 +- .../puzzleUiBackgroundSource.ts | 34 +- src/services/runtimeAudioFeedback.ts | 6 + 42 files changed, 3872 insertions(+), 443 deletions(-) create mode 100644 src/components/match3d-runtime/match3dHotspot.test.ts create mode 100644 src/components/match3d-runtime/match3dHotspot.ts create mode 100644 src/services/match3d-runtime/match3dTrayLayout.test.ts create mode 100644 src/services/match3d-runtime/match3dTrayLayout.ts diff --git a/.hermes/shared-memory/decision-log.md b/.hermes/shared-memory/decision-log.md index 7ec7b82b..c3196289 100644 --- a/.hermes/shared-memory/decision-log.md +++ b/.hermes/shared-memory/decision-log.md @@ -16,6 +16,14 @@ --- +## 2026-05-16 è‰ç¨¿ç”Ÿæˆä¸­æ€ç”±åŽç«¯ä½œå“æ‘˜è¦æ¢å¤ + +- 背景:è‰ç¨¿ä½œå“在生æˆä¸­æ—¶ï¼Œç”¨æˆ·é€€å‡ºæˆ–刷新页é¢åŽï¼Œå¦‚æžœå‰ç«¯åªä¾èµ–内存 notice,生æˆé®ç½©ä¼šä¸¢å¤±ï¼Œä½œå“架会误显示为普通è‰ç¨¿ã€‚ +- å†³ç­–ï¼šä½œå“æž¶ä¸Žå…¥å£å£³å±‚æ¢å¤ç”Ÿæˆä¸­æ€æ—¶ï¼Œç»Ÿä¸€ä»¥åŽç«¯ work summary 下å‘çš„ `generationStatus` 为准;å‰ç«¯å†…å­˜ notice åªæ‰¿æ‹…本轮会è¯çš„峿—¶å馈。拼图è‰ç¨¿åœ¨ç»“果页编译时会把 `generating` å†™å›žå¯æŒä¹…化的 work profile å…³å¡çжæ€ï¼ŒæŠ“大鹅è‰ç¨¿åˆ™é€šè¿‡ work summary 的素æå®Œæ•´æ€§å›žæŽ¨ç”Ÿæˆæ€ã€‚ +- å½±å“范围:è‰ç¨¿é¡µä½œå“æž¶ã€å¹³å°å…¥å£å£³å±‚ã€æ‹¼å›¾ / 抓大鹅 work summary 契约与åŽç«¯ç¼–排。 +- éªŒè¯æ–¹å¼ï¼šåˆ·æ–°æˆ–釿–°è¿›å…¥äº§å“åŽï¼Œä»å¯ä»Ž `generationStatus=generating` æ¢å¤ç­‰å¾…é®ç½©ï¼›ç›¸å…³æµ‹è¯•è§ `creationWorkShelf.test.ts`ã€æ‹¼å›¾ / 抓大鹅 works åˆçº¦æµ‹è¯•与åŽç«¯ç¼–排测试。 +- å…³è”æ–‡æ¡£ï¼š`docs/ã€çŽ©æ³•åˆ›ä½œã€‘å¹³å°å…¥å£ä¸ŽçŽ©æ³•é“¾è·¯-2026-05-15.md`。 + ## 2026-05-15 抓大鹅结果页 UI 预览å¤ç”¨è¿è¡Œæ€å¸ƒå±€ - 背景:抓大鹅结果页 `ç´ æé…ç½® > UI` 的预览弹层曾手写简化 HUD 和容器布局,和真实è¿è¡Œæ€é¡¶éƒ¨å…³å¡å¡ç‰‡ã€å³ä¸Šè®¾ç½®å…¥å£ã€å®¹å™¨å›¾å®šä½åŠæ§½ä½æ ·å¼å‡ºçŽ°æ¼‚ç§»ã€‚ diff --git a/.hermes/shared-memory/pitfalls.md b/.hermes/shared-memory/pitfalls.md index e001f6ba..987a88b3 100644 --- a/.hermes/shared-memory/pitfalls.md +++ b/.hermes/shared-memory/pitfalls.md @@ -22,6 +22,14 @@ - 验è¯ï¼šæ‹¼å›¾å…¥å£æµ‹è¯•ä»å¯é€šè¿‡ï¼Œä¸”新组件å¯é€šè¿‡ä¸åŒé¡µé¢å¤ç”¨è€Œä¸éœ€è¦å¤åˆ¶ä¸Šä¼ å¡å®žçŽ°ã€‚ - å…³è”:`src/components/common/CreativeImageInputPanel.tsx`ã€`src/components/puzzle-agent/PuzzleAgentWorkspace.tsx`。 +## 拼图å‚考图生æˆè‰ç¨¿æŠ¥ VectorEngine 编辑接å£è¶…时先查é™çº§é“¾è·¯ + +- 现象:上传å‚è€ƒå›¾å¹¶å¼€å¯ AI é‡ç»˜ç”Ÿæˆæ‹¼å›¾è‰ç¨¿æ—¶ï¼Œé¡µé¢æŠ¥ `VectorEngine` 编辑接å£è¶…时,生æˆè¿‡ç¨‹æ˜¾ç¤ºæœªåˆ°å‰ç«¯é•¿è¶…时上é™ä½†è‰ç¨¿å¤±è´¥ã€‚ +- 原因:å‚考图 AI é‡ç»˜ä¼˜å…ˆèµ° `/v1/images/edits` multipart,旧逻辑在编辑接å£è¶…æ—¶åŽç›´æŽ¥å¤±è´¥ï¼Œæ²¡æœ‰æ”¹èµ°æ”¯æŒå¼±å‚考图的 `/v1/images/generations`。 +- 处ç†ï¼š`api-server` åªåœ¨ç¼–辑接å£è¶…时类错误时é™çº§åˆ° `/v1/images/generations`,把åŒä¸€å‚è€ƒå›¾åŽ‹æˆ Data URL åŽæ”¾è¿› `image` 数组;鉴æƒã€å‚æ•°ã€å‚考图格å¼ç­‰éžè¶…时错误ä»åŽŸæ ·è¿”å›žã€‚ +- 验è¯ï¼š`cargo test -p api-server puzzle_vector_engine --manifest-path server-rs/Cargo.toml` 覆盖超时映射ã€é™çº§æ¡ä»¶å’Œç”Ÿæˆè¯·æ±‚体æºå¸¦å‚考图。 +- å…³è”:`server-rs/crates/api-server/src/puzzle.rs`ã€`docs/ã€çŽ©æ³•åˆ›ä½œã€‘å¹³å°å…¥å£ä¸ŽçŽ©æ³•é“¾è·¯-2026-05-15.md`。 + ## æ±ªæ±ªå£°æµªé‡æ–°å¼€æ”¾æ—¶ä¸è¦å†å›žåˆ°ç‹¬ç«‹é…置阶段 - 现象:汪汪声浪入å£å¦‚果继续切æ¢åˆ°ç‹¬ç«‹é…ç½®é˜¶æ®µï¼Œä¼šå’Œæ‹¼å›¾ã€æŠ“å¤§é¹…çš„åˆ›ä½œé¡µå†…åµŒç»“æž„ä¸ä¸€è‡´ï¼Œç”¨æˆ·ä¼šæ„Ÿè§‰å…¥å£è·³é¡µã€‚ @@ -262,6 +270,14 @@ - 验è¯ï¼š`npm run test -- src/components/puzzle-result/PuzzleResultView.test.tsx`,以åŠè‡ªåŠ¨è¯•çŽ©å…¥å£æµ‹è¯• `npm run test -- src/components/rpg-entry/RpgEntryFlowShell.agent.interaction.test.tsx -t "puzzle draft generation auto starts trial"`。 - å…³è”:`docs/ã€çŽ©æ³•åˆ›ä½œã€‘å¹³å°å…¥å£ä¸ŽçŽ©æ³•é“¾è·¯-2026-05-15.md`ã€`docs/ã€é¡¹ç›®åŸºçº¿ã€‘当å‰äº§å“与工程约æŸ-2026-05-15.md`ã€`docs/ã€å¼€å‘è¿ç»´ã€‘本地开å‘验è¯ä¸Žç”Ÿäº§è¿ç»´-2026-05-15.md`。 +## 拼图æŸå…³ç”Ÿæˆå®ŒæˆåŽä¸è¦æŠ¢å å…¶å®ƒå…³å¡ç¼–è¾‘é¢æ¿ + +- 现象:用户在第 2 关图片生æˆä¸­ç»§ç»­ç¼–辑第 3 关时,第 2 关生æˆå®Œæˆå›žåŒ…到达åŽï¼Œç»“果页关å¡è¯¦æƒ…颿¿ä¼šçªç„¶å…³é—­ã€åˆ‡å›žæˆ–弹出第 2 关,打断当å‰è¾“入。 +- 原因:生æˆå®Œæˆå›žåŒ…åªåŒ…å«åŽç«¯å·²çŸ¥å…³å¡å¿«ç…§ï¼Œå¯èƒ½ä¸åŒ…嫿œ¬åœ°æ­£åœ¨ç¼–辑或刚新增的关å¡ï¼›å¦‚æžœ `activeLevelId` 按回包原始 `levels` æ ¡éªŒï¼Œå°±ä¼šæŠŠå½“å‰æœ¬åœ°å…³å¡è¯¯åˆ¤ä¸ºä¸å­˜åœ¨å¹¶æ¸…ç©ºé¢æ¿çжæ€ã€‚ +- 处ç†ï¼š`PuzzleResultView` 收到新 draft 时先通过 `mergeDraftEditStateWithIncomingState(...)` 得到åˆå¹¶åŽçš„æœ¬åœ°ç¼–辑æ€ï¼Œå†ç”¨åˆå¹¶æ€ç»´æŠ¤ `activeLevelId` å’Œ `generationRuntimeByLevelId`。生æˆå®Œæˆåªæ›´æ–°å¯¹åº”å…³å¡ç´ æï¼Œä¸ä¸»åŠ¨æ‰“å¼€æˆ–åˆ‡æ¢è¯¦æƒ…颿¿ã€‚ +- 验è¯ï¼š`npm run test -- src/components/puzzle-result/PuzzleResultView.test.tsx -t "keeps the current level dialog open"`。 +- å…³è”:`src/components/puzzle-result/PuzzleResultView.tsx`ã€`docs/ã€çŽ©æ³•åˆ›ä½œã€‘å¹³å°å…¥å£ä¸ŽçŽ©æ³•é“¾è·¯-2026-05-15.md`。 + ## 自动è‰ç¨¿æˆåŠŸä½†ç¼ºéŸ³ä¹æˆ– UI 先查åŽç«¯åžé”™ - 现象:拼图或抓大鹅生æˆé¡µæç¤ºå®Œæˆï¼Œä½†è‰ç¨¿é¡µä»æ˜¾ç¤ºâ€œæš‚无音ä¹â€ï¼Œæ‹¼å›¾ UI 仿˜¯é»˜è®¤é¢„览,试玩局内也没有生æˆéŸ³ä¹æˆ– UI 背景。 @@ -270,6 +286,14 @@ - 验è¯ï¼š`cargo test -p api-server puzzle_initial_draft_assets_must_include_music_and_ui_background match3d_background_music_ready_requires_audio_src match3d_background_music_title_is_required_for_auto_draft --manifest-path server-rs\Cargo.toml`,并é‡å¯ `npm run api-server` åŽæ£€æŸ¥ `/healthz`。 - å…³è”:`docs/ã€åŽç«¯æž¶æž„】server-rs与SpacetimeDBæ•°æ®å¥‘约-2026-05-15.md`ã€`docs/ã€çŽ©æ³•åˆ›ä½œã€‘å¹³å°å…¥å£ä¸ŽçŽ©æ³•é“¾è·¯-2026-05-15.md`。 +## è‰ç¨¿ç”Ÿæˆä¸­æ€ä¸èƒ½åªé å‰ç«¯ notice + +- 现象:用户在拼图或抓大鹅生æˆä¸­é€€å‡ºäº§å“或刷新页é¢åŽï¼Œä½œå“架上的等待é®ç½©æ¶ˆå¤±ï¼Œç”Ÿæˆä¸­çš„è‰ç¨¿çœ‹èµ·æ¥åƒæ™®é€šè‰ç¨¿ã€‚ +- 原因:生æˆä¸­æ€åªåœç•™åœ¨å‰ç«¯å†…å­˜ notice,åŽç«¯ work summary æ²¡æœ‰ç¨³å®šä¸‹å‘ `generationStatus`ï¼Œåˆ·æ–°åŽæ— æ³•é‡å»ºã€‚ +- 处ç†ï¼šä½œå“架和平å°å…¥å£å£³å±‚统一以åŽç«¯ work summary çš„ `generationStatus` æ¢å¤ç”Ÿæˆä¸­æ€ï¼›å‰ç«¯ notice åªåšå½“å‰ä¼šè¯çš„峿—¶å馈。拼图编译è‰ç¨¿æ—¶è¦æŠŠ `generating` å†™å›žå¯æŒä¹…化的 work profile,抓大鹅则用素æå’ŒèƒŒæ™¯å®Œæ•´æ€§å›žæŽ¨ç”Ÿæˆæ€ã€‚ +- 验è¯ï¼šåˆ·æ–°æˆ–釿–°è¿›å…¥åŽä»èƒ½çœ‹åˆ°ç­‰å¾…é®ç½©ï¼›`src/components/custom-world-home/creationWorkShelf.test.ts`ã€æ‹¼å›¾ / 抓大鹅 works åˆçº¦æµ‹è¯•å’ŒåŽç«¯ç¼–排测试覆盖æ¢å¤ã€‚ +- å…³è”:`docs/ã€çŽ©æ³•åˆ›ä½œã€‘å¹³å°å…¥å£ä¸ŽçŽ©æ³•é“¾è·¯-2026-05-15.md`ã€`docs/ã€å¼€å‘è¿ç»´ã€‘本地开å‘验è¯ä¸Žç”Ÿäº§è¿ç»´-2026-05-15.md`。 + ## 拼图è‰ç¨¿ç”Ÿæˆ 180 ç§’åŽ 502/504 先查 VectorEngine 超时与å‰ç«¯é‡è¯• - çŽ°è±¡ï¼šç‚¹å‡»â€œç”Ÿæˆæ‹¼å›¾æ¸¸æˆè‰ç¨¿â€åŽï¼Œ`POST /api/runtime/puzzle/agent/sessions/{sessionId}/actions` 等待约 180 秒返回 `502 Bad Gateway` 或 `504 Gateway Timeout`ï¼›é’±åŒ…æµæ°´é‡ŒåŒä¸€ session å¯èƒ½å‡ºçŽ°è¿žç»­ä¸¤ç»„ `puzzle_initial_image` 扣费åŽé€€æ¬¾ã€‚ @@ -278,6 +302,14 @@ - 验è¯ï¼šè¿è¡Œ `npm run test -- src/services/creation-agent/creationAgentClientFactory.test.ts src/services/apiClient.test.ts`ã€`cargo test -p api-server puzzle_vector_engine --manifest-path server-rs/Cargo.toml`,真实è”è°ƒé‡å¯ `npm run api-server` åŽæ£€æŸ¥ `/healthz`。 - å…³è”:`docs/ã€åŽç«¯æž¶æž„】server-rs与SpacetimeDBæ•°æ®å¥‘约-2026-05-15.md`ã€`docs/ã€çŽ©æ³•åˆ›ä½œã€‘å¹³å°å…¥å£ä¸ŽçŽ©æ³•é“¾è·¯-2026-05-15.md`。 +## 拼图è‰ç¨¿é”å±å޿Х VectorEngine è¶…æ—¶å…ˆå¤è¯» session + +- 现象:电脑é”å±ã€æ¯å±æˆ–æµè§ˆå™¨åŽå°æŒ‚èµ·åŽï¼Œæ‹¼å›¾ç”Ÿæˆé¡µæ˜¾ç¤º VectorEngine `images/edits` 超时,但页é¢è®¡æ—¶åªæ˜¾ç¤ºä¸åˆ° 90 ç§’ï¼›ç¨åŽè‰ç¨¿æˆ–åŽç«¯ session 里å¯èƒ½å·²ç»æœ‰ç”Ÿæˆå¥½çš„å…³å¡å›¾ã€‚ +- 原因:å‰ç«¯é¡µé¢ç”Ÿå‘½å‘¨æœŸæˆ–网络连接先中断,HTTP action promise 进入失败分支;åŽç«¯å®žé™…长耗时生图和回写å¯èƒ½ä»åœ¨ç»§ç»­ã€‚è‹¥å‰ç«¯ç›´æŽ¥æŒ‰å¤±è´¥æ”¶å°¾ï¼Œä¼šæŠŠâ€œå‰ç«¯æ–­è¿žâ€è¯¯æŠ¥æˆâ€œæœåŠ¡ç«¯ç”Ÿæˆå¤±è´¥â€ï¼Œä¸”釿–°æ‰“开生æˆé¡µæ—¶è‹¥ç”¨ `Date.now()` é‡å»ºçжæ€ï¼Œä¼šè®©å·²è€—时看起æ¥è¢«é‡ç½®ã€‚ +- 处ç†ï¼šæ‹¼å›¾ action 失败åŽå…ˆ `getPuzzleAgentSession(sessionId)` å¤è¯»æœ€æ–° sessionï¼›åªè¦è¯»åˆ° `draft.coverImageSrc`ã€é¦–å…³ `coverImageSrc` 或首关候选图,就把 session è§„èŒƒæˆ readyã€å†»ç»“ `finishedAtMs`ã€åˆ·æ–°ä½œå“架并继续自动试玩/结果页链路。生æˆé¡µä»Žè‰ç¨¿æž¶æ¢å¤æ—¶ç”¨ä½œå“ `updatedAt` 还原 `startedAtMs`,完æˆ/失败æ€ä¸è¦ç»§ç»­ç´¯åŠ è€—æ—¶ã€‚ +- 验è¯ï¼š`npm run test -- src/components/rpg-entry/RpgEntryFlowShell.agent.interaction.test.tsx -t "embedded puzzle form recovers"`ã€`npm run test -- src/services/miniGameDraftGenerationProgress.test.ts`ã€`npm run typecheck`。 +- å…³è”:`src/components/platform-entry/PlatformEntryFlowShellImpl.tsx`ã€`src/services/miniGameDraftGenerationProgress.ts`ã€`docs/ã€çŽ©æ³•åˆ›ä½œã€‘å¹³å°å…¥å£ä¸ŽçŽ©æ³•é“¾è·¯-2026-05-15.md`。 + ## 本地脚本调 VectorEngine 生图å¡ä½å…ˆåŒºåˆ† fetch 首部超时 - 现象:用 Node `fetch` 直接请求 `POST /v1/images/generations`,已ç»è®¾ç½®è¾ƒé•¿çš„ AbortController 超时,但ä»åœ¨çº¦ 180 到 300 ç§’åŽæŠ› `AbortError`ã€`TypeError: fetch failed` 或 `UND_ERR_HEADERS_TIMEOUT`ï¼›åŒä¸€ prompt 改用原生 `https.request` å¯ä»¥åœ¨è¾ƒçŸ­æ—¶é—´å†…æˆåŠŸè¿”å›žå›¾ç‰‡ã€‚ @@ -724,8 +756,8 @@ - 现象:è‰ç¨¿æ¢å¤ã€ç»“果页素æé…ç½®ã€UI é¢„è§ˆæˆ–è¯•çŽ©æ—¶ä»æ˜¾ç¤ºé»˜è®¤èƒŒæ™¯ / 默认容器,但 work profile 的首个 `generatedItemAssets[].backgroundAsset` é‡Œå·²ç»æœ‰ç”Ÿæˆçš„背景和容器图。 - 原因:UI 背景和容器资产没有独立表字段,åŽç«¯æŒä¹…化常è½åœ¨ `generatedItemAssets[].backgroundAsset`;如果 session 映射ã€ç»“果页 profileã€æŽ¨èè¿è¡Œæ€è¯¦æƒ…补读åŽä¸æå‡åˆ°é¡¶å±‚ `generatedBackgroundAsset` å’Œ `backgroundImageSrc`,åŽç»­ç»„ä»¶ä¼šè¯¯åˆ¤â€œæ²¡æœ‰ç”Ÿæˆ UI 资产â€ã€‚ -- 处ç†ï¼šAgent session 返回å‰è¦ç”¨æŒä¹…化 work profile 资产回填 draftï¼›å‰ç«¯è¿›å…¥ç»“æžœé¡µã€æž„建è‰ç¨¿ profileã€æŽ¨è / 公开作å“å¯åЍè¿è¡Œæ€å‰ï¼Œéƒ½è¦æŠŠ `generatedItemAssets[].backgroundAsset` æå‡ä¸ºé¡¶å±‚背景字段。容器图在è¿è¡Œæ€å’Œ UI 预览å¤ç”¨åŒä¸€å¥—居中 `object-contain` æ ·å¼ï¼Œç§»åŠ¨ç«¯å®½åº¦æŽ¥è¿‘å±å®½ï¼Œåªæœ‰ç¼ºå¤±æˆ–加载失败时æ‰ä½¿ç”¨é€æ˜Žå‚考图兜底。 -- 验è¯ï¼š`cargo test -p api-server match3d_agent_session_response_hydrates_persisted_ui_assets --manifest-path server-rs/Cargo.toml`ã€`npm run test -- src/components/match3d-result/Match3DResultView.test.tsx src/components/match3d-runtime/Match3DRuntimeShell.test.tsx`。 +- 处ç†ï¼šAgent session 返回å‰è¦ç”¨æŒä¹…化 work profile 资产回填 draftï¼›è‰ç¨¿ç¼–译åŽçš„ `draft_json` 也必须æºå¸¦ `generated_item_assets_json` 快照,且 HTTP facade 在 work detail 回读为空时ä¸å¾—清空 draft 内已有 UI 资产。å‰ç«¯è¿›å…¥ç»“æžœé¡µã€æž„建è‰ç¨¿ profileã€ä½œå“æž¶ / 广场列表刷新ã€ç”Ÿæˆå®Œæˆè‡ªåŠ¨è¯•çŽ©ã€ç»“æžœé¡µæ‰‹åŠ¨è¯•çŽ©ã€æŽ¨è / 公开作å“å¯åЍè¿è¡Œæ€å‰ï¼Œéƒ½è¦æŠŠ `generatedItemAssets[].backgroundAsset` æå‡ä¸ºé¡¶å±‚背景字段。容器图在è¿è¡Œæ€å’Œ UI 预览å¤ç”¨åŒä¸€å¥—居中 `object-contain` æ ·å¼ï¼Œç§»åŠ¨ç«¯å®½åº¦ç•¥å¤§äºŽå±å®½å¹¶ä¿æŒåŽŸå›¾æ¯”ä¾‹ï¼Œåªæœ‰ç¼ºå¤±æˆ–加载失败时æ‰ä½¿ç”¨é€æ˜Žå‚考图兜底。 +- 验è¯ï¼š`cargo test -p api-server match3d_agent_session_response --manifest-path server-rs/Cargo.toml`ã€`cargo test -p spacetime-client match3d --manifest-path server-rs/Cargo.toml`ã€`cargo check -p spacetime-module --manifest-path server-rs/Cargo.toml`ã€`npm run test -- src/components/match3d-result/Match3DResultView.test.tsx src/components/match3d-runtime/Match3DRuntimeShell.test.tsx`。 ## 抓大鹅é‡å¯æ—¶ä¸è¦æ¸…空 generated 图片签å缓存 @@ -819,7 +851,7 @@ - 现象:抓大鹅生æˆçš„物å“视角图è£å‰ªåŽä»å¸¦ç™½è¾¹ï¼Œæˆ–者整å—çº¯ç»¿è‰²ç»¿å¹•èƒŒæ™¯æ²¡æœ‰è¢«é€æ˜ŽåŒ–,è¿è¡Œæ€çœ‹åˆ°ç»¿è‰²æ–¹å—。 - 原因:素æ sheet å¯èƒ½æ˜¯â€œæ¯æ ¼å†…éƒ¨ç»¿å¹•ã€æ•´å¼ å›¾å¤–圈近白底â€ï¼Œå†…部绿幕ä¸ä¸€å®šè¿žé€šåˆ° sheet 外边缘;旧 flood fill åªä»Žå¤–è¾¹ç¼˜æ‰¾èƒŒæ™¯ä¼šæ¼æŽ‰è¿™ç§ç»¿å¹•å—。白底抗锯齿如果ä¸çº³å…¥æŠ åƒå’Œè¾¹ç¼˜åŽ»æ±¡æŸ“ï¼Œä¹Ÿä¼šéšè£å‰ªè¾“出æˆä¸€åœˆç™½è¾¹ã€‚å³ä½¿é¡ºåºå·²æ˜¯å…ˆæ•´å¼  sheet 去绿å†è£å‰ªï¼Œè¾ƒåŽšçš„åŠé€æ˜Žæˆ–混色软绿边ä»å¯èƒ½ä½ŽäºŽé«˜ç½®ä¿¡ç»¿å¹•é˜ˆå€¼ï¼Œè¢«å½“ä½œå‰æ™¯å¸¦è¿›ç‹¬ç«‹ PNG。 -- 处ç†ï¼š`api-server` çš„ `slice_match3d_material_sheet` 必须先在整张 sheet 上åšé€æ˜ŽèƒŒæ™¯åŽå¤„ç†ï¼šå¤–边缘连通绿幕/近白底清 alpha,éžè¿žé€šä½†é«˜ç½®ä¿¡çº¯ç»¿å—也清 alpha,沿整张 sheet 逿˜ŽèƒŒæ™¯ç»§ç»­åƒæŽ‰è½¯ç»¿è¾¹ï¼›æ¯ä¸ªè§†è§’å•图还è¦ä»¥æ‰©å¤§çš„ PNG 边界带为ç§å­ï¼ŒæŠŠè¿žé€šçš„æµ…绿 / è¿‘ç™½æŠ—é”¯é½¿è¾¹ç›´æŽ¥æ”¹ä¸ºé€æ˜Žï¼Œå†æŒ‰å‰©ä½™å¯è§ä¸»ä½“æ”¶ç´§è£è¾¹ï¼ŒåŒæ—¶ä¿æŠ¤ä¸å¤Ÿçº¯çš„绿色主体åƒç´ ã€‚ä¸è¦æ”¹æˆå…ˆè£å‰ªå•æ ¼å†åŽ»ç»¿ã€‚ +- 处ç†ï¼š`api-server` çš„ `slice_match3d_material_sheet` 必须先在整张 sheet 上åšé€æ˜ŽèƒŒæ™¯åŽå¤„ç†ï¼šå¤–边缘连通绿幕/近白底清 alpha,éžè¿žé€šä½†é«˜ç½®ä¿¡çº¯ç»¿å—也清 alpha,沿整张 sheet 逿˜ŽèƒŒæ™¯ç»§ç»­åƒæŽ‰è½¯ç»¿è¾¹ï¼›æ¯ä¸ªè§†è§’å•图还è¦ä»¥æ‰©å¤§çš„ PNG 边界带为ç§å­ï¼ŒæŠŠè¿žé€šçš„æµ…绿 / è¿‘ç™½æŠ—é”¯é½¿è¾¹ç›´æŽ¥æ”¹ä¸ºé€æ˜Žï¼Œå¹¶å¯¹è´´é€æ˜ŽèƒŒæ™¯çš„弱绿 / 暗绿轮廓åƒç´ åšåŽ»ç»¿æ±¡æŸ“å¤„ç†ï¼Œå†æŒ‰å‰©ä½™å¯è§ä¸»ä½“æ”¶ç´§è£è¾¹ï¼ŒåŒæ—¶ä¿æŠ¤ä¸å¤Ÿçº¯çš„绿色主体åƒç´ ã€‚ä¸è¦æ”¹æˆå…ˆè£å‰ªå•æ ¼å†åŽ»ç»¿ã€‚ - 验è¯ï¼š`cargo test -p api-server match3d_material_sheet_slicing --manifest-path server-rs\Cargo.toml` 覆盖éžè¿žé€šç»¿å¹•ã€ç™½è¾¹ã€è´´è¾¹ä¸»ä½“ä¿ç•™å’Œå›ºå®š 5x5 切图。 - å…³è”:`docs/ã€åŽç«¯æž¶æž„】server-rs与SpacetimeDBæ•°æ®å¥‘约-2026-05-15.md`ã€`docs/ã€çŽ©æ³•åˆ›ä½œã€‘å¹³å°å…¥å£ä¸ŽçŽ©æ³•é“¾è·¯-2026-05-15.md`。 @@ -865,11 +897,11 @@ ## 拼图 UI èƒŒæ™¯åªæœ‰ objectKey æ—¶ä¸è¦å›žé€€é»˜è®¤ UI -- 现象:拼图è‰ç¨¿é¡µã€è¯•玩和正å¼è¿è¡Œæ€éƒ½æ˜¾ç¤ºé»˜è®¤ UI,或者åªåœ¨ç»“果页看到生æˆå›¾ï¼Œè¿›å…¥è¯•玩åŽåˆå›žåˆ°é»˜è®¤èƒŒæ™¯ã€‚ -- 原因:`uiBackgroundImageSrc` å¯èƒ½ä¸ºç©ºè€ŒçœŸå®žç”Ÿæˆç»“æžœåªå†™äº† `uiBackgroundImageObjectKey`;如果å‰ç«¯å’Œè¿è¡Œæ€åªè¯» `src`,或者本地试玩 / æ­£å¼ run 没把 `objectKey` 一起传递,就会丢掉已有背景。 -- 处ç†ï¼šç»Ÿä¸€é€šè¿‡ä¸€ä¸ªè§£æžå…¥å£æŠŠ `uiBackgroundImageSrc || uiBackgroundImageObjectKey` 归一到å¯å±•示路径;本地试玩和正å¼è¿è¡Œæ€éƒ½è¦ä¿ç•™ `uiBackgroundImageObjectKey`,并在 `uiBackgroundImageSrc` 为空时æ¢ç­¾è¯»å–。 -- 验è¯ï¼šç»“果页 UI Tabã€`startLocalPuzzleRun` å’Œ `PuzzleRuntimeShell` 都应在仅有 `objectKey` 时显示生æˆèƒŒæ™¯ï¼Œä¸å†å›žè½é»˜è®¤ UI。 -- å…³è”:`src/services/puzzle-runtime/puzzleUiBackgroundSource.ts`ã€`src/components/puzzle-result/PuzzleResultView.tsx`ã€`src/services/puzzle-runtime/puzzleLocalRuntime.ts`ã€`src/components/puzzle-runtime/PuzzleRuntimeShell.tsx`ã€`server-rs/crates/module-puzzle/src/application.rs`。 +- 现象:拼图è‰ç¨¿é¡µã€è¯•玩和正å¼è¿è¡Œæ€éƒ½æ˜¾ç¤ºé»˜è®¤ UI,或者åªåœ¨ç»“果页看到生æˆå›¾ï¼Œè¿›å…¥è¯•玩åŽåˆå›žåˆ°é»˜è®¤èƒŒæ™¯ï¼›ä¹Ÿå¯èƒ½ç¬¬ä¸€å…³åº”ç”¨äº†ç”Ÿæˆ UI 背景,第二关开始回到默认 UI 背景。 +- 原因:`uiBackgroundImageSrc` å¯èƒ½ä¸ºç©ºè€ŒçœŸå®žç”Ÿæˆç»“æžœåªå†™äº† `uiBackgroundImageObjectKey`;如果å‰ç«¯å’Œè¿è¡Œæ€åªè¯» `src`,或者本地试玩 / æ­£å¼ run 没把 `objectKey` 一起传递,就会丢掉已有背景。多关å¡ä½œå“里 UI 背景是作å“è¿è¡Œæ€èƒŒæ™¯ï¼Œä¸æ˜¯åªå±žäºŽç¬¬ä¸€å…³çš„å…³å¡å›¾ï¼›å¦‚æžœåŽç»­å…³å¡å­—段为空但è¿è¡Œæ€åªè¯»å½“å‰å…³å¡å­—段,也会回退默认 UI。 +- 处ç†ï¼šç»Ÿä¸€é€šè¿‡ä¸€ä¸ªè§£æžå…¥å£æŠŠ `uiBackgroundImageSrc || uiBackgroundImageObjectKey` 归一到å¯å±•示路径;本地试玩和正å¼è¿è¡Œæ€éƒ½è¦ä¿ç•™ `uiBackgroundImageObjectKey`,并在 `uiBackgroundImageSrc` 为空时æ¢ç­¾è¯»å–ã€‚ç›´è¾¾æŒ‡å®šå…³å¡æˆ–推进åŒä½œå“åŽç»­å…³å¡æ—¶ï¼ŒæŒ‰â€œç›®æ ‡å…³å¡ UI 背景 > åŒä½œå“首个å¯ç”¨ UI 背景 > 当å‰è¿è¡Œæ€å¿«ç…§èƒŒæ™¯ > 默认 UIâ€è§£æžã€‚ +- 验è¯ï¼šç»“果页 UI Tabã€`startLocalPuzzleRun`ã€`PuzzleRuntimeShell` å’Œ `module-puzzle` åŒä½œå“下一关推进都应在仅有 `objectKey` 或åŽç»­å…³å¡ç¼ºå­—段时显示生æˆèƒŒæ™¯ï¼Œä¸å†å›žè½é»˜è®¤ UI。 +- å…³è”:`src/services/puzzle-runtime/puzzleUiBackgroundSource.ts`ã€`src/components/puzzle-result/PuzzleResultView.tsx`ã€`src/services/puzzle-runtime/puzzleLocalRuntime.ts`ã€`src/components/puzzle-runtime/PuzzleRuntimeShell.tsx`ã€`server-rs/crates/module-puzzle/src/application.rs`ã€`server-rs/crates/spacetime-module/src/puzzle.rs`。 ## 拼图 UI 背景æç¤ºè¯æˆ–作å“元信æ¯å¼‚常先查首关命å契约 @@ -918,3 +950,19 @@ - 处ç†ï¼šå‰ç«¯æ ‡é¢˜å’Œé€‰ä¸­æ ‡ç­¾ä»Ž `imageSrc` 路径末尾推导,例如 `image.png`;时间解æžå…¼å®¹ ISO 与 `1713686400.000000Z`;创作页主图ã€åކå²åˆ—表图和结果页å‚考图继续用 `ResolvedAssetImage`,æäº¤ç»™åŽç«¯æ—¶ä»ä¿ç•™åŽŸå§‹ `imageSrc`。 - 验è¯ï¼š`npm run test -- src/components/puzzle-agent/PuzzleAgentWorkspace.interaction.test.tsx src/components/puzzle-result/PuzzleResultView.test.tsx`,并执行 `npm run check:encoding`。 - å…³è”:`docs/ã€åŽç«¯æž¶æž„】server-rs与SpacetimeDBæ•°æ®å¥‘约-2026-05-15.md`ã€`docs/ã€çŽ©æ³•åˆ›ä½œã€‘å¹³å°å…¥å£ä¸ŽçŽ©æ³•é“¾è·¯-2026-05-15.md`ã€`docs/ã€é¡¹ç›®åŸºçº¿ã€‘当å‰äº§å“与工程约æŸ-2026-05-15.md`ã€`docs/ã€å¼€å‘è¿ç»´ã€‘本地开å‘验è¯ä¸Žç”Ÿäº§è¿ç»´-2026-05-15.md`。 + +## æŠ“å¤§é¹…é€æ˜Ž PNG ä¸èƒ½åªæŒ‰åœ†å½¢ç²—筛命中 + +- 现象:抓大鹅物体看起æ¥ç‚¹ä¸­äº†é€æ˜Žè¾¹è§’或 `object-contain` 留白,但局内ä»è¢«åˆ¤å®šä¸ºå¯ç‚¹å‡»ã€‚ +- 原因:仅用中心圆åŠå¾„åšç²—ç­›ï¼Œä¼šæŠŠå›¾ç‰‡é€æ˜Žè§’和留白一起算进热区;`itemSize` 缩å°åŽï¼Œç©ºç™½æ›´æ˜Žæ˜¾ã€‚ +- 处ç†ï¼šå…ˆç”¨åœ†å½¢åŠå¾„åšç²—ç­›ï¼Œå†æŒ‰å½“å‰å±•示图的 alpha åƒç´ åšç²¾ç­›ï¼›é€æ˜Žåƒç´ ã€`object-contain` 留白和缩放åŽçš„空白区都ä¸èƒ½å‘½ä¸­ã€‚è‹¥ alpha 读å–失败,å†å›žé€€ç²—ç­›ä¿ç•™å¯ç‚¹èƒ½åŠ›ã€‚ +- 验è¯ï¼š`npm run test -- src/components/match3d-runtime/match3dHotspot.test.ts`。 +- å…³è”:`src/components/match3d-runtime/match3dHotspot.ts`ã€`src/components/match3d-runtime/Match3DRuntimeShell.tsx`ã€`docs/ã€çŽ©æ³•åˆ›ä½œã€‘å¹³å°å…¥å£ä¸ŽçŽ©æ³•é“¾è·¯-2026-05-15.md`。 + +## æŠ“å¤§é¹…ç‰©å“æ æ’å…¥è¦æŒ‰åŒç±»åŽæ’并在三消åŽå‰åŽ‹è¡¥ä½ + +- 现象:抓大鹅è¿è¡Œæ€ç‚¹å‡»ç‰©å“åŽï¼Œåº•éƒ¨ç‰©å“æ åªæ˜¯ç®€å•追加到第一个空ä½ï¼Œå¯¼è‡´åŒç±»ç‰©å“顺åºè¢«æ‰“乱;三件åŒç±»å‡‘é½åŽåªæ˜¯çž¬æ—¶æ¸…空,没有“左å³å‘ä¸­é—´åˆæˆå†ä¸€èµ·æ¶ˆå¤±â€çš„过渡感,åŽé¢çš„物å“也ä¸ä¼šåœ¨è§†è§‰ä¸Šå‰åŽ‹è¡¥ä½ã€‚ +- 原因:å‰ç«¯å’ŒåŽç«¯éƒ½æ›¾æŒ‰â€œç¬¬ä¸€ä¸ªç©ºæ§½â€å…¥æ§½ï¼Œæ²¡ä¿ç•™åŒç±»æœ«å°¾æ’入的托盘布局规则;清除åŽä¹Ÿåªåšäº†é™æ€å¿«ç…§æ›´æ–°ï¼Œæ²¡æœ‰å•ç‹¬çš„åˆæˆè¦†ç›–层和补ä½åŠ¨ç”»å±‚ã€‚ +- 处ç†ï¼šæ‰˜ç›˜æ’入统一改æˆâ€œå…ˆæ‰¾åŒç±»æœ€åŽä¸€ä¸ªç‰©å“,æ’到它åŽé¢ï¼›æ²¡æœ‰åŒç±»å°±è¿½åŠ åˆ°æœ«å°¾â€ï¼Œå¹¶åœ¨å‰ç«¯ä¸ºåŒç±»ä¸‰æ¶ˆå¢žåŠ è¦†ç›–å±‚åŠ¨ç”»ï¼šä¸‰ä¸ªç‰©å“在飞入结æŸåŽå‘托盘中点收拢并淡出,éšåŽåŽé¢çš„æ‰˜ç›˜ç‰©å“åšå‰åŽ‹è¡¥ä½ã€‚åŽç«¯ç¡®è®¤é€»è¾‘åªæ¸…当å‰ç‚¹å‡»ç±»åž‹çš„三连,清除åŽå†åŽ‹ç¼©æ§½ä½ã€‚ +- 验è¯ï¼š`npm run test -- src/services/match3d-runtime/match3dTrayLayout.test.ts src/components/match3d-runtime/match3dHotspot.test.ts src/components/match3d-runtime/Match3DRuntimeShell.test.tsx`,`cargo test -p module-match3d --manifest-path server-rs/Cargo.toml`,`npm run typecheck`。 +- å…³è”:`src/services/match3d-runtime/match3dTrayLayout.ts`ã€`src/services/match3d-runtime/match3dLocalRuntime.ts`ã€`src/components/match3d-runtime/Match3DRuntimeShell.tsx`ã€`server-rs/crates/module-match3d/src/application.rs`。 diff --git a/docs/ã€çŽ©æ³•åˆ›ä½œã€‘å¹³å°å…¥å£ä¸ŽçŽ©æ³•é“¾è·¯-2026-05-15.md b/docs/ã€çŽ©æ³•åˆ›ä½œã€‘å¹³å°å…¥å£ä¸ŽçŽ©æ³•é“¾è·¯-2026-05-15.md index 4f448db2..d323e87d 100644 --- a/docs/ã€çŽ©æ³•åˆ›ä½œã€‘å¹³å°å…¥å£ä¸ŽçŽ©æ³•é“¾è·¯-2026-05-15.md +++ b/docs/ã€çŽ©æ³•åˆ›ä½œã€‘å¹³å°å…¥å£ä¸ŽçŽ©æ³•é“¾è·¯-2026-05-15.md @@ -14,7 +14,8 @@ 2. è‰ç¨¿ / å·²å‘布状æ€å°½é‡å›¾æ ‡åŒ–,ä¸ä½¿ç”¨å¤§æ®µçŠ¶æ€æ–‡æ¡ˆã€‚ 3. è‰ç¨¿å¡å¸¸æ€ä¸å¤–露低频动作;已å‘布作å“å¡å³ä¸Šè§’å¯ç›´æŽ¥æ˜¾ç¤ºæ— è¾¹æ¡†åˆ†äº« iconï¼Œåˆ é™¤ç­‰ç ´åæ€§åŠ¨ä½œç»§ç»­æ”¶å£åˆ°å·¦æ»‘或长按æ“作层。 4. 生æˆä¸­ä½œå“在整å¡ä¸ŠåŠ ç­‰å¾…é®ç½©ï¼Œä½†ä¸ç§»é™¤ä½œå“基础信æ¯ã€‚ -5. ç§æœ‰ generated 图片必须通过 `ResolvedAssetImage` / `/api/assets/read-url` æ¢ç­¾è¯»å–。 +5. 生æˆä¸­çжæ€ä¸èƒ½åªå­˜åœ¨å‰ç«¯å†…å­˜ notice。åŽç«¯ä½œå“摘è¦å¿…须下å‘坿¢å¤çš„ `generationStatus`ï¼›å‰ç«¯åˆ·æ–°æˆ–退出产å“åŽï¼Œä½œå“架优先用摘è¦çŠ¶æ€æ¢å¤ç­‰å¾…é®ç½©ï¼Œæœ¬è½®å†…å­˜ notice åªä½œä¸ºå³æ—¶å馈。 +6. ç§æœ‰ generated 图片必须通过 `ResolvedAssetImage` / `/api/assets/read-url` æ¢ç­¾è¯»å–。 ## 拼图 @@ -28,8 +29,13 @@ - 图åƒè¾“å…¥å¤ç”¨ `CreativeImageInputPanel`。 - 支æŒç”»é¢æè¿°ç”Ÿå›¾ã€å¤šå‚考图生图ã€ä¸Šä¼ ä¸»å›¾åŽ AI é‡ç»˜ã€ä¸Šä¼ ä¸»å›¾åŽä¸é‡ç»˜ã€‚ -- è‰ç¨¿ç”Ÿæˆä¼šä¿ç•™å…³å¡å›¾å’Œ UI 背景;当å‰ä¸è‡ªåŠ¨ç”ŸæˆèƒŒæ™¯éŸ³ä¹ã€‚ +- è‰ç¨¿ç”Ÿæˆä¼šå…ˆæŒä¹…化 `generationStatus=generating` çš„ä½œå“æ‘˜è¦ï¼Œç”Ÿæˆå®Œæˆå¹¶å›žå†™å…³å¡å›¾ã€UI 背景åŽå†å˜ä¸º `ready`;当å‰ä¸è‡ªåŠ¨ç”ŸæˆèƒŒæ™¯éŸ³ä¹ã€‚ +- 拼图è‰ç¨¿ç¼–译是长耗时 action,å‰ç«¯ action 请求默认等待 `1_000_000ms` 且ä¸è‡ªåЍé‡è¯•;生æˆé¡µæ¢å¤æ—¶å¿…é¡»æ²¿ç”¨ä½œå“æ‘˜è¦ `updatedAt` 作为原始 `startedAtMs`,失败/å®Œæˆæ€ç”¨ `finishedAtMs` 冻结耗时,ä¸èƒ½åœ¨é”屿ˆ–返回è‰ç¨¿é¡µåŽé‡æ–°ä»Ž 0 计时。 +- è‹¥æµè§ˆå™¨é”å±ã€æ¯å±æˆ–网络切æ¢å¯¼è‡´ compile 请求失败,å‰ç«¯åœ¨æ ‡è®°å¤±è´¥å‰å¿…须先å¤è¯» `getPuzzleAgentSession(sessionId)`ï¼›åªæœ‰æœ€æ–° session ä»ç¼º `draft.coverImageSrc`ã€é¦–å…³ `coverImageSrc` 或候选图时æ‰å±•示失败,å¤è¯»åˆ°å·²ç”Ÿæˆè‰ç¨¿æ—¶æŒ‰æˆåŠŸæ”¶å°¾ã€åˆ·æ–°ä½œå“架并继续自动试玩/结果页链路。 +- 拼图å‚考图 AI é‡ç»˜ä¼˜å…ˆèµ° VectorEngine `/v1/images/edits`;若编辑接å£è¶…时,`api-server` 会é™çº§ä¸º `/v1/images/generations`,并把åŒä¸€å‚考图塞进 `image` 数组继续生æˆï¼Œé¿å…å‚考图è‰ç¨¿æ•´å•失败。 - 结果页素æé…置当å‰åªä¿ç•™ UI 相关能力;旧背景音ä¹å…¥å£éšè—。 +- 结果页å…许多关å¡å¹¶è¡Œç¼–辑和生æˆï¼›æŸä¸€å…³å¡å›¾ç‰‡ç”Ÿæˆå®Œæˆå›žåŒ…åªé™é»˜æ›´æ–°è¯¥å…³å¡ç´ æä¸Žç”Ÿæˆæ€ï¼Œä¸å¾—自动打开或切æ¢å…³å¡è¯¦æƒ…颿¿ï¼Œé¿å…打断用户正在编辑的其它关å¡ã€‚ +- 拼图 UI 背景是作å“è¿è¡Œæ€èƒŒæ™¯ï¼Œä¸åªå±žäºŽç¬¬ä¸€å…³ï¼›æœ¬åœ°è¯•玩ã€ç›´è¾¾æŒ‡å®šå…³å¡å’Œæ­£å¼ `next-level` 推进时,目标关å¡ç¼º `uiBackgroundImageSrc/uiBackgroundImageObjectKey` 必须继承åŒä½œå“首个å¯ç”¨ UI 背景,ä»ç¼ºå¤±æ—¶æ‰æ²¿ç”¨å½“å‰è¿è¡Œæ€å¿«ç…§èƒŒæ™¯æˆ–默认 UI。 - 拼图è¿è¡Œæ€æ£‹ç›˜ä¸å åŠ åˆ†å—è’™ç‰ˆã€æè¾¹ã€é˜´å½±ã€é€‰ä¸­åº•色或åˆå¹¶å— SVG 轮廓;拼图片本体需è¦è£åˆ‡ä¸ºåœ†è§’形状,å•å—使用独立圆角è£åˆ‡ï¼Œåˆå¹¶å—使用 SVG 原生 `clipPath` è£åˆ‡æ•´ä½“外轮廓,外凸角和内凹角分别计算åŠå¾„,内凹角åŠå¾„è¦æ¯”外凸角更明显以é¿å…手机 WebView 中看起æ¥ä»æ˜¯ç›´è§’。原图é“å…·åªåœ¨ç”¨æˆ·ä¸»åŠ¨ç¡®è®¤åŽæ‰“开独立原图查看层,ä¸åœ¨å½“剿‹¼å›¾æ£‹ç›˜ä¸Šå åŠ åŽŸå›¾ã€‚ - 拼图è¿è¡Œæ€æ‹–æ‹½å¿…é¡»å®Œå…¨è·Ÿéšæ‰‹æŒ‡æˆ–é¼ æ ‡ä½ç½®ï¼Œ`pointermove` æœŸé—´å³æ—¶å†™å…¥å¯è§æ‹¼å—çš„ transform,ä¸ä¾èµ–等待åŽç«¯å›žåŒ…ã€React 釿¸²æŸ“或下一帧动画队列;进入拖动åŽä¸å±•示拼å—é€‰ä¸­æ€æˆ–â€œå·²é€‰æ‹©â€æç¤ºï¼Œæ¾æ‰‹åŽå†æäº¤ç›®æ ‡æ ¼åŒæ­¥è§„则真相。 - 拼图è¿è¡Œæ€çš„æç¤ºã€è®¾ç½®ç­‰ç‚¹å‡»å¼¹å±‚è·Ÿéšå½“å‰è¿è¡Œæ€ä¸»è‰²ä¸»é¢˜ï¼Œä½¿ç”¨æ™®é€šåœ†è§’ä¸»é¢˜é¢æ¿ï¼Œä¸å¤ç”¨åƒç´ ä¹å®«æ ¼ç´ ææ¡†ã€‚ @@ -51,24 +57,24 @@ 难度映射: | 难度 | clearCount | difficulty | æ€»ç‰©å“æ•° | 物å“ç§ç±» | -| --- | ---: | ---: | ---: | ---: | -| è½»æ¾ | 8 | 2 | 24 | 3 | -| 标准 | 12 | 4 | 36 | 9 | -| 进阶 | 16 | 6 | 48 | 15 | -| 硬核 | 21 | 8 | 63 | 21 | +| ---- | ---------: | ---------: | -------: | -------: | +| è½»æ¾ | 8 | 2 | 24 | 3 | +| 标准 | 12 | 4 | 36 | 9 | +| 进阶 | 16 | 6 | 48 | 15 | +| 硬核 | 21 | 8 | 63 | 21 | 当å‰ç´ æç”Ÿæˆæµæ°´çº¿ï¼š 1. 点击生æˆå‰å¼¹å‡ºæ³¥ç‚¹ç¡®è®¤ï¼Œè‰ç¨¿ç”Ÿæˆå›ºå®šæ¶ˆè€— `10` 泥点。 -2. å…ˆå†™å…¥å¯æ¢å¤è‰ç¨¿ profileï¼Œå†æ‰§è¡Œæ–‡æœ¬è®¡åˆ’ã€å›¾ç‰‡ç”Ÿæˆã€åˆ‡å›¾ã€OSS 上传ã€èƒŒæ™¯å’Œå®¹å™¨ç”Ÿæˆï¼›è‰ç¨¿å®Œæˆæ¡ä»¶ä¸åŒ…å« `backgroundMusic`。 +2. å…ˆå†™å…¥å¯æ¢å¤è‰ç¨¿ profileï¼Œå†æ‰§è¡Œæ–‡æœ¬è®¡åˆ’ã€å›¾ç‰‡ç”Ÿæˆã€åˆ‡å›¾ã€OSS 上传ã€èƒŒæ™¯å’Œå®¹å™¨ç”Ÿæˆï¼›ä½œå“摘è¦åœ¨ç´ ææˆ–èƒŒæ™¯æœªå®Œæ•´æ—¶ä¸‹å‘ `generationStatus=generating`,素æå’ŒèƒŒæ™¯å®Œæ•´åŽä¸‹å‘ `ready`,è‰ç¨¿å®Œæˆæ¡ä»¶ä¸åŒ…å« `backgroundMusic`。 3. 物å“ç´ æä¸å†è°ƒç”¨ Hyper3D Rodin,ä¸å†ç”Ÿæˆ GLB。新è‰ç¨¿å’Œæ‰¹é‡æ–°å¢žå›ºå®šç”Ÿæˆ 2D 五视角素æã€‚ 4. ç‰©å“ sheet èµ° VectorEngine Gemini `gemini-3-pro-image-preview` 原生 `generateContent`,å•å¼  `1:1` 图固定 `5*5`,æ¯å¼ æ‰¿è½½ `5` 个物å“ã€æ¯ä¸ªç‰©å“ `5` 个视角。 -5. 切图å‰å…ˆåœ¨æ•´å¼  sheet 上åšç»¿å¹• / è¿‘ç™½åº•é€æ˜ŽåŒ–å’Œè¾¹ç¼˜åŽ»æ±¡æŸ“ï¼Œå†æŒ‰æ ¼å­å¯¼å‡ºç‹¬ç«‹ PNGï¼›æ¯ä¸ªè§†è§’图å†ä»¥æ‰©å¤§çš„ PNG 边界带为ç§å­ï¼ŒæŠŠè¿žé€šçš„æµ…绿 / è¿‘ç™½æŠ—é”¯é½¿è¾¹ç›´æŽ¥æ”¹ä¸ºé€æ˜Žï¼Œå¹¶æŒ‰å‰©ä½™å¯è§ä¸»ä½“二次收紧;ä¸è¦å…ˆè£å‰ªå•æ ¼å†å„自去绿。 +5. 切图å‰å…ˆåœ¨æ•´å¼  sheet 上åšç»¿å¹• / è¿‘ç™½åº•é€æ˜ŽåŒ–å’Œè¾¹ç¼˜åŽ»æ±¡æŸ“ï¼Œå†æŒ‰æ ¼å­å¯¼å‡ºç‹¬ç«‹ PNGï¼›æ¯ä¸ªè§†è§’图å†ä»¥æ‰©å¤§çš„ PNG 边界带为ç§å­ï¼ŒæŠŠè¿žé€šçš„æµ…绿 / è¿‘ç™½æŠ—é”¯é½¿è¾¹ç›´æŽ¥æ”¹ä¸ºé€æ˜Žï¼Œå¹¶å¯¹è´´é€æ˜ŽèƒŒæ™¯çš„弱绿 / 暗绿轮廓åƒç´ åšåŽ»ç»¿æ±¡æŸ“å¤„ç†ï¼Œæœ€åŽæŒ‰å‰©ä½™å¯è§ä¸»ä½“二次收紧;ä¸è¦å…ˆè£å‰ªå•æ ¼å†å„自去绿。 6. `generatedItemAssets[].imageViews[]` 是新素æä¸»å­—段,`imageSrc/imageObjectKey` åªå…¼å®¹é¦–张视角。 7. 文本生æˆç‰©å“åç§°æ—¶å¿…é¡»åŒæ—¶ç”Ÿæˆ `itemSize`,åªå…许 `大`ã€`中`ã€`å°`ã€‚è¯¥å­—æ®µéš `generatedItemAssets[].itemSize` æŒä¹…化并下å‘;历å²ç¼ºå¤±å­—æ®µçš„ç´ ææŒ‰ `大` å…¼å®¹ï¼Œæ¨¡åž‹ç¼ºå¤±æˆ–éžæ³•值按物å“åæœ¬åœ°æŽ¨æ–­ã€‚ 8. 局内 `9:16` 纯背景图和 `1:1` 中心容器 UI 图分开生æˆã€‚纯背景ä¸å¾—包å«é”…ã€ç›˜ã€æ‰˜ç›˜ã€HUDã€æŒ‰é’®ã€æ–‡å­—或物å“,且入库å‰å¿…é¡»åˆæˆä¸ºå…¨ç”»å¹…ä¸é€æ˜Žå›¾ç‰‡ï¼Œä¸å…è®¸å‡ºçŽ°é€æ˜ŽåŒºåŸŸï¼›å®¹å™¨å›¾èµ° `/v1/images/edits` å‚è€ƒé€æ˜Žå®¹å™¨å›¾ã€‚ 9. å½“å‰æŠ“å¤§é¹…éŸ³é¢‘ç”Ÿæˆå…³é—­ï¼šå…¥å£æ—  `生æˆéŸ³æ•ˆ`,è‰ç¨¿ä¸ç”ŸæˆèƒŒæ™¯éŸ³ä¹æˆ–点击音效,结果页ä¸å±•ç¤ºèƒŒæ™¯éŸ³ä¹ Tab 或点击音效生æˆå…¥å£ã€‚åŽ†å² `backgroundMusic` / `clickSound` 字段继续兼容传递。 -10. UI 背景和容器资产的æŒä¹…化真相ä»åœ¨ `generatedItemAssets[].backgroundAsset`ï¼›Agent sessionã€work summary/detailã€ç»“果页和è¿è¡Œæ€å…¥å£éƒ½å¿…须把该字段æå‡ä¸º `backgroundImageSrc/backgroundImageObjectKey/generatedBackgroundAsset` 读å–,é¿å…è‰ç¨¿é‡è¿›ã€ç»“果页预览或试玩退回默认素æã€‚ +10. UI 背景和容器资产的æŒä¹…化真相ä»åœ¨ `generatedItemAssets[].backgroundAsset`ï¼›Agent sessionã€work summary/detailã€ç»“果页和è¿è¡Œæ€å…¥å£éƒ½å¿…须把该字段æå‡ä¸º `backgroundImageSrc/backgroundImageObjectKey/generatedBackgroundAsset` 读å–。è‰ç¨¿ç¼–译åŽçš„ `draftJson` 自身也必须æºå¸¦ `generatedItemAssets` å¿«ç…§ï¼›HTTP facade ä¸èƒ½åªä¾èµ– work detail å›žè¯»è¡¥é½ UI 资产,外部回读为空时也ä¸å¾—清空è‰ç¨¿å†…已有的背景 / 容器图。平å°å£³å±‚ä»Žä½œå“æž¶ã€å¹¿åœºã€ç”Ÿæˆå®Œæˆå›žè°ƒã€ç»“果页ä¿å­˜ / å‘布 / 试玩回调进入 Match3D profile 时也è¦å…ˆå½’一化并æå‡ï¼Œé¿å…é¦–æ¬¡è¯•çŽ©ã€æ‰‹åŠ¨è¯•çŽ©ã€æŽ¨èæµæˆ–å…¬å¼€è¯¦æƒ…è¿è¡Œæ€é€€å›žé»˜è®¤èƒŒæ™¯ / 默认容器。 结果页当å‰ç»“构: @@ -81,15 +87,16 @@ è¿è¡Œæ€å½“å‰å£å¾„: - 规则真相在åŽç«¯ï¼›å‰ç«¯åªåšå³æ—¶è¡¨çްã€ç‚¹å‡»å€™é€‰ã€é£žå…¥ã€å…¥æ§½ã€ä¸‰æ¶ˆå’Œèƒœè´Ÿè¿‡æ¸¡ã€‚ -- 物å“选择åªåœ¨ `pointerup` æ—¶æäº¤ï¼›`pointerdown` / `pointermove` åªæ›´æ–°å€™é€‰æ ·å¼ã€‚æ¾æ‰‹æ—¶æŒ‰å½“å‰ä½ç½®å’Œæœ€æ–°å¿«ç…§å‘½ä¸­ä¸€ä¸ªæœ€ä¸Šå±‚å¯ç‚¹å‡»ç‰©å“。 +- 物å“选择åªåœ¨ `pointerup` æ—¶æäº¤ï¼›`pointerdown` / `pointermove` åªæ›´æ–°å€™é€‰æ ·å¼ã€‚æ¾æ‰‹æ—¶æŒ‰å½“å‰ä½ç½®å’Œæœ€æ–°å¿«ç…§å‘½ä¸­ä¸€ä¸ªæœ€ä¸Šå±‚å¯ç‚¹å‡»ç‰©å“ï¼›ç”Ÿæˆ 2D PNG 物å“必须按当å‰å±•示图的 alpha åƒç´ åšçƒ­åŒºç²¾ç­›ï¼Œé€æ˜Žåƒç´ ã€`object-contain` 留白和 `itemSize` 缩å°åŽçš„空白区ä¸èƒ½å“应点击。 - ç‰©å“ DOM åªè´Ÿè´£å±•示,ä¸é€šè¿‡è‡ªèº« `click` 事件直接æäº¤ï¼Œé¿å…æµè§ˆå™¨åŽç»­ click ç»•è¿‡æ¾æ‰‹åˆ¤å®šé€ æˆé‡å¤æäº¤ã€‚ - åˆå§‹ç‰©å“åæ ‡å›´ç»•容器å£ä¸­å¿ƒç”Ÿæˆï¼Œå¹¶ä¿ç•™å†…缩安全è·ç¦»ï¼Œé¿å…贴边和局部角è½èšé›†ã€‚ - 本地试玩与 Rust `module-match3d` åŽç«¯é¢†åŸŸç”Ÿæˆä½¿ç”¨åŒä¸€å¥—中心铺开å£å¾„;生æˆç‚¹è¦†ç›–四象é™ä¸”å‡å€¼æŽ¥è¿‘中心。 - è¿è¡Œæ€ä¼˜å…ˆæ¶ˆè´¹ 2D 生æˆå›¾ï¼›é»˜è®¤ç§¯æœ¨ / 程åºåŒ– 3D 表现åªä½œä¸ºè§†è§‰åˆ†æ”¯å’Œå…œåº•ï¼Œä¸æ”¹å˜è§„则真相。 -- è¿è¡Œæ€å¯åЍå‰è¦é¢„加载 `generatedItemAssets[].imageViews[]`ã€é¡¶å±‚ `generatedBackgroundAsset`ã€ç‰©å“挂载 `backgroundAsset` 中的背景和容器图;å¡ç‰‡æ‘˜è¦ç¼º UI 背景或容器字段时,进入è¿è¡Œæ€å‰å¿…须补读 work detail。补读åŽçš„ profile 也è¦å†æ¬¡æå‡ `generatedItemAssets[].backgroundAsset`,确ä¿èƒŒæ™¯å’Œå®¹å™¨å­—段传给 `Match3DRuntimeShell`。 -- 局内容器图在移动端宽度接近å±å¹•å®½åº¦å¹¶å±…ä¸­æ˜¾ç¤ºï¼Œä¿æŒåŽŸå›¾æ¯”ä¾‹ä¸æ‹‰ä¼¸ï¼›ç”Ÿæˆå®¹å™¨å›¾åŠ è½½æˆåŠŸåŽæ£‹ç›˜å¤–壳逿˜Žä¸” `overflow-visible`ï¼Œåªæœ‰ç”Ÿæˆå›¾ç¼ºå¤±æˆ–åŠ è½½å¤±è´¥æ—¶æ‰æ˜¾ç¤ºé€æ˜Žå‚考容器兜底。 +- è¿è¡Œæ€å¯åЍå‰è¦é¢„加载 `generatedItemAssets[].imageViews[]`ã€é¡¶å±‚ `generatedBackgroundAsset`ã€ç‰©å“挂载 `backgroundAsset` 中的背景和容器图;首次生æˆè‡ªåŠ¨è¯•çŽ©ã€ç»“æžœé¡µæ‰‹åŠ¨è¯•çŽ©ã€æŽ¨èæµå’Œå…¬å¼€è¯¦æƒ…å¯åŠ¨éƒ½å¿…é¡»ä¼ å…¥æå‡åŽçš„ profile。å¡ç‰‡æ‘˜è¦ç¼º UI 背景或容器字段时,进入è¿è¡Œæ€å‰å¿…须补读 work detail。补读åŽçš„ profile 也è¦å†æ¬¡æå‡ `generatedItemAssets[].backgroundAsset`,确ä¿èƒŒæ™¯å’Œå®¹å™¨å­—段传给 `Match3DRuntimeShell`。 +- 局内容器图在移动端宽度大于å±å¹•宽度并略å‘下压,当å‰è¿è¡Œæ€ä½¿ç”¨ `w-[min(116vw,42rem)]` 与 `top-[54%]` æ”¾å¤§å’Œä¸‹ç§»å®¹å™¨å›¾æœ¬ä½“ï¼Œä¿æŒåŽŸå›¾æ¯”ä¾‹ä¸æ‹‰ä¼¸ä¸”䏿”¹å˜åŽç«¯ç‰©å“布局ã€ç‚¹å‡»åŠå¾„或消除规则;生æˆå®¹å™¨å›¾åŠ è½½æˆåŠŸåŽæ£‹ç›˜å¤–壳逿˜Žä¸” `overflow-visible`ï¼Œåªæœ‰ç”Ÿæˆå›¾ç¼ºå¤±æˆ–åŠ è½½å¤±è´¥æ—¶æ‰æ˜¾ç¤ºé€æ˜Žå‚考容器兜底。 - generated ç§æœ‰å›¾æ¢ç­¾æœªå®Œæˆæ—¶ï¼Œå±€å†…物å“å…ˆéšè—等待,ä¸å¾—短暂显示默认积木;åŒä¸€æ‰¹èµ„æºåœ¨é‡å¯ run æ—¶ä¿ç•™å·²è§£æžç­¾å URLï¼Œåªæœ‰èµ„æºæºåˆ—表å˜åŒ–或æ¢ç­¾å¤±è´¥åŽæ‰å…许进入兜底视觉。 -- `itemSize` åªç¼©æ”¾ç”Ÿæˆ 2D 图片本体:`大` 使用当å‰é»˜è®¤æ˜¾ç¤ºå°ºå¯¸ï¼Œ`中` å’Œ `å°` ç¼©å°æ˜¾ç¤ºï¼›ä¸æ”¹å˜åŽç«¯ä¸‹å‘的布局åŠå¾„ã€ç‚¹å‡»åŠå¾„或三消规则。 +- `itemSize` åªç¼©æ”¾ç”Ÿæˆ 2D 图片本体:`大`ã€`中`ã€`å°` 凿Œ‰ç›¸å¯¹å°ºå¯¸ç¼©æ”¾ï¼Œå…¶ä¸­ `大` 也比原始图片略å°ï¼Œ`中` å’Œ `å°` 进一步缩å°ï¼›ä¸æ”¹å˜åŽç«¯ä¸‹å‘的布局åŠå¾„ã€ç‚¹å‡»åŠå¾„或三消规则。 +- 物å“è¿›å…¥åº•éƒ¨ç‰©å“æ æ—¶æŒ‰åŒç±»åž‹æ’å…¥ï¼šå¦‚æžœç‰©å“æ å·²æœ‰åŒç±»ç‰©å“ï¼Œæ–°ç‰©å“æ’到该类型最åŽä¸€ä¸ªç‰©å“åŽé¢ï¼ŒåŽç»­ç‰©å“整体åŽç§»ï¼›æ²¡æœ‰åŒç±»æ—¶è¿½åŠ åˆ°å½“å‰æœ«å°¾ã€‚达到三件åŒç±»æ—¶ï¼Œåœ¨é£žå…¥ç‰©å“æ åŠ¨ç”»ç»“æŸåŽï¼Œå·¦ä¾§å’Œå³ä¾§åŒç±»ç‰©å“å‘ä¸­é—´åˆæˆï¼Œä¸‰ä»¶ä¸€èµ·æ¶ˆå¤±ï¼Œæ’­æ”¾åˆæˆéŸ³æ•ˆï¼Œä¸å±•示星星图标,åŽé¢çš„物å“å†å‘å‰è¡¥ä½ã€‚è¯¥åŠ¨æ•ˆåªæ˜¯å‰ç«¯è¡¨çŽ°å±‚ï¼ŒåŽç«¯å’Œæœ¬åœ°è¯•玩ä»è´Ÿè´£æƒå¨æ’å…¥ã€æŒ‡å®šç‚¹å‡»ç±»åž‹æ¸…除与补ä½åŽçš„æ§½ä½å¿«ç…§ã€‚ - 抓大鹅è¿è¡Œæ€å³ä¸Šè§’常驻设置入å£ï¼Œä¸ç›´æŽ¥æš´éœ²é‡æ–°å¼€å§‹æŒ‰é’®ï¼›é‡æ–°å¼€å§‹æ”¶å£åˆ°è®¾ç½®é¢æ¿å†…,结算弹层ä»ä¿ç•™ç»“æžœæ€çš„冿¥ä¸€å±€åŠ¨ä½œã€‚ - 高 DPR 移动端 WebGL canvas å¿…é¡»é”定 CSS 尺寸,é¿å…å³ä¸‹æº¢å‡ºã€‚ diff --git a/packages/shared/src/contracts/match3dWorks.ts b/packages/shared/src/contracts/match3dWorks.ts index d1e4e4f8..f54ac624 100644 --- a/packages/shared/src/contracts/match3dWorks.ts +++ b/packages/shared/src/contracts/match3dWorks.ts @@ -5,6 +5,7 @@ import type { CreationAudioAsset } from './creationAudio'; export type Match3DWorkPublicationStatus = 'draft' | 'published' | string; +export type Match3DWorkGenerationStatus = 'idle' | 'generating' | 'ready' | string; export type Match3DGeneratedItemAssetStatus = | 'pending' @@ -163,6 +164,7 @@ export interface Match3DWorkSummary { updatedAt: string; publishedAt?: string | null; publishReady: boolean; + generationStatus?: Match3DWorkGenerationStatus | null; backgroundPrompt?: string | null; backgroundImageSrc?: string | null; backgroundImageObjectKey?: string | null; diff --git a/packages/shared/src/contracts/puzzleWorkSummary.ts b/packages/shared/src/contracts/puzzleWorkSummary.ts index 3bfd4a44..b1e69499 100644 --- a/packages/shared/src/contracts/puzzleWorkSummary.ts +++ b/packages/shared/src/contracts/puzzleWorkSummary.ts @@ -2,6 +2,7 @@ import type { JsonObject } from './common'; import type { PuzzleAnchorPack, PuzzleDraftLevel } from './puzzleAgentDraft'; export type PuzzleWorkPublicationStatus = 'draft' | 'published'; +export type PuzzleWorkGenerationStatus = PuzzleDraftLevel['generationStatus']; export interface PuzzleWorkSummary { workId: string; @@ -28,6 +29,7 @@ export interface PuzzleWorkSummary { pointIncentiveTotalPoints?: number; pointIncentiveClaimablePoints?: number; publishReady: boolean; + generationStatus?: PuzzleWorkGenerationStatus | null; levels?: PuzzleDraftLevel[]; } diff --git a/server-rs/crates/api-server/src/match3d.rs b/server-rs/crates/api-server/src/match3d.rs index 4d42df69..cd4058a6 100644 --- a/server-rs/crates/api-server/src/match3d.rs +++ b/server-rs/crates/api-server/src/match3d.rs @@ -6016,13 +6016,16 @@ fn remove_match3d_material_view_edge_matte(pixels: &mut [u8], width: usize, heig let mut background_mask = vec![0u8; pixel_count]; let mut queue = Vec::::new(); let mut queue_index = 0usize; + let mut transparent_pixel_count = 0usize; for pixel_index in 0..pixel_count { let offset = pixel_index * 4; if pixels[offset + 3] == 0 { background_mask[pixel_index] = 1; queue.push(pixel_index); + transparent_pixel_count = transparent_pixel_count.saturating_add(1); } } + let has_transparent_background = transparent_pixel_count > pixel_count / 200; // 中文注释:å•å›¾è¢«å‰æ™¯è¾¹ç•Œæ”¶ç´§åŽï¼Œæµ…绿框å¯èƒ½æ­£å¥½è´´åœ¨ PNG 外缘; // 把外缘一段宽度作为去背ç§å­ï¼Œä½†åªæ¸…ç†ç»¿å¹• / 近白 matte,é¿å…误伤贴边主体。 @@ -6136,6 +6139,98 @@ fn remove_match3d_material_view_edge_matte(pixels: &mut [u8], width: usize, heig } } + if has_transparent_background { + let mut visible_mask = vec![0u8; pixel_count]; + for pixel_index in 0..pixel_count { + let offset = pixel_index * 4; + if is_match3d_material_visible_pixel([ + pixels[offset], + pixels[offset + 1], + pixels[offset + 2], + pixels[offset + 3], + ]) { + visible_mask[pixel_index] = 1; + } + } + + for _ in 0..2 { + let mut changed_this_round = false; + for y in 0..height { + for x in 0..width { + let pixel_index = y * width + x; + if visible_mask[pixel_index] == 0 { + continue; + } + let offset = pixel_index * 4; + let pixel = [ + pixels[offset], + pixels[offset + 1], + pixels[offset + 2], + pixels[offset + 3], + ]; + if !is_match3d_material_green_contaminated_edge_pixel(pixel) { + continue; + } + if !touches_match3d_material_background_mask( + x, + y, + width, + height, + &background_mask, + ) { + continue; + } + + if is_match3d_material_strong_green_contamination(pixel) { + pixels[offset] = 0; + pixels[offset + 1] = 0; + pixels[offset + 2] = 0; + pixels[offset + 3] = 0; + visible_mask[pixel_index] = 0; + background_mask[pixel_index] = 1; + changed = true; + changed_this_round = true; + continue; + } + + let replacement = collect_match3d_material_visible_neighbor_color( + pixels, + width, + height, + x, + y, + &background_mask, + &visible_mask, + ) + .unwrap_or(( + pixels[offset], + pixels[offset + 1], + pixels[offset + 2], + )); + let next_red = replacement.0.max(pixels[offset]); + let next_blue = replacement.2.max(pixels[offset + 2]); + let next_green = replacement + .1 + .min(next_red.max(next_blue).saturating_add(12)); + if next_red != pixels[offset] + || next_green != pixels[offset + 1] + || next_blue != pixels[offset + 2] + { + pixels[offset] = next_red; + pixels[offset + 1] = next_green; + pixels[offset + 2] = next_blue; + changed = true; + changed_this_round = true; + } + background_mask[pixel_index] = 1; + } + } + if !changed_this_round { + break; + } + } + } + changed } @@ -6167,6 +6262,98 @@ fn is_match3d_material_soft_edge_pixel(pixel: [u8; 4]) -> bool { && (red >= 48 || blue >= 96 || pixel[3] < 236) } +fn is_match3d_material_green_contaminated_edge_pixel(pixel: [u8; 4]) -> bool { + if pixel[3] == 0 { + return false; + } + + let red = pixel[0]; + let green = pixel[1]; + let blue = pixel[2]; + green >= 72 && green.saturating_sub(red.max(blue)) >= 18 +} + +fn is_match3d_material_strong_green_contamination(pixel: [u8; 4]) -> bool { + let red = pixel[0]; + let green = pixel[1]; + let blue = pixel[2]; + green >= 148 && green.saturating_sub(red.max(blue)) >= 34 +} + +fn collect_match3d_material_visible_neighbor_color( + pixels: &[u8], + width: usize, + height: usize, + x: usize, + y: usize, + background_mask: &[u8], + visible_mask: &[u8], +) -> Option<(u8, u8, u8)> { + let mut total_weight = 0.0f32; + let mut total_red = 0.0f32; + let mut total_green = 0.0f32; + let mut total_blue = 0.0f32; + + for offset_y in -3i32..=3 { + for offset_x in -3i32..=3 { + if offset_x == 0 && offset_y == 0 { + continue; + } + let next_x = x as i32 + offset_x; + let next_y = y as i32 + offset_y; + if next_x < 0 || next_x >= width as i32 || next_y < 0 || next_y >= height as i32 { + continue; + } + + let next_pixel_index = next_y as usize * width + next_x as usize; + if background_mask[next_pixel_index] != 0 || visible_mask[next_pixel_index] == 0 { + continue; + } + + let next_offset = next_pixel_index * 4; + let next_alpha = pixels[next_offset + 3]; + if next_alpha < 96 { + continue; + } + let pixel = [ + pixels[next_offset], + pixels[next_offset + 1], + pixels[next_offset + 2], + next_alpha, + ]; + if is_match3d_material_green_contaminated_edge_pixel(pixel) + || is_match3d_material_soft_edge_pixel(pixel) + { + continue; + } + + let distance = offset_x.unsigned_abs() + offset_y.unsigned_abs(); + let weight = (next_alpha as f32 / 255.0) + * if distance <= 1 { + 2.0 + } else if distance <= 3 { + 1.2 + } else { + 0.7 + }; + total_weight += weight; + total_red += pixels[next_offset] as f32 * weight; + total_green += pixels[next_offset + 1] as f32 * weight; + total_blue += pixels[next_offset + 2] as f32 * weight; + } + } + + if total_weight <= 0.0 { + return None; + } + + Some(( + (total_red / total_weight).round() as u8, + (total_green / total_weight).round() as u8, + (total_blue / total_weight).round() as u8, + )) +} + fn apply_match3d_material_green_screen_alpha(source: image::DynamicImage) -> image::DynamicImage { let mut image = source.to_rgba8(); let (width, height) = image.dimensions(); @@ -7571,6 +7758,37 @@ mod tests { ); } + #[test] + fn match3d_material_view_edge_matte_neutralizes_dark_green_contour_pixels() { + let width = 64; + let height = 64; + let mut view = image::RgbaImage::from_pixel(width, height, image::Rgba([0, 0, 0, 0])); + for y in 16..48 { + for x in 16..48 { + if x <= 18 || x >= 45 || y <= 18 || y >= 45 { + view.put_pixel(x, y, image::Rgba([42, 118, 36, 255])); + } else { + view.put_pixel(x, y, image::Rgba([174, 92, 72, 255])); + } + } + } + + let cleaned = + crop_match3d_material_view_edge_matte(image::DynamicImage::ImageRgba8(view)).to_rgba8(); + + assert!( + cleaned.pixels().all(|pixel| { + let [red, green, blue, alpha] = pixel.0; + alpha == 0 || green <= red.max(blue).saturating_add(18) + }), + "æš—ç»¿è½®å»“æ±¡æŸ“ä¹Ÿå¿…é¡»è¢«é€æ˜ŽåŒ–或去绿,ä¸èƒ½æ®‹ç•™å¯è§ç»¿è¾¹" + ); + assert!( + cleaned.pixels().any(|pixel| pixel.0 == [174, 92, 72, 255]), + "暗绿轮廓清ç†ä¸èƒ½è¯¯åˆ ç‰©å“主体" + ); + } + #[test] fn match3d_material_sheet_slicing_cleans_white_matte_edge() { let width = 500; @@ -8388,6 +8606,7 @@ mod tests { total_item_count: 36, publish_ready: false, blockers: Vec::new(), + generated_item_assets_json: None, }), messages: Vec::new(), last_assistant_reply: None, @@ -8472,6 +8691,131 @@ mod tests { ); } + #[test] + fn match3d_agent_session_response_keeps_draft_ui_assets_without_work_detail_hydration() { + let assets = vec![Match3DGeneratedItemAsset { + item_id: "match3d-item-1".to_string(), + item_name: "è‰èŽ“".to_string(), + item_size: Some(MATCH3D_ITEM_SIZE_SMALL.to_string()), + image_src: Some( + "/generated-match3d-assets/session/profile/items/strawberry/view-01.png" + .to_string(), + ), + image_object_key: Some( + "generated-match3d-assets/session/profile/items/strawberry/view-01.png".to_string(), + ), + image_views: Vec::new(), + model_src: None, + model_object_key: None, + model_file_name: None, + task_uuid: None, + subscription_key: None, + sound_prompt: None, + background_music_title: None, + background_music_style: None, + background_music_prompt: None, + background_music: None, + click_sound: None, + background_asset: Some(Match3DGeneratedBackgroundAsset { + prompt: "果园背景".to_string(), + image_src: Some( + "/generated-match3d-assets/session/profile/background/background.png" + .to_string(), + ), + image_object_key: Some( + "generated-match3d-assets/session/profile/background/background.png" + .to_string(), + ), + container_prompt: Some("果园容器".to_string()), + container_image_src: Some( + "/generated-match3d-assets/session/profile/ui-container/container.png" + .to_string(), + ), + container_image_object_key: Some( + "generated-match3d-assets/session/profile/ui-container/container.png" + .to_string(), + ), + status: "image_ready".to_string(), + error: None, + }), + status: "image_ready".to_string(), + error: None, + }]; + let session = Match3DAgentSessionRecord { + session_id: "match3d-session-1".to_string(), + current_turn: 3, + progress_percent: 100, + stage: "DraftCompiled".to_string(), + anchor_pack: Match3DAnchorPackRecord { + theme: Match3DAnchorItemRecord { + key: "theme".to_string(), + label: "题æä¸»é¢˜".to_string(), + value: "æ°´æžœ".to_string(), + status: "confirmed".to_string(), + }, + clear_count: Match3DAnchorItemRecord { + key: "clearCount".to_string(), + label: "消除次数".to_string(), + value: "12".to_string(), + status: "confirmed".to_string(), + }, + difficulty: Match3DAnchorItemRecord { + key: "difficulty".to_string(), + label: "难度".to_string(), + value: "4".to_string(), + status: "confirmed".to_string(), + }, + }, + config: None, + draft: Some(Match3DResultDraftRecord { + profile_id: "match3d-profile-1".to_string(), + game_name: "水果抓大鹅".to_string(), + theme_text: "æ°´æžœ".to_string(), + summary_text: "水果主题".to_string(), + tags: vec!["æ°´æžœ".to_string(), "抓大鹅".to_string()], + cover_image_src: None, + reference_image_src: None, + clear_count: 12, + difficulty: 4, + total_item_count: 36, + publish_ready: false, + blockers: Vec::new(), + generated_item_assets_json: serialize_match3d_generated_item_assets(&assets), + }), + messages: Vec::new(), + last_assistant_reply: None, + published_profile_id: None, + updated_at: "2026-05-15T00:00:00.000Z".to_string(), + }; + + let response = map_match3d_agent_session_response_with_assets(session, &[]); + let draft = response.draft.expect("session draft should exist"); + + assert_eq!(draft.generated_item_assets.len(), 1); + assert_eq!( + draft.background_image_src.as_deref(), + Some("/generated-match3d-assets/session/profile/background/background.png") + ); + assert_eq!( + draft.background_image_object_key.as_deref(), + Some("generated-match3d-assets/session/profile/background/background.png") + ); + assert_eq!( + draft + .generated_background_asset + .as_ref() + .and_then(|asset| asset.container_image_src.as_deref()), + Some("/generated-match3d-assets/session/profile/ui-container/container.png") + ); + assert_eq!( + draft.generated_item_assets[0] + .background_asset + .as_ref() + .and_then(|asset| asset.image_src.as_deref()), + Some("/generated-match3d-assets/session/profile/background/background.png") + ); + } + #[test] fn match3d_tag_normalization_only_strips_numbered_list_prefix() { assert_eq!(normalize_match3d_tag("3Dç´ æ"), "3Dç´ æ"); @@ -8761,9 +9105,59 @@ mod tests { assert_eq!(response.generated_item_assets.len(), 1); assert_eq!(response.generated_item_assets[0].item_name, "è‰èŽ“"); assert_eq!(response.generated_item_assets[0].status, "image_ready"); + assert_eq!(response.generation_status.as_deref(), Some("generating")); assert_eq!( response.generated_item_assets[0].image_src.as_deref(), Some("/generated-match3d-assets/session/profile/items/match3d-item-1-item/image.png") ); } + + #[test] + fn match3d_work_summary_marks_complete_generated_assets_ready() { + let assets = vec![Match3DGeneratedItemAsset { + background_asset: Some(Match3DGeneratedBackgroundAsset { + prompt: "水果厨房背景".to_string(), + image_src: Some( + "/generated-match3d-assets/session/profile/background.png".to_string(), + ), + image_object_key: Some( + "generated-match3d-assets/session/profile/background.png".to_string(), + ), + container_prompt: None, + container_image_src: Some( + "/generated-match3d-assets/session/profile/container.png".to_string(), + ), + container_image_object_key: Some( + "generated-match3d-assets/session/profile/container.png".to_string(), + ), + status: "image_ready".to_string(), + error: None, + }), + ..test_match3d_generated_item_asset(1, "è‰èŽ“") + }]; + let response = map_match3d_work_summary_response(Match3DWorkProfileRecord { + work_id: "match3d-profile-1".to_string(), + profile_id: "match3d-profile-1".to_string(), + owner_user_id: "user-1".to_string(), + source_session_id: Some("match3d-session-1".to_string()), + author_display_name: "玩家".to_string(), + game_name: "水果抓大鹅".to_string(), + theme_text: "æ°´æžœ".to_string(), + summary: "水果主题".to_string(), + tags: vec!["æ°´æžœ".to_string()], + cover_image_src: None, + cover_asset_id: None, + reference_image_src: None, + clear_count: 3, + difficulty: 3, + publication_status: "draft".to_string(), + play_count: 0, + updated_at: "2026-05-10T00:00:00.000Z".to_string(), + published_at: None, + publish_ready: false, + generated_item_assets_json: serialize_match3d_generated_item_assets(&assets), + }); + + assert_eq!(response.generation_status.as_deref(), Some("ready")); + } } diff --git a/server-rs/crates/api-server/src/match3d/mappers.rs b/server-rs/crates/api-server/src/match3d/mappers.rs index 3bf0da7a..983159a8 100644 --- a/server-rs/crates/api-server/src/match3d/mappers.rs +++ b/server-rs/crates/api-server/src/match3d/mappers.rs @@ -32,6 +32,10 @@ pub(super) fn map_match3d_agent_session_response_with_assets( ) -> Match3DAgentSessionSnapshotResponse { let mut response = map_match3d_agent_session_response(session); if let Some(draft) = response.draft.as_mut() { + if generated_item_assets.is_empty() { + return response; + } + draft.generated_item_assets = generated_item_assets .iter() .cloned() @@ -129,7 +133,15 @@ pub(super) fn map_match3d_config_response( pub(super) fn map_match3d_draft_response( draft: Match3DResultDraftRecord, ) -> Match3DResultDraftResponse { - Match3DResultDraftResponse { + // 中文注释:session draft 自身也å¯èƒ½æºå¸¦ç”Ÿæˆç´ æå¿«ç…§ï¼Œä¸èƒ½åªä¾èµ– work detail å›žè¯»è¡¥é½ UI 背景和容器图。 + let generated_item_assets = parse_match3d_generated_item_assets( + draft.generated_item_assets_json.as_deref(), + ) + .into_iter() + .map(Match3DGeneratedItemAsset::from) + .collect::>(); + let background_asset = find_match3d_generated_background_asset(&generated_item_assets); + let mut response = Match3DResultDraftResponse { profile_id: draft.profile_id, game_name: draft.game_name, theme_text: draft.theme_text, @@ -147,8 +159,24 @@ pub(super) fn map_match3d_draft_response( background_image_src: None, background_image_object_key: None, generated_background_asset: None, - generated_item_assets: Vec::new(), + generated_item_assets: generated_item_assets + .iter() + .cloned() + .map(map_match3d_generated_item_asset_for_agent) + .collect(), + }; + + if response + .cover_image_src + .as_deref() + .map(str::trim) + .unwrap_or_default() + .is_empty() + { + response.cover_image_src = resolve_match3d_default_cover_image_src(&generated_item_assets); } + apply_match3d_background_asset_to_agent_draft(&mut response, background_asset); + response } pub(super) fn map_match3d_generated_item_asset_for_agent( @@ -365,6 +393,45 @@ pub(super) fn build_match3d_work_profile_record_with_assets( item } +fn match3d_text_present(value: Option<&String>) -> bool { + value.is_some_and(|value| !value.trim().is_empty()) +} + +fn match3d_item_asset_has_image(asset: &Match3DGeneratedItemAssetJson) -> bool { + match3d_text_present(asset.image_src.as_ref()) + || match3d_text_present(asset.image_object_key.as_ref()) + || asset.image_views.iter().any(|view| { + match3d_text_present(view.image_src.as_ref()) + || match3d_text_present(view.image_object_key.as_ref()) + }) +} + +fn match3d_background_asset_has_image(asset: &Match3DGeneratedBackgroundAsset) -> bool { + match3d_text_present(asset.image_src.as_ref()) + || match3d_text_present(asset.image_object_key.as_ref()) + || match3d_text_present(asset.container_image_src.as_ref()) + || match3d_text_present(asset.container_image_object_key.as_ref()) +} + +fn resolve_match3d_work_generation_status( + item: &Match3DWorkProfileRecord, + assets: &[Match3DGeneratedItemAssetJson], + background_asset: Option<&Match3DGeneratedBackgroundAsset>, +) -> Option { + if item.publication_status.eq_ignore_ascii_case("published") { + return Some("ready".to_string()); + } + + if assets.is_empty() + || !assets.iter().any(match3d_item_asset_has_image) + || !background_asset.is_some_and(match3d_background_asset_has_image) + { + return Some("generating".to_string()); + } + + Some("ready".to_string()) +} + pub(super) fn map_match3d_message_response( message: Match3DAgentMessageRecord, ) -> Match3DAgentMessageResponse { @@ -383,6 +450,11 @@ pub(super) fn map_match3d_work_summary_response( let generated_item_asset_json = parse_match3d_generated_item_assets(item.generated_item_assets_json.as_deref()); let background_asset = find_match3d_generated_background_asset_json(&generated_item_asset_json); + let generation_status = resolve_match3d_work_generation_status( + &item, + &generated_item_asset_json, + background_asset.as_ref(), + ); let generated_background_asset = background_asset .clone() .map(map_match3d_background_asset_for_work); @@ -408,6 +480,7 @@ pub(super) fn map_match3d_work_summary_response( updated_at: item.updated_at, published_at: item.published_at, publish_ready: item.publish_ready, + generation_status, background_prompt: background_asset.as_ref().map(|asset| asset.prompt.clone()), background_image_src: background_asset .as_ref() diff --git a/server-rs/crates/api-server/src/puzzle.rs b/server-rs/crates/api-server/src/puzzle.rs index 65363cb6..24999dff 100644 --- a/server-rs/crates/api-server/src/puzzle.rs +++ b/server-rs/crates/api-server/src/puzzle.rs @@ -4030,6 +4030,11 @@ fn map_puzzle_generation_endpoint_error(error: AppError) -> AppError { error } +fn should_fallback_puzzle_reference_edit_to_generation(error: &AppError) -> bool { + error.status_code() == StatusCode::GATEWAY_TIMEOUT + || is_puzzle_request_timeout_message(error.body_text().as_str()) +} + async fn generate_puzzle_image_candidates( state: &AppState, owner_user_id: &str, @@ -4111,7 +4116,7 @@ async fn generate_puzzle_image_candidates( "message": "AI é‡ç»˜éœ€è¦æä¾›å‚考图。", })) })?; - create_puzzle_vector_engine_image_edit( + let edit_result = create_puzzle_vector_engine_image_edit( &http_client, &settings, actual_prompt.as_str(), @@ -4120,7 +4125,34 @@ async fn generate_puzzle_image_candidates( count, reference_image, ) - .await + .await; + match edit_result { + Ok(generated) => Ok(generated), + Err(error) if should_fallback_puzzle_reference_edit_to_generation(&error) => { + tracing::warn!( + provider = resolved_model.provider_name(), + image_model = resolved_model.request_model_name(), + session_id, + level_name, + reference_mime = %reference_image.mime_type, + reference_bytes = reference_image.bytes_len, + error = %error, + "拼图å‚考图编辑接å£è¶…时,é™çº§ä¸ºå¸¦å‚è€ƒå›¾çš„ç”ŸæˆæŽ¥å£" + ); + create_puzzle_vector_engine_image_generation( + &http_client, + &settings, + resolved_model, + actual_prompt.as_str(), + PUZZLE_DEFAULT_NEGATIVE_PROMPT, + PUZZLE_VECTOR_ENGINE_GENERATED_IMAGE_SIZE, + count, + Some(reference_image), + ) + .await + } + Err(error) => Err(error), + } } else { create_puzzle_vector_engine_image_generation( &http_client, @@ -4130,6 +4162,7 @@ async fn generate_puzzle_image_candidates( PUZZLE_DEFAULT_NEGATIVE_PROMPT, PUZZLE_VECTOR_ENGINE_GENERATED_IMAGE_SIZE, count, + None, ) .await } @@ -4263,6 +4296,7 @@ mod tests { PUZZLE_DEFAULT_NEGATIVE_PROMPT, PUZZLE_VECTOR_ENGINE_GENERATED_IMAGE_SIZE, 4, + None, ); assert_eq!(body["model"], VECTOR_ENGINE_GPT_IMAGE_2_MODEL); @@ -4278,6 +4312,40 @@ mod tests { ); } + #[test] + fn puzzle_vector_engine_generation_fallback_includes_reference_image() { + let image = image::DynamicImage::ImageRgb8(image::RgbImage::new(4, 4)); + let mut cursor = std::io::Cursor::new(Vec::new()); + image + .write_to(&mut cursor, ImageFormat::Png) + .expect("test image should encode"); + let reference_image = PuzzleResolvedReferenceImage { + mime_type: "image/png".to_string(), + bytes_len: cursor.get_ref().len(), + bytes: cursor.into_inner(), + }; + + let body = build_puzzle_vector_engine_image_request_body( + PuzzleImageModel::GptImage2, + "å‚考图里的å°çŒ«åšæˆæ‹¼å›¾ä¸»å›¾ã€‚", + PUZZLE_DEFAULT_NEGATIVE_PROMPT, + PUZZLE_VECTOR_ENGINE_GENERATED_IMAGE_SIZE, + 1, + Some(&reference_image), + ); + + let images = body["image"] + .as_array() + .expect("fallback generation should include reference image array"); + assert_eq!(images.len(), 1); + assert!( + images[0] + .as_str() + .unwrap_or_default() + .starts_with("data:image/png;base64,") + ); + } + #[test] fn puzzle_vector_engine_edit_url_uses_images_edits_endpoint() { let settings = PuzzleVectorEngineSettings { @@ -4363,6 +4431,39 @@ mod tests { assert_eq!(response.status(), StatusCode::GATEWAY_TIMEOUT); } + #[test] + fn puzzle_vector_engine_upstream_timeout_maps_to_gateway_timeout() { + let error = map_puzzle_vector_engine_upstream_error( + reqwest::StatusCode::GATEWAY_TIMEOUT, + r#"{"error":{"message":"VectorEngine edit endpoint timeout"}}"#, + "创建拼图 VectorEngine 图片编辑任务失败", + ); + + let response = error.into_response(); + assert_eq!(response.status(), StatusCode::GATEWAY_TIMEOUT); + } + + #[test] + fn puzzle_reference_edit_fallback_only_accepts_timeout_errors() { + let timeout_error = map_puzzle_vector_engine_upstream_error( + reqwest::StatusCode::GATEWAY_TIMEOUT, + r#"{"error":{"message":"VectorEngine edit endpoint timeout"}}"#, + "创建拼图 VectorEngine 图片编辑任务失败", + ); + assert!(should_fallback_puzzle_reference_edit_to_generation( + &timeout_error + )); + + let auth_error = map_puzzle_vector_engine_upstream_error( + reqwest::StatusCode::UNAUTHORIZED, + r#"{"error":{"message":"invalid api key"}}"#, + "创建拼图 VectorEngine 图片编辑任务失败", + ); + assert!(!should_fallback_puzzle_reference_edit_to_generation( + &auth_error + )); + } + #[test] fn puzzle_vector_engine_reqwest_error_maps_to_bad_gateway() { let error = match reqwest::Client::new().get("http://[::1").build() { @@ -4834,6 +4935,7 @@ mod tests { ); assert_eq!(response.levels.len(), 1); + assert_eq!(response.generation_status.as_deref(), Some("ready")); assert_eq!( response.levels[0].cover_image_src.as_deref(), Some("/generated-puzzle-assets/session/cover.png") @@ -5242,6 +5344,7 @@ async fn create_puzzle_vector_engine_image_generation( negative_prompt: &str, size: &str, candidate_count: u32, + reference_image: Option<&PuzzleResolvedReferenceImage>, ) -> Result { let request_body = build_puzzle_vector_engine_image_request_body( image_model, @@ -5249,6 +5352,7 @@ async fn create_puzzle_vector_engine_image_generation( negative_prompt, size, candidate_count, + reference_image, ); let request_url = puzzle_vector_engine_images_generation_url(settings); let request_started_at = Instant::now(); @@ -5277,7 +5381,7 @@ async fn create_puzzle_vector_engine_image_generation( status = status.as_u16(), prompt_chars = prompt.chars().count(), size, - has_reference_image = false, + has_reference_image = reference_image.is_some(), elapsed_ms = upstream_elapsed_ms, "拼图 VectorEngine å›¾ç‰‡ç”Ÿæˆ HTTP 返回" ); @@ -5434,8 +5538,9 @@ fn build_puzzle_vector_engine_image_request_body( negative_prompt: &str, size: &str, candidate_count: u32, + reference_image: Option<&PuzzleResolvedReferenceImage>, ) -> Value { - Value::Object(Map::from_iter([ + let mut body = Map::from_iter([ ( "model".to_string(), Value::String(image_model.request_model_name().to_string()), @@ -5446,7 +5551,15 @@ fn build_puzzle_vector_engine_image_request_body( ), ("n".to_string(), json!(candidate_count.clamp(1, 1))), ("size".to_string(), Value::String(size.to_string())), - ])) + ]); + if let Some(reference_image) = reference_image + && let Some(reference_data_url) = + build_puzzle_generation_reference_image_data_url(reference_image) + { + body.insert("image".to_string(), json!([reference_data_url])); + } + + Value::Object(body) } fn build_puzzle_vector_engine_generation_prompt(prompt: &str, has_reference_image: bool) -> String { @@ -5465,6 +5578,32 @@ fn build_puzzle_vector_engine_generation_prompt(prompt: &str, has_reference_imag ) } +fn build_puzzle_generation_reference_image_data_url( + image: &PuzzleResolvedReferenceImage, +) -> Option { + let bytes = resize_puzzle_generation_reference_image_bytes(image.bytes.as_slice()) + .unwrap_or_else(|| image.bytes.clone()); + let mime_type = if bytes.starts_with(b"\x89PNG\r\n\x1A\n") { + "image/png" + } else { + image.mime_type.as_str() + }; + + Some(format!( + "data:{};base64,{}", + normalize_puzzle_downloaded_image_mime_type(mime_type), + BASE64_STANDARD.encode(bytes) + )) +} + +fn resize_puzzle_generation_reference_image_bytes(bytes: &[u8]) -> Option> { + let image = image::load_from_memory(bytes).ok()?; + let resized = image.resize(1024, 1024, image::imageops::FilterType::Triangle); + let mut cursor = std::io::Cursor::new(Vec::new()); + resized.write_to(&mut cursor, ImageFormat::Png).ok()?; + Some(cursor.into_inner()) +} + fn has_puzzle_reference_image(reference_image_src: Option<&str>) -> bool { reference_image_src .map(str::trim) @@ -6185,19 +6324,28 @@ fn map_puzzle_vector_engine_upstream_error( ) -> AppError { let message = parse_puzzle_api_error_message(raw_text, fallback_message); let raw_excerpt = trim_puzzle_upstream_excerpt(raw_text, 800); + let is_timeout = is_puzzle_request_timeout_message(message.as_str()) + || is_puzzle_request_timeout_message(raw_excerpt.as_str()); + let status = if is_timeout { + StatusCode::GATEWAY_TIMEOUT + } else { + StatusCode::BAD_GATEWAY + }; tracing::warn!( provider = VECTOR_ENGINE_PROVIDER, upstream_status = upstream_status.as_u16(), + timeout = is_timeout, message = %message, raw_excerpt = %raw_excerpt, "拼图 VectorEngine 上游请求失败" ); - AppError::from_status(StatusCode::BAD_GATEWAY).with_details(json!({ + AppError::from_status(status).with_details(json!({ "provider": VECTOR_ENGINE_PROVIDER, "upstreamStatus": upstream_status.as_u16(), "message": message, "rawExcerpt": raw_excerpt, + "timeout": is_timeout, })) } diff --git a/server-rs/crates/api-server/src/puzzle/mappers.rs b/server-rs/crates/api-server/src/puzzle/mappers.rs index e60e6900..daefe7d3 100644 --- a/server-rs/crates/api-server/src/puzzle/mappers.rs +++ b/server-rs/crates/api-server/src/puzzle/mappers.rs @@ -278,10 +278,31 @@ pub(super) fn map_puzzle_result_preview_finding_response( } } +fn resolve_puzzle_work_generation_status(item: &PuzzleWorkProfileRecord) -> Option { + item.levels + .iter() + .map(|level| level.generation_status.trim()) + .find(|status| *status == "generating") + .or_else(|| { + item.levels + .iter() + .map(|level| level.generation_status.trim()) + .find(|status| *status == "ready") + }) + .or_else(|| { + item.levels + .iter() + .map(|level| level.generation_status.trim()) + .find(|status| !status.is_empty()) + }) + .map(str::to_string) +} + pub(super) fn map_puzzle_work_summary_response( state: &AppState, item: PuzzleWorkProfileRecord, ) -> PuzzleWorkSummaryResponse { + let generation_status = resolve_puzzle_work_generation_status(&item); let author = resolve_work_author_by_user_id( state, &item.owner_user_id, @@ -316,6 +337,7 @@ pub(super) fn map_puzzle_work_summary_response( .saturating_div(2) .saturating_sub(item.point_incentive_claimed_points), publish_ready: item.publish_ready, + generation_status, levels: Vec::new(), } } diff --git a/server-rs/crates/module-match3d/src/application.rs b/server-rs/crates/module-match3d/src/application.rs index 111550e7..64ddef75 100644 --- a/server-rs/crates/module-match3d/src/application.rs +++ b/server-rs/crates/module-match3d/src/application.rs @@ -237,7 +237,9 @@ pub fn confirm_click_at( return Ok(rejected(next, Match3DClickRejectReason::ItemNotClickable)); } - let Some(slot_index) = first_empty_slot_index(&next.tray_slots) else { + let Some(slot_index) = + insert_item_into_tray_after_same_type(&mut next.tray_slots, &mut next.items, item_index) + else { next = fail_run(next, Match3DFailureReason::TrayFull, client_action_id); return Ok(rejected(next, Match3DClickRejectReason::TrayFull)); }; @@ -246,7 +248,6 @@ pub fn confirm_click_at( next.items[item_index].state = Match3DItemState::InTray; next.items[item_index].clickable = false; next.items[item_index].tray_slot_index = Some(slot_index); - fill_tray_slot(&mut next.tray_slots, slot_index, &next.items[item_index]); let cleared_item_instance_ids = clear_first_triple(&mut next, &item_type_id); compact_tray(&mut next); @@ -540,12 +541,64 @@ fn first_empty_slot_index(slots: &[Match3DTraySlot]) -> Option { .map(|slot| slot.slot_index) } -fn fill_tray_slot(slots: &mut [Match3DTraySlot], slot_index: u32, item: &Match3DItemSnapshot) { - if let Some(slot) = slots.iter_mut().find(|slot| slot.slot_index == slot_index) { - slot.item_instance_id = Some(item.item_instance_id.clone()); - slot.item_type_id = Some(item.item_type_id.clone()); - slot.visual_key = Some(item.visual_key.clone()); +fn insert_item_into_tray_after_same_type( + slots: &mut [Match3DTraySlot], + items: &mut [Match3DItemSnapshot], + item_index: usize, +) -> Option { + let occupied = slots + .iter() + .filter_map(|slot| { + Some(( + slot.item_instance_id.clone()?, + slot.item_type_id.clone()?, + slot.visual_key.clone()?, + )) + }) + .collect::>(); + if occupied.len() >= slots.len() { + return None; } + + let item = items.get(item_index)?.clone(); + let insertion_index = occupied + .iter() + .rposition(|(_, item_type_id, _)| item_type_id == &item.item_type_id) + .map(|index| index + 1) + .unwrap_or(occupied.len()); + let mut next_occupied = occupied; + next_occupied.insert( + insertion_index, + ( + item.item_instance_id.clone(), + item.item_type_id.clone(), + item.visual_key.clone(), + ), + ); + + for slot in slots.iter_mut() { + slot.item_instance_id = None; + slot.item_type_id = None; + slot.visual_key = None; + } + for (index, (item_instance_id, item_type_id, visual_key)) in + next_occupied.into_iter().enumerate() + { + let slot_index = index as u32; + if let Some(slot) = slots.iter_mut().find(|slot| slot.slot_index == slot_index) { + slot.item_instance_id = Some(item_instance_id.clone()); + slot.item_type_id = Some(item_type_id); + slot.visual_key = Some(visual_key); + } + if let Some(entry) = items + .iter_mut() + .find(|entry| entry.item_instance_id == item_instance_id) + { + entry.tray_slot_index = Some(slot_index); + } + } + + Some(insertion_index as u32) } fn clear_first_triple(run: &mut Match3DRunSnapshot, item_type_id: &str) -> Vec { @@ -579,6 +632,7 @@ fn clear_first_triple(run: &mut Match3DRunSnapshot, item_type_id: &str) -> Vec= MATCH3D_BOARD_CENTER { "r" } else { "l" }, - if item.y >= MATCH3D_BOARD_CENTER { "b" } else { "t" }, + if item.x >= MATCH3D_BOARD_CENTER { + "r" + } else { + "l" + }, + if item.y >= MATCH3D_BOARD_CENTER { + "b" + } else { + "t" + }, ); *quadrants.entry(quadrant).or_default() += 1; } @@ -1108,6 +1170,82 @@ mod tests { ); } + #[test] + fn clicking_item_inserts_after_same_type_and_shifts_following_slots() { + let mut run = Match3DRunSnapshot { + run_id: "run-insert".to_string(), + profile_id: "profile-1".to_string(), + owner_user_id: "user-1".to_string(), + status: Match3DRunStatus::Running, + started_at_ms: 0, + duration_limit_ms: MATCH3D_DEFAULT_DURATION_LIMIT_MS, + remaining_ms: MATCH3D_DEFAULT_DURATION_LIMIT_MS, + clear_count: 3, + total_item_count: 4, + cleared_item_count: 0, + board_version: 1, + items: vec![ + manual_item("apple-3", "apple", None), + manual_item("apple-1", "apple", Some(0)), + manual_item("apple-2", "apple", Some(1)), + manual_item("pear-1", "pear", Some(2)), + ], + tray_slots: empty_tray_slots(), + failure_reason: None, + last_confirmed_action_id: None, + }; + run.tray_slots[0].item_instance_id = Some("apple-1".to_string()); + run.tray_slots[0].item_type_id = Some("apple".to_string()); + run.tray_slots[0].visual_key = Some("apple".to_string()); + run.tray_slots[1].item_instance_id = Some("apple-2".to_string()); + run.tray_slots[1].item_type_id = Some("apple".to_string()); + run.tray_slots[1].visual_key = Some("apple".to_string()); + run.tray_slots[2].item_instance_id = Some("pear-1".to_string()); + run.tray_slots[2].item_type_id = Some("pear".to_string()); + run.tray_slots[2].visual_key = Some("pear".to_string()); + + let confirmed = confirm_click_at( + &run, + &Match3DClickInput { + run_id: run.run_id.clone(), + owner_user_id: run.owner_user_id.clone(), + item_instance_id: "apple-3".to_string(), + client_action_id: "action-insert".to_string(), + snapshot_version: run.board_version, + clicked_at_ms: 1_000, + }, + ) + .expect("click should confirm"); + + assert_eq!(confirmed.entered_slot_index, Some(2)); + assert_eq!( + confirmed + .run + .tray_slots + .iter() + .map(|slot| slot.item_instance_id.as_deref()) + .collect::>(), + vec![Some("pear-1"), None, None, None, None, None, None] + ); + assert_eq!( + confirmed + .run + .items + .iter() + .find(|item| item.item_instance_id == "pear-1") + .and_then(|item| item.tray_slot_index), + Some(0) + ); + assert_eq!( + confirmed.cleared_item_instance_ids, + vec![ + "apple-1".to_string(), + "apple-2".to_string(), + "apple-3".to_string() + ] + ); + } + #[test] fn tray_full_fails_when_no_triple_can_clear() { let mut run = Match3DRunSnapshot { diff --git a/server-rs/crates/module-puzzle/src/application.rs b/server-rs/crates/module-puzzle/src/application.rs index b592292d..dfa24169 100644 --- a/server-rs/crates/module-puzzle/src/application.rs +++ b/server-rs/crates/module-puzzle/src/application.rs @@ -786,6 +786,45 @@ fn first_profile_level(profile: &PuzzleWorkProfile) -> Option .next() } +fn first_profile_ui_background_level(profile: &PuzzleWorkProfile) -> Option { + normalize_puzzle_levels(profile.levels.clone(), &profile.theme_tags) + .unwrap_or_else(|_| profile.levels.clone()) + .into_iter() + .find(|level| { + level + .ui_background_image_src + .as_deref() + .and_then(normalize_required_string) + .is_some() + || level + .ui_background_image_object_key + .as_deref() + .and_then(normalize_required_string) + .is_some() + }) +} + +fn resolve_puzzle_runtime_ui_background_fields( + level: Option<&PuzzleDraftLevel>, + fallback_level: Option<&PuzzleDraftLevel>, +) -> (Option, Option) { + for candidate in [level, fallback_level].into_iter().flatten() { + let image_src = candidate + .ui_background_image_src + .as_deref() + .and_then(normalize_required_string); + let object_key = candidate + .ui_background_image_object_key + .as_deref() + .and_then(|value| normalize_required_string(value.trim_start_matches('/'))); + if image_src.is_some() || object_key.is_some() { + return (image_src, object_key); + } + } + + (None, None) +} + pub fn resolve_puzzle_runtime_remaining_ms(level: &PuzzleRuntimeLevelSnapshot, now_ms: u64) -> u64 { let time_limit_ms = if level.time_limit_ms == 0 { resolve_puzzle_level_time_limit_ms_by_index(level.level_index) @@ -1047,6 +1086,12 @@ pub fn start_run_with_shuffle_seed_at( let grid_size = level_config.grid_size; let board = build_initial_board_with_seed(grid_size, shuffle_seed)?; let current_profile_level = first_profile_level(entry_profile); + let ui_background_level = first_profile_ui_background_level(entry_profile); + let (ui_background_image_src, ui_background_image_object_key) = + resolve_puzzle_runtime_ui_background_fields( + current_profile_level.as_ref(), + ui_background_level.as_ref(), + ); Ok(PuzzleRunSnapshot { run_id: run_id.clone(), entry_profile_id: entry_profile.profile_id.clone(), @@ -1067,12 +1112,8 @@ pub fn start_run_with_shuffle_seed_at( author_display_name: entry_profile.author_display_name.clone(), theme_tags: entry_profile.theme_tags.clone(), cover_image_src: entry_profile.cover_image_src.clone(), - ui_background_image_src: current_profile_level - .as_ref() - .and_then(|level| level.ui_background_image_src.clone()), - ui_background_image_object_key: current_profile_level - .as_ref() - .and_then(|level| level.ui_background_image_object_key.clone()), + ui_background_image_src, + ui_background_image_object_key, background_music: current_profile_level .as_ref() .and_then(|level| level.background_music.clone()), @@ -1326,6 +1367,16 @@ pub fn advance_next_level_at( let mut played_profile_ids = run.played_profile_ids.clone(); played_profile_ids.push(next_profile.profile_id.clone()); let current_profile_level = first_profile_level(next_profile); + let ui_background_level = first_profile_ui_background_level(next_profile); + let (mut ui_background_image_src, mut ui_background_image_object_key) = + resolve_puzzle_runtime_ui_background_fields( + current_profile_level.as_ref(), + ui_background_level.as_ref(), + ); + if ui_background_image_src.is_none() && ui_background_image_object_key.is_none() { + ui_background_image_src = current_level.ui_background_image_src.clone(); + ui_background_image_object_key = current_level.ui_background_image_object_key.clone(); + } Ok(PuzzleRunSnapshot { run_id: run.run_id.clone(), @@ -1347,12 +1398,8 @@ pub fn advance_next_level_at( author_display_name: next_profile.author_display_name.clone(), theme_tags: next_profile.theme_tags.clone(), cover_image_src: next_profile.cover_image_src.clone(), - ui_background_image_src: current_profile_level - .as_ref() - .and_then(|level| level.ui_background_image_src.clone()), - ui_background_image_object_key: current_profile_level - .as_ref() - .and_then(|level| level.ui_background_image_object_key.clone()), + ui_background_image_src, + ui_background_image_object_key, background_music: current_profile_level .as_ref() .and_then(|level| level.background_music.clone()), @@ -1408,6 +1455,12 @@ pub fn advance_to_new_work_first_level_at( played_profile_ids.push(next_profile.profile_id.clone()); } let current_profile_level = first_profile_level(next_profile); + let ui_background_level = first_profile_ui_background_level(next_profile); + let (ui_background_image_src, ui_background_image_object_key) = + resolve_puzzle_runtime_ui_background_fields( + current_profile_level.as_ref(), + ui_background_level.as_ref(), + ); Ok(PuzzleRunSnapshot { run_id: run.run_id.clone(), @@ -1429,12 +1482,8 @@ pub fn advance_to_new_work_first_level_at( author_display_name: next_profile.author_display_name.clone(), theme_tags: next_profile.theme_tags.clone(), cover_image_src: next_profile.cover_image_src.clone(), - ui_background_image_src: current_profile_level - .as_ref() - .and_then(|level| level.ui_background_image_src.clone()), - ui_background_image_object_key: current_profile_level - .as_ref() - .and_then(|level| level.ui_background_image_object_key.clone()), + ui_background_image_src, + ui_background_image_object_key, background_music: current_profile_level .as_ref() .and_then(|level| level.background_music.clone()), @@ -3151,8 +3200,7 @@ mod tests { .background_music .as_ref() .map(|music| music.audio_src.as_str()), - Some("/generated-puzzle-assets/background.mp3".to_string()) - .as_deref() + Some("/generated-puzzle-assets/background.mp3".to_string()).as_deref() ); assert_eq!( current_level.ui_background_image_object_key.as_deref(), @@ -3175,8 +3223,8 @@ mod tests { current_level.cleared_at_ms = Some(2_000); current_level.elapsed_ms = Some(1_000); - let next_run = - advance_to_new_work_first_level_at(&cleared_run, &next_profile, 3_000).expect("next run"); + let next_run = advance_to_new_work_first_level_at(&cleared_run, &next_profile, 3_000) + .expect("next run"); assert_eq!( next_run @@ -3187,6 +3235,52 @@ mod tests { ); } + #[test] + fn same_work_next_level_inherits_first_available_ui_background() { + let mut profile = build_published_profile("entry", "owner-a", vec!["奇幻"]); + profile.levels[0].ui_background_image_src = + Some("/generated-puzzle-assets/entry-ui.png".to_string()); + profile.levels.push(PuzzleDraftLevel { + level_id: "puzzle-level-2".to_string(), + level_name: "第二关".to_string(), + picture_description: "第二关画é¢".to_string(), + picture_reference: None, + ui_background_prompt: None, + ui_background_image_src: None, + ui_background_image_object_key: None, + background_music: None, + candidates: Vec::new(), + selected_candidate_id: None, + cover_image_src: Some("/level-2.png".to_string()), + cover_asset_id: None, + generation_status: "ready".to_string(), + }); + + let mut run = start_run("run-same-work-ui".to_string(), &profile, 0).expect("run"); + run.cleared_level_count = run.current_level_index; + let current_level = run.current_level.as_mut().expect("level"); + current_level.status = PuzzleRuntimeLevelStatus::Cleared; + current_level.cleared_at_ms = Some(2_000); + current_level.elapsed_ms = Some(1_000); + let next_level = selected_profile_level_after_runtime_level(&profile, current_level) + .expect("same work next level"); + let mut next_profile = profile.clone(); + next_profile.level_name = next_level.level_name.clone(); + next_profile.cover_image_src = next_level.cover_image_src.clone(); + next_profile.cover_asset_id = next_level.cover_asset_id.clone(); + next_profile.levels = vec![next_level]; + + let next_run = advance_next_level_at(&run, &next_profile, 3_000).expect("next run"); + + assert_eq!( + next_run + .current_level + .as_ref() + .and_then(|level| level.ui_background_image_src.as_deref()), + Some("/generated-puzzle-assets/entry-ui.png") + ); + } + #[test] fn swap_pieces_marks_cleared_when_back_to_origin() { let profile = build_published_profile("entry", "owner-a", vec!["蒸汽城市", "雨夜", "猫咪"]); diff --git a/server-rs/crates/shared-contracts/src/match3d_works.rs b/server-rs/crates/shared-contracts/src/match3d_works.rs index 01c0efa5..bc68558b 100644 --- a/server-rs/crates/shared-contracts/src/match3d_works.rs +++ b/server-rs/crates/shared-contracts/src/match3d_works.rs @@ -151,6 +151,8 @@ pub struct Match3DWorkSummaryResponse { #[serde(default)] pub published_at: Option, pub publish_ready: bool, + #[serde(default)] + pub generation_status: Option, #[serde(default, skip_serializing_if = "Option::is_none")] pub background_prompt: Option, #[serde(default, skip_serializing_if = "Option::is_none")] @@ -282,4 +284,36 @@ mod tests { assert_eq!(payload["gameName"], json!("水果抓大鹅")); assert_eq!(payload["clearCount"], json!(4)); } + + #[test] + fn match3d_work_summary_uses_camel_case_generation_status() { + let payload = serde_json::to_value(Match3DWorkSummaryResponse { + work_id: "work-1".to_string(), + profile_id: "profile-1".to_string(), + owner_user_id: "user-1".to_string(), + source_session_id: Some("session-1".to_string()), + game_name: "水果抓大鹅".to_string(), + theme_text: "æ°´æžœ".to_string(), + summary: "水果主题".to_string(), + tags: vec!["æ°´æžœ".to_string()], + cover_image_src: None, + reference_image_src: None, + clear_count: 4, + difficulty: 5, + publication_status: "draft".to_string(), + play_count: 0, + updated_at: "2026-05-01T00:00:00Z".to_string(), + published_at: None, + publish_ready: false, + generation_status: Some("generating".to_string()), + background_prompt: None, + background_image_src: None, + background_image_object_key: None, + generated_background_asset: None, + generated_item_assets: Vec::new(), + }) + .expect("payload should serialize"); + + assert_eq!(payload["generationStatus"], json!("generating")); + } } diff --git a/server-rs/crates/shared-contracts/src/puzzle_works.rs b/server-rs/crates/shared-contracts/src/puzzle_works.rs index 339b4f52..8eb2afe0 100644 --- a/server-rs/crates/shared-contracts/src/puzzle_works.rs +++ b/server-rs/crates/shared-contracts/src/puzzle_works.rs @@ -57,6 +57,8 @@ pub struct PuzzleWorkSummaryResponse { pub point_incentive_claimable_points: u64, pub publish_ready: bool, #[serde(default)] + pub generation_status: Option, + #[serde(default)] pub levels: Vec, } @@ -91,6 +93,7 @@ mod tests { point_incentive_total_points: 1.5, point_incentive_claimable_points: 0, publish_ready: true, + generation_status: Some("ready".to_string()), levels: Vec::new(), }) .expect("payload should serialize"); @@ -99,6 +102,7 @@ mod tests { assert_eq!(payload["pointIncentiveClaimedPoints"], 1); assert_eq!(payload["pointIncentiveTotalPoints"], 1.5); assert_eq!(payload["pointIncentiveClaimablePoints"], 0); + assert_eq!(payload["generationStatus"], "ready"); } } diff --git a/server-rs/crates/spacetime-client/src/mapper.rs b/server-rs/crates/spacetime-client/src/mapper.rs index 225b632c..cef4511b 100644 --- a/server-rs/crates/spacetime-client/src/mapper.rs +++ b/server-rs/crates/spacetime-client/src/mapper.rs @@ -3195,6 +3195,7 @@ fn map_match3d_result_draft( total_item_count: snapshot.clear_count.saturating_mul(3), publish_ready: false, blockers: Vec::new(), + generated_item_assets_json: snapshot.generated_item_assets_json, } } @@ -6398,6 +6399,7 @@ pub struct Match3DResultDraftRecord { pub total_item_count: u32, pub publish_ready: bool, pub blockers: Vec, + pub generated_item_assets_json: Option, } #[derive(Clone, Debug, PartialEq, Eq)] @@ -6541,6 +6543,8 @@ struct Match3DDraftJsonRecord { tags: Vec, clear_count: u32, difficulty: u32, + #[serde(default)] + generated_item_assets_json: Option, } #[derive(Clone, Debug, PartialEq, Eq, serde::Deserialize)] diff --git a/server-rs/crates/spacetime-module/src/match3d/mod.rs b/server-rs/crates/spacetime-module/src/match3d/mod.rs index a4ed030e..70a38de2 100644 --- a/server-rs/crates/spacetime-module/src/match3d/mod.rs +++ b/server-rs/crates/spacetime-module/src/match3d/mod.rs @@ -459,6 +459,11 @@ fn compile_match3d_draft_tx( config.theme_text.as_str(), ); let summary_text = resolve_compile_summary_text(&input.summary_text, existing_work.as_ref()); + let compiled_at = Timestamp::from_micros_since_unix_epoch(input.compiled_at_micros); + let generated_item_assets_json = resolve_generated_item_assets_json_for_compile( + input.generated_item_assets_json.as_deref(), + existing_work.as_ref(), + )?; let draft = Match3DDraftSnapshot { profile_id: input.profile_id.clone(), game_name: game_name.clone(), @@ -467,12 +472,9 @@ fn compile_match3d_draft_tx( tags: tags.clone(), clear_count: config.clear_count, difficulty: config.difficulty, + // 中文注释:è‰ç¨¿å“应本身也æºå¸¦ç”Ÿæˆç´ æå¿«ç…§ï¼Œé¿å… HTTP facade 回读 work 详情失败时丢失背景/容器图。 + generated_item_assets_json: generated_item_assets_json.clone(), }; - let compiled_at = Timestamp::from_micros_since_unix_epoch(input.compiled_at_micros); - let generated_item_assets_json = resolve_generated_item_assets_json_for_compile( - input.generated_item_assets_json.as_deref(), - existing_work.as_ref(), - )?; let previous_publication_status = existing_work .as_ref() .map(|work| work.publication_status.clone()) @@ -1889,6 +1891,32 @@ mod tests { ); } + #[test] + fn match3d_draft_snapshot_keeps_generated_item_assets_json() { + let draft = Match3DDraftSnapshot { + profile_id: "profile-1".to_string(), + game_name: "水果抓大鹅".to_string(), + theme_text: "æ°´æžœ".to_string(), + summary_text: "水果主题".to_string(), + tags: vec!["æ°´æžœ".to_string()], + clear_count: 3, + difficulty: 3, + generated_item_assets_json: Some( + r#"[{"itemId":"match3d-item-1","itemName":"è‰èŽ“","backgroundAsset":{"prompt":"果园背景","imageSrc":"/generated-match3d-assets/session/profile/background/background.png","containerImageSrc":"/generated-match3d-assets/session/profile/ui-container/container.png","status":"image_ready"},"status":"image_ready"}]"# + .to_string(), + ), + }; + + let row_json = to_json_string(&draft); + let restored = + parse_json::(&row_json, "match3d draft_json").unwrap(); + + assert_eq!( + restored.generated_item_assets_json.as_deref(), + draft.generated_item_assets_json.as_deref() + ); + } + #[test] fn match3d_work_update_preserves_assets_and_allows_empty_summary() { let existing = Match3DWorkProfileRow { diff --git a/server-rs/crates/spacetime-module/src/match3d/types.rs b/server-rs/crates/spacetime-module/src/match3d/types.rs index 292cc877..ab79903f 100644 --- a/server-rs/crates/spacetime-module/src/match3d/types.rs +++ b/server-rs/crates/spacetime-module/src/match3d/types.rs @@ -256,6 +256,8 @@ pub struct Match3DDraftSnapshot { pub tags: Vec, pub clear_count: u32, pub difficulty: u32, + #[serde(default)] + pub generated_item_assets_json: Option, } #[derive(Clone, Debug, PartialEq, Eq, Serialize, Deserialize)] diff --git a/server-rs/crates/spacetime-module/src/puzzle.rs b/server-rs/crates/spacetime-module/src/puzzle.rs index 703e880e..b9b12e0d 100644 --- a/server-rs/crates/spacetime-module/src/puzzle.rs +++ b/server-rs/crates/spacetime-module/src/puzzle.rs @@ -870,7 +870,10 @@ fn compile_puzzle_agent_draft_tx( } let anchor_pack = infer_anchor_pack(&row.seed_text, Some(&row.seed_text)); let messages = list_session_messages(ctx, &row.session_id); - let draft = compile_result_draft_from_seed(&anchor_pack, &messages, Some(&row.seed_text)); + let draft = mark_puzzle_draft_generation_status( + compile_result_draft_from_seed(&anchor_pack, &messages, Some(&row.seed_text)), + "generating", + ); // 创作中心的拼图è‰ç¨¿å¡åªæ˜¯ Agent session 的列表投影, // æ¯æ¬¡ç¼–è¯‘ç»“æžœé¡µæ—¶åŒæ­¥ upsert,ä¿è¯åŽç»­èƒ½æŒ‰ source_session_id æ¢å¤èŠå¤©ã€‚ upsert_puzzle_draft_work_profile( @@ -2472,10 +2475,52 @@ fn profile_for_single_level( level: &module_puzzle::PuzzleDraftLevel, ) -> PuzzleWorkProfile { let mut next_profile = profile.clone(); + let ui_background_carrier = profile.levels.iter().find(|candidate| { + candidate + .ui_background_image_src + .as_deref() + .map(str::trim) + .map(|value| !value.is_empty()) + .unwrap_or(false) + || candidate + .ui_background_image_object_key + .as_deref() + .map(str::trim) + .map(|value| !value.is_empty()) + .unwrap_or(false) + }); + let mut single_level = level.clone(); + if single_level + .ui_background_image_src + .as_deref() + .map(str::trim) + .unwrap_or("") + .is_empty() + && single_level + .ui_background_image_object_key + .as_deref() + .map(str::trim) + .unwrap_or("") + .is_empty() + && let Some(carrier) = ui_background_carrier + { + single_level.ui_background_image_src = carrier + .ui_background_image_src + .as_deref() + .map(str::trim) + .filter(|value| !value.is_empty()) + .map(str::to_string); + single_level.ui_background_image_object_key = carrier + .ui_background_image_object_key + .as_deref() + .map(str::trim) + .filter(|value| !value.is_empty()) + .map(|value| value.trim_start_matches('/').to_string()); + } next_profile.level_name = level.level_name.clone(); next_profile.cover_image_src = level.cover_image_src.clone(); next_profile.cover_asset_id = level.cover_asset_id.clone(); - next_profile.levels = vec![level.clone()]; + next_profile.levels = vec![single_level]; next_profile } @@ -2496,6 +2541,17 @@ fn micros_to_millis(value: i64) -> u64 { (value as u64).saturating_div(1_000) } +fn mark_puzzle_draft_generation_status( + mut draft: PuzzleResultDraft, + generation_status: &str, +) -> PuzzleResultDraft { + draft.generation_status = generation_status.to_string(); + for level in &mut draft.levels { + level.generation_status = generation_status.to_string(); + } + draft +} + fn upsert_puzzle_draft_work_profile( ctx: &TxContext, session_id: &str, @@ -3466,6 +3522,37 @@ mod tests { assert!(preview.publish_ready); } + #[test] + fn puzzle_draft_generation_status_updates_all_levels() { + let anchor_pack = infer_anchor_pack("蒸汽城市雨夜猫咪", Some("蒸汽城市雨夜猫咪")); + let mut draft = compile_result_draft(&anchor_pack, &[]); + draft.levels.push(module_puzzle::PuzzleDraftLevel { + level_id: "puzzle-level-2".to_string(), + level_name: "第二关".to_string(), + picture_description: "第二关画é¢".to_string(), + picture_reference: None, + ui_background_prompt: None, + ui_background_image_src: None, + ui_background_image_object_key: None, + background_music: None, + candidates: Vec::new(), + selected_candidate_id: None, + cover_image_src: None, + cover_asset_id: None, + generation_status: "idle".to_string(), + }); + + let draft = mark_puzzle_draft_generation_status(draft, "generating"); + + assert_eq!(draft.generation_status, "generating"); + assert!( + draft + .levels + .iter() + .all(|level| level.generation_status == "generating") + ); + } + #[test] fn puzzle_generated_images_replace_existing_candidate() { let anchor_pack = infer_anchor_pack("蒸汽城市雨夜猫咪", Some("蒸汽城市雨夜猫咪")); diff --git a/server-rs/crates/spacetime-module/src/runtime/creation_entry_config.rs b/server-rs/crates/spacetime-module/src/runtime/creation_entry_config.rs index 683c4b2f..05c9db50 100644 --- a/server-rs/crates/spacetime-module/src/runtime/creation_entry_config.rs +++ b/server-rs/crates/spacetime-module/src/runtime/creation_entry_config.rs @@ -215,8 +215,7 @@ fn migrate_visual_novel_entry_from_old_visible_default(ctx: &ReducerContext, now && row.subtitle == "分支å™äº‹ä½“验" && row.image_src == "/creation-type-references/visual-novel.webp" && row.visible - && ((row.badge == "å¯åˆ›å»º" && row.open) - || (row.badge == "敬请期待" && !row.open)) + && ((row.badge == "å¯åˆ›å»º" && row.open) || (row.badge == "敬请期待" && !row.open)) && row.sort_order == 60; if !still_old_visible_default { return; diff --git a/src/components/custom-world-home/creationWorkShelf.test.ts b/src/components/custom-world-home/creationWorkShelf.test.ts index 300961d3..156f8ce7 100644 --- a/src/components/custom-world-home/creationWorkShelf.test.ts +++ b/src/components/custom-world-home/creationWorkShelf.test.ts @@ -86,6 +86,58 @@ test('buildCreationWorkShelfItems attaches open and delete actions through shelf expect(onDeletePuzzle).toHaveBeenCalledWith(puzzleWork); }); +test('buildCreationWorkShelfItems restores persisted generation state for puzzle and match3d drafts', () => { + const items = buildCreationWorkShelfItems({ + rpgItems: [], + bigFishItems: [], + puzzleItems: [ + { + workId: 'puzzle:generating', + profileId: 'puzzle-profile-generating', + ownerUserId: 'user-1', + sourceSessionId: 'puzzle-session-generating', + authorDisplayName: '测试作者', + levelName: '生æˆä¸­æ‹¼å›¾', + summary: '退出产å“åŽä»åº”显示生æˆä¸­ã€‚', + themeTags: [], + coverImageSrc: null, + publicationStatus: 'draft', + updatedAt: '2026-05-08T00:00:00.000Z', + publishedAt: null, + publishReady: false, + generationStatus: 'generating', + }, + ], + match3dItems: [ + { + workId: 'match3d:generating', + profileId: 'match3d-profile-generating', + ownerUserId: 'user-1', + sourceSessionId: 'match3d-session-generating', + gameName: '生æˆä¸­æŠ“é¹…', + themeText: '糖果厨房', + summary: '退出产å“åŽä»åº”显示生æˆä¸­ã€‚', + tags: [], + coverImageSrc: null, + clearCount: 18, + difficulty: 1, + publicationStatus: 'draft', + playCount: 0, + updatedAt: '2026-05-07T00:00:00.000Z', + publishReady: false, + generationStatus: 'generating', + }, + ], + }); + + expect(items.find((item) => item.kind === 'puzzle')?.isGenerating).toBe( + true, + ); + expect(items.find((item) => item.kind === 'match3d')?.isGenerating).toBe( + true, + ); +}); + test('buildCreationWorkShelfItems maps baby object match local drafts', () => { const onOpenBabyObjectMatchDetail = vi.fn(); const onDeleteBabyObjectMatch = vi.fn(); diff --git a/src/components/custom-world-home/creationWorkShelf.ts b/src/components/custom-world-home/creationWorkShelf.ts index e97c6551..410f7ea7 100644 --- a/src/components/custom-world-home/creationWorkShelf.ts +++ b/src/components/custom-world-home/creationWorkShelf.ts @@ -238,13 +238,19 @@ export function buildCreationWorkShelfItems(params: { ] .map((item) => { const state = getItemState?.(item); + const persistedIsGenerating = isPersistedCreationWorkGenerating(item); return state ? { ...item, - isGenerating: state.isGenerating, + isGenerating: Boolean(state.isGenerating || persistedIsGenerating), hasUnreadUpdate: state.hasUnreadUpdate, } - : item; + : persistedIsGenerating + ? { + ...item, + isGenerating: true, + } + : item; }) .sort( (left, right) => @@ -793,6 +799,17 @@ function buildPuzzleWorkShelfActions( }; } +function isPersistedCreationWorkGenerating(item: CreationWorkShelfItem) { + switch (item.source.kind) { + case 'match3d': + return item.source.item.generationStatus === 'generating'; + case 'puzzle': + return item.source.item.generationStatus === 'generating'; + default: + return false; + } +} + function buildRpgWorkShelfActions( item: CustomWorldWorkSummary, adapter: RpgWorkShelfAdapter, diff --git a/src/components/match3d-result/Match3DResultView.test.tsx b/src/components/match3d-result/Match3DResultView.test.tsx index 3ded600e..31ffebab 100644 --- a/src/components/match3d-result/Match3DResultView.test.tsx +++ b/src/components/match3d-result/Match3DResultView.test.tsx @@ -1359,7 +1359,7 @@ describe('Match3DResultView', () => { 'img[src="/match3d-background-references/pot-fused-reference.png"]', ); expect(containerImage).toBeTruthy(); - expect(containerImage?.className).toContain('w-[min(99vw,34rem)]'); + expect(containerImage?.className).toContain('w-[min(108vw,38rem)]'); expect(containerImage?.className).toContain('-translate-x-1/2'); expect( document.querySelector('.animate-spin, [class*="border-l-transparent"]'), diff --git a/src/components/match3d-runtime/Match3DRuntimeShell.test.tsx b/src/components/match3d-runtime/Match3DRuntimeShell.test.tsx index ed1a51fd..e80e1b3f 100644 --- a/src/components/match3d-runtime/Match3DRuntimeShell.test.tsx +++ b/src/components/match3d-runtime/Match3DRuntimeShell.test.tsx @@ -52,6 +52,19 @@ import { import { Match3DRuntimeShell } from './Match3DRuntimeShell'; import { resolveGeometryAsset } from './match3dVisualAssets'; +const runtimeAudioFeedback = vi.hoisted(() => ({ + playRuntimeMergeSound: vi.fn(), +})); + +vi.mock('../../services/runtimeAudioFeedback', async (importOriginal) => { + const actual = + await importOriginal(); + return { + ...actual, + playRuntimeMergeSound: runtimeAudioFeedback.playRuntimeMergeSound, + }; +}); + vi.mock('./Match3DPhysicsBoard', async (importOriginal) => { const actual = await importOriginal(); return { @@ -82,6 +95,7 @@ afterEach(() => { __MATCH3D_KEEP_3D_TEST_RENDER__?: boolean; } ).__MATCH3D_KEEP_3D_TEST_RENDER__; + runtimeAudioFeedback.playRuntimeMergeSound.mockReset(); vi.restoreAllMocks(); }); @@ -519,6 +533,318 @@ test('è¿è¡Œæ€æŒ‰ç”Ÿæˆç´ æçš„相对尺寸缩放场内和托盘图片', () => ).toBe('scale(0.58)'); }); +test('点击物å“ä¹è§‚æ’å…¥åˆ°ç‰©å“æ åŒç±»åŽé¢å¹¶åŽç§»åŽç»­ç‰©å“', async () => { + const baseRun = startLocalMatch3DRun(3); + const [appleBoard, pearTray, appleTray] = baseRun.items.slice(0, 3); + expect(appleBoard && pearTray && appleTray).toBeTruthy(); + const run: Match3DRunSnapshot = { + ...baseRun, + items: [ + { + ...appleBoard!, + itemInstanceId: 'apple-3', + itemTypeId: 'apple', + visualKey: 'block-red-2x4', + clickable: true, + state: 'InBoard', + x: 0.5, + y: 0.5, + layer: 10, + traySlotIndex: null, + }, + { + ...pearTray!, + itemInstanceId: 'apple-1', + itemTypeId: 'apple', + visualKey: 'block-red-2x4', + clickable: false, + state: 'InTray', + traySlotIndex: 0, + }, + { + ...appleTray!, + itemInstanceId: 'apple-2', + itemTypeId: 'apple', + visualKey: 'block-red-2x4', + clickable: false, + state: 'InTray', + traySlotIndex: 1, + }, + { + ...baseRun.items[3]!, + itemInstanceId: 'pear-1', + itemTypeId: 'pear', + visualKey: 'block-blue-1x2', + clickable: false, + state: 'InTray', + traySlotIndex: 2, + }, + ...baseRun.items.slice(4).map((item) => ({ + ...item, + clickable: false, + state: 'InBoard' as const, + })), + ], + traySlots: baseRun.traySlots.map((slot) => { + if (slot.slotIndex === 0) { + return { + slotIndex: 0, + itemInstanceId: 'apple-1', + itemTypeId: 'apple', + visualKey: 'block-red-2x4', + }; + } + if (slot.slotIndex === 1) { + return { + slotIndex: 1, + itemInstanceId: 'apple-2', + itemTypeId: 'apple', + visualKey: 'block-red-2x4', + }; + } + if (slot.slotIndex === 2) { + return { + slotIndex: 2, + itemInstanceId: 'pear-1', + itemTypeId: 'pear', + visualKey: 'block-blue-1x2', + }; + } + return { slotIndex: slot.slotIndex }; + }), + }; + const onClickItem = vi.fn(async (payload: Match3DClickItemRequest) => ({ + status: 'Accepted' as const, + acceptedItemInstanceId: payload.itemInstanceId, + clearedItemInstanceIds: [], + run: { + ...run, + snapshotVersion: run.snapshotVersion + 1, + }, + })); + const onOptimisticRunChange = vi.fn(); + render( + , + ); + const board = screen.getByTestId('match3d-board'); + mockMatch3DBoardRect(); + mockMatch3DPointerCapture(board); + Object.defineProperty(screen.getAllByTestId('match3d-tray-slot')[3]!, 'getBoundingClientRect', { + configurable: true, + value: () => ({ + bottom: 530, + height: 56, + left: 220, + right: 276, + top: 474, + width: 56, + x: 220, + y: 474, + toJSON: () => ({}), + }), + }); + + const point = toMatch3DBoardClientPoint(run.items[0]!); + fireMatch3DBoardPointer(board, 'pointerdown', point, 14); + fireMatch3DBoardPointer(board, 'pointerup', point, 14); + + await waitFor(() => expect(onOptimisticRunChange).toHaveBeenCalled()); + const optimisticRun = onOptimisticRunChange.mock.calls[0]?.[0] as + | Match3DRunSnapshot + | undefined; + expect(optimisticRun?.traySlots.map((slot) => slot.itemInstanceId ?? null)).toEqual([ + 'apple-1', + 'apple-2', + 'apple-3', + 'pear-1', + null, + null, + null, + ]); + expect( + optimisticRun?.items.find((item) => item.itemInstanceId === 'apple-3') + ?.traySlotIndex, + ).toBe(2); +}); + +test('三消确认åŽç‰©å“æ æ’­æ”¾åˆæˆåŠ¨ç”»å¹¶éšè—æƒå¨å¿«ç…§ä¸­å·²æ¸…除的槽ä½', async () => { + const baseRun = startLocalMatch3DRun(1); + const [first, second, third, fourth] = baseRun.items.slice(0, 4); + expect(first && second && third).toBeTruthy(); + const run: Match3DRunSnapshot = { + ...baseRun, + totalItemCount: 4, + items: [ + { + ...first!, + itemInstanceId: 'apple-1', + itemTypeId: 'apple', + visualKey: 'block-red-2x4', + state: 'InTray', + clickable: false, + traySlotIndex: 0, + }, + { + ...second!, + itemInstanceId: 'apple-2', + itemTypeId: 'apple', + visualKey: 'block-red-2x4', + state: 'InTray', + clickable: false, + traySlotIndex: 1, + }, + { + ...third!, + itemInstanceId: 'apple-3', + itemTypeId: 'apple', + visualKey: 'block-red-2x4', + state: 'InBoard', + clickable: true, + x: 0.5, + y: 0.5, + layer: 10, + traySlotIndex: null, + }, + { + ...(fourth ?? third!), + itemInstanceId: 'pear-1', + itemTypeId: 'pear', + visualKey: 'block-blue-1x2', + state: 'InTray', + clickable: false, + traySlotIndex: 2, + }, + ], + traySlots: baseRun.traySlots.map((slot) => { + if (slot.slotIndex === 0) { + return { + slotIndex: 0, + itemInstanceId: 'apple-1', + itemTypeId: 'apple', + visualKey: 'block-red-2x4', + }; + } + if (slot.slotIndex === 1) { + return { + slotIndex: 1, + itemInstanceId: 'apple-2', + itemTypeId: 'apple', + visualKey: 'block-red-2x4', + }; + } + if (slot.slotIndex === 2) { + return { + slotIndex: 2, + itemInstanceId: 'pear-1', + itemTypeId: 'pear', + visualKey: 'block-blue-1x2', + }; + } + return { slotIndex: slot.slotIndex }; + }), + }; + const acceptedRun: Match3DRunSnapshot = { + ...run, + snapshotVersion: run.snapshotVersion + 1, + clearedItemCount: 3, + items: run.items.map((item) => + item.itemTypeId === 'apple' + ? { + ...item, + state: 'Cleared' as const, + clickable: false, + traySlotIndex: null, + } + : { ...item, traySlotIndex: 0 }, + ), + traySlots: run.traySlots.map((slot) => + slot.slotIndex === 0 + ? { + slotIndex: 0, + itemInstanceId: 'pear-1', + itemTypeId: 'pear', + visualKey: 'block-blue-1x2', + } + : { slotIndex: slot.slotIndex }, + ), + }; + const onClickItem = vi.fn(async (payload: Match3DClickItemRequest) => ({ + status: 'Accepted' as const, + acceptedItemInstanceId: payload.itemInstanceId, + clearedItemInstanceIds: ['apple-1', 'apple-2', 'apple-3'], + run: acceptedRun, + })); + const onOptimisticRunChange = vi.fn((nextRun: Match3DRunSnapshot) => { + rerender( + , + ); + }); + const { rerender } = render( + , + ); + const board = screen.getByTestId('match3d-board'); + mockMatch3DBoardRect(); + mockMatch3DPointerCapture(board); + screen.getAllByTestId('match3d-tray-slot').forEach((slot, index) => { + Object.defineProperty(slot, 'getBoundingClientRect', { + configurable: true, + value: () => ({ + bottom: 530, + height: 56, + left: 52 + index * 58, + right: 108 + index * 58, + top: 474, + width: 56, + x: 52 + index * 58, + y: 474, + toJSON: () => ({}), + }), + }); + }); + + const point = toMatch3DBoardClientPoint(run.items[2]!); + fireMatch3DBoardPointer(board, 'pointerdown', point, 15); + fireMatch3DBoardPointer(board, 'pointerup', point, 15); + + await waitFor(() => + expect(screen.getByTestId('match3d-tray-clear-animation')).toBeTruthy(), + ); + expect(screen.getAllByTestId('match3d-tray-clear-token')).toHaveLength(3); + expect(screen.getByTestId('match3d-merge-feedback')).toBeTruthy(); + expect(screen.queryByTestId('match3d-merge-feedback')?.querySelector('svg')).toBeNull(); + expect(runtimeAudioFeedback.playRuntimeMergeSound).toHaveBeenCalledTimes(1); + const latestRun = onOptimisticRunChange.mock.calls.at(-1)?.[0] as + | Match3DRunSnapshot + | undefined; + expect(latestRun?.traySlots.map((slot) => slot.itemInstanceId ?? null)).toEqual([ + 'pear-1', + null, + null, + null, + null, + null, + null, + ]); +}); + test('ç‚¹å‡»ç‰©å“æ—¶æ’­æ”¾é£žå…¥åº•部æ ä½åŠ¨ç”»å¹¶ä½¿ç”¨ç¬¬ä¸€å¼ ç‰©å“视图', async () => { const run = startLocalMatch3DRun(1); const clickableItem = run.items.find((item) => item.clickable)!; @@ -1025,9 +1351,10 @@ test('è¿è¡Œæ€ä¼šæ¢ç­¾å¹¶æ¸²æŸ“抓大鹅中心容器 UI 图', async () => { const containerImage = screen.getByTestId( 'match3d-container-image', ) as HTMLImageElement; - expect(containerImage.className).toContain('w-[min(99vw,34rem)]'); + expect(containerImage.className).toContain('w-[min(116vw,42rem)]'); expect(containerImage.className).toContain('h-auto'); expect(containerImage.className).toContain('left-1/2'); + expect(containerImage.className).toContain('top-[54%]'); expect(containerImage.className).toContain('-translate-x-1/2'); expect(screen.getByTestId('match3d-board').className).toContain( 'bg-transparent', diff --git a/src/components/match3d-runtime/Match3DRuntimeShell.tsx b/src/components/match3d-runtime/Match3DRuntimeShell.tsx index c6745bea..839de016 100644 --- a/src/components/match3d-runtime/Match3DRuntimeShell.tsx +++ b/src/components/match3d-runtime/Match3DRuntimeShell.tsx @@ -4,7 +4,6 @@ import { Clock3, RotateCcw, Settings, - Sparkles, XCircle, } from 'lucide-react'; import { @@ -12,6 +11,7 @@ import { type PointerEvent, useCallback, useEffect, + useLayoutEffect, useMemo, useRef, useState, @@ -30,20 +30,35 @@ import type { } from '../../../packages/shared/src/contracts/match3dWorks'; import { isGeneratedLegacyPath, + readAssetBytes, resolveAssetReadUrl, } from '../../services/assetReadUrlService'; import { getMatch3DGeneratedImageViewSources, normalizeMatch3DGeneratedItemAssetsForRuntime, } from '../../services/match3dGeneratedModelCache'; +import { + buildMatch3DTrayInsertionPlan, + resolveMatch3DTrayItemIdToSlotIndexMap, + syncMatch3DItemTraySlotIndexes, +} from '../../services/match3d-runtime/match3dTrayLayout'; import { DEFAULT_RUNTIME_LEVEL_AUDIO_CONFIG, playRuntimeClickSound, playRuntimeCountdownSound, playRuntimeLevelClearSound, + playRuntimeMergeSound, resolveRuntimeCountdownSecondBucket, } from '../../services/runtimeAudioFeedback'; import { useAuthUi } from '../auth/AuthUiContext'; +import { + findMatch3DHitItem, + type Match3DAlphaHitMask, + type Match3DGeneratedItemRelativeSize, + type Match3DResolvedImageSourceEntry, + resolveMatch3DImageSourceEntryForItem, + resolveMatch3DItemSizeScale, +} from './match3dHotspot'; import { isItemState, isRunState, @@ -103,6 +118,19 @@ type Match3DBoardPoint = { y: number; }; +type Match3DTraySlotLayout = { + left: number; + top: number; + width: number; + height: number; +}; + +type Match3DTrayMovingItemAnimation = { + itemInstanceId: string; + offsetX: number; + offsetY: number; +}; + type Match3DFlyingTrayAnimation = { id: string; item: Match3DItemSnapshot; @@ -116,7 +144,24 @@ type Match3DFlyingTrayAnimation = { toSize: number; }; -type Match3DGeneratedItemRelativeSize = '大' | '中' | 'å°'; +type Match3DTrayClearAnimation = { + id: string; + items: Array<{ + itemInstanceId: string; + itemTypeId: string; + visualKey: string; + imageSrc: string; + itemSize: Match3DGeneratedItemRelativeSize; + fromX: number; + fromY: number; + toX: number; + toY: number; + width: number; + height: number; + }>; + centerX: number; + centerY: number; +}; function resolveTrayPreviewItem( run: Match3DRunSnapshot, @@ -168,26 +213,6 @@ function buildClientEventId(itemInstanceId: string) { )}`; } -function isPointInsideCircle( - pointX: number, - pointY: number, - item: Match3DItemSnapshot, -) { - const frame = resolveRenderableItemFrame(item); - return Math.hypot(pointX - frame.x, pointY - frame.y) <= frame.radius; -} - -function findHitItem(run: Match3DRunSnapshot, pointX: number, pointY: number) { - return run.items - .filter( - (item) => - isItemState(item.state, 'in_board') && - item.clickable && - isPointInsideCircle(pointX, pointY, item), - ) - .sort((left, right) => right.layer - left.layer)[0]; -} - function resolveBoardPointFromPointerEvent( event: Pick, 'clientX' | 'clientY'>, stage: HTMLElement | null, @@ -284,26 +309,47 @@ function resolveStaticMatch3DReadUrlMap(sources: readonly string[]) { ); } -function buildResolvedMatch3DImageSourcesByType( +function buildResolvedMatch3DImageSourceEntriesByType( imageSourcesByType: ReadonlyMap, resolvedImageSources: ReadonlyMap, ) { return new Map( [...imageSourcesByType.entries()].map(([typeId, sources]) => [ typeId, - sources - .map((source) => { - const resolvedSource = resolvedImageSources.get(source); - if (resolvedSource) { - return resolvedSource; - } - return isGeneratedLegacyPath(source) ? '' : source; - }) - .filter(Boolean), + sources.flatMap((rawSource) => { + const source = rawSource.trim(); + if (!source) { + return []; + } + const resolvedSource = resolvedImageSources.get(source); + if (resolvedSource) { + return [{ source, resolvedSource }]; + } + return isGeneratedLegacyPath(source) + ? [] + : [{ source, resolvedSource: source }]; + }), ]), ); } +function resolveMatch3DAlphaHitMaskCacheKey( + imageSourceEntriesByType: ReadonlyMap< + string, + readonly Match3DResolvedImageSourceEntry[] + >, +) { + return [ + ...new Set( + [...imageSourceEntriesByType.values()].flatMap((entries) => + entries.map((entry) => entry.source.trim()).filter(Boolean), + ), + ), + ] + .sort() + .join('|'); +} + function normalizeMatch3DGeneratedItemSize( itemSize: Match3DGeneratedItemAsset['itemSize'] | null | undefined, ): Match3DGeneratedItemRelativeSize { @@ -342,35 +388,17 @@ function buildMatch3DItemSizeByType( ); } -function resolveMatch3DItemSizeScale( - itemSize: Match3DGeneratedItemRelativeSize | undefined, -) { - if (itemSize === 'å°') { - return 0.58; - } - if (itemSize === '中') { - return 0.78; - } - return 1; -} - -function hashMatch3DString(value: string) { - let hash = 0; - for (let index = 0; index < value.length; index += 1) { - hash = (hash * 31 + value.charCodeAt(index)) >>> 0; - } - return hash; -} - -function resolveMatch3DImageForItem( +function resolveMatch3DResolvedImageForItem( item: Match3DItemSnapshot, - imageSourcesByType: ReadonlyMap, + imageSourceEntriesByType: ReadonlyMap< + string, + readonly Match3DResolvedImageSourceEntry[] + >, ) { - const sources = imageSourcesByType.get(item.itemTypeId); - if (!sources || sources.length <= 0) { - return ''; - } - return sources[hashMatch3DString(item.itemInstanceId) % sources.length] ?? ''; + return ( + resolveMatch3DImageSourceEntryForItem(item, imageSourceEntriesByType) + ?.resolvedSource ?? '' + ); } function hasPendingMatch3DGeneratedImageForItem( @@ -391,13 +419,6 @@ function hasPendingMatch3DGeneratedImageForItem( ); } -function resolveMatch3DFirstImageForItem( - item: Match3DItemSnapshot, - imageSourcesByType: ReadonlyMap, -) { - return imageSourcesByType.get(item.itemTypeId)?.[0] ?? ''; -} - function resolveMatch3DItemSizeForType( item: Pick, itemSizeByType: ReadonlyMap, @@ -405,38 +426,106 @@ function resolveMatch3DItemSizeForType( return itemSizeByType.get(item.itemTypeId) ?? '大'; } +function resolveMatch3DSlotLayout( + element: HTMLElement | null, +): Match3DTraySlotLayout | null { + const rect = element?.getBoundingClientRect(); + if (!rect || rect.width <= 0 || rect.height <= 0) { + return null; + } + return { + left: rect.left, + top: rect.top, + width: rect.width, + height: rect.height, + }; +} + function buildOptimisticRun( run: Match3DRunSnapshot, item: Match3DItemSnapshot, ) { - const nextSlot = run.traySlots.find((slot) => !slot.itemInstanceId); - if (!nextSlot) { + const insertion = buildMatch3DTrayInsertionPlan(run.traySlots, item); + if (!insertion) { return run; } + const nextItems = run.items.map((entry) => + entry.itemInstanceId === item.itemInstanceId + ? { + ...entry, + state: 'Flying' as const, + clickable: false, + traySlotIndex: insertion.slotIndex, + } + : entry, + ); return { ...run, - items: run.items.map((entry) => - entry.itemInstanceId === item.itemInstanceId - ? { - ...entry, - state: 'Flying' as const, - clickable: false, - } - : entry, - ), - traySlots: run.traySlots.map((slot) => - slot.slotIndex === nextSlot.slotIndex - ? { - slotIndex: slot.slotIndex, - itemInstanceId: item.itemInstanceId, - itemTypeId: item.itemTypeId, - visualKey: item.visualKey, - } - : slot, - ), + items: syncMatch3DItemTraySlotIndexes(nextItems, insertion.traySlots), + traySlots: insertion.traySlots, }; } +function loadMatch3DAlphaHitMaskImage(source: string) { + return new Promise((resolve, reject) => { + const image = new Image(); + image.onload = () => resolve(image); + image.onerror = () => reject(new Error('è¯»å–æŠ“å¤§é¹…ç‰©å“热区图片失败')); + image.src = source; + }); +} + +async function loadMatch3DAlphaHitMask( + source: string, + signal: AbortSignal, +): Promise { + const response = await readAssetBytes(source, { + signal, + expireSeconds: 300, + }); + const blob = await response.blob(); + const canCreateObjectUrl = + typeof URL.createObjectURL === 'function' && + typeof URL.revokeObjectURL === 'function'; + const imageSource = canCreateObjectUrl + ? URL.createObjectURL(blob) + : await new Promise((resolve, reject) => { + const reader = new FileReader(); + reader.onload = () => resolve(String(reader.result ?? '')); + reader.onerror = () => reject(new Error('è¯»å–æŠ“å¤§é¹…çƒ­åŒºå›¾ç‰‡å¤±è´¥')); + reader.readAsDataURL(blob); + }); + try { + const image = await loadMatch3DAlphaHitMaskImage(imageSource); + if (signal.aborted) { + throw new DOMException('热区图片读å–已喿¶ˆ', 'AbortError'); + } + const width = Math.max(1, image.naturalWidth || image.width || 1); + const height = Math.max(1, image.naturalHeight || image.height || 1); + const canvas = document.createElement('canvas'); + canvas.width = width; + canvas.height = height; + const context = canvas.getContext('2d', { + willReadFrequently: true, + }); + if (!context) { + throw new Error('æµè§ˆå™¨ä¸æ”¯æŒè¯»å–物å“热区图片'); + } + context.clearRect(0, 0, width, height); + context.drawImage(image, 0, 0, width, height); + const pixels = context.getImageData(0, 0, width, height).data; + const alpha = new Uint8ClampedArray(width * height); + for (let index = 0; index < alpha.length; index += 1) { + alpha[index] = pixels[index * 4 + 3] ?? 0; + } + return { width, height, alpha }; + } finally { + if (canCreateObjectUrl) { + URL.revokeObjectURL(imageSource); + } + } +} + function Match3DToken({ item, imageSrc, @@ -514,11 +603,15 @@ function Match3DTrayToken({ imageSrc, itemSize, isArriving = false, + isClearing = false, + moveAnimation = null, }: { slot: Match3DTraySlot; imageSrc?: string; itemSize?: Match3DGeneratedItemRelativeSize; isArriving?: boolean; + isClearing?: boolean; + moveAnimation?: Match3DTrayMovingItemAnimation | null; }) { if (!slot.visualKey) { return ( @@ -526,11 +619,20 @@ function Match3DTrayToken({ ); } const visualSeed = resolveVisualSeed(slot.visualKey); + const style = moveAnimation + ? ({ + '--match3d-tray-shift-x': `${moveAnimation.offsetX}px`, + '--match3d-tray-shift-y': `${moveAnimation.offsetY}px`, + } as CSSProperties) + : undefined; return ( {imageSrc ? ( @@ -603,6 +705,65 @@ function Match3DFlyingTrayToken({ ); } +function Match3DTrayClearToken({ + animation, + onDone, +}: { + animation: Match3DTrayClearAnimation; + onDone: (id: string) => void; +}) { + return ( + @@ -1415,6 +1844,7 @@ export function Match3DRuntimeShell({ key={slot.slotIndex} className={MATCH3D_RUNTIME_GLASS_TRAY_SLOT_CLASS} data-testid="match3d-tray-slot" + data-slot-index={slot.slotIndex} ref={(element) => { traySlotRefs.current[slot.slotIndex] = element; }} @@ -1425,12 +1855,24 @@ export function Match3DRuntimeShell({ flyingTrayAnimation?.item.itemInstanceId === slot.itemInstanceId } + isClearing={ + Boolean(slot.itemInstanceId) && + (trayClearAnimation?.items.some( + (clearItem) => + clearItem.itemInstanceId === slot.itemInstanceId, + ) ?? + false) + } + moveAnimation={ + slot.itemInstanceId + ? (trayMovingItemAnimationById.get( + slot.itemInstanceId, + ) ?? null) + : null + } imageSrc={ trayItem - ? resolveMatch3DFirstImageForItem( - trayItem, - resolvedImageSourcesByType, - ) + ? resolveFirstResolvedImageForItem(trayItem) : '' } itemSize={ @@ -1466,6 +1908,17 @@ export function Match3DRuntimeShell({ /> ) : null} + {trayClearAnimation ? ( + + setTrayClearAnimation((current) => + current?.id === id ? null : current, + ) + } + /> + ) : null} + ({ slotIndex })), + items: [ + { + clickable: true, + itemInstanceId: 'alpha-hotspot-item', + itemTypeId: 'match3d-type-01', + visualKey: 'block-red-2x2', + x: 0.5, + y: 0.5, + radius: 0.1, + layer: 1, + state: 'InBoard', + }, + ], + }; +} + +test('逿˜Žåƒç´ ä¸ä½œä¸ºæŠ“大鹅物å“点击热区', () => { + const run = buildMatch3DHotspotRun(); + const item = run.items[0]!; + const mask = { + width: 4, + height: 4, + alpha: new Uint8ClampedArray([ + 0, 0, 0, 0, + 0, 255, 255, 0, + 0, 255, 255, 0, + 0, 0, 0, 0, + ]), + }; + const imageSourceEntriesByType = new Map([ + [ + 'match3d-type-01', + [ + { + source: '/generated-match3d-assets/item-01.png', + resolvedSource: 'https://oss.example.com/item-01.png', + }, + ], + ], + ]); + const alphaHitMasks = new Map([ + ['/generated-match3d-assets/item-01.png', mask], + ]); + const itemSizeByType = new Map([['match3d-type-01', '大' as const]]); + const frame = resolveRenderableItemFrame(item); + + expect( + findMatch3DHitItem(run, frame.x - frame.radius * 0.6, 0.5, { + alphaHitMasks, + imageSourceEntriesByType, + itemSizeByType, + }), + ).toBeUndefined(); + expect( + findMatch3DHitItem(run, 0.5, 0.5, { + alphaHitMasks, + imageSourceEntriesByType, + itemSizeByType, + })?.itemInstanceId, + ).toBe('alpha-hotspot-item'); +}); + +test('å°å°ºå¯¸ç‰©å“åªåœ¨ç¼©æ”¾åŽçš„éžé€æ˜Žä¸»ä½“内命中', () => { + const item = buildMatch3DHotspotRun().items[0]!; + const mask = { + width: 2, + height: 2, + alpha: new Uint8ClampedArray([255, 255, 255, 255]), + }; + + expect( + isPointInsideMatch3DItemAlphaHotspot({ + item, + pointX: 0.5, + pointY: 0.5, + mask, + itemSize: 'å°', + }), + ).toBe(true); + expect( + isPointInsideMatch3DItemAlphaHotspot({ + item, + pointX: 0.38, + pointY: 0.5, + mask, + itemSize: 'å°', + }), + ).toBe(false); +}); + +test('抓大鹅生æˆç‰©å“大和中也会åšä¸€å®šç¨‹åº¦ç¼©å°', () => { + expect(resolveMatch3DItemSizeScale('大')).toBeLessThan(1); + expect(resolveMatch3DItemSizeScale('中')).toBeLessThan(0.78); + expect(resolveMatch3DItemSizeScale('大')).toBeGreaterThan( + resolveMatch3DItemSizeScale('中'), + ); + expect(resolveMatch3DItemSizeScale('中')).toBeGreaterThan( + resolveMatch3DItemSizeScale('å°'), + ); +}); diff --git a/src/components/match3d-runtime/match3dHotspot.ts b/src/components/match3d-runtime/match3dHotspot.ts new file mode 100644 index 00000000..44774141 --- /dev/null +++ b/src/components/match3d-runtime/match3dHotspot.ts @@ -0,0 +1,194 @@ +import type { + Match3DItemSnapshot, + Match3DRunSnapshot, +} from '../../../packages/shared/src/contracts/match3dRuntime'; +import { isGeneratedLegacyPath } from '../../services/assetReadUrlService'; +import { + isItemState, + resolveRenderableItemFrame, +} from './match3dRuntimePresentation'; + +export type Match3DGeneratedItemRelativeSize = '大' | '中' | 'å°'; + +export type Match3DAlphaHitMask = { + width: number; + height: number; + alpha: Uint8ClampedArray; +}; + +export type Match3DResolvedImageSourceEntry = { + source: string; + resolvedSource: string; +}; + +const MATCH3D_HIT_ALPHA_THRESHOLD = 8; + +function isPointInsideCircle( + pointX: number, + pointY: number, + item: Match3DItemSnapshot, +) { + const frame = resolveRenderableItemFrame(item); + return Math.hypot(pointX - frame.x, pointY - frame.y) <= frame.radius; +} + +function clampMatch3DHitPixelIndex(value: number, size: number) { + return Math.min(size - 1, Math.max(0, Math.floor(value * size))); +} + +export function resolveMatch3DItemSizeScale( + itemSize: Match3DGeneratedItemRelativeSize | undefined, +) { + if (itemSize === 'å°') { + return 0.58; + } + if (itemSize === '中') { + return 0.68; + } + return 0.88; +} + +function isPointInsideAlphaHitMask( + localX: number, + localY: number, + mask: Match3DAlphaHitMask, + itemSize: Match3DGeneratedItemRelativeSize, +) { + if ( + mask.width <= 0 || + mask.height <= 0 || + mask.alpha.length < mask.width * mask.height || + localX < 0 || + localX > 1 || + localY < 0 || + localY > 1 + ) { + return false; + } + + const aspectRatio = mask.width / mask.height; + const containWidth = aspectRatio >= 1 ? 1 : aspectRatio; + const containHeight = aspectRatio >= 1 ? 1 / aspectRatio : 1; + const imageScale = resolveMatch3DItemSizeScale(itemSize); + const renderedWidth = containWidth * imageScale; + const renderedHeight = containHeight * imageScale; + const imageLeft = (1 - renderedWidth) / 2; + const imageTop = (1 - renderedHeight) / 2; + + if ( + localX < imageLeft || + localX > imageLeft + renderedWidth || + localY < imageTop || + localY > imageTop + renderedHeight + ) { + return false; + } + + const imageX = (localX - imageLeft) / renderedWidth; + const imageY = (localY - imageTop) / renderedHeight; + const pixelX = clampMatch3DHitPixelIndex(imageX, mask.width); + const pixelY = clampMatch3DHitPixelIndex(imageY, mask.height); + return ( + (mask.alpha[pixelY * mask.width + pixelX] ?? 0) > + MATCH3D_HIT_ALPHA_THRESHOLD + ); +} + +export function isPointInsideMatch3DItemAlphaHotspot({ + item, + pointX, + pointY, + mask, + itemSize, +}: { + item: Match3DItemSnapshot; + pointX: number; + pointY: number; + mask: Match3DAlphaHitMask; + itemSize: Match3DGeneratedItemRelativeSize; +}) { + const frame = resolveRenderableItemFrame(item); + const diameter = frame.radius * 2; + if (diameter <= 0) { + return false; + } + return isPointInsideAlphaHitMask( + (pointX - (frame.x - frame.radius)) / diameter, + (pointY - (frame.y - frame.radius)) / diameter, + mask, + itemSize, + ); +} + +export function hashMatch3DString(value: string) { + let hash = 0; + for (let index = 0; index < value.length; index += 1) { + hash = (hash * 31 + value.charCodeAt(index)) >>> 0; + } + return hash; +} + +export function resolveMatch3DImageSourceEntryForItem( + item: Match3DItemSnapshot, + imageSourceEntriesByType: ReadonlyMap< + string, + readonly Match3DResolvedImageSourceEntry[] + >, +) { + const sources = imageSourceEntriesByType.get(item.itemTypeId); + if (!sources || sources.length <= 0) { + return null; + } + return sources[hashMatch3DString(item.itemInstanceId) % sources.length] ?? null; +} + +export function findMatch3DHitItem( + run: Match3DRunSnapshot, + pointX: number, + pointY: number, + options: { + imageSourceEntriesByType?: ReadonlyMap< + string, + readonly Match3DResolvedImageSourceEntry[] + >; + alphaHitMasks?: ReadonlyMap; + failedAlphaHitMaskSources?: ReadonlySet; + itemSizeByType?: ReadonlyMap; + } = {}, +) { + return run.items + .filter((item) => { + if ( + !isItemState(item.state, 'in_board') || + !item.clickable || + !isPointInsideCircle(pointX, pointY, item) + ) { + return false; + } + + const imageSourceEntry = resolveMatch3DImageSourceEntryForItem( + item, + options.imageSourceEntriesByType ?? new Map(), + ); + const mask = imageSourceEntry + ? options.alphaHitMasks?.get(imageSourceEntry.source) + : null; + if (!mask) { + return ( + !imageSourceEntry || + !isGeneratedLegacyPath(imageSourceEntry.source) || + options.failedAlphaHitMaskSources?.has(imageSourceEntry.source) === + true + ); + } + + return isPointInsideMatch3DItemAlphaHotspot({ + item, + pointX, + pointY, + mask, + itemSize: options.itemSizeByType?.get(item.itemTypeId) ?? '大', + }); + }) + .sort((left, right) => right.layer - left.layer)[0]; +} diff --git a/src/components/match3d-runtime/match3dRuntimeUiStyles.ts b/src/components/match3d-runtime/match3dRuntimeUiStyles.ts index 1420923b..6d595319 100644 --- a/src/components/match3d-runtime/match3dRuntimeUiStyles.ts +++ b/src/components/match3d-runtime/match3dRuntimeUiStyles.ts @@ -27,7 +27,7 @@ export const MATCH3D_RUNTIME_GLASS_TRAY_SLOT_CLASS = 'relative z-0 h-14 min-w-0 rounded-xl border border-white/52 bg-white/56 p-1 shadow-[inset_0_1px_0_rgba(255,255,255,0.44)] sm:h-16'; export const MATCH3D_RUNTIME_STAGE_CLASS = - 'relative mt-3 flex min-h-0 flex-1 items-center justify-center'; + 'relative mt-5 flex min-h-0 flex-1 items-center justify-center'; export const MATCH3D_RUNTIME_BOARD_BASE_CLASS = 'relative aspect-square max-w-full'; @@ -41,7 +41,7 @@ export const MATCH3D_RUNTIME_BOARD_FALLBACK_CLASS = 'overflow-hidden rounded-full border-[10px] border-[#e6d19b] bg-[radial-gradient(circle_at_50%_42%,#f2d993_0%,#c88f43_56%,#835223_100%)] shadow-[inset_0_8px_34px_rgba(72,41,16,0.34),0_22px_42px_rgba(15,23,42,0.28)]'; export const MATCH3D_RUNTIME_CONTAINER_IMAGE_CLASS = - 'pointer-events-none absolute left-1/2 top-1/2 z-0 h-auto w-[min(99vw,34rem)] max-w-none -translate-x-1/2 -translate-y-1/2 object-contain drop-shadow-[0_22px_42px_rgba(15,23,42,0.28)]'; + 'pointer-events-none absolute left-1/2 top-[54%] z-0 h-auto w-[min(116vw,42rem)] max-w-none -translate-x-1/2 -translate-y-1/2 object-contain drop-shadow-[0_22px_42px_rgba(15,23,42,0.28)]'; export const MATCH3D_RUNTIME_CONTAINER_PLACEHOLDER_CLASS = 'pointer-events-none absolute inset-[7%] z-0 rounded-full border border-white/22 bg-[radial-gradient(circle_at_44%_35%,rgba(255,255,255,0.22),transparent_28%)]'; diff --git a/src/components/platform-entry/PlatformEntryFlowShellImpl.tsx b/src/components/platform-entry/PlatformEntryFlowShellImpl.tsx index 3b372891..cc3e7501 100644 --- a/src/components/platform-entry/PlatformEntryFlowShellImpl.tsx +++ b/src/components/platform-entry/PlatformEntryFlowShellImpl.tsx @@ -188,6 +188,7 @@ import { buildPuzzleGenerationAnchorEntries, buildSquareHoleGenerationAnchorEntries, createMiniGameDraftGenerationState, + type MiniGameDraftGenerationKind, type MiniGameDraftGenerationState, } from '../../services/miniGameDraftGenerationProgress'; import { getPlatformProfileDashboard } from '../../services/platform-entry/platformProfileClient'; @@ -629,7 +630,9 @@ function resolveVisiblePuzzleDetailCoverCount( function mapMatch3DWorkToPublicWorkDetail( item: Match3DWorkSummary, ): PlatformPublicGalleryCard { - return mapMatch3DWorkToPlatformGalleryCard(item); + return mapMatch3DWorkToPlatformGalleryCard( + normalizeMatch3DWorkForRuntimeUi(item), + ); } function mapSquareHoleWorkToPublicWorkDetail( @@ -753,6 +756,23 @@ function promoteMatch3DGeneratedBackgroundAsset< }; } +function normalizeMatch3DWorkForRuntimeUi( + profile: T, +): T { + return promoteMatch3DGeneratedBackgroundAsset({ + ...profile, + generatedItemAssets: normalizeMatch3DGeneratedItemAssetsForRuntime( + profile.generatedItemAssets, + ), + }); +} + +function mapMatch3DWorksForRuntimeUi( + profiles: readonly T[], +): T[] { + return profiles.map(normalizeMatch3DWorkForRuntimeUi); +} + function buildMatch3DProfileFromSession( session: Match3DAgentSessionSnapshot | null, ): Match3DWorkProfile | null { @@ -1648,14 +1668,121 @@ function normalizeDraftNoticeId(id: string | null | undefined) { function createPendingDraftShelfState( status: DraftGenerationNoticeStatus, seen = false, + updatedAt = new Date().toISOString(), ): PendingDraftShelfState { return { status, seen, - updatedAt: new Date().toISOString(), + updatedAt, }; } +function parseDraftGenerationStartedAtMs(value: string | null | undefined) { + const parsedMs = value ? Date.parse(value) : Number.NaN; + return Number.isFinite(parsedMs) ? parsedMs : Date.now(); +} + +function createMiniGameDraftGenerationStateFromStartedAt( + kind: MiniGameDraftGenerationKind, + startedAtMs: number, +): MiniGameDraftGenerationState { + return { + ...createMiniGameDraftGenerationState(kind), + startedAtMs, + }; +} + +function resolveFinishedMiniGameDraftGenerationState( + state: MiniGameDraftGenerationState, + phase: 'ready' | 'failed', + options: { + error?: string | null; + completedAssetCount?: number; + totalAssetCount?: number; + } = {}, +): MiniGameDraftGenerationState { + return { + ...state, + phase, + finishedAtMs: Date.now(), + error: options.error ?? state.error, + completedAssetCount: + options.completedAssetCount ?? state.completedAssetCount, + totalAssetCount: options.totalAssetCount ?? state.totalAssetCount, + }; +} + +function normalizeRecoveredPuzzleDraftSession( + session: PuzzleAgentSessionSnapshot, +): PuzzleAgentSessionSnapshot { + const draft = session.draft; + if (!draft) { + return session; + } + + const primaryLevel = draft.levels?.[0]; + const selectedCandidate = + primaryLevel?.candidates.find((candidate) => candidate.selected) ?? + primaryLevel?.candidates[0] ?? + draft.candidates.find((candidate) => candidate.selected) ?? + draft.candidates[0] ?? + null; + const coverImageSrc = + draft.coverImageSrc?.trim() || + primaryLevel?.coverImageSrc?.trim() || + selectedCandidate?.imageSrc.trim() || + null; + const coverAssetId = + draft.coverAssetId?.trim() || + primaryLevel?.coverAssetId?.trim() || + selectedCandidate?.assetId.trim() || + null; + const selectedCandidateId = + draft.selectedCandidateId ?? + primaryLevel?.selectedCandidateId ?? + selectedCandidate?.candidateId ?? + null; + + return { + ...session, + draft: { + ...draft, + coverImageSrc, + coverAssetId, + selectedCandidateId, + generationStatus: 'ready', + levels: draft.levels?.map((level, index) => + index === 0 + ? { + ...level, + coverImageSrc: level.coverImageSrc ?? coverImageSrc, + coverAssetId: level.coverAssetId ?? coverAssetId, + selectedCandidateId: + level.selectedCandidateId ?? selectedCandidateId, + generationStatus: 'ready', + } + : level, + ), + }, + }; +} + +function hasRecoverableGeneratedPuzzleDraft( + session: PuzzleAgentSessionSnapshot, +) { + const draft = session.draft; + if (!draft) { + return false; + } + + const firstLevel = draft.levels?.[0]; + return Boolean( + draft.coverImageSrc?.trim() || + firstLevel?.coverImageSrc?.trim() || + firstLevel?.candidates.some((candidate) => candidate.imageSrc.trim()), + ); +} + function getGenerationNoticeShelfKeys(item: CreationWorkShelfItem) { switch (item.source.kind) { case 'rpg': @@ -1790,6 +1917,7 @@ function buildPendingMatch3DWorks( updatedAt: state.updatedAt, publishedAt: null, publishReady: false, + generationStatus: state.status === 'generating' ? 'generating' : 'ready', generatedItemAssets: [], })); } @@ -1867,6 +1995,7 @@ function buildPendingPuzzleWorks( remixCount: 0, likeCount: 0, publishReady: false, + generationStatus: state.status === 'generating' ? 'generating' : 'ready', levels: [], }; }); @@ -2885,7 +3014,7 @@ export function PlatformEntryFlowShellImpl({ try { const worksResponse = await listMatch3DWorks(); - setMatch3DWorks(worksResponse.items); + setMatch3DWorks(mapMatch3DWorksForRuntimeUi(worksResponse.items)); match3DErrorSetterRef.current(null); } catch (error) { match3DErrorSetterRef.current( @@ -2899,8 +3028,9 @@ export function PlatformEntryFlowShellImpl({ const refreshMatch3DGallery = useCallback(async () => { try { const galleryResponse = await listMatch3DGallery(); - setMatch3DGalleryEntries(galleryResponse.items); - return galleryResponse.items; + const items = mapMatch3DWorksForRuntimeUi(galleryResponse.items); + setMatch3DGalleryEntries(items); + return items; } catch { // 中文注释:公开广场是首页展示数æ®ï¼Œå¤±è´¥æ—¶åªé™çº§ä¸ºç©ºåˆ—表; // ä¸å†™å…¥åˆ›ä½œé”™è¯¯æ€ï¼Œé¿å…æŒ¡ä½æŠ“å¤§é¹…å…±åˆ›å…¥å£ã€‚ @@ -3247,7 +3377,7 @@ export function PlatformEntryFlowShellImpl({ .map(mapBabyObjectMatchDraftToPlatformGalleryCard) : []; const match3dPublicEntries = match3dGalleryEntries.map( - mapMatch3DWorkToPlatformGalleryCard, + mapMatch3DWorkToPublicWorkDetail, ); const puzzlePublicEntries = puzzleGalleryEntries.map( mapPuzzleWorkToPlatformGalleryCard, @@ -3289,7 +3419,7 @@ export function PlatformEntryFlowShellImpl({ ...(isBigFishCreationVisible ? bigFishGalleryEntries.map(mapBigFishWorkToPlatformGalleryCard) : []), - ...match3dGalleryEntries.map(mapMatch3DWorkToPlatformGalleryCard), + ...match3dGalleryEntries.map(mapMatch3DWorkToPublicWorkDetail), ...puzzleGalleryEntries.map(mapPuzzleWorkToPlatformGalleryCard), ...squareHoleGalleryEntries.map( mapSquareHoleWorkToPlatformGalleryCard, @@ -3395,7 +3525,8 @@ export function PlatformEntryFlowShellImpl({ getGenerationNoticeShelfKeys(item), ); return { - isGenerating: notice?.status === 'generating', + isGenerating: + notice?.status === 'generating' || item.isGenerating === true, hasUnreadUpdate: notice?.status === 'ready' && !notice.seen, }; }, @@ -3769,14 +3900,12 @@ export function PlatformEntryFlowShellImpl({ } setBigFishGenerationState((current) => current - ? { - ...current, - phase: 'ready', + ? resolveFinishedMiniGameDraftGenerationState(current, 'ready', { completedAssetCount: response.session.assetSlots.filter( (slot) => slot.status === 'ready', ).length, totalAssetCount: response.session.assetSlots.length, - } + }) : current, ); const openResult = selectionStageRef.current === 'big-fish-generating'; @@ -3808,11 +3937,9 @@ export function PlatformEntryFlowShellImpl({ } setBigFishGenerationState((current) => current - ? { - ...current, - phase: 'failed', + ? resolveFinishedMiniGameDraftGenerationState(current, 'failed', { error: errorMessage, - } + }) : current, ); }, @@ -3864,14 +3991,12 @@ export function PlatformEntryFlowShellImpl({ const openResult = selectionStageRef.current === 'match3d-generating'; setMatch3DGenerationState((current) => current - ? { - ...current, - phase: 'ready', + ? resolveFinishedMiniGameDraftGenerationState(current, 'ready', { completedAssetCount: response.session.draft?.generatedItemAssets?.length ?? 5, totalAssetCount: response.session.draft?.generatedItemAssets?.length ?? 5, - } + }) : current, ); @@ -3885,7 +4010,7 @@ export function PlatformEntryFlowShellImpl({ let runtimeProfile: Match3DWorkProfile | null = null; try { const { item } = await getMatch3DWorkDetail(profileId); - runtimeProfile = promoteMatch3DGeneratedBackgroundAsset({ + runtimeProfile = normalizeMatch3DWorkForRuntimeUi({ ...item, generatedItemAssets: mergeMatch3DGeneratedItemAssetsForRuntime( response.session.draft?.generatedItemAssets, @@ -3949,11 +4074,9 @@ export function PlatformEntryFlowShellImpl({ } setMatch3DGenerationState((current) => current - ? { - ...current, - phase: 'failed', + ? resolveFinishedMiniGameDraftGenerationState(current, 'failed', { error: errorMessage, - } + }) : current, ); try { @@ -3964,7 +4087,7 @@ export function PlatformEntryFlowShellImpl({ latestSession.draft?.profileId ?? latestSession.publishedProfileId; if (profileId) { const { item } = await getMatch3DWorkDetail(profileId); - setMatch3DProfile(item); + setMatch3DProfile(normalizeMatch3DWorkForRuntimeUi(item)); } await refreshMatch3DShelf().catch(() => undefined); } catch { @@ -4101,15 +4224,19 @@ export function PlatformEntryFlowShellImpl({ const { item } = await getSquareHoleWorkDetail(assetProfileId); const shouldOpenResult = shouldOpenSquareHoleResult(); setSquareHoleProfile(item); - setSquareHoleGenerationState((current) => ({ - ...(current ?? createMiniGameDraftGenerationState('square-hole')), - phase: 'ready', - completedAssetCount: - item.shapeOptions.length + item.holeOptions.length + 2, - totalAssetCount: - item.shapeOptions.length + item.holeOptions.length + 2, - error: null, - })); + setSquareHoleGenerationState((current) => + resolveFinishedMiniGameDraftGenerationState( + current ?? createMiniGameDraftGenerationState('square-hole'), + 'ready', + { + completedAssetCount: + item.shapeOptions.length + item.holeOptions.length + 2, + totalAssetCount: + item.shapeOptions.length + item.holeOptions.length + 2, + error: null, + }, + ), + ); await refreshSquareHoleShelf().catch(() => undefined); markPendingDraftReady( 'square-hole', @@ -4131,11 +4258,13 @@ export function PlatformEntryFlowShellImpl({ 'ç”Ÿæˆæ–¹æ´žæŒ‘战图片失败。', ); setSquareHoleError(errorMessage); - setSquareHoleGenerationState((current) => ({ - ...(current ?? createMiniGameDraftGenerationState('square-hole')), - phase: 'failed', - error: errorMessage, - })); + setSquareHoleGenerationState((current) => + resolveFinishedMiniGameDraftGenerationState( + current ?? createMiniGameDraftGenerationState('square-hole'), + 'failed', + { error: errorMessage }, + ), + ); setSquareHoleProfile( buildSquareHoleProfileFromSession(response.session), ); @@ -4150,15 +4279,19 @@ export function PlatformEntryFlowShellImpl({ const { item } = await getSquareHoleWorkDetail(profileId); const shouldOpenResult = shouldOpenSquareHoleResult(); setSquareHoleProfile(item); - setSquareHoleGenerationState((current) => ({ - ...(current ?? createMiniGameDraftGenerationState('square-hole')), - phase: 'ready', - completedAssetCount: - item.shapeOptions.length + item.holeOptions.length + 2, - totalAssetCount: - item.shapeOptions.length + item.holeOptions.length + 2, - error: null, - })); + setSquareHoleGenerationState((current) => + resolveFinishedMiniGameDraftGenerationState( + current ?? createMiniGameDraftGenerationState('square-hole'), + 'ready', + { + completedAssetCount: + item.shapeOptions.length + item.holeOptions.length + 2, + totalAssetCount: + item.shapeOptions.length + item.holeOptions.length + 2, + error: null, + }, + ), + ); await refreshSquareHoleShelf().catch(() => undefined); markPendingDraftReady( 'square-hole', @@ -4198,11 +4331,13 @@ export function PlatformEntryFlowShellImpl({ payload.action === 'square_hole_compile_draft' || payload.action === 'square_hole_generate_visual_assets' ) { - setSquareHoleGenerationState((current) => ({ - ...(current ?? createMiniGameDraftGenerationState('square-hole')), - phase: 'failed', - error: errorMessage, - })); + setSquareHoleGenerationState((current) => + resolveFinishedMiniGameDraftGenerationState( + current ?? createMiniGameDraftGenerationState('square-hole'), + 'failed', + { error: errorMessage }, + ), + ); if (selectionStageRef.current === 'square-hole-generating') { setSelectionStage('square-hole-generating'); } @@ -4275,12 +4410,10 @@ export function PlatformEntryFlowShellImpl({ const openResult = selectionStageRef.current === 'puzzle-generating'; setPuzzleGenerationState((current) => current - ? { - ...current, - phase: 'ready', + ? resolveFinishedMiniGameDraftGenerationState(current, 'ready', { completedAssetCount: 1, totalAssetCount: 1, - } + }) : current, ); const profileId = @@ -4385,19 +4518,56 @@ export function PlatformEntryFlowShellImpl({ markPendingDraftGenerating('puzzle', session.sessionId); selectionStageRef.current = 'puzzle-generating'; setSelectionStage('puzzle-generating'); - setPuzzleGenerationState(createMiniGameDraftGenerationState('puzzle')); + const nextGenerationState = createMiniGameDraftGenerationState('puzzle'); + setPuzzleGenerationState(nextGenerationState); + setPuzzleBackgroundCompileTasks((current) => ({ + ...current, + [session.sessionId]: { + session, + payload: formPayload ?? buildPuzzleFormPayloadFromSession(session), + generationState: nextGenerationState, + error: null, + }, + })); }, - onActionError: ({ payload, errorMessage }) => { + onActionError: async ({ payload, errorMessage, session, setSession }) => { if (payload.action !== 'compile_puzzle_draft') { return; } + const generationState = + puzzleBackgroundCompileTasks[session.sessionId]?.generationState ?? + puzzleGenerationState ?? + createMiniGameDraftGenerationState('puzzle'); + const formPayload = + buildPuzzleFormPayloadFromAction(payload) ?? + puzzleBackgroundCompileTasks[session.sessionId]?.payload ?? + buildPuzzleFormPayloadFromSession(session); + const recovered = await recoverCompletedPuzzleDraftGeneration({ + sessionId: session.sessionId, + payload: formPayload, + generationState, + setSession, + }); + if (recovered) { + return; + } + const failedGenerationState = resolveFinishedMiniGameDraftGenerationState( + generationState, + 'failed', + { error: errorMessage }, + ); + setPuzzleBackgroundCompileTasks((current) => ({ + ...current, + [session.sessionId]: { + session, + payload: formPayload, + generationState: failedGenerationState, + error: errorMessage, + }, + })); setPuzzleGenerationState((current) => current - ? { - ...current, - phase: 'failed', - error: errorMessage, - } + ? failedGenerationState : current, ); }, @@ -4527,6 +4697,124 @@ export function PlatformEntryFlowShellImpl({ setMatch3DError, ); }, [ensureEnoughDraftGenerationPointsFromServer, setMatch3DError]); + const recoverCompletedPuzzleDraftGeneration = useCallback( + async ({ + sessionId, + payload, + generationState, + setSession, + }: { + sessionId: string; + payload: CreatePuzzleAgentSessionRequest; + generationState: MiniGameDraftGenerationState; + setSession?: (session: PuzzleAgentSessionSnapshot) => void; + }) => { + let latestSession: PuzzleAgentSessionSnapshot; + try { + const response = await getPuzzleAgentSession(sessionId); + latestSession = normalizeRecoveredPuzzleDraftSession(response.session); + } catch { + return null; + } + + if (!hasRecoverableGeneratedPuzzleDraft(latestSession)) { + return null; + } + + const readyGenerationState = resolveFinishedMiniGameDraftGenerationState( + generationState, + 'ready', + { + completedAssetCount: 1, + totalAssetCount: 1, + error: null, + }, + ); + const openResult = isViewingPuzzleGeneration(sessionId); + const profileId = + latestSession.publishedProfileId ?? + buildPuzzleResultProfileId(latestSession.sessionId); + + setPuzzleBackgroundCompileTasks((current) => ({ + ...current, + [sessionId]: { + session: latestSession, + payload, + generationState: readyGenerationState, + error: null, + }, + })); + + setSession?.(latestSession); + setPuzzleFormDraftPayload(payload); + setPuzzleOperation(null); + puzzleErrorSetterRef.current(null); + if (isViewingPuzzleGeneration(sessionId)) { + setPuzzleGenerationState(readyGenerationState); + } + + markPendingDraftReady('puzzle', latestSession.sessionId, openResult); + markDraftReady( + 'puzzle', + [ + latestSession.sessionId, + buildPuzzleResultWorkId(latestSession.sessionId), + profileId, + ], + openResult, + ); + await refreshPuzzleShelf().catch(() => undefined); + + if (!openResult) { + return { openResult }; + } + + const draft = latestSession.draft; + if (!draft?.coverImageSrc || !profileId) { + puzzleErrorSetterRef.current( + !draft?.coverImageSrc + ? 'è¯·å…ˆé€‰æ‹©ä¸€å¼ æ­£å¼æ‹¼å›¾å›¾ç‰‡ã€‚' + : '这份拼图è‰ç¨¿ç¼ºå°‘会è¯ä¿¡æ¯ï¼Œè¯·é‡æ–°å¼€å§‹åˆ›ä½œã€‚', + ); + setSelectionStage('puzzle-result'); + return { openResult: false }; + } + + try { + const { item } = await updatePuzzleWork(profileId, { + workTitle: draft.workTitle, + workDescription: draft.workDescription, + levelName: draft.levelName, + summary: draft.summary, + themeTags: draft.themeTags, + coverImageSrc: draft.coverImageSrc, + coverAssetId: draft.coverAssetId, + levels: draft.levels ?? [], + }); + const run = startLocalPuzzleRun(item); + setSelectedPuzzleDetail(item); + setPuzzleRun(run); + setPuzzleRuntimeAuthMode('default'); + setPuzzleRuntimeReturnStage('puzzle-result'); + setSelectionStage('puzzle-runtime'); + } catch (error) { + puzzleErrorSetterRef.current( + resolvePuzzleErrorMessage(error, 'å¯åŠ¨æ‹¼å›¾è¯•çŽ©å¤±è´¥ã€‚'), + ); + setSelectionStage('puzzle-result'); + } + + return { openResult: false }; + }, + [ + isViewingPuzzleGeneration, + markDraftReady, + markPendingDraftReady, + refreshPuzzleShelf, + resolvePuzzleErrorMessage, + setSelectionStage, + ], + ); const activeMatch3DGenerationSessionId = selectionStage === 'match3d-generating' @@ -4612,11 +4900,12 @@ export function PlatformEntryFlowShellImpl({ return; } - setMatch3DProfile(item); + const normalizedItem = normalizeMatch3DWorkForRuntimeUi(item); + setMatch3DProfile(normalizedItem); setMatch3DGenerationState((current) => resolveMatch3DGenerationStateFromAssets( current, - item.generatedItemAssets, + normalizedItem.generatedItemAssets, ), ); } catch { @@ -4780,12 +5069,14 @@ export function PlatformEntryFlowShellImpl({ ); setPuzzleOperation(response.operation); const openResult = isViewingPuzzleGeneration(nextSession.sessionId); - const readyGenerationState = { - ...generationState, - phase: 'ready' as const, - completedAssetCount: 1, - totalAssetCount: 1, - }; + const readyGenerationState = resolveFinishedMiniGameDraftGenerationState( + generationState, + 'ready', + { + completedAssetCount: 1, + totalAssetCount: 1, + }, + ); setPuzzleBackgroundCompileTasks((current) => ({ ...current, [nextSession.sessionId]: { @@ -4859,11 +5150,20 @@ export function PlatformEntryFlowShellImpl({ error, '执行拼图æ“作失败。', ); - const failedGenerationState = { - ...generationState, - phase: 'failed' as const, - error: errorMessage, - }; + const recovered = await recoverCompletedPuzzleDraftGeneration({ + sessionId: nextSession.sessionId, + payload, + generationState, + setSession: puzzleFlow.setSession, + }); + if (recovered) { + return; + } + const failedGenerationState = resolveFinishedMiniGameDraftGenerationState( + generationState, + 'failed', + { error: errorMessage }, + ); setPuzzleBackgroundCompileTasks((current) => ({ ...current, [nextSession.sessionId]: { @@ -4888,6 +5188,7 @@ export function PlatformEntryFlowShellImpl({ preflightPuzzleDraftGeneration, puzzleFlow, refreshPuzzleShelf, + recoverCompletedPuzzleDraftGeneration, resolvePuzzleErrorMessage, setPuzzleError, setSelectionStage, @@ -4950,14 +5251,16 @@ export function PlatformEntryFlowShellImpl({ }, ); const openResult = isViewingMatch3DGeneration(nextSession.sessionId); - const readyGenerationState = { - ...generationState, - phase: 'ready' as const, - completedAssetCount: - response.session.draft?.generatedItemAssets?.length ?? 5, - totalAssetCount: - response.session.draft?.generatedItemAssets?.length ?? 5, - }; + const readyGenerationState = resolveFinishedMiniGameDraftGenerationState( + generationState, + 'ready', + { + completedAssetCount: + response.session.draft?.generatedItemAssets?.length ?? 5, + totalAssetCount: + response.session.draft?.generatedItemAssets?.length ?? 5, + }, + ); setMatch3DBackgroundCompileTasks((current) => ({ ...current, [nextSession.sessionId]: { @@ -4984,7 +5287,7 @@ export function PlatformEntryFlowShellImpl({ let runtimeProfile: Match3DWorkProfile | null = null; try { const { item } = await getMatch3DWorkDetail(profileId); - runtimeProfile = promoteMatch3DGeneratedBackgroundAsset({ + runtimeProfile = normalizeMatch3DWorkForRuntimeUi({ ...item, generatedItemAssets: mergeMatch3DGeneratedItemAssetsForRuntime( response.session.draft?.generatedItemAssets, @@ -5039,11 +5342,11 @@ export function PlatformEntryFlowShellImpl({ error, '执行抓大鹅æ“作失败。', ); - const failedGenerationState = { - ...generationState, - phase: 'failed' as const, - error: errorMessage, - }; + const failedGenerationState = resolveFinishedMiniGameDraftGenerationState( + generationState, + 'failed', + { error: errorMessage }, + ); setMatch3DBackgroundCompileTasks((current) => ({ ...current, [nextSession.sessionId]: { @@ -5075,7 +5378,7 @@ export function PlatformEntryFlowShellImpl({ latestSession.draft?.profileId ?? latestSession.publishedProfileId; if (profileId) { const { item } = await getMatch3DWorkDetail(profileId); - setMatch3DProfile(item); + setMatch3DProfile(normalizeMatch3DWorkForRuntimeUi(item)); } } await refreshMatch3DShelf().catch(() => undefined); @@ -5203,12 +5506,10 @@ export function PlatformEntryFlowShellImpl({ setBabyObjectMatchGenerationPhase('ready'); setBabyObjectMatchGenerationState((current) => current - ? { - ...current, - phase: 'ready', + ? resolveFinishedMiniGameDraftGenerationState(current, 'ready', { completedAssetCount: response.draft.itemAssets.length, totalAssetCount: response.draft.itemAssets.length, - } + }) : current, ); const openResult = @@ -5230,11 +5531,9 @@ export function PlatformEntryFlowShellImpl({ setBabyObjectMatchError(errorMessage); setBabyObjectMatchGenerationState((current) => current - ? { - ...current, - phase: 'failed', + ? resolveFinishedMiniGameDraftGenerationState(current, 'failed', { error: errorMessage, - } + }) : current, ); } finally { @@ -6702,13 +7001,13 @@ export function PlatformEntryFlowShellImpl({ ) { try { const { item } = await getMatch3DWorkDetail(profile.profileId); - runtimeProfile = { + runtimeProfile = normalizeMatch3DWorkForRuntimeUi({ ...item, generatedItemAssets: mergeMatch3DGeneratedItemAssetsForRuntime( item.generatedItemAssets, profile.generatedItemAssets, ), - }; + }); } catch { // 中文注释:详情补读åªä¸ºæ‹¿å®Œæ•´ç”Ÿæˆç´ æï¼›å¤±è´¥æ—¶ç»§ç»­æŒ‰æ‘˜è¦å¼€å±€ï¼Œé¿å…æŽ¨èæµå¡æ­»ã€‚ } @@ -7777,7 +8076,7 @@ export function PlatformEntryFlowShellImpl({ void deleteMatch3DWork(work.profileId) .then((response) => { markDraftNoticeSeen(noticeKeys); - setMatch3DWorks(response.items); + setMatch3DWorks(mapMatch3DWorksForRuntimeUi(response.items)); void refreshMatch3DGallery(); }) .catch((error) => { @@ -8632,7 +8931,12 @@ export function PlatformEntryFlowShellImpl({ ); puzzleFlow.setSession(latestSession); setPuzzleFormDraftPayload(buildPuzzleFormPayloadFromSession(latestSession)); - setPuzzleGenerationState(createMiniGameDraftGenerationState('puzzle')); + setPuzzleGenerationState( + createMiniGameDraftGenerationStateFromStartedAt( + 'puzzle', + parseDraftGenerationStartedAtMs(item.updatedAt), + ), + ); enterCreateTab(); selectionStageRef.current = 'puzzle-generating'; activePuzzleGenerationSessionIdRef.current = item.sourceSessionId; @@ -8734,13 +9038,14 @@ export function PlatformEntryFlowShellImpl({ setMatch3DFormDraftPayload(null); const profileId = latestSession.draft?.profileId ?? item.profileId; const { item: profile } = await getMatch3DWorkDetail(profileId); + const normalizedProfile = normalizeMatch3DWorkForRuntimeUi(profile); match3dFlow.setIsBusy(false); const started = await startMatch3DRunFromProfile( - profile, + normalizedProfile, 'match3d-result', ); if (!started) { - setMatch3DProfile(profile); + setMatch3DProfile(normalizedProfile); enterCreateTab(); setSelectionStage('match3d-result'); } @@ -8823,7 +9128,7 @@ export function PlatformEntryFlowShellImpl({ try { const profileId = restoredSession.draft?.profileId ?? item.profileId; const { item: profile } = await getMatch3DWorkDetail(profileId); - setMatch3DProfile(profile); + setMatch3DProfile(normalizeMatch3DWorkForRuntimeUi(profile)); } catch (error) { setMatch3DProfile(buildMatch3DProfileFromSession(restoredSession)); setMatch3DError( @@ -11538,29 +11843,33 @@ export function PlatformEntryFlowShellImpl({ returnToCreationCenterFromGeneration(); }} onSaved={(profile) => { - setMatch3DProfile(profile); + setMatch3DProfile(normalizeMatch3DWorkForRuntimeUi(profile)); }} onPublished={(profile) => { - setMatch3DProfile(profile); + const normalizedProfile = + normalizeMatch3DWorkForRuntimeUi(profile); + setMatch3DProfile(normalizedProfile); void Promise.allSettled([ refreshMatch3DShelf(), refreshMatch3DGallery(), ]); openPublicWorkDetail( - mapMatch3DWorkToPublicWorkDetail(profile), + mapMatch3DWorkToPublicWorkDetail(normalizedProfile), ); openPublishShareModal({ - title: profile.gameName, + title: normalizedProfile.gameName, publicWorkCode: buildMatch3DPublicWorkCode( - profile.profileId, + normalizedProfile.profileId, ), stage: 'work-detail', }); }} onStartTestRun={(profile, options) => { - setMatch3DProfile(profile); + const normalizedProfile = + normalizeMatch3DWorkForRuntimeUi(profile); + setMatch3DProfile(normalizedProfile); void startMatch3DRunFromProfile( - profile, + normalizedProfile, 'match3d-result', false, options, diff --git a/src/components/puzzle-result/PuzzleResultView.test.tsx b/src/components/puzzle-result/PuzzleResultView.test.tsx index 65560e70..62cfd7e1 100644 --- a/src/components/puzzle-result/PuzzleResultView.test.tsx +++ b/src/components/puzzle-result/PuzzleResultView.test.tsx @@ -567,6 +567,96 @@ describe('PuzzleResultView', () => { ).toHaveProperty('disabled', true); }); + test('keeps the current level dialog open when another level generation completes', () => { + const base = createSession(); + const firstLevel = base.draft!.levels![0]!; + const generatingSecondLevel = { + ...firstLevel, + levelId: 'puzzle-level-2', + levelName: '第二关', + pictureDescription: 'ç¬¬äºŒå…³ç”»é¢æ­£åœ¨ç”Ÿæˆã€‚', + candidates: [], + selectedCandidateId: null, + coverImageSrc: null, + coverAssetId: null, + generationStatus: 'generating' as const, + }; + const localThirdLevel = { + ...firstLevel, + levelId: 'puzzle-level-3', + levelName: '第三关', + pictureDescription: '第三关åˆç¨¿ã€‚', + candidates: [], + selectedCandidateId: null, + coverImageSrc: null, + coverAssetId: null, + generationStatus: 'idle' as const, + }; + const completedSecondLevel = { + ...generatingSecondLevel, + candidates: [ + { + candidateId: 'candidate-level-2', + imageSrc: '/puzzle/level-2.png', + assetId: 'asset-level-2', + prompt: '第二关画é¢', + actualPrompt: null, + sourceType: 'generated' as const, + selected: true, + }, + ], + selectedCandidateId: 'candidate-level-2', + coverImageSrc: '/puzzle/level-2.png', + coverAssetId: 'asset-level-2', + generationStatus: 'ready' as const, + }; + + const { rerender } = render( + {}} + onExecuteAction={() => {}} + />, + ); + + openPuzzleLevelsTab(); + fireEvent.click(screen.getByText('第三关')); + const dialog = screen.getByRole('dialog', { name: 'å…³å¡è¯¦æƒ…' }); + fireEvent.change(within(dialog).getByLabelText('ç”»é¢æè¿°'), { + target: { value: '正在编辑第三关的信æ¯ã€‚' }, + }); + + rerender( + {}} + onExecuteAction={() => {}} + />, + ); + + const currentDialog = screen.getByRole('dialog', { name: 'å…³å¡è¯¦æƒ…' }); + expect(within(currentDialog).getByLabelText('å…³å¡åç§°')).toHaveProperty( + 'value', + '第三关', + ); + expect(within(currentDialog).getByLabelText('ç”»é¢æè¿°')).toHaveProperty( + 'value', + '正在编辑第三关的信æ¯ã€‚', + ); + expect(within(currentDialog).queryByDisplayValue('第二关')).toBeNull(); + }); + test('publishes with work info and serialized levels', () => { const onExecuteAction = vi.fn(); diff --git a/src/components/puzzle-result/PuzzleResultView.tsx b/src/components/puzzle-result/PuzzleResultView.tsx index ce3ca861..34319a7c 100644 --- a/src/components/puzzle-result/PuzzleResultView.tsx +++ b/src/components/puzzle-result/PuzzleResultView.tsx @@ -24,9 +24,9 @@ import type { PuzzleResultDraft, } from '../../../packages/shared/src/contracts/puzzleAgentDraft'; import type { PuzzleAgentSessionSnapshot } from '../../../packages/shared/src/contracts/puzzleAgentSession'; +import { resolvePuzzleUiBackgroundSource } from '../../services/puzzle-runtime/puzzleUiBackgroundSource'; import { updatePuzzleWork } from '../../services/puzzle-works'; import { getPuzzleHistoryAssetReferenceLabel } from '../../services/puzzle-works/puzzleHistoryAsset'; -import { resolvePuzzleUiBackgroundSource } from '../../services/puzzle-runtime/puzzleUiBackgroundSource'; import { readPuzzleReferenceImageAsDataUrl } from '../../services/puzzleReferenceImage'; import { useAuthUi } from '../auth/AuthUiContext'; import PuzzleHistoryAssetPickerDialog from '../puzzle-agent/PuzzleHistoryAssetPickerDialog'; @@ -1833,13 +1833,21 @@ export function PuzzleResultView({ Record >({}); const [generationNowMs, setGenerationNowMs] = useState(() => Date.now()); + const latestEditStateRef = useRef( + draft ? createDraftEditState(draft) : null, + ); const savedEditStateRef = useRef( draft ? createDraftEditState(draft) : null, ); + useEffect(() => { + latestEditStateRef.current = editState; + }, [editState]); + useEffect(() => { if (!draft) { setEditState(null); + latestEditStateRef.current = null; setActiveLevelId(null); setAutoSaveState('idle'); setAutoSaveError(null); @@ -1847,17 +1855,16 @@ export function PuzzleResultView({ return; } const nextState = createDraftEditState(draft); - setEditState((currentState) => { - const mergedState = mergeDraftEditStateWithIncomingState( - currentState, - nextState, - ); - savedEditStateRef.current = nextState; - return mergedState; - }); + const mergedState = mergeDraftEditStateWithIncomingState( + latestEditStateRef.current, + nextState, + ); + latestEditStateRef.current = mergedState; + savedEditStateRef.current = nextState; + setEditState(mergedState); setGenerationRuntimeByLevelId((current) => { const nextRuntimes: Record = {}; - nextState.levels.forEach((level) => { + mergedState.levels.forEach((level) => { if (level.generationStatus === 'generating') { nextRuntimes[level.levelId] = current[level.levelId] ?? { @@ -1870,7 +1877,7 @@ export function PuzzleResultView({ }); setActiveLevelId((currentLevelId) => currentLevelId && - nextState.levels.some((level) => level.levelId === currentLevelId) + mergedState.levels.some((level) => level.levelId === currentLevelId) ? currentLevelId : null, ); diff --git a/src/components/rpg-entry/RpgEntryFlowShell.agent.interaction.test.tsx b/src/components/rpg-entry/RpgEntryFlowShell.agent.interaction.test.tsx index 351ef722..9e9ea966 100644 --- a/src/components/rpg-entry/RpgEntryFlowShell.agent.interaction.test.tsx +++ b/src/components/rpg-entry/RpgEntryFlowShell.agent.interaction.test.tsx @@ -1526,6 +1526,65 @@ function buildMockPuzzleAgentSession( }; } +function buildReadyPuzzleDraft( + overrides: Partial = {}, +): PuzzleResultDraft { + return { + workTitle: '自动æ¢å¤æ‹¼å›¾', + workDescription: 'å‰ç«¯æ–­è¿žåŽå¤è¯» session æ¢å¤çš„æ‹¼å›¾ã€‚', + levelName: '雨夜猫街', + summary: '屋æªä¸‹çš„猫与暖ç¯è¡—角。', + themeTags: ['猫咪', '雨夜', '拼图'], + forbiddenDirectives: [], + creatorIntent: null, + anchorPack: buildPuzzleAnchorPack(), + candidates: [ + { + candidateId: 'candidate-1', + imageSrc: '/puzzle/recovered-candidate.png', + assetId: 'asset-1', + prompt: '雨夜猫街', + actualPrompt: null, + sourceType: 'generated', + selected: true, + }, + ], + selectedCandidateId: 'candidate-1', + coverImageSrc: '/puzzle/recovered-candidate.png', + coverAssetId: 'asset-1', + generationStatus: 'ready', + levels: [ + { + levelId: 'puzzle-level-1', + levelName: '雨夜猫街', + pictureDescription: '屋æªä¸‹çš„猫与暖ç¯è¡—角。', + pictureReference: null, + candidates: [ + { + candidateId: 'candidate-1', + imageSrc: '/puzzle/recovered-candidate.png', + assetId: 'asset-1', + prompt: '雨夜猫街', + actualPrompt: null, + sourceType: 'generated', + selected: true, + }, + ], + selectedCandidateId: 'candidate-1', + coverImageSrc: '/puzzle/recovered-candidate.png', + coverAssetId: 'asset-1', + uiBackgroundPrompt: '雨夜猫街竖å±çº¯èƒŒæ™¯', + uiBackgroundImageSrc: + '/generated-puzzle-assets/puzzle-session-recovered/ui/background.png', + uiBackgroundImageObjectKey: + 'generated-puzzle-assets/puzzle-session-recovered/ui/background.png', + generationStatus: 'ready', + }, + ], + ...overrides, + }; +} + function buildClearedPuzzleRun(params: { runId: string; entryProfileId: string; @@ -1591,6 +1650,20 @@ function buildMockMatch3DRun(profileId: string): Match3DRunSnapshot { }; } +const match3DGeneratedUiAsset = { + prompt: '果园竖å±çº¯èƒŒæ™¯', + imageSrc: '/generated-match3d-assets/session/profile/background/background.png', + imageObjectKey: + 'generated-match3d-assets/session/profile/background/background.png', + containerPrompt: '果园浅盘容器', + containerImageSrc: + '/generated-match3d-assets/session/profile/ui-container/container.png', + containerImageObjectKey: + 'generated-match3d-assets/session/profile/ui-container/container.png', + status: 'image_ready', + error: null, +} satisfies NonNullable; + function buildMockMatch3DAgentSession( overrides: Partial = {}, ): Match3DAgentSessionSnapshot { @@ -3829,6 +3902,7 @@ test('match3d result trial passes generated models into first runtime mount', as subscriptionKey: 'sub-strawberry', status: 'model_ready', error: null, + backgroundAsset: match3DGeneratedUiAsset, }, ]; const match3dDraftWork: Match3DWorkSummary = { @@ -4076,6 +4150,7 @@ test('match3d draft generation auto starts trial and runtime back opens draft re subscriptionKey: 'sub-strawberry', status: 'model_ready', error: null, + backgroundAsset: match3DGeneratedUiAsset, }, ]; const generatedSession = buildMockMatch3DAgentSession({ @@ -4139,6 +4214,26 @@ test('match3d draft generation auto starts trial and runtime back opens draft re expect( await screen.findByTestId('match3d-runtime-generated-model-count'), ).toHaveProperty('textContent', '1'); + await waitFor(() => { + expect( + screen.getByTestId('match3d-runtime-top-level-background-count'), + ).toHaveProperty('textContent', '1'); + }); + expect( + screen.getByTestId('match3d-runtime-top-level-container-ui-count'), + ).toHaveProperty('textContent', '1'); + expect( + match3dGeneratedModelCache.preloadMatch3DGeneratedRuntimeAssets, + ).toHaveBeenCalledWith( + expect.any(Array), + expect.objectContaining({ + imageSrc: + '/generated-match3d-assets/session/profile/background/background.png', + containerImageSrc: + '/generated-match3d-assets/session/profile/ui-container/container.png', + }), + { expireSeconds: 300 }, + ); await user.click(screen.getByRole('button', { name: '返回' })); @@ -4146,6 +4241,110 @@ test('match3d draft generation auto starts trial and runtime back opens draft re expect(screen.getByText('自动试玩抓大鹅')).toBeTruthy(); }); +test('match3d result trial loads generated background and container assets', async () => { + const user = userEvent.setup(); + const generatedItemAssets: Match3DWorkSummary['generatedItemAssets'] = [ + { + itemId: 'match3d-trial-item-1', + itemName: 'è‰èŽ“', + imageSrc: + '/generated-match3d-assets/session/profile/items/match3d-trial-item-1-item/image.png', + imageObjectKey: + 'generated-match3d-assets/session/profile/items/match3d-trial-item-1-item/image.png', + imageViews: [], + modelSrc: null, + modelObjectKey: null, + modelFileName: null, + taskUuid: null, + subscriptionKey: null, + status: 'image_ready', + error: null, + backgroundAsset: match3DGeneratedUiAsset, + }, + ]; + const match3dDraftWork: Match3DWorkSummary = { + workId: 'match3d-work-trial-ui', + profileId: 'match3d-profile-trial-ui', + ownerUserId: 'user-1', + sourceSessionId: 'match3d-session-trial-ui', + gameName: '手动试玩抓大鹅', + themeText: 'æ°´æžœ', + summary: '', + tags: ['æ°´æžœ', '抓大鹅'], + coverImageSrc: null, + referenceImageSrc: null, + clearCount: 12, + difficulty: 4, + publicationStatus: 'draft', + playCount: 0, + updatedAt: '2026-05-14T11:00:00.000Z', + publishedAt: null, + publishReady: false, + generatedBackgroundAsset: null, + generatedItemAssets, + }; + + vi.mocked(listMatch3DWorks).mockResolvedValue({ + items: [match3dDraftWork], + }); + vi.mocked(match3dCreationClient.getSession).mockResolvedValue({ + session: buildMockMatch3DAgentSession({ + sessionId: 'match3d-session-trial-ui', + stage: 'draft_ready', + draft: { + profileId: 'match3d-profile-trial-ui', + gameName: '手动试玩抓大鹅', + themeText: 'æ°´æžœ', + summary: '', + tags: ['æ°´æžœ', '抓大鹅'], + coverImageSrc: null, + referenceImageSrc: null, + clearCount: 12, + difficulty: 4, + generatedItemAssets, + }, + }), + }); + vi.mocked(getMatch3DWorkDetail).mockResolvedValue({ + item: match3dDraftWork, + }); + match3dServerRuntimeAdapterMock.startRun.mockResolvedValue({ + run: buildMockMatch3DRun(match3dDraftWork.profileId), + }); + + render(); + + await openDraftHub(user); + await user.click( + await screen.findByRole('button', { name: /继续创作《手动试玩抓大鹅》/u }), + ); + expect(await screen.findByText('抓大鹅结果页')).toBeTruthy(); + + await user.click(screen.getByRole('button', { name: '试玩' })); + + expect(await screen.findByText(/抓大鹅è¿è¡Œæ€/u)).toBeTruthy(); + await waitFor(() => { + expect( + screen.getByTestId('match3d-runtime-top-level-background-count'), + ).toHaveProperty('textContent', '1'); + }); + expect( + screen.getByTestId('match3d-runtime-top-level-container-ui-count'), + ).toHaveProperty('textContent', '1'); + expect( + match3dGeneratedModelCache.preloadMatch3DGeneratedRuntimeAssets, + ).toHaveBeenCalledWith( + expect.any(Array), + expect.objectContaining({ + imageSrc: + '/generated-match3d-assets/session/profile/background/background.png', + containerImageSrc: + '/generated-match3d-assets/session/profile/ui-container/container.png', + }), + { expireSeconds: 300 }, + ); +}); + test('completed match3d draft notice first opens trial then reopens result', async () => { const user = userEvent.setup(); const generatedItemAssets: Match3DWorkSummary['generatedItemAssets'] = [ @@ -4164,6 +4363,7 @@ test('completed match3d draft notice first opens trial then reopens result', asy subscriptionKey: 'sub-notice-strawberry', status: 'image_ready', error: null, + backgroundAsset: match3DGeneratedUiAsset, }, ]; const runningSession = buildMockMatch3DAgentSession({ @@ -4257,6 +4457,14 @@ test('completed match3d draft notice first opens trial then reopens result', asy expect(await screen.findByText(/抓大鹅è¿è¡Œæ€/u)).toBeTruthy(); expect(screen.queryByText('抓大鹅è‰ç¨¿ç”Ÿæˆè¿›åº¦')).toBeNull(); expect(match3dServerRuntimeAdapterMock.startRun).toHaveBeenCalledTimes(1); + await waitFor(() => { + expect( + screen.getByTestId('match3d-runtime-top-level-background-count'), + ).toHaveProperty('textContent', '1'); + }); + expect( + screen.getByTestId('match3d-runtime-top-level-container-ui-count'), + ).toHaveProperty('textContent', '1'); await user.click(screen.getByRole('button', { name: '返回' })); expect(await screen.findByText('抓大鹅结果页')).toBeTruthy(); await user.click(screen.getByRole('button', { name: '返回' })); @@ -4383,51 +4591,14 @@ test('completed baby object match draft shows unread marker after leaving genera test('puzzle draft generation auto starts trial and runtime back opens draft result', async () => { const user = userEvent.setup(); - const generatedDraft: PuzzleResultDraft = { + const generatedDraft = buildReadyPuzzleDraft({ workTitle: '自动试玩拼图', workDescription: '生æˆå®ŒæˆåŽç›´æŽ¥è¯•玩。', - levelName: '雨夜猫街', - summary: '屋æªä¸‹çš„猫与暖ç¯è¡—角。', - themeTags: ['猫咪', '雨夜', '拼图'], - forbiddenDirectives: [], - creatorIntent: null, - anchorPack: buildPuzzleAnchorPack(), - candidates: [ - { - candidateId: 'candidate-1', - imageSrc: '/puzzle/auto-candidate.png', - assetId: 'asset-1', - prompt: '雨夜猫街', - actualPrompt: null, - sourceType: 'generated', - selected: true, - }, - ], - selectedCandidateId: 'candidate-1', coverImageSrc: '/puzzle/auto-candidate.png', - coverAssetId: 'asset-1', - generationStatus: 'ready', levels: [ { - levelId: 'puzzle-level-1', - levelName: '雨夜猫街', - pictureDescription: '屋æªä¸‹çš„猫与暖ç¯è¡—角。', - pictureReference: null, - candidates: [ - { - candidateId: 'candidate-1', - imageSrc: '/puzzle/auto-candidate.png', - assetId: 'asset-1', - prompt: '雨夜猫街', - actualPrompt: null, - sourceType: 'generated', - selected: true, - }, - ], - selectedCandidateId: 'candidate-1', + ...buildReadyPuzzleDraft().levels![0]!, coverImageSrc: '/puzzle/auto-candidate.png', - coverAssetId: 'asset-1', - uiBackgroundPrompt: 'æ°´æžœä¹å›­ç«–å±çº¯èƒŒæ™¯', uiBackgroundImageSrc: '/generated-puzzle-assets/puzzle-session-auto-1/ui/background.png', uiBackgroundImageObjectKey: @@ -4443,10 +4614,9 @@ test('puzzle draft generation auto starts trial and runtime back opens draft res title: 'æ°´æžœä¹å›­', updatedAt: '2026-05-14T10:00:00.000Z', }, - generationStatus: 'ready', }, ], - }; + }); const generatedSession: PuzzleAgentSessionSnapshot = { sessionId: 'puzzle-session-auto-1', seedText: '屋æªä¸‹çš„猫与暖ç¯è¡—角。', @@ -4530,6 +4700,63 @@ test('puzzle draft generation auto starts trial and runtime back opens draft res expect(screen.getByDisplayValue('雨夜猫街')).toBeTruthy(); }); +test('embedded puzzle form recovers when compile request times out after backend completion', async () => { + const user = userEvent.setup(); + const generatedDraft = buildReadyPuzzleDraft(); + const generatedSession = buildMockPuzzleAgentSession({ + sessionId: 'puzzle-session-recovered', + stage: 'ready_to_publish', + progressPercent: 100, + draft: generatedDraft, + lastAssistantReply: '拼图è‰ç¨¿å·²ç»ç”Ÿæˆã€‚', + resultPreview: { + draft: generatedDraft, + publishReady: true, + blockers: [], + qualityFindings: [], + }, + updatedAt: '2026-05-12T10:00:00.000Z', + }); + + vi.mocked(createPuzzleAgentSession).mockResolvedValueOnce({ + session: buildMockPuzzleAgentSession({ + sessionId: 'puzzle-session-recovered', + }), + }); + vi.mocked(executePuzzleAgentAction).mockRejectedValueOnce( + Object.assign(new Error('请求超时:90000ms'), { + name: 'TimeoutError', + }), + ); + vi.mocked(getPuzzleAgentSession).mockResolvedValueOnce({ + session: generatedSession, + }); + + render(); + + await openCreateTemplateHub(user); + await user.click(screen.getByRole('button', { name: '生æˆè‰ç¨¿' })); + + await waitFor(() => { + expect(getPuzzleAgentSession).toHaveBeenCalledWith( + 'puzzle-session-recovered', + ); + }); + await waitFor(() => { + expect(updatePuzzleWork).toHaveBeenCalledWith( + 'puzzle-profile-recovered', + expect.objectContaining({ + levelName: '雨夜猫街', + coverImageSrc: '/puzzle/recovered-candidate.png', + }), + ); + }); + expect(screen.queryByText('执行拼图æ“作失败。')).toBeNull(); + expect(screen.queryByText('请求超时:90000ms')).toBeNull(); + expect(screen.queryByText('拼图è‰ç¨¿ç”Ÿæˆè¿›åº¦')).toBeNull(); + expect(startLocalPuzzleRun).toHaveBeenCalledTimes(1); +}); + test('embedded puzzle form routes through requireAuth while logged out', async () => { const user = userEvent.setup(); const requireAuth = vi.fn(); @@ -5601,6 +5828,12 @@ test('home recommendation Match3D runtime keeps image, music and UI assets witho 'textContent', '1', ); + expect( + screen.getByTestId('match3d-runtime-top-level-background-count'), + ).toHaveProperty('textContent', '1'); + expect( + screen.getByTestId('match3d-runtime-top-level-container-ui-count'), + ).toHaveProperty('textContent', '1'); }); test('home recommendation Match3D runtime passes top-level UI background assets', async () => { @@ -5774,6 +6007,12 @@ test('home recommendation Match3D runtime reloads detail when card only has UI a expect( await screen.findByTestId('match3d-runtime-generated-item-image-count'), ).toHaveProperty('textContent', '1'); + expect( + screen.getByTestId('match3d-runtime-top-level-background-count'), + ).toHaveProperty('textContent', '1'); + expect( + screen.getByTestId('match3d-runtime-top-level-container-ui-count'), + ).toHaveProperty('textContent', '1'); }); test('home recommendation surfaces start failure instead of staying in loading state', async () => { diff --git a/src/index.css b/src/index.css index 1af40768..af0c8bc7 100644 --- a/src/index.css +++ b/src/index.css @@ -281,8 +281,124 @@ body { will-change: transform, opacity; } +@keyframes match3d-tray-token-shift { + 0% { + transform: translate3d( + var(--match3d-tray-shift-x, 0px), + var(--match3d-tray-shift-y, 0px), + 0 + ); + } + + 100% { + transform: translate3d(0, 0, 0); + } +} + +.match3d-tray-token-shift { + animation: match3d-tray-token-shift 0.24s cubic-bezier(0.2, 0.8, 0.2, 1) + both; + will-change: transform; +} + +@keyframes match3d-tray-token-clear { + 0% { + opacity: 1; + transform: translate3d(-50%, -50%, 0) scale(1); + } + + 62% { + opacity: 1; + transform: + translate3d( + calc(-50% + var(--match3d-tray-clear-dx, 0px)), + calc(-50% + var(--match3d-tray-clear-dy, 0px)), + 0 + ) + scale(0.84); + } + + 100% { + opacity: 0; + transform: + translate3d( + calc(-50% + var(--match3d-tray-clear-dx, 0px)), + calc(-50% + var(--match3d-tray-clear-dy, 0px)), + 0 + ) + scale(0.38); + filter: blur(1px); + } +} + +.match3d-tray-token-clear { + transform: translate3d(-50%, -50%, 0); + animation: match3d-tray-token-clear 0.46s cubic-bezier(0.2, 0.72, 0.18, 1) + both; + transform-origin: center; + will-change: transform, opacity; +} + +@keyframes match3d-tray-clear-flash { + 0% { + opacity: 0; + transform: translate(-50%, -50%) scale(0.2); + } + + 42% { + opacity: 0.95; + } + + 100% { + opacity: 0; + transform: translate(-50%, -50%) scale(1.45); + } +} + +.match3d-tray-clear-flash { + width: 4rem; + height: 4rem; + border-radius: 9999px; + background: + radial-gradient(circle, rgba(255, 255, 255, 0.95) 0 10%, transparent 12%), + radial-gradient(circle, rgba(255, 236, 157, 0.48) 0 42%, transparent 64%); + box-shadow: + 0 0 22px rgba(255, 255, 255, 0.62), + 0 0 52px rgba(251, 191, 36, 0.34); + mix-blend-mode: screen; + animation: match3d-tray-clear-flash 0.46s ease-out both; + transform: translate(-50%, -50%); +} + +@keyframes match3d-merge-feedback-pulse { + 0% { + opacity: 0; + transform: scale(0.48); + } + + 42% { + opacity: 0.8; + } + + 100% { + opacity: 0; + transform: scale(1.28); + } +} + +.match3d-merge-feedback-pulse { + animation: match3d-merge-feedback-pulse 0.52s ease-out both; + background: + radial-gradient(circle, rgba(255, 255, 255, 0.56) 0 18%, transparent 20%), + radial-gradient(circle, rgba(255, 255, 255, 0.28) 0 45%, transparent 68%); +} + @media (prefers-reduced-motion: reduce) { - .match3d-token-fly-to-tray { + .match3d-token-fly-to-tray, + .match3d-tray-token-shift, + .match3d-tray-token-clear, + .match3d-tray-clear-flash, + .match3d-merge-feedback-pulse { animation-duration: 1ms; } } diff --git a/src/services/creation-agent/creationAgentClientFactory.test.ts b/src/services/creation-agent/creationAgentClientFactory.test.ts index 4d9e4da4..6ec9dea5 100644 --- a/src/services/creation-agent/creationAgentClientFactory.test.ts +++ b/src/services/creation-agent/creationAgentClientFactory.test.ts @@ -45,6 +45,7 @@ test('creation agent action requests are not auto-retried by default', async () '执行失败', expect.objectContaining({ retry: expect.objectContaining({ maxRetries: 0 }), + timeoutMs: 1_000_000, }), ); }); diff --git a/src/services/creation-agent/creationAgentClientFactory.ts b/src/services/creation-agent/creationAgentClientFactory.ts index 9b939350..dacbbbc7 100644 --- a/src/services/creation-agent/creationAgentClientFactory.ts +++ b/src/services/creation-agent/creationAgentClientFactory.ts @@ -41,6 +41,7 @@ const DEFAULT_CREATION_AGENT_WRITE_RETRY: ApiRetryOptions = { const DEFAULT_CREATION_AGENT_ACTION_RETRY: ApiRetryOptions = { maxRetries: 0, }; +const DEFAULT_CREATION_AGENT_ACTION_TIMEOUT_MS = 1_000_000; function buildJsonPostInit(payload: unknown): RequestInit { return { @@ -182,7 +183,8 @@ export function createCreationAgentClient< messages.executeAction, { retry: executeActionRetry, - timeoutMs: executeActionTimeoutMs, + timeoutMs: + executeActionTimeoutMs ?? DEFAULT_CREATION_AGENT_ACTION_TIMEOUT_MS, }, ); diff --git a/src/services/match3d-runtime/match3dLocalRuntime.ts b/src/services/match3d-runtime/match3dLocalRuntime.ts index e4c650fa..e9c6bdbf 100644 --- a/src/services/match3d-runtime/match3dLocalRuntime.ts +++ b/src/services/match3d-runtime/match3dLocalRuntime.ts @@ -5,10 +5,16 @@ import type { Match3DRunSnapshot, Match3DTraySlot, } from '../../../packages/shared/src/contracts/match3dRuntime'; +import { + buildMatch3DTrayInsertionPlan, + compactMatch3DTraySlots, + syncMatch3DItemTraySlotIndexes, +} from './match3dTrayLayout'; const MATCH3D_TRAY_SLOT_COUNT = 7; const MATCH3D_LOCAL_DURATION_MS = 600_000; const MATCH3D_MAX_ITEM_TYPE_COUNT = 25; +const MATCH3D_ITEMS_PER_CLEAR = 3; const MATCH3D_LOCAL_BASE_RADIUS = 0.072; const MATCH3D_LOCAL_BOARD_CENTER = 0.5; const MATCH3D_LOCAL_BOARD_RADIUS = 0.5; @@ -373,10 +379,6 @@ function recomputeClickable(items: Match3DItemSnapshot[]) { }); } -function findNextTrayIndex(traySlots: Match3DTraySlot[]) { - return traySlots.find((slot) => !slot.itemInstanceId)?.slotIndex ?? -1; -} - function countClearedItems(items: Match3DItemSnapshot[]) { return items.filter((item) => item.state === 'Cleared').length; } @@ -410,27 +412,28 @@ function resolveRunStatus(run: Match3DRunSnapshot): Match3DRunSnapshot { }; } -function settleMatchedTrayItems(run: Match3DRunSnapshot) { - const slotsByType = new Map(); - for (const slot of run.traySlots) { - if (!slot.itemTypeId || !slot.itemInstanceId) { - continue; - } - slotsByType.set(slot.itemTypeId, [ - ...(slotsByType.get(slot.itemTypeId) ?? []), - slot, - ]); - } - - const matchedSlots = [...slotsByType.values()].find( - (slots) => slots.length >= 3, - ); +function settleMatchedTrayItems( + run: Match3DRunSnapshot, + itemTypeId: string, +) { + const matchedSlots = run.traySlots + .filter( + (slot) => + slot.itemInstanceId && slot.itemTypeId && slot.itemTypeId === itemTypeId, + ) + .slice(0, MATCH3D_ITEMS_PER_CLEAR); if (!matchedSlots) { return { run, clearedItemInstanceIds: [] as string[], }; } + if (matchedSlots.length < MATCH3D_ITEMS_PER_CLEAR) { + return { + run, + clearedItemInstanceIds: [] as string[], + }; + } const clearedItemInstanceIds = matchedSlots .slice(0, 3) @@ -439,26 +442,36 @@ function settleMatchedTrayItems(run: Match3DRunSnapshot) { Boolean(itemInstanceId), ); const clearedSet = new Set(clearedItemInstanceIds); - const nextRun = { - ...run, - traySlots: run.traySlots.map((slot) => + const compactedTraySlots = compactMatch3DTraySlots( + run.traySlots.map((slot) => slot.itemInstanceId && clearedSet.has(slot.itemInstanceId) ? { slotIndex: slot.slotIndex } : slot, ), + ); + const nextRun = { + ...run, + traySlots: compactedTraySlots, items: run.items.map((item) => clearedSet.has(item.itemInstanceId) ? { ...item, state: 'Cleared' as const, clickable: false, + traySlotIndex: null, } : item, ), }; return { - run: nextRun, + run: { + ...nextRun, + items: syncMatch3DItemTraySlotIndexes( + nextRun.items, + nextRun.traySlots, + ), + }, clearedItemInstanceIds, }; } @@ -509,31 +522,27 @@ export function buildLocalMatch3DOptimisticRun( const targetItem = run.items.find( (item) => item.itemInstanceId === itemInstanceId, ); - const nextTrayIndex = findNextTrayIndex(run.traySlots); - if (!targetItem || targetItem.state !== 'InBoard' || nextTrayIndex < 0) { + if (!targetItem || targetItem.state !== 'InBoard') { return run; } + const insertion = buildMatch3DTrayInsertionPlan(run.traySlots, targetItem); + if (!insertion) { + return run; + } + const nextItems = run.items.map((item) => + item.itemInstanceId === itemInstanceId + ? { + ...item, + state: 'Flying' as const, + clickable: false, + traySlotIndex: insertion.slotIndex, + } + : item, + ); return { ...run, - items: run.items.map((item) => - item.itemInstanceId === itemInstanceId - ? { - ...item, - state: 'Flying' as const, - clickable: false, - } - : item, - ), - traySlots: run.traySlots.map((slot) => - slot.slotIndex === nextTrayIndex - ? { - slotIndex: slot.slotIndex, - itemInstanceId: targetItem.itemInstanceId, - itemTypeId: targetItem.itemTypeId, - visualKey: targetItem.visualKey, - } - : slot, - ), + items: syncMatch3DItemTraySlotIndexes(nextItems, insertion.traySlots), + traySlots: insertion.traySlots, }; } @@ -582,8 +591,8 @@ export async function confirmLocalMatch3DClick( clearedItemInstanceIds: [], }; } - const nextTrayIndex = findNextTrayIndex(run.traySlots); - if (nextTrayIndex < 0) { + const insertion = buildMatch3DTrayInsertionPlan(timedRun.traySlots, targetItem); + if (!insertion) { const failedRun = { ...timedRun, status: 'Failed' as const, @@ -601,27 +610,22 @@ export async function confirmLocalMatch3DClick( const movedRun: Match3DRunSnapshot = { ...timedRun, snapshotVersion: run.snapshotVersion + 1, - items: timedRun.items.map((item) => - item.itemInstanceId === targetItem.itemInstanceId - ? { - ...item, - state: 'InTray' as const, - clickable: false, - } - : item, - ), - traySlots: timedRun.traySlots.map((slot) => - slot.slotIndex === nextTrayIndex - ? { - slotIndex: slot.slotIndex, - itemInstanceId: targetItem.itemInstanceId, - itemTypeId: targetItem.itemTypeId, - visualKey: targetItem.visualKey, - } - : slot, + items: syncMatch3DItemTraySlotIndexes( + timedRun.items.map((item) => + item.itemInstanceId === targetItem.itemInstanceId + ? { + ...item, + state: 'InTray' as const, + clickable: false, + traySlotIndex: insertion.slotIndex, + } + : item, + ), + insertion.traySlots, ), + traySlots: insertion.traySlots, }; - const settled = settleMatchedTrayItems(movedRun); + const settled = settleMatchedTrayItems(movedRun, targetItem.itemTypeId); const nextRun = resolveRunStatus({ ...settled.run, items: recomputeClickable(settled.run.items), diff --git a/src/services/match3d-runtime/match3dTrayLayout.test.ts b/src/services/match3d-runtime/match3dTrayLayout.test.ts new file mode 100644 index 00000000..77b404e9 --- /dev/null +++ b/src/services/match3d-runtime/match3dTrayLayout.test.ts @@ -0,0 +1,167 @@ +import { expect, test } from 'vitest'; + +import type { + Match3DItemSnapshot, + Match3DTraySlot, +} from '../../../packages/shared/src/contracts/match3dRuntime'; +import { confirmLocalMatch3DClick } from './match3dLocalRuntime'; +import { + buildMatch3DTrayInsertionPlan, + compactMatch3DTraySlots, + syncMatch3DItemTraySlotIndexes, +} from './match3dTrayLayout'; + +function slot( + slotIndex: number, + itemInstanceId?: string, + itemTypeId?: string, +): Match3DTraySlot { + return itemInstanceId && itemTypeId + ? { + slotIndex, + itemInstanceId, + itemTypeId, + visualKey: itemTypeId, + } + : { slotIndex }; +} + +function item( + itemInstanceId: string, + itemTypeId: string, + traySlotIndex: number | null = null, +): Match3DItemSnapshot { + return { + itemInstanceId, + itemTypeId, + visualKey: itemTypeId, + x: 0.5, + y: 0.5, + radius: 0.08, + layer: 1, + state: traySlotIndex === null ? 'InBoard' : 'InTray', + clickable: traySlotIndex === null, + traySlotIndex, + }; +} + +test('抓大鹅托盘点击新物å“会æ’入到åŒç±»åŽé¢å¹¶åŽç§»å…¶å®ƒç‰©å“', () => { + const plan = buildMatch3DTrayInsertionPlan( + [ + slot(0, 'apple-1', 'apple'), + slot(1, 'pear-1', 'pear'), + slot(2, 'apple-2', 'apple'), + slot(3, 'melon-1', 'melon'), + slot(4), + slot(5), + slot(6), + ], + item('apple-3', 'apple'), + ); + + expect(plan?.slotIndex).toBe(3); + expect(plan?.traySlots.map((entry) => entry.itemInstanceId ?? null)).toEqual([ + 'apple-1', + 'pear-1', + 'apple-2', + 'apple-3', + 'melon-1', + null, + null, + ]); +}); + +test('æŠ“å¤§é¹…ä¸‰æ¶ˆåŽæ‰˜ç›˜ä¼šå‘å‰è¡¥ä½å¹¶åŒæ­¥ç‰©å“æ§½ä½ç´¢å¼•', () => { + const traySlots = compactMatch3DTraySlots([ + slot(0), + slot(1), + slot(2), + slot(3, 'melon-1', 'melon'), + slot(4, 'pear-1', 'pear'), + slot(5), + slot(6), + ]); + const items = syncMatch3DItemTraySlotIndexes( + [ + item('melon-1', 'melon', 3), + item('pear-1', 'pear', 4), + { ...item('apple-1', 'apple', 0), state: 'Cleared' as const }, + ], + traySlots, + ); + + expect(traySlots.map((entry) => entry.itemInstanceId ?? null)).toEqual([ + 'melon-1', + 'pear-1', + null, + null, + null, + null, + null, + ]); + expect(items.find((entry) => entry.itemInstanceId === 'melon-1')?.traySlotIndex).toBe( + 0, + ); + expect(items.find((entry) => entry.itemInstanceId === 'pear-1')?.traySlotIndex).toBe( + 1, + ); + expect(items.find((entry) => entry.itemInstanceId === 'apple-1')?.traySlotIndex).toBeNull(); +}); + +test('æœ¬åœ°æŠ“å¤§é¹…ç¡®è®¤åªæ¸…除本次点击类型的三连', async () => { + const run = { + runId: 'local-triple-run', + profileId: 'local-triple-profile', + status: 'Running' as const, + snapshotVersion: 1, + startedAtMs: Date.now(), + durationLimitMs: 600_000, + serverNowMs: Date.now(), + remainingMs: 600_000, + clearCount: 3, + totalItemCount: 7, + clearedItemCount: 0, + items: [ + item('apple-1', 'apple', 0), + item('apple-2', 'apple', 1), + item('apple-3', 'apple', 2), + item('pear-1', 'pear', 3), + item('pear-2', 'pear', 4), + item('pear-3', 'pear', null), + item('melon-1', 'melon', 5), + ], + traySlots: [ + slot(0, 'apple-1', 'apple'), + slot(1, 'apple-2', 'apple'), + slot(2, 'apple-3', 'apple'), + slot(3, 'pear-1', 'pear'), + slot(4, 'pear-2', 'pear'), + slot(5, 'melon-1', 'melon'), + slot(6), + ], + }; + run.items[5]!.clickable = true; + run.items[5]!.state = 'InBoard'; + + const result = await confirmLocalMatch3DClick(run, { + runId: run.runId, + itemInstanceId: 'pear-3', + clientSnapshotVersion: run.snapshotVersion, + clientEventId: 'click-pear-3', + clickedAtMs: Date.now(), + }); + + expect(result.clearedItemInstanceIds).toEqual(['pear-1', 'pear-2', 'pear-3']); + expect(result.run.items.find((entry) => entry.itemInstanceId === 'apple-1')?.state).toBe( + 'InTray', + ); + expect(result.run.traySlots.map((entry) => entry.itemInstanceId ?? null)).toEqual([ + 'apple-1', + 'apple-2', + 'apple-3', + 'melon-1', + null, + null, + null, + ]); +}); diff --git a/src/services/match3d-runtime/match3dTrayLayout.ts b/src/services/match3d-runtime/match3dTrayLayout.ts new file mode 100644 index 00000000..e4dc1a7d --- /dev/null +++ b/src/services/match3d-runtime/match3dTrayLayout.ts @@ -0,0 +1,134 @@ +import type { + Match3DItemSnapshot, + Match3DTraySlot, +} from '../../../packages/shared/src/contracts/match3dRuntime'; + +type Match3DTrayOccupant = { + itemInstanceId: string; + itemTypeId: string; + visualKey: string; +}; + +export type Match3DTrayInsertionPlan = { + slotIndex: number; + traySlots: Match3DTraySlot[]; +}; + +function resolveMatch3DTraySlotOrder(traySlots: Match3DTraySlot[]) { + return [...traySlots].sort((left, right) => left.slotIndex - right.slotIndex); +} + +function resolveMatch3DTrayOccupants(traySlots: Match3DTraySlot[]) { + return resolveMatch3DTraySlotOrder(traySlots).flatMap((slot) => + slot.itemInstanceId && slot.itemTypeId && slot.visualKey + ? [ + { + itemInstanceId: slot.itemInstanceId, + itemTypeId: slot.itemTypeId, + visualKey: slot.visualKey, + }, + ] + : [], + ); +} + +function rebuildMatch3DTraySlots( + traySlots: Match3DTraySlot[], + occupants: Match3DTrayOccupant[], +) { + return resolveMatch3DTraySlotOrder(traySlots).map((slot, index) => { + const occupant = occupants[index]; + return occupant + ? { + slotIndex: slot.slotIndex, + itemInstanceId: occupant.itemInstanceId, + itemTypeId: occupant.itemTypeId, + visualKey: occupant.visualKey, + } + : { slotIndex: slot.slotIndex }; + }); +} + +export function buildMatch3DTrayInsertionPlan( + traySlots: Match3DTraySlot[], + item: Pick< + Match3DItemSnapshot, + 'itemInstanceId' | 'itemTypeId' | 'visualKey' + >, +): Match3DTrayInsertionPlan | null { + const orderedSlots = resolveMatch3DTraySlotOrder(traySlots); + const occupants = resolveMatch3DTrayOccupants(orderedSlots); + if (occupants.length >= orderedSlots.length) { + return null; + } + + let lastSameTypeIndex = -1; + for (let index = occupants.length - 1; index >= 0; index -= 1) { + if (occupants[index]?.itemTypeId === item.itemTypeId) { + lastSameTypeIndex = index; + break; + } + } + const insertionIndex = + lastSameTypeIndex >= 0 ? lastSameTypeIndex + 1 : occupants.length; + occupants.splice(insertionIndex, 0, { + itemInstanceId: item.itemInstanceId, + itemTypeId: item.itemTypeId, + visualKey: item.visualKey, + }); + + return { + slotIndex: orderedSlots[insertionIndex]?.slotIndex ?? insertionIndex, + traySlots: rebuildMatch3DTraySlots(orderedSlots, occupants), + }; +} + +export function syncMatch3DItemTraySlotIndexes( + items: Match3DItemSnapshot[], + traySlots: Match3DTraySlot[], +) { + const slotByItemId = new Map( + traySlots.flatMap((slot) => + slot.itemInstanceId + ? [[slot.itemInstanceId, slot.slotIndex] as const] + : [], + ), + ); + + return items.map((item) => + item.state === 'InTray' && slotByItemId.has(item.itemInstanceId) + ? { + ...item, + traySlotIndex: slotByItemId.get(item.itemInstanceId), + } + : item.state === 'Cleared' + ? { + ...item, + traySlotIndex: null, + } + : item, + ); +} + +export function compactMatch3DTraySlots(traySlots: Match3DTraySlot[]) { + return rebuildMatch3DTraySlots( + resolveMatch3DTraySlotOrder(traySlots), + resolveMatch3DTrayOccupants(traySlots), + ); +} + +export function resolveMatch3DTrayItemIdToSlotIndexMap( + traySlots: Match3DTraySlot[], +) { + return new Map( + resolveMatch3DTraySlotOrder(traySlots).flatMap((slot) => + slot.itemInstanceId ? [[slot.itemInstanceId, slot.slotIndex] as const] : [], + ), + ); +} + +export function resolveMatch3DTraySlotRectIndexOrder( + traySlots: Match3DTraySlot[], +) { + return resolveMatch3DTraySlotOrder(traySlots).map((slot) => slot.slotIndex); +} diff --git a/src/services/miniGameDraftGenerationProgress.test.ts b/src/services/miniGameDraftGenerationProgress.test.ts index 3c58fe11..7a5ddaf4 100644 --- a/src/services/miniGameDraftGenerationProgress.test.ts +++ b/src/services/miniGameDraftGenerationProgress.test.ts @@ -99,6 +99,22 @@ describe('miniGameDraftGenerationProgress', () => { ); }); + test('finished draft generation keeps elapsed time pinned to completion time', () => { + const state: MiniGameDraftGenerationState = { + kind: 'puzzle', + phase: 'failed', + startedAtMs: 1_000, + finishedAtMs: 151_000, + completedAssetCount: 0, + totalAssetCount: 0, + error: 'VectorEngine 图片编辑请求超时', + }; + + const progress = buildMiniGameDraftGenerationProgress(state, 500_000); + + expect(progress?.elapsedMs).toBe(150_000); + }); + test('big fish draft generation exposes multiple draft steps', () => { const state: MiniGameDraftGenerationState = { kind: 'big-fish', diff --git a/src/services/miniGameDraftGenerationProgress.ts b/src/services/miniGameDraftGenerationProgress.ts index a1e3093c..6c47a4f8 100644 --- a/src/services/miniGameDraftGenerationProgress.ts +++ b/src/services/miniGameDraftGenerationProgress.ts @@ -59,6 +59,7 @@ export type MiniGameDraftGenerationState = { kind: MiniGameDraftGenerationKind; phase: MiniGameDraftGenerationPhase; startedAtMs: number; + finishedAtMs?: number; completedAssetCount: number; totalAssetCount: number; error: string | null; @@ -445,7 +446,11 @@ export function buildMiniGameDraftGenerationProgress( return null; } - const elapsedMs = Math.max(0, nowMs - state.startedAtMs); + const effectiveNowMs = + typeof state.finishedAtMs === 'number' && Number.isFinite(state.finishedAtMs) + ? state.finishedAtMs + : nowMs; + const elapsedMs = Math.max(0, effectiveNowMs - state.startedAtMs); const puzzleTimeline = state.kind === 'puzzle' && state.phase !== 'failed' && diff --git a/src/services/puzzle-runtime/puzzleLocalRuntime.test.ts b/src/services/puzzle-runtime/puzzleLocalRuntime.test.ts index 50e1d7bb..819a43fb 100644 --- a/src/services/puzzle-runtime/puzzleLocalRuntime.test.ts +++ b/src/services/puzzle-runtime/puzzleLocalRuntime.test.ts @@ -603,6 +603,53 @@ describe('puzzleLocalRuntime', () => { ); }); + test('本地试玩直达åŽç»­å…³å¡æ—¶ç»§æ‰¿ä½œå“ UI 背景', () => { + const workWithLevels: PuzzleWorkSummary = { + ...baseWork, + levels: [ + { + levelId: 'puzzle-level-1', + levelName: '第一关', + pictureDescription: '第一关画é¢', + candidates: [], + selectedCandidateId: null, + coverImageSrc: '/level-1.png', + coverAssetId: null, + uiBackgroundImageSrc: + '/generated-puzzle-assets/session/ui/background.png', + uiBackgroundImageObjectKey: + 'generated-puzzle-assets/session/ui/background.png', + backgroundMusic: null, + generationStatus: 'ready', + }, + { + levelId: 'puzzle-level-2', + levelName: '第二关', + pictureDescription: '第二关画é¢', + candidates: [], + selectedCandidateId: null, + coverImageSrc: '/level-2.png', + coverAssetId: null, + uiBackgroundImageSrc: null, + uiBackgroundImageObjectKey: null, + backgroundMusic: null, + generationStatus: 'ready', + }, + ], + }; + + const run = startLocalPuzzleRun(workWithLevels, 'puzzle-level-2'); + + expect(run.currentLevel?.levelId).toBe('puzzle-level-2'); + expect(run.currentLevel?.coverImageSrc).toBe('/level-2.png'); + expect(run.currentLevel?.uiBackgroundImageSrc).toBe( + '/generated-puzzle-assets/session/ui/background.png', + ); + expect(run.currentLevel?.uiBackgroundImageObjectKey).toBe( + 'generated-puzzle-assets/session/ui/background.png', + ); + }); + test('æš‚åœå’Œå†»ç»“æ—¶é—´ä¸ä¼šæ¶ˆè€—本地倒计时', () => { const run = startLocalPuzzleRun(baseWork); const pausedRun = setLocalPuzzlePaused( diff --git a/src/services/puzzle-runtime/puzzleLocalRuntime.ts b/src/services/puzzle-runtime/puzzleLocalRuntime.ts index 2225e773..327d9111 100644 --- a/src/services/puzzle-runtime/puzzleLocalRuntime.ts +++ b/src/services/puzzle-runtime/puzzleLocalRuntime.ts @@ -12,7 +12,10 @@ import type { } from '../../../packages/shared/src/contracts/puzzleRuntimeSession'; import type { PuzzleDraftLevel } from '../../../packages/shared/src/contracts/puzzleAgentDraft'; import type { PuzzleWorkSummary } from '../../../packages/shared/src/contracts/puzzleWorkSummary'; -import { resolvePuzzleUiBackgroundSource } from './puzzleUiBackgroundSource'; +import { + resolvePuzzleUiBackgroundFields, + resolvePuzzleUiBackgroundSource, +} from './puzzleUiBackgroundSource'; const LOCAL_PUZZLE_RUN_ID_PREFIX = 'local-puzzle-run-'; const PUZZLE_FREEZE_TIME_DURATION_MS = 10_000; @@ -761,6 +764,15 @@ function resolveNextSameWorkLevel( return levels[nextLevelIndex] ?? null; } +function resolvePuzzleWorkUiBackgroundCarrier( + work: PuzzleWorkSummary | null | undefined, +) { + return ( + work?.levels?.find((level) => resolvePuzzleUiBackgroundSource(level)) ?? + null + ); +} + function applyLocalNextLevelHandoff( run: PuzzleRunSnapshot, work: PuzzleWorkSummary | null | undefined, @@ -803,11 +815,11 @@ function buildFallbackLocalLevel( buildLocalLevelName(currentLevel.levelName, nextLevelIndex); const nextCoverImageSrc = nextLevel?.coverImageSrc ?? currentLevel.coverImageSrc; - const nextUiBackgroundImageSrc = - resolvePuzzleUiBackgroundSource(nextLevel) ?? currentLevel.uiBackgroundImageSrc; - const nextUiBackgroundImageObjectKey = resolvePuzzleUiBackgroundSource(nextLevel) - ? nextLevel?.uiBackgroundImageObjectKey?.trim() || null - : currentLevel.uiBackgroundImageObjectKey ?? null; + const nextUiBackground = resolvePuzzleUiBackgroundFields( + nextLevel, + resolvePuzzleWorkUiBackgroundCarrier(work), + currentLevel, + ); const nextBackgroundMusic = nextLevel?.backgroundMusic ?? currentLevel.backgroundMusic; @@ -838,8 +850,8 @@ function buildFallbackLocalLevel( clearedAtMs: null, elapsedMs: null, coverImageSrc: nextCoverImageSrc, - uiBackgroundImageSrc: nextUiBackgroundImageSrc, - uiBackgroundImageObjectKey: nextUiBackgroundImageObjectKey, + uiBackgroundImageSrc: nextUiBackground.uiBackgroundImageSrc, + uiBackgroundImageObjectKey: nextUiBackground.uiBackgroundImageObjectKey, backgroundMusic: nextBackgroundMusic, ...buildLevelTimerFields(nextLevelIndex), leaderboardEntries: [], @@ -865,9 +877,10 @@ export function startLocalPuzzleRun( const firstLevel = item.levels?.[currentLevelIndex] ?? null; const firstLevelName = firstLevel?.levelName || item.levelName; const firstCoverImageSrc = firstLevel?.coverImageSrc ?? item.coverImageSrc; - const firstUiBackgroundImageSrc = resolvePuzzleUiBackgroundSource(firstLevel); - const firstUiBackgroundImageObjectKey = - firstLevel?.uiBackgroundImageObjectKey?.trim() || null; + const firstUiBackground = resolvePuzzleUiBackgroundFields( + firstLevel, + resolvePuzzleWorkUiBackgroundCarrier(item), + ); const firstBackgroundMusic = firstLevel?.backgroundMusic ?? null; const nextSameWorkLevel = item.levels?.[currentLevelIndex + 1] ?? null; return { @@ -888,8 +901,8 @@ export function startLocalPuzzleRun( authorDisplayName: item.authorDisplayName, themeTags: item.themeTags, coverImageSrc: firstCoverImageSrc, - uiBackgroundImageSrc: firstUiBackgroundImageSrc, - uiBackgroundImageObjectKey: firstUiBackgroundImageObjectKey, + uiBackgroundImageSrc: firstUiBackground.uiBackgroundImageSrc, + uiBackgroundImageObjectKey: firstUiBackground.uiBackgroundImageObjectKey, backgroundMusic: firstBackgroundMusic, board: buildInitialBoard(gridSize, runId, item.profileId, 1), status: 'playing', diff --git a/src/services/puzzle-runtime/puzzleUiBackgroundSource.ts b/src/services/puzzle-runtime/puzzleUiBackgroundSource.ts index 491e9872..a72d2afe 100644 --- a/src/services/puzzle-runtime/puzzleUiBackgroundSource.ts +++ b/src/services/puzzle-runtime/puzzleUiBackgroundSource.ts @@ -6,15 +6,27 @@ type PuzzleUiBackgroundFields = { export function resolvePuzzleUiBackgroundSource( level: PuzzleUiBackgroundFields | null | undefined, ) { - const imageSrc = level?.uiBackgroundImageSrc?.trim(); - if (imageSrc) { - return imageSrc; - } - - const objectKey = level?.uiBackgroundImageObjectKey?.trim().replace(/^\/+/u, ''); - if (!objectKey) { - return null; - } - - return `/${objectKey}`; + return resolvePuzzleUiBackgroundFields(level).uiBackgroundImageSrc; +} + +export function resolvePuzzleUiBackgroundFields( + ...sources: Array +) { + for (const source of sources) { + const imageSrc = source?.uiBackgroundImageSrc?.trim(); + const objectKey = source?.uiBackgroundImageObjectKey + ?.trim() + .replace(/^\/+/u, ''); + if (imageSrc || objectKey) { + return { + uiBackgroundImageSrc: imageSrc || (objectKey ? `/${objectKey}` : null), + uiBackgroundImageObjectKey: objectKey || null, + }; + } + } + + return { + uiBackgroundImageSrc: null, + uiBackgroundImageObjectKey: null, + }; } diff --git a/src/services/runtimeAudioFeedback.ts b/src/services/runtimeAudioFeedback.ts index 62bd9034..ec2527e6 100644 --- a/src/services/runtimeAudioFeedback.ts +++ b/src/services/runtimeAudioFeedback.ts @@ -1,6 +1,8 @@ export const DEFAULT_RUNTIME_CLICK_SOUND_SRC = '/audio/ui-click-soft.wav'; export const DEFAULT_RUNTIME_LEVEL_CLEAR_SOUND_SRC = '/audio/ui-level-clear.wav'; +export const DEFAULT_RUNTIME_MERGE_SOUND_SRC = + DEFAULT_RUNTIME_LEVEL_CLEAR_SOUND_SRC; export const DEFAULT_RUNTIME_COUNTDOWN_SOUND_SRC = '/audio/ui-countdown-warning.wav'; export const DEFAULT_RUNTIME_COUNTDOWN_WARNING_THRESHOLD_MS = 5_000; @@ -53,6 +55,10 @@ export function playRuntimeLevelClearSound(volume = 0.6) { playRuntimeClickSound(DEFAULT_RUNTIME_LEVEL_CLEAR_SOUND_SRC, volume); } +export function playRuntimeMergeSound(volume = 0.6) { + playRuntimeClickSound(DEFAULT_RUNTIME_MERGE_SOUND_SRC, volume); +} + export function playRuntimeCountdownSound(volume = 0.6) { playRuntimeClickSound(DEFAULT_RUNTIME_COUNTDOWN_SOUND_SRC, volume); }