From 21ac5642e891bb642d0ac9414f810b026ff5c973 Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?=E4=BA=94=E9=A6=99=E4=B8=B8=E5=AD=90?= <15518898337@163.com> Date: Thu, 11 Jun 2026 00:50:18 +0800 Subject: [PATCH] =?UTF-8?q?fix:=20=E5=AE=8C=E5=96=84=E6=8B=BC=E6=B6=88?= =?UTF-8?q?=E6=B6=88=E6=A8=A1=E6=9D=BF=E8=BF=90=E8=A1=8C=E8=A7=84=E5=88=99?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit --- .hermes/shared-memory/decision-log.md | 6 +- .hermes/shared-memory/pitfalls.md | 32 + ...€çŽ©æ³•åˆ›ä½œã€‘æ‹¼æ¶ˆæ¶ˆçŽ©æ³•æ¨¡æ¿PRD-2026-05-30.md | 30 +- ...æ³•åˆ›ä½œã€‘æ‹¼æ¶ˆæ¶ˆçŽ©æ³•æ¨¡æ¿æŠ€æœ¯æ–¹æ¡ˆ-2026-05-30.md | 24 +- ...玩法创作】平å°å…¥å£ä¸ŽçŽ©æ³•é“¾è·¯-2026-05-15.md | 16 +- .../crates/api-server/src/puzzle_clear.rs | 794 +++++++++++++++++- .../module-puzzle-clear/src/application.rs | 279 +++++- .../spacetime-module/src/puzzle_clear.rs | 43 +- .../PlatformEntryFlowShellImpl.tsx | 44 +- .../PuzzleClearWorkspace.test.tsx | 90 +- .../PuzzleClearWorkspace.tsx | 179 ++-- .../PuzzleClearResultView.test.tsx | 44 +- .../PuzzleClearResultView.tsx | 139 ++- .../PuzzleClearRuntimeShell.test.tsx | 108 ++- .../PuzzleClearRuntimeShell.tsx | 83 +- src/index.css | 53 +- src/index.test.ts | 29 + .../puzzleClearLocalRuntime.test.ts | 136 ++- .../puzzle-clear/puzzleClearLocalRuntime.ts | 140 ++- 19 files changed, 1952 insertions(+), 317 deletions(-) diff --git a/.hermes/shared-memory/decision-log.md b/.hermes/shared-memory/decision-log.md index 68d8cd56..9e13545d 100644 --- a/.hermes/shared-memory/decision-log.md +++ b/.hermes/shared-memory/decision-log.md @@ -16,10 +16,10 @@ --- -## 2026-06-03 拼消消收敛为å•å…³ 6x6 与 4-sheet ç´ æç­–ç•¥ +## 2026-06-03 拼消消收敛为 4 å…³ 6x6 与 4-sheet ç´ æç­–ç•¥ - èƒŒæ™¯ï¼šæœ€åˆ 4 å…³ / 135 次消除 / å•张大 atlas 方案生图数é‡å’Œç©ºé—´ä¸€è‡´æ€§æˆæœ¬è¿‡é«˜ï¼ŒçœŸå®ž image2 结果容易被布局æç¤ºè¯è¯±å¯¼æˆå¸¦æ–‡å­—ã€è¾¹æ¡†æˆ–ç¼–å·çš„说明图,ä¸é€‚åˆè¿è¡Œæ€ 1x1 切片。 -- 决策:拼消消è¿è¡Œæ€æ”¶æ•›ä¸ºå•å…³ `6x6 / 35 次消除 / 600 ç§’`ï¼Œç›´æŽ¥è§£é” `1x2`ã€`1x3`ã€`2x2`ã€`2x3`;素æç”Ÿæˆæ”¹ä¸º 4 å¼  `1024x1536` 竖版 sheet,æ¯å¼ æŒ‰ `4x6`ã€æ¯æ ¼ `256x256` 切片,å†ç”±æœåŠ¡ç«¯åˆæˆ `10x10 / 2560x2560` 最终 atlasã€‚å½¢çŠ¶é…æ¯”固定为 `1x2=23`ã€`1x3=5`ã€`2x2=4`ã€`2x3=3`,总计 35 个å¤åˆå›¾æ¡ˆç»„å’Œ 95 个 1x1 å¡ç‰Œåˆ‡ç‰‡ã€‚ +- 决策:拼消消è¿è¡Œæ€æ”¶æ•›ä¸º 4 å…³ `6x6` æ¸è¿›è§„则:第 1 关目标 15ã€300 ç§’ã€ä»… `1x2`;第 2 关目标 20ã€300 ç§’ã€è§£é” `1x2/1x3`;第 3 关目标 30ã€420 ç§’ã€è§£é” `1x2/1x3/2x2`;第 4 关目标 35ã€600 ç§’ã€è§£é”全部 `1x2/1x3/2x2/2x3`。目标消除数就是本关实际å¤åˆå›¾æ¡ˆç»„总数,胜利æ¡ä»¶æ°¸è¿œæ˜¯æ¶ˆé™¤å®Œæœ¬å…³å…¨éƒ¨å¡ç‰Œï¼›æ£‹ç›˜æ”¾ä¸ä¸‹çš„牌进入顶部准备区,牌ä¸è¶³æ£‹ç›˜æ ¼æ•°æ—¶ä¿ç•™ç©ºæ ¼å¹¶éœ²å‡ºèƒŒæ™¯å›¾ã€‚ç´ æç”Ÿæˆä»ä½¿ç”¨ 4 å¼  `1024x1536` 竖版 sheet,æ¯å¼ æŒ‰ `4x6`ã€æ¯æ ¼ `256x256` 切片,å†ç”±æœåŠ¡ç«¯åˆæˆ `10x10 / 2560x2560` 最终 atlasã€‚å½¢çŠ¶é…æ¯”固定为 `1x2=23`ã€`1x3=5`ã€`2x2=4`ã€`2x3=3`,总计 35 个å¤åˆå›¾æ¡ˆç»„å’Œ 95 个 1x1 å¡ç‰Œåˆ‡ç‰‡ã€‚ - å½±å“范围:`module-puzzle-clear` å…³å¡ä¸Žå›¾æ¡ˆç»„规划ã€api-server 拼消消素æç”Ÿæˆç¼–排ã€å‰ç«¯è‰ç¨¿è¯•玩本地 runtimeã€ç»“果页 atlas é¢„è§ˆã€æ‹¼æ¶ˆæ¶ˆ PRD / 技术方案 / å¹³å°é“¾è·¯æ–‡æ¡£ã€‚ - éªŒè¯æ–¹å¼ï¼š`cargo test -p module-puzzle-clear --manifest-path server-rs/Cargo.toml`ã€`cargo test -p api-server puzzle_clear --manifest-path server-rs/Cargo.toml -- --nocapture`ã€`npm run test -- src/services/puzzle-clear/puzzleClearLocalRuntime.test.ts`ã€`npm run test -- src/components/puzzle-clear-result/PuzzleClearResultView.test.tsx src/components/puzzle-clear-runtime/PuzzleClearRuntimeShell.test.tsx`。 - å…³è”æ–‡æ¡£ï¼š`docs/prd/ã€çŽ©æ³•åˆ›ä½œã€‘æ‹¼æ¶ˆæ¶ˆçŽ©æ³•æ¨¡æ¿PRD-2026-05-30.md`ã€`docs/technical/ã€çŽ©æ³•åˆ›ä½œã€‘æ‹¼æ¶ˆæ¶ˆçŽ©æ³•æ¨¡æ¿æŠ€æœ¯æ–¹æ¡ˆ-2026-05-30.md`ã€`docs/ã€çŽ©æ³•åˆ›ä½œã€‘å¹³å°å…¥å£ä¸ŽçŽ©æ³•é“¾è·¯-2026-05-15.md`ã€`docs/ã€åŽç«¯æž¶æž„】server-rs与SpacetimeDBæ•°æ®å¥‘约-2026-05-15.md`。 @@ -28,7 +28,7 @@ - èƒŒæ™¯ï¼šæ‹¼æ¶ˆæ¶ˆä»¥æ‹¼å›¾äº¤æ¢æ‰‹æ„Ÿä¸ºåŸºç¡€ï¼Œä½†æ ¸å¿ƒè§„则从“拼完整å•图过关â€å˜ä¸ºâ€œæ‹¼æˆå¤šä¸ªå¤åˆå›¾æ¡ˆç»„åŽé€ä¸ªæ¶ˆé™¤â€ï¼ŒåŒæ—¶éœ€è¦é¡¶éƒ¨è¡¥ç‰Œã€é˜²æ­»å±€ã€åŠé”定局部拼接组和正å¼ç»Ÿè®¡ï¼Œä¸èƒ½ç»§ç»­å¤ç”¨æ‹¼å›¾è¿è¡Œæ€è§„则本体。 - 决策:`puzzle-clear` 作为独立玩法域接入,公开作å“ç å‰ç¼€å›ºå®šä¸º `PC-`ï¼›åˆ›ä½œé“¾è·¯é‡‡ç”¨è¡¨å• / å›¾ç‰‡è¾“å…¥å·¥ä½œå° -> 独立生æˆé¡µ -> 结果页 -> 试玩 -> å‘布 -> 统一作å“详情 -> æ­£å¼ runtime。领域规则è½åœ¨ `module-puzzle-clear`,SpacetimeDB 新增 `puzzle_clear_*` 表 / procedure / view,并接入统一 `public_work_gallery_entry` / `public_work_detail_entry`ï¼›å‰ç«¯åªè¡¨çްåŽç«¯ snapshot/action ç»“æžœï¼Œä¸æŠŠèƒœè´Ÿã€è¡¥ç‰Œæˆ–消除è£å†³åšæˆå‰ç«¯äº‹å®žæºã€‚ -- 补充约æŸï¼šè‰ç¨¿ç¼–译和å‘布都必须拒ç»ç¼ºå¤±æˆ– `placeholder` atlas / card assets,ä¸å…许åŽç«¯ facade 或 SpacetimeDB åˆæˆä¸´æ—¶ç´ æï¼›å½“å‰å•å…³æ­£å¼ runtime 终æ€äº‹ä»¶ä½¿ç”¨ `run-finished`ã€`level-failed`ï¼Œå¹¶å†™å…¥åŒ…å« `status`ã€`level`ã€`clears`ã€`clearDelta`ã€`elapsedMs` 的结果 JSON。 +- 补充约æŸï¼šè‰ç¨¿ç¼–译和å‘布都必须拒ç»ç¼ºå¤±æˆ– `placeholder` atlas / card assets,ä¸å…许åŽç«¯ facade 或 SpacetimeDB åˆæˆä¸´æ—¶ç´ æï¼›æ­£å¼ runtime 终æ€äº‹ä»¶ä½¿ç”¨ `run-finished`ã€`level-failed`ï¼Œå¹¶å†™å…¥åŒ…å« `status`ã€`level`ã€`clears`ã€`clearDelta`ã€`elapsedMs` 的结果 JSON。 - 补充约æŸï¼šæ‹¼æ¶ˆæ¶ˆç»“果页è‰ç¨¿è¯•玩使用å‰ç«¯æœ¬åœ° `runtimeMode=draft` snapshot,ä¸è°ƒç”¨ `/api/runtime/puzzle-clear/runs`,ä¸å†™æ­£å¼ run ç»Ÿè®¡ï¼›å…¬å¼€è¯¦æƒ…å’ŒæŽ¨èæµæ­£å¼è¿è¡Œç»§ç»­èµ°åŽç«¯ `/api/runtime/puzzle-clear/*`,客户端需è¦åŒºåˆ†åˆ›ä½œè¯¦æƒ… `/api/creation/puzzle-clear/works/{profileId}` 与公开è¿è¡Œæ€è¯¦æƒ… `/api/runtime/puzzle-clear/works/{profileId}`。 - å½±å“范围:`CONTEXT.md`ã€æ‹¼æ¶ˆæ¶ˆ PRD / 技术方案ã€å¹³å°çŽ©æ³•é“¾è·¯æ–‡æ¡£ã€`shared-contracts` / `packages/shared`ã€`api-server`ã€`spacetime-module`ã€`spacetime-client`ã€ä½œå“æž¶ / 广场 / 统一作å“详情 / runtime å‰ç«¯åˆ†æµã€‚ - éªŒè¯æ–¹å¼ï¼šPRD 和技术方案必须覆盖资产槽ä½ã€ç´ æå·¥ä½œè¡¨é£Žé™©ã€åˆ‡ç‰‡éªŒè¯ã€æ¢å¤è¯­ä¹‰ã€API 命å空间和验è¯å‘½ä»¤ï¼›å®žçŽ°ä¾§è‡³å°‘è¿è¡Œ `npm run spacetime:generate`ã€`npm run check:spacetime-schema`ã€`npm run check:spacetime-runtime-access`ã€`npm run check:server-rs-ddd`ã€`npm run typecheck`ã€`npm run check:encoding`ã€ç›¸å…³å‰ç«¯æµ‹è¯•å’Œ `cargo test -p module-puzzle-clear --manifest-path server-rs/Cargo.toml`。 diff --git a/.hermes/shared-memory/pitfalls.md b/.hermes/shared-memory/pitfalls.md index f5fd1ae3..4163c3ac 100644 --- a/.hermes/shared-memory/pitfalls.md +++ b/.hermes/shared-memory/pitfalls.md @@ -120,6 +120,22 @@ - 验è¯ï¼š`npm run test -- src/components/puzzle-clear-runtime/PuzzleClearRuntimeShell.test.tsx`,æµè§ˆå™¨é‡Œç¡®è®¤å±€éƒ¨æ‹¼åˆä¼šé—ªã€å®Œæ•´æ¶ˆé™¤ä¼šæ”¾å¤§æ·¡å‡ºã€è¡¥ç‰Œåœ¨æ·¡å‡ºåŽæ®µæ‰å¼€å§‹æŽ‰è½ã€‚ - å…³è”:`src/components/puzzle-clear-runtime/PuzzleClearRuntimeShell.tsx`ã€`src/index.css`ã€`src/components/puzzle-clear-runtime/PuzzleClearRuntimeShell.test.tsx`。 +## 拼消消补牌下è½å å±‚ä¸èƒ½æ¯”动画æå‰å¸è½½ + +- 现象:å°ç‰Œè¡¥ç‰Œä¸‹è½æ—¶çœ‹èµ·æ¥åªæ˜¯é—ªä¸€ä¸‹å°±åˆ‡åˆ°ç›®æ ‡æ ¼ï¼Œä¸‹æ»‘过程ä¸è¿žè´¯ï¼Œé€”中还会闪出白色底。 +- 原因:补牌å å±‚çš„æ¸…ç†æ—¶é—´åªæŒ‰æ¶ˆé™¤åŠ¨ç”»å›ºå®šæ—¶é•¿è®¡ç®—ï¼Œæ²¡æœ‰è¦†ç›–æ¯å¼ å¡è‡ªå·±çš„下è½å»¶è¿Ÿã€ä¸‹è½è·ç¦»æ—¶é•¿å’Œæ”¶å°¾ç¼“å†²ï¼›åŒæ—¶ä¸‹è½å å±‚曾带 `bg-white` / `border-white` å¤–å£³ï¼ŒåŠ¨ç”»èµ·ç‚¹é€æ˜Žåº¦å’Œæäº®æ»¤é•œä¼šæŠŠå¤–壳闪æˆç™½å—。 +- 处ç†ï¼šä¸‹è½å¡ç‰‡æŒ‰è·ç¦»å†™å…¥ `--puzzle-clear-drop-duration`,清ç†å®šæ—¶å™¨å– `delay + duration + settle buffer` 的最大值;CSS 下è½åŠ¨ç”»ä½¿ç”¨ `translate3d`ã€è½»å¾®è¿‡å†²å’Œå›žå¼¹ï¼Œå å±‚ä¿æŒé€æ˜Žã€æ— è¾¹æ¡†ã€æ— äº®åº¦æå‡ï¼Œåªæ¸²æŸ“真实å¡ç‰‡å›¾ã€‚ +- 验è¯ï¼š`npm run test -- src/components/puzzle-clear-runtime/PuzzleClearRuntimeShell.test.tsx src/index.test.ts`ï¼›æµè§ˆå™¨é‡Œå®Œæ•´æ¶ˆé™¤åŽæ–°è¡¥å…¥å°ç‰Œåº”有连续下滑和轻微è½ä½æ„Ÿï¼Œè¿‡ç¨‹ä¸­ä¸åº”出现白色闪å—。 +- å…³è”:`src/components/puzzle-clear-runtime/PuzzleClearRuntimeShell.tsx`ã€`src/index.css`ã€`src/index.test.ts`ã€`docs/technical/ã€çŽ©æ³•åˆ›ä½œã€‘æ‹¼æ¶ˆæ¶ˆçŽ©æ³•æ¨¡æ¿æŠ€æœ¯æ–¹æ¡ˆ-2026-05-30.md`。 + +## 拼消消替æ¢é£žè¡Œå±‚è¦é£žç›®æ ‡å¡ä¸”ä¸èƒ½å¸¦ç™½åº• + +- 现象:按下å¡ç‰Œã€æ‹–动覆盖å¦ä¸€å¼ å¡å¹¶å®Œæˆæ›¿æ¢æ—¶ï¼Œè¢«æ›¿æ¢çš„å°å¡åœ¨æ–°ä½ç½®å…ˆé—ªä¸€ä¸‹ç™½åº•ï¼Œç„¶åŽæ‰æ˜¾ç¤ºæ­£ç¡®å›¾åƒã€‚ +- 原因:替æ¢é£žè¡Œå±‚如果å¤ç”¨è¢«æ‹–动的æºå¡ï¼Œå¹¶ä¸”容器本身带 `background: white` / 白色边框,就会和拖拽 ghost åŒå‘å åœ¨ç›®æ ‡æ ¼é™„近;真正应该回到æºç©ºä½çš„ç›®æ ‡å¡æ²¡æœ‰è¦†ç›–æºä½ç©ºæ§½ï¼Œè§†è§‰ä¸Šå°±ä¼šéœ²å‡ºç™½å£³æˆ–白底。 +- 处ç†ï¼šæ‹–拽 ghost åªè´Ÿè´£æºå¡è½åˆ°ç›®æ ‡æ ¼ï¼›`swapFlight` 必须使用被覆盖的 `target.card`,从目标格飞回æºç©ºä½ã€‚é£žè¡Œå±‚å®¹å™¨ä¿æŒé€æ˜Žã€æ— è¾¹æ¡†ï¼Œé˜´å½±åªåŠ åœ¨çœŸå®žå¡å›¾ä¸Šã€‚ +- 验è¯ï¼š`npm run test -- src/components/puzzle-clear-runtime/PuzzleClearRuntimeShell.test.tsx src/index.test.ts` 覆盖目标å¡å›¾ç‰‡è¿›å…¥ `puzzle-clear-swap-flight`,并断言 `.puzzle-clear-swap-flight` ä¸å†å‡ºçŽ°ç™½åº•æˆ–ç™½è¾¹ã€‚ +- å…³è”:`src/components/puzzle-clear-runtime/PuzzleClearRuntimeShell.tsx`ã€`src/index.css`ã€`src/components/puzzle-clear-runtime/PuzzleClearRuntimeShell.test.tsx`ã€`src/index.test.ts`。 + ## 首页推è分æµå‚æ•°ä¸èƒ½æ¡ä»¶æ€§è°ƒç”¨ hook - 现象:桌é¢é¦–页或移动首页在 HMRã€æ–­ç‚¹åˆ‡æ¢æˆ–釿–°æ¸²æŸ“åŽç›´æŽ¥æŠ¥ React hook 顺åºé”™è¯¯ï¼Œé¡µé¢åœåœ¨â€œæ­£åœ¨åŠ è½½å†…å®¹â€ã€‚ @@ -1825,6 +1841,22 @@ - 验è¯ï¼šæµè§ˆå™¨æ‹–拽时能看到跟手 ghostã€æºä½ç©ºæ§½ã€è½ç‚¹é£žå…¥å’Œæ•´ç»„拼接层;`src/components/puzzle-clear-runtime/PuzzleClearRuntimeShell.test.tsx` 应覆盖这些行为。 - å…³è”:`src/components/puzzle-clear-runtime/PuzzleClearRuntimeShell.tsx`ã€`src/components/puzzle-clear-runtime/PuzzleClearRuntimeShell.test.tsx`ã€`src/index.css`。 +## 拼消消整组拖拽越界è¦å‰ç«¯å›žå¼¹ï¼Œä¸è¦å‘无效 swap + +- çŽ°è±¡ï¼šæ­£å¼æ‹¼æ¶ˆæ¶ˆ runtime 玩到已有局部拼接组åŽï¼ŒæŠŠæ•´ç»„拖到棋盘边缘外侧,å‰ç«¯å¼¹å‡ºâ€œpuzzle-clear åæ ‡æ— æ•ˆâ€ï¼ŒåŽç«¯æ—¥å¿—里对应 `/api/runtime/puzzle-clear/runs/{runId}/swap`。 +- 原因:åŽç«¯ `move_locked_group` 会按整组平移åŽçš„æ¯ä¸ªæ ¼å­åšè¾¹ç•Œæ ¡éªŒï¼›å‰ç«¯æ—©æœŸåªæ ¡éªŒæ¾æ‰‹ç›®æ ‡æ ¼å­˜åœ¨ï¼Œæ²¡æœ‰æ ¡éªŒæ•´ç»„çš„å…¶å®ƒæ ¼å­æ˜¯å¦ä¼šè¶Šç•Œï¼Œå› æ­¤ä¼šæŠŠåŽç«¯å¿…ç„¶æ‹’ç»çš„动作å‘出去。 +- 处ç†ï¼šæ‹–动带 `lockedGroupId` 的拼接组时,å‰ç«¯å…ˆç”¨å½“å‰ board snapshot 计算 `rowDelta/colDelta`,确认组内所有格å­å¹³ç§»åŽä»åœ¨æ£‹ç›˜å†…ï¼›è¶Šç•Œæ—¶åªæ’­æ”¾å›žå¼¹ï¼Œä¸è°ƒç”¨ `onSwapCards`。åŽç«¯ç»§ç»­ä¿ç•™æœ€ç»ˆè§„则è£å†³ï¼›`puzzle-clear åæ ‡æ— æ•ˆ`ã€ç¼ºå¡ã€éž playingã€è¶…时这类玩法动作校验错误应映射为 400,é¿å…误判为网关问题。 +- 验è¯ï¼š`npm run test -- src/components/puzzle-clear-runtime/PuzzleClearRuntimeShell.test.tsx` 覆盖“已拼接局部拖到会越界的ä½ç½®æ—¶åªå›žå¼¹ä¸æäº¤åŽç«¯åŠ¨ä½œâ€ï¼›`cargo check -p api-server --manifest-path server-rs/Cargo.toml` 通过。 +- å…³è”:`src/components/puzzle-clear-runtime/PuzzleClearRuntimeShell.tsx`ã€`src/components/puzzle-clear-runtime/PuzzleClearRuntimeShell.test.tsx`ã€`server-rs/crates/module-puzzle-clear/src/application.rs`ã€`server-rs/crates/api-server/src/puzzle_clear.rs`。 + +## 拼消消多行局部拼åˆè¦æŒ‰è¿žé€šå—整体é”定和拖起 + +- 现象:`2x2` 或 `2x3` å›¾æ¡ˆæ‹¼æˆ L å½¢ã€è½¬è§’或多行局部åŽï¼ŒçŽ©å®¶æ‹–èµ·å·²æ‹¼åˆç»„æ—¶ä¼šçœ‹åˆ°æœ‰ä¸€å¼ å¡æ²¡æœ‰è¢«æ‹¿èµ·ï¼Œä»ç•™åœ¨æ ¼å­å±‚里,并被格å­ç™½åº•或é”定组覆盖层压ä½ã€‚ +- 原因:åŠé”å®šå±€éƒ¨æ‹¼æŽ¥ç»„å¦‚æžœåªæŒ‰ `partY/partX` 排åºåŽçš„çº¿æ€§ç›¸é‚»å…³ç³»åˆ¤æ–­ï¼Œä¼šæ¼æŽ‰ 2D å½¢çŠ¶é‡Œçš„åˆ†å‰ / 转角连通å—ï¼›å‰ç«¯å¦‚æžœåªéšè—起点和线性邻格,也会让未写入åŒä¸€ `lockedGroupId` çš„å¡ç»§ç»­æŒ‰æ™®é€šæ ¼å­å±‚渲染。 +- 处ç†ï¼šæœ¬åœ°è‰ç¨¿ runtime å’Œ Rust `module-puzzle-clear` éƒ½è¦æŒ‰åŒç»„å¡ç‰Œâ€œç´ æå标相邻且棋盘格相邻â€çš„连通å—识别åŠé”定组,`1x3`ã€`2x2`ã€`2x3` 的局部拼åˆå¿…须整体写入åŒä¸€ä¸ª `lockedGroupId`。å‰ç«¯æ‹–èµ·é”定组时,活动组覆盖层ä¸å†æ¸²æŸ“ï¼›ç»„å†…æ‰€æœ‰æºæ ¼æ ‡è®°ä¸º `drag-group-source`,格å­å±‚䏿¸²æŸ“å¡å›¾ï¼Œåªä¿ç•™é€æ˜Žç©ºæ§½ï¼Œå¡å›¾åªå‡ºçŽ°åœ¨ `document.body` portal 里的 drag ghost。 +- 验è¯ï¼š`npm run test -- src/components/puzzle-clear-runtime/PuzzleClearRuntimeShell.test.tsx src/services/puzzle-clear/puzzleClearLocalRuntime.test.ts`ã€`cargo test -p module-puzzle-clear --manifest-path server-rs/Cargo.toml`。 +- å…³è”:`src/components/puzzle-clear-runtime/PuzzleClearRuntimeShell.tsx`ã€`src/services/puzzle-clear/puzzleClearLocalRuntime.ts`ã€`server-rs/crates/module-puzzle-clear/src/application.rs`ã€`src/index.css`。 + ## 拼消消空格ä½å¿…é¡»å…许è½ä½ï¼Œä¸èƒ½å½“æˆä¸å¯äº¤äº’死格 - 现象:è¿è¡Œåˆ°æŸä¸€å…³åŽï¼Œæ£‹ç›˜é‡Œå‡ºçŽ°ç©ºæ ¼ä½ï¼Œç”¨æˆ·èƒ½çœ‹è§ç©ºæ´žä½†æ‹–ä¸è¿›åŽ»ï¼Œä¹Ÿç‚¹ä¸åŠ¨ã€‚ diff --git a/docs/prd/ã€çŽ©æ³•åˆ›ä½œã€‘æ‹¼æ¶ˆæ¶ˆçŽ©æ³•æ¨¡æ¿PRD-2026-05-30.md b/docs/prd/ã€çŽ©æ³•åˆ›ä½œã€‘æ‹¼æ¶ˆæ¶ˆçŽ©æ³•æ¨¡æ¿PRD-2026-05-30.md index 6adfb9a7..8c6e1a41 100644 --- a/docs/prd/ã€çŽ©æ³•åˆ›ä½œã€‘æ‹¼æ¶ˆæ¶ˆçŽ©æ³•æ¨¡æ¿PRD-2026-05-30.md +++ b/docs/prd/ã€çŽ©æ³•åˆ›ä½œã€‘æ‹¼æ¶ˆæ¶ˆçŽ©æ³•æ¨¡æ¿PRD-2026-05-30.md @@ -17,8 +17,8 @@ - å·¥ä½œå°æ¨¡å¼ï¼šè¡¨å• / 图片输入创作工作å°ã€‚ - åˆ›ä½œé“¾è·¯ï¼šå…¥å£ -> å·¥ä½œå° -> 生æˆé¡µ -> 结果页 -> 试玩 -> å‘布 -> è¿è¡Œæ€ã€‚ - å•图资产槽ä½ï¼š - - `board-background` / `ui-background` / `中央场地底图` / `boardBackgroundPrompt` 优先ã€ç©ºå€¼æ—¶å›žé€€ `themePrompt`,并支æŒç”¨æˆ·ä¸Šä¼ å›¾ / 写回 `draft.boardBackgroundAsset`ã€`draft.boardBackgroundPrompt`ã€`work.boardBackgroundAsset` 与 `work.boardBackgroundPrompt` / å…许历å²å›¾ / å…许 AI é‡ç»˜ã€‚ - - ä¸­å¤®åœºåœ°åº•å›¾çš„å­—æ®µåæ²¿ç”¨å¹³å°è¡¨é¢å£å¾„ï¼Œå®žé™…ä½œç”¨æ˜¯çŽ©å®¶é€æ­¥æ¶ˆé™¤æ¸…ç©ºä¸­å¤®æ£‹ç›˜åŽæ…¢æ…¢çœ‹åˆ°çš„主题目标图;AI 生æˆå°ºå¯¸å¿…须与中央棋盘一致,使用 1:1 正方形画é¢ã€‚prompt 必须强绑定主题ã€ç”»é¢ç²¾è‡´ã€å¼ºè¡¨çŽ°åŠ›å¹¶ä¸€çœ¼ä½“çŽ°ä¸»é¢˜ï¼Œå¸¦æ¥æŽ¢ç´¢ã€æ­å¼€å…¨è²Œå’Œè¿½æ±‚目标完æˆçš„æ„Ÿå—ï¼›ä¸å¾—ç»§ç»­è¦æ±‚“画é¢å¹²å‡€â€æˆ–“适åˆä½œä¸ºå¡ç‰Œæ£‹ç›˜åº•图â€ã€‚ + - `board-background` / `ui-background` / `背景图` / `boardBackgroundPrompt` 优先ã€ç©ºå€¼æ—¶å›žé€€ `themePrompt`,并支æŒç”¨æˆ·ä¸Šä¼ å›¾ç‰‡æˆ–å¡«å†™ç”»é¢æè¿°ç”Ÿå›¾ / 写回 `draft.boardBackgroundAsset`ã€`draft.boardBackgroundPrompt`ã€`work.boardBackgroundAsset` 与 `work.boardBackgroundPrompt` / å…许历å²å›¾ / å…许 AI é‡ç»˜ã€‚ + - èƒŒæ™¯å›¾å®žé™…ä½œç”¨æ˜¯çŽ©å®¶é€æ­¥æ¶ˆé™¤æ¸…ç©ºä¸­å¤®æ£‹ç›˜åŽæ…¢æ…¢çœ‹åˆ°çš„主题目标图;AI 生æˆå°ºå¯¸å¿…须与中央棋盘一致,使用 1:1 正方形画é¢ã€‚prompt 必须强绑定主题ã€ç”»é¢ç²¾è‡´ã€å¼ºè¡¨çŽ°åŠ›å¹¶ä¸€çœ¼ä½“çŽ°ä¸»é¢˜ï¼Œå¸¦æ¥æŽ¢ç´¢ã€æ­å¼€å…¨è²Œå’Œè¿½æ±‚目标完æˆçš„æ„Ÿå—ï¼›ä¸å¾—ç»§ç»­è¦æ±‚“画é¢å¹²å‡€â€æˆ–“适åˆä½œä¸ºå¡ç‰Œæ£‹ç›˜åº•图â€ã€‚ - ç³»åˆ—ç´ ææ§½ä½ï¼š - `batchId=puzzle-clear-pattern-atlas-v1`。 - `sheetSpec`:4 å¼ ç´ æå·¥ä½œè¡¨ï¼Œæ¯å¼  `1024x1536` 竖版,åŽå°æŒ‰ `4 列 x 6 行` è£åˆ‡ï¼Œæ¯ä¸ª 1x1 å•元为 `256x256`ï¼›æœåŠ¡ç«¯å†æŠŠåˆ‡ç‰‡åˆæˆä¸€å¼  `10x10 / 2560x2560` 最终 atlas。å¤åˆå›¾æ¡ˆç»„总数为 `35`ï¼Œå½¢çŠ¶é…æ¯” `1x2=23`ã€`1x3=5`ã€`2x2=4`ã€`2x3=3`,总计 `95` 个 1x1 å¡ç‰Œåˆ‡ç‰‡ã€‚ @@ -36,12 +36,12 @@ | 字段 | 契约字段 | 默认值 | 校验 | è½åº“ | | --- | --- | --- | --- | --- | -| ä½œå“æ ‡é¢˜ | `workTitle` | 空 | 必填,1-30 å­— | session draft / work profile | -| 简介 | `workDescription` | 空 | 0-120 å­— | session draft / work profile | -| ä¸»é¢˜è¯ | `themePrompt` | 空 | 必填,1-80 å­— | ç”Ÿæˆ prompt 与è‰ç¨¿ | -| åœºåœ°åº•å›¾ä¸»é¢˜è¯ | `boardBackgroundPrompt` | 空 | 0-80 字;为空时底图生æˆå›žé€€ `themePrompt` | session draft / work profile / ä¸»é¢˜ç›®æ ‡å›¾ç”Ÿæˆ prompt | -| 中央场地底图 | `boardBackgroundAsset` | 空 | 上传或 AI 生æˆè‡³å°‘ä¸€ç§ | å•å›¾èµ„äº§æ§½ä½ | -| AI 生æˆåº•图 | `generateBoardBackground` | `true` | boolean | 生æˆç¼–æŽ’å‚æ•° | +| å¡ç‰Œç´ æä¸»é¢˜ | `themePrompt` | 空 | 必填,1-80 å­— | ç”Ÿæˆ prompt 与è‰ç¨¿ï¼›å·¥ä½œå°å†…部派生è‰ç¨¿å ä½æ ‡é¢˜ï¼Œä¸å‘ç”¨æˆ·å±•ç¤ºä½œå“æ ‡é¢˜è¾“å…¥ | +| ç”»é¢æè¿° | `boardBackgroundPrompt` | 空 | 0-80 字;为空时背景图生æˆå›žé€€ `themePrompt` | session draft / work profile / ä¸»é¢˜ç›®æ ‡å›¾ç”Ÿæˆ prompt | +| 背景图 | `boardBackgroundAsset` | 空 | 上传图片或 AI 生æˆè‡³å°‘ä¸€ç§ | å•å›¾èµ„äº§æ§½ä½ | +| AI 生æˆèƒŒæ™¯å›¾ | `generateBoardBackground` | `true` | boolean | 生æˆç¼–æŽ’å‚æ•° | + +ä½œå“æ ‡é¢˜ `workTitle` 与简介 `workDescription` ä¸å±žäºŽå·¥ä½œå°æ¸¸æˆå†…容é…置;å‘å¸ƒå‰æ£€æŸ¥çŽ¯èŠ‚å¿…é¡»è®©ç”¨æˆ·å¡«å†™ / 修改标题与简介,ä¿å­˜ä¸º `update-work-meta` åŽå†å‘布。工作å°é˜¶æ®µåªå±•示玩法标题ã€å¡ç‰Œç´ æä¸»é¢˜å’ŒèƒŒæ™¯å›¾é…置;背景图å¯ä»¥ä¸Šä¼ å›¾ç‰‡ï¼Œä¹Ÿå¯ä»¥å¡«å†™ç”»é¢æè¿°åŽä½¿ç”¨ AI 生æˆï¼Œä¸å†åœ¨åŒä¸€ç•Œé¢æ··æŽ’å‘布元信æ¯ã€‚ è§„åˆ™å‚æ•°ä¸å¼€æ”¾åˆ›ä½œè€…编辑:棋盘尺寸ã€å€’è®¡æ—¶ã€æ¶ˆé™¤æ¬¡æ•°ã€å½¢çŠ¶è§£é”ã€é˜²æ­»å±€å‘牌和åŠé”定规则固定。 @@ -49,20 +49,24 @@ | å…³å¡ | 棋盘 | 目标消除 | 倒计时 | è§£é”形状 | | --- | --- | --- | --- | --- | -| 1 | 6x6 | 35 | 10 分钟 | 1x2ã€1x3ã€2x2ã€2x3 | +| 1 | 6x6 | 15 | 5 分钟 | 1x2 | +| 2 | 6x6 | 20 | 5 分钟 | 1x2ã€1x3 | +| 3 | 6x6 | 30 | 7 分钟 | 1x2ã€1x3ã€2x2 | +| 4 | 6x6 | 35 | 10 分钟 | 1x2ã€1x3ã€2x2ã€2x3 | -- 开局æ¯ä¸ªå°æ ¼å­ä»ŽèƒŒé¢ç¿»å‘æ­£é¢ã€‚ +- å¼€å±€åªæ”¾å…¥æœ¬å…³ç›®æ ‡æ¶ˆé™¤æ•°å¯¹åº”的全部å¡ç‰Œï¼›æ£‹ç›˜æ”¾ä¸ä¸‹çš„牌进入顶部准备区,牌ä¸è¶³æ£‹ç›˜æ ¼æ•°æ—¶ç©ºæ ¼ä¿ç•™ã€‚ +- æ¯ä¸ªæœ‰å¡ç‰Œçš„å°æ ¼å­ä»ŽèƒŒé¢ç¿»å‘æ­£é¢ã€‚ - 坿¶ˆé™¤å›¾ç”±æ¨ªå‘或纵å‘å¤åˆå›¾æ¡ˆç»„组æˆï¼Œæœ€å°æ¶ˆé™¤å•ä½ä¸ºä¸¤å¼ å›¾æ‹¼æŽ¥ã€‚ - 完æˆä¸€ä¸ªå¤åˆå›¾æ¡ˆç»„åŽï¼Œè¯¥ç»„所有 1x1 å¡ç‰Œç¢Žç‰‡æ¶ˆé™¤ã€‚ -- 消除åŽç©ºä½æŒ‰åˆ—由顶部å¡ç‰Œå‡†å¤‡åŒºä¸‹è½è¡¥é½ã€‚ +- 消除åŽç©ºä½æŒ‰åˆ—由顶部å¡ç‰Œå‡†å¤‡åŒºä¸‹è½è¡¥é½ï¼›è‹¥é¡¶éƒ¨æ²¡æœ‰æ–°ç‰Œï¼Œåˆ™ç©ºæ ¼ç•™åœ¨åœºä¸Šå¹¶éœ²å‡ºèƒŒæ™¯å›¾ã€‚ - æ¯æ¬¡è¡¥ç‰Œè‡³å°‘ä¿è¯æŽ‰è½å¡ä¸­æœ‰ä¸€å¼ å¯ä»¥ä¸Žåœºä¸Šå‰©ä½™æŸå¼ å¡æ‹¼æŽ¥ï¼Œé˜²æ­¢æ­»å±€ã€‚ - éž 2 格消除时,若场上已有局部完æˆçš„åŠé”定拼接组,补牌ä¸å¾—ç ´å它。 - åŠé”å®šæ‹¼æŽ¥ç»„å¯æ•´ä½“æ‹–åŠ¨ï¼›çŽ©å®¶ç”¨å¤–éƒ¨å•æ ¼æ’žå…¥ç»„å†…æŸæ ¼æ—¶ï¼Œåªäº¤æ¢è¯¥æ ¼ï¼Œç»„其余部分ä¿ç•™ï¼Œç»„状æ€é€€å›žåŠå®Œæˆã€‚ -- è¶…æ—¶åªåˆ¤å½“å‰å…³å¤±è´¥ï¼Œå¯é‡è¯•当å‰å…³ï¼›å®Œæˆ 35 æ¬¡ç›®æ ‡å¹¶æ¸…ç©ºå½“å‰æ£‹ç›˜åŽæ•´å±€å®Œæˆã€‚ +- è¶…æ—¶åªåˆ¤å½“å‰å…³å¤±è´¥ï¼Œå¯é‡è¯•当å‰å…³ï¼›èƒœåˆ©æ¡ä»¶æ°¸è¿œæ˜¯æ¶ˆé™¤å®Œæœ¬å…³å…¨éƒ¨å¡ç‰Œï¼Œè¾¾åˆ°ç›®æ ‡æ¶ˆé™¤æ•°ä¸”棋盘与顶部准备区都没有剩余å¡ç‰ŒåŽè¿›å…¥ä¸‹ä¸€å…³ï¼Œå®Œæˆç¬¬ 4 关全部å¡ç‰ŒåŽæ•´å±€å®Œæˆã€‚ ## 结果页 -结果页展示:素æ atlasã€ä¸­å¤®åœºåœ°åº•图ã€å‘布状æ€ã€è¯•玩入å£å’Œå¤±è´¥é‡è¯•。结果页ä¸å†™åŠŸèƒ½è¯´æ˜Žç±»æ–‡æ¡ˆï¼Œä¸å¼€æ”¾è§„åˆ™ç¼–è¾‘å™¨ï¼Œä¸æ–°å¢žæŽ’行榜é…置。 +结果页展示:素æ atlasã€èƒŒæ™¯å›¾ã€å‘布状æ€ã€è¯•玩入å£å’Œå¤±è´¥é‡è¯•。点击å‘布时弹出å‘å¸ƒå‰æ£€æŸ¥é¢æ¿ï¼Œæ”¶é›†ä½œå“标题和简介并ä¿å­˜ä½œå“ä¿¡æ¯åŽå†å‘布。结果页ä¸å†™åŠŸèƒ½è¯´æ˜Žç±»æ–‡æ¡ˆï¼Œä¸å¼€æ”¾è§„åˆ™ç¼–è¾‘å™¨ï¼Œä¸æ–°å¢žæŽ’行榜é…置。 ## 统计 diff --git a/docs/technical/ã€çŽ©æ³•åˆ›ä½œã€‘æ‹¼æ¶ˆæ¶ˆçŽ©æ³•æ¨¡æ¿æŠ€æœ¯æ–¹æ¡ˆ-2026-05-30.md b/docs/technical/ã€çŽ©æ³•åˆ›ä½œã€‘æ‹¼æ¶ˆæ¶ˆçŽ©æ³•æ¨¡æ¿æŠ€æœ¯æ–¹æ¡ˆ-2026-05-30.md index b66efa99..6bdb65ed 100644 --- a/docs/technical/ã€çŽ©æ³•åˆ›ä½œã€‘æ‹¼æ¶ˆæ¶ˆçŽ©æ³•æ¨¡æ¿æŠ€æœ¯æ–¹æ¡ˆ-2026-05-30.md +++ b/docs/technical/ã€çŽ©æ³•åˆ›ä½œã€‘æ‹¼æ¶ˆæ¶ˆçŽ©æ³•æ¨¡æ¿æŠ€æœ¯æ–¹æ¡ˆ-2026-05-30.md @@ -28,17 +28,19 @@ | 形状 | æ•°é‡ | å•组å•元数 | è§£é” | | --- | ---: | ---: | --- | | 1x2 | 23 | 2 | 第 1 å…³ | -| 1x3 | 5 | 3 | 第 1 å…³ | -| 2x2 | 4 | 4 | 第 1 å…³ | -| 2x3 | 3 | 6 | 第 1 å…³ | +| 1x3 | 5 | 3 | 第 2 å…³ | +| 2x2 | 4 | 4 | 第 3 å…³ | +| 2x3 | 3 | 6 | 第 4 å…³ | æµç¨‹ï¼š ```text -ä¸»é¢˜è¯ / åœºåœ°åº•å›¾ä¸»é¢˜è¯ / 用户底图 -> 4 å¼  sheet åæ ‡è§„划 -> gpt-image-2 生æˆç´ æå·¥ä½œè¡¨ -> 按 4x6 è£åˆ‡ 1x1 -> åˆæˆæœ€ç»ˆ atlas -> atlas 与å¡ç‰Œåˆ‡ç‰‡æŒä¹…化 -> OSS / asset_object / bind -> session draft 回写 +å¡ç‰Œç´ æä¸»é¢˜ / èƒŒæ™¯å›¾ç”»é¢æè¿° / 用户背景图 -> 4 å¼  sheet åæ ‡è§„划 -> gpt-image-2 生æˆç´ æå·¥ä½œè¡¨ -> 按 4x6 è£åˆ‡ 1x1 -> åˆæˆæœ€ç»ˆ atlas -> atlas 与å¡ç‰Œåˆ‡ç‰‡æŒä¹…化 -> OSS / asset_object / bind -> session draft 回写 ``` -中央场地底图的 prompt æ¥æºå›ºå®šä¸ºï¼šè‹¥ç”¨æˆ·å¡«å†™ `boardBackgroundPrompt`,AI 生æˆåº•图åªè¯»å–该字段;若该字段为空,æ‰å›žé€€è¯»å– `themePrompt`。用户直接上传底图资产时ä¸å†ç”¨ä¸»é¢˜è¯é‡å†™è¯¥èµ„äº§ï¼Œåªæ‰§è¡Œå¹³å°èµ„产æŒä¹…化与æ¢ç­¾ã€‚中央场地底图在è¿è¡Œæ€ä¸æ˜¯æ™®é€šæ£‹ç›˜è¡¬åº•ï¼Œè€Œæ˜¯çŽ©å®¶é€æ¸æ¶ˆé™¤å¡ç‰ŒåŽéœ²å‡ºçš„主题目标图;生æˆè¯·æ±‚使用与中央棋盘一致的 1:1 正方形尺寸,prompt å¿…é¡»å¼ºè°ƒæŽ¢ç´¢ã€æ­å¼€å…¨è²Œã€è¿½æ±‚完æˆç›®æ ‡ã€ç²¾è‡´ä¸»é¢˜ä¸»è§†è§‰å’Œå¼ºä¸»é¢˜è¡¨çŽ°ï¼Œä¸å†™â€œç”»é¢å¹²å‡€â€æˆ–“适åˆä½œä¸ºå¡ç‰Œæ£‹ç›˜åº•图â€ã€‚ +背景图的 prompt æ¥æºå›ºå®šä¸ºï¼šè‹¥ç”¨æˆ·å¡«å†™ `boardBackgroundPrompt`,AI 生æˆèƒŒæ™¯å›¾åªè¯»å–该字段;若该字段为空,æ‰å›žé€€è¯»å– `themePrompt`。用户直接上传背景图资产时ä¸å†ç”¨ä¸»é¢˜è¯é‡å†™è¯¥èµ„äº§ï¼Œåªæ‰§è¡Œå¹³å°èµ„产æŒä¹…化与æ¢ç­¾ã€‚背景图在è¿è¡Œæ€ä¸æ˜¯æ•´é¡µæ°›å›´èƒŒæ™¯ï¼Œè€Œæ˜¯çީ家逿¸æ¶ˆé™¤å¡ç‰ŒåŽéœ²å‡ºçš„主题目标图;生æˆè¯·æ±‚使用与中央棋盘一致的 1:1 正方形尺寸,prompt å¿…é¡»å¼ºè°ƒæŽ¢ç´¢ã€æ­å¼€å…¨è²Œã€è¿½æ±‚完æˆç›®æ ‡ã€ç²¾è‡´ä¸»é¢˜ä¸»è§†è§‰å’Œå¼ºä¸»é¢˜è¡¨çŽ°ï¼Œä¸å†™â€œç”»é¢å¹²å‡€â€æˆ–“适åˆä½œä¸ºå¡ç‰Œæ£‹ç›˜åº•图â€ã€‚ + +å·¥ä½œå° UI 坹齿‹¼å›¾åˆ›ä½œæµç¨‹ï¼šå·¦ä¸Šè§’ä¿ç•™è¿”回按钮,下方展示大标题“拼消消创作â€ï¼›å·¥ä½œå°åªæ”¶é›†æ¸¸æˆå†…容é…置,包括å¡ç‰Œç´ æä¸»é¢˜å’ŒèƒŒæ™¯å›¾é…置。背景图é…置通过åŒä¸€ä¸ªå•图槽ä½è¡¨è¾¾â€œä¸Šä¼ å›¾ç‰‡ / å¡«å†™ç”»é¢æè¿°â€ä¸¤ç§è¾“入方å¼ï¼Œæ§½ä½æ ‡é¢˜æ˜¾ç¤ºä¸ºâ€œèƒŒæ™¯å›¾â€ï¼Œæè¿°è¾“å…¥æ˜¾ç¤ºä¸ºâ€œç”»é¢æè¿°â€ï¼Œå·¥ä½œå°å†…容区ä¸å†å¥—é¢å¤–å¤–å±‚ä¿¡æ¯æ¡†ã€‚ä½œå“æ ‡é¢˜ä¸Žç®€ä»‹å±žäºŽå‘布元信æ¯ï¼Œä¸åœ¨å·¥ä½œå°å‡ºçŽ°ï¼›å‰ç«¯åˆ›å»ºè‰ç¨¿æ—¶å¯æŒ‰ä¸»é¢˜æ´¾ç”Ÿå†…部å ä½æ ‡é¢˜ï¼Œç»“果页点击å‘布时弹出å‘å¸ƒå‰æ£€æŸ¥é¢æ¿ï¼Œå…ˆé€šè¿‡ `update-work-meta` ä¿å­˜æ ‡é¢˜ / 简介,å†è°ƒç”¨å‘布接å£ã€‚ ### ç´ æå·¥ä½œè¡¨é£Žé™©ä¸Žåˆ‡ç‰‡éªŒè¯ @@ -65,13 +67,13 @@ `module-puzzle-clear` 已固定以下规则: -- å…³å¡é…置:å•å…³ `6x6/35`,600 秒。 +- å…³å¡é…置:4 关,棋盘å‡ä¸º `6x6`;第 1 关目标 15ã€300 ç§’ã€ä»… `1x2`;第 2 关目标 20ã€300 ç§’ã€è§£é” `1x2/1x3`;第 3 关目标 30ã€420 ç§’ã€è§£é” `1x2/1x3/2x2`;第 4 关目标 35ã€600 ç§’ã€è§£é”全部 `1x2/1x3/2x2/2x3`。 - å›¾æ¡ˆç»„é…æ¯”:`1x2=23`ã€`1x3=5`ã€`2x2=4`ã€`2x3=3`。 -- å¼€å±€éšæœºé“ºæ»¡å¹¶ä¿è¯è‡³å°‘一步å¯è§£ã€‚ -- 补牌按列é‡åЛ䏋è½ï¼›è¡¥ç‰ŒåŽä»ä¿è¯è‡³å°‘一步å¯è§£ã€‚ +- å¼€å±€åªæ”¾å…¥æœ¬å…³ç›®æ ‡æ¶ˆé™¤æ•°å¯¹åº”的全部å¡ç‰Œï¼›æ£‹ç›˜æ”¾ä¸ä¸‹çš„牌进入顶部准备区,牌ä¸è¶³æ£‹ç›˜æ ¼æ•°æ—¶ç©ºæ ¼ä¿ç•™ï¼›å¼€å±€ä»ä¿è¯è‡³å°‘一步å¯è§£ã€‚ +- 补牌按列é‡åЛ䏋è½ï¼›é¡¶éƒ¨æ²¡æœ‰æ–°ç‰Œæ—¶ç©ºæ ¼ç•™åœ¨åœºä¸Šå¹¶éœ²å‡ºèƒŒæ™¯å›¾ï¼›è¡¥ç‰ŒåŽè‹¥åœºä¸Šä»æœ‰å¡ç‰Œåˆ™ä¿è¯è‡³å°‘一步å¯è§£ã€‚ - 完整图案组消除并清空对应格。 - åŠé”定拼接组åªç”±çŽ©å®¶ä¸»åŠ¨äº¤æ¢ / 撞入打散,补牌ä¸ç ´å。 -- 超时失败åªä½œç”¨äºŽå½“å‰å•关,å¯é‡è¯•ï¼›å®Œæˆ 35 æ¬¡æ¶ˆé™¤ç›®æ ‡å¹¶æ¸…ç©ºæ£‹ç›˜åŽæ•´å±€å®Œæˆã€‚ +- 超时失败åªä½œç”¨äºŽå½“å‰å…³ï¼Œå¯é‡è¯•;胜利æ¡ä»¶æ°¸è¿œæ˜¯æ¶ˆé™¤å®Œæœ¬å…³å…¨éƒ¨å¡ç‰Œï¼Œè¾¾åˆ°ç›®æ ‡æ¶ˆé™¤æ•°ä¸”棋盘与顶部准备区都没有剩余å¡ç‰ŒåŽè¿›å…¥ä¸‹ä¸€å…³ï¼Œç¬¬ 4 关全部å¡ç‰Œæ¶ˆé™¤åŽæ•´å±€å®Œæˆã€‚ ## API 命å空间 @@ -94,7 +96,7 @@ api-server 路由熔断使用 SpacetimeDB 创作入å£é…ç½® `puzzle-clear`, æ­£å¼ `published` run 记录开局ã€å…¨å±€å®Œæˆã€å½“å‰å…³å¤±è´¥ã€è€—时和消除统计。runtime action 返回的终æ€äº‹ä»¶åŒ…括: -- `run-finished`:第 1 关完æˆå¹¶ç»“æŸæ•´å±€ï¼Œç»“æžœ JSON è‡³å°‘åŒ…å« `status`ã€`level`ã€`clears`ã€`clearDelta`ã€`elapsedMs`。 +- `run-finished`:第 4 关完æˆå¹¶ç»“æŸæ•´å±€ï¼Œç»“æžœ JSON è‡³å°‘åŒ…å« `status`ã€`level`ã€`clears`ã€`clearDelta`ã€`elapsedMs`。 - `level-failed`:当å‰å…³è¶…时失败,结果 JSON è‡³å°‘åŒ…å« `status`ã€`level`ã€`clears`ã€`clearDelta`ã€`elapsedMs`。 è‰ç¨¿è¯•çŽ©åªæ¶ˆè´¹åŒä¸€ä»½ snapshot/action 结果åšè¡¨çŽ°ï¼Œä¸å†™æ­£å¼ç»Ÿè®¡ã€‚ @@ -108,7 +110,7 @@ api-server 路由熔断使用 SpacetimeDB 创作入å£é…ç½® `puzzle-clear`, - `puzzle-clear-result` -> `/creation/puzzle-clear/result` - `puzzle-clear-runtime` -> `/runtime/puzzle-clear` -runtime 移动端优先,首å±ç»“构为顶部倒计时 / å•关铭牌ã€é¡¶éƒ¨åˆ—å‡†å¤‡åŒºã€æ£‹ç›˜ã€å¤±è´¥ / 完æˆå¼¹å±‚。棋盘主网格ã€åŠé”定组覆盖层和消除 / 掉è½è¦†ç›–层统一使用 1.5px 格间è·ã€‚动画包括开场翻转ã€å±€éƒ¨æ­£ç¡®æ‹¼åˆé«˜å…‰ã€å®Œæ•´æ¶ˆé™¤æ”¾å¤§æ·¡å‡ºå’Œåˆ—补牌延迟下è½ï¼Œä¸å†æœ‰ä¸‹ä¸€å…³åˆ‡æ¢ã€‚消除和补牌动画åªèƒ½ä½œä¸ºå½“å‰åŽç«¯ snapshot 的表现层覆盖;已有场上å¡ç‰‡å› é‡åŠ›ä¸‹æ²‰åŽçš„æœ€ç»ˆæ ¼ä¸å¾—è¢«æ—§æ¶ˆé™¤åæ ‡æˆ–掉è½è¦†ç›–层éšè—,é¿å…出现“下方空ä½ä½†ä¸Šæ–¹å¡ç‰‡æœªä¸‹è½â€çš„视觉å‡è±¡ï¼›æ–°è¡¥å…¥å¡ç‰Œåº”等完整消除淡出进入尾段åŽå†æ’­æ”¾ä¸‹è½å馈。 +runtime 移动端优先,首å±ç»“构为顶部倒计时 / å…³å¡é“­ç‰Œã€é¡¶éƒ¨åˆ—å‡†å¤‡åŒºã€æ£‹ç›˜ã€å¤±è´¥ / 完æˆå¼¹å±‚。棋盘主网格ã€åŠé”定组覆盖层和消除 / 掉è½è¦†ç›–层统一使用 1.5px 格间è·ã€‚动画包括开场翻转ã€å±€éƒ¨æ­£ç¡®æ‹¼åˆé«˜å…‰ã€å®Œæ•´æ¶ˆé™¤æ”¾å¤§æ·¡å‡ºã€åˆ—补牌延迟下è½å’Œå…³å¡å®ŒæˆåŽçš„下一关切æ¢ã€‚消除和补牌动画åªèƒ½ä½œä¸ºå½“å‰åŽç«¯ snapshot 的表现层覆盖;已有场上å¡ç‰‡å› é‡åŠ›ä¸‹æ²‰åŽçš„æœ€ç»ˆæ ¼ä¸å¾—è¢«æ—§æ¶ˆé™¤åæ ‡æˆ–掉è½è¦†ç›–层éšè—,é¿å…出现“下方空ä½ä½†ä¸Šæ–¹å¡ç‰‡æœªä¸‹è½â€çš„视觉å‡è±¡ï¼›æ–°è¡¥å…¥å¡ç‰Œåº”等完整消除淡出进入尾段åŽå†æ’­æ”¾ä¸‹è½å馈。列补牌下è½çš„过渡层生命周期必须覆盖 `delay + duration + settle buffer`,并按下è½è·ç¦»å»¶é•¿åŠ¨ç”»æ—¶é•¿ï¼Œé¿å…å å±‚在延迟åŽåˆšå‡ºçŽ°å°±è¢«å¸è½½ï¼›ä¸‹è½å å±‚ä¸å¾—带白色背景ã€ç™½è‰²è¾¹æ¡†æˆ–æäº®æ»¤é•œï¼Œå¡ç‰‡å›¾æœ¬èº«è´Ÿè´£è§†è§‰ä¸»ä½“,é¿å…ä¸‹æ»‘æ—¶ç™½é—ªã€‚æ‹–æ‹½è¦†ç›–æ›¿æ¢æ—¶ï¼Œæ‹–动å¡ç”±æ‹–拽 ghost è½åˆ°ç›®æ ‡æ ¼ï¼Œè¢«è¦†ç›–çš„ç›®æ ‡å¡æ‰ä½¿ç”¨æ›¿æ¢é£žè¡Œå±‚回到æºç©ºä½ï¼›æ›¿æ¢é£žè¡Œå±‚åŒæ ·ä¸å¾—带白底ã€ç™½è¾¹æˆ–白色外壳,é¿å…目标å¡åœ¨æ–°ä½ç½®å…ˆé—ªç™½å†æ˜¾ç¤ºã€‚ ## 验è¯è®¡åˆ’ diff --git a/docs/ã€çŽ©æ³•åˆ›ä½œã€‘å¹³å°å…¥å£ä¸ŽçŽ©æ³•é“¾è·¯-2026-05-15.md b/docs/ã€çŽ©æ³•åˆ›ä½œã€‘å¹³å°å…¥å£ä¸ŽçŽ©æ³•é“¾è·¯-2026-05-15.md index 3a6fb116..e0f6b8a2 100644 --- a/docs/ã€çŽ©æ³•åˆ›ä½œã€‘å¹³å°å…¥å£ä¸ŽçŽ©æ³•é“¾è·¯-2026-05-15.md +++ b/docs/ã€çŽ©æ³•åˆ›ä½œã€‘å¹³å°å…¥å£ä¸ŽçŽ©æ³•é“¾è·¯-2026-05-15.md @@ -193,20 +193,20 @@ RPG / 拼图等è¿è¡Œæ€å­˜æ¡£ä»ä»¥ `/api/profile/save-archives` çš„åŽç«¯åˆ— åˆ›ä½œå…¥å£ -> 轻表å•å·¥ä½œå° -> 生æˆè¿‡ç¨‹é¡µ -> 结果页 -> 试玩 -> å‘布 -> 统一作å“详情 -> æ­£å¼è¿è¡Œæ€ ``` -工作å°å­—æ®µå›ºå®šä¸ºä½œå“æ ‡é¢˜ã€ç®€ä»‹ã€ä¸»é¢˜è¯ã€åœºåœ°åº•å›¾ä¸»é¢˜è¯ `boardBackgroundPrompt`ã€ä¸­å¤®åœºåœ°åº•图槽ä½ã€æ˜¯å¦ AI 生æˆåº•图。中央场地底图必须å¤ç”¨ `CreativeImageInputPanel`,支æŒä¸Šä¼ ã€åކå²å›¾å’Œ AI é‡ç»˜ï¼›è‹¥ç”¨æˆ·å¡«å†™ `boardBackgroundPrompt`,AI 生æˆåº•图åªè¯»å–该字段,字段为空时æ‰å›žé€€è¯»å– `themePrompt`;用户上传底图时ä¸å†ç”¨ä¸»é¢˜è¯é‡å†™è¯¥èµ„产。中央场地底图的字段åä¿ç•™å¹³å°å£å¾„ï¼Œä½†å®žé™…è¯­ä¹‰æ˜¯çŽ©å®¶é€æ­¥æ¶ˆé™¤æ¸…空棋盘åŽéœ²å‡ºçš„主题目标图,生æˆå°ºå¯¸å¿…须与中央棋盘一致,按 1:1 正方形出图;prompt 必须强绑定主题ã€ç”»é¢ç²¾è‡´ã€å¼ºè¡¨çŽ°åŠ›å¹¶ä¸€çœ¼ä½“çŽ°ä¸»é¢˜ï¼Œä¸å†è¦æ±‚“画é¢å¹²å‡€â€æˆ–“适åˆä½œä¸ºå¡ç‰Œæ£‹ç›˜åº•图â€ã€‚è¿è¡Œæ€å¿…é¡»æŠŠä¸­å¤®åœºåœ°åº•å›¾ä½œä¸ºæ£‹ç›˜å†…éƒ¨é™æ€åº•图使用,ä¸èƒ½é™çº§æˆæ•´é¡µæ°›å›´èƒŒæ™¯ï¼›å¡ç‰Œæ¶ˆé™¤åŽäº§ç”Ÿçš„空ä½å’Œæ‹–拽æºä½åº”露出该棋盘底图。å¡é¢èƒŒé¢èƒŒæ™¯ v1 使用默认å ä½å›¾ï¼Œä¸ä½œä¸ºåˆ›ä½œè€…é…ç½®é¡¹ã€‚è§„åˆ™å‚æ•°ä¸å¼€æ”¾ç¼–辑:å•å…³ `6x6`ã€æ¯å±€ 10 分钟ã€35 次目标消除ã€å½¢çŠ¶è§£é”ã€é˜²æ­»å±€å‘牌和åŠé”定规则å‡ç”±åŽç«¯è§„则集固定。 +工作å°å­—段固定为å¡ç‰Œç´ æä¸»é¢˜ `themePrompt`ã€èƒŒæ™¯å›¾ç”»é¢æè¿° `boardBackgroundPrompt`ã€èƒŒæ™¯å›¾æ§½ä½å’Œæ˜¯å¦ AI 生æˆèƒŒæ™¯å›¾ï¼›ä½œå“标题与简介ä¸åœ¨å·¥ä½œå°å¡«å†™ï¼Œå‘å¸ƒå‰æ£€æŸ¥çŽ¯èŠ‚ä¿å­˜ä¸ºä½œå“ä¿¡æ¯åŽå†å‘布。背景图必须å¤ç”¨ `CreativeImageInputPanel`,支æŒä¸Šä¼ ã€åކå²å›¾å’Œ AI é‡ç»˜ï¼›å·¥ä½œå°ç•Œé¢ç”¨åŒä¸€æ§½ä½è¡¨è¾¾â€œä¸Šä¼ å›¾ç‰‡ / å¡«å†™ç”»é¢æè¿°â€ä¸¤ç§è¾“入方å¼ï¼Œæ§½ä½æ ‡é¢˜æ˜¾ç¤ºä¸ºâ€œèƒŒæ™¯å›¾â€ï¼Œæè¿°è¾“å…¥æ˜¾ç¤ºä¸ºâ€œç”»é¢æè¿°â€ï¼Œå†…容区ä¸å†å¥—é¢å¤–å¤–å±‚ä¿¡æ¯æ¡†ã€‚若用户填写 `boardBackgroundPrompt`,AI 生æˆèƒŒæ™¯å›¾åªè¯»å–该字段,字段为空时æ‰å›žé€€è¯»å– `themePrompt`;用户上传背景图时ä¸å†ç”¨ä¸»é¢˜è¯é‡å†™è¯¥èµ„äº§ã€‚èƒŒæ™¯å›¾çš„å®žé™…è¯­ä¹‰æ˜¯çŽ©å®¶é€æ­¥æ¶ˆé™¤æ¸…空棋盘åŽéœ²å‡ºçš„主题目标图,生æˆå°ºå¯¸å¿…须与中央棋盘一致,按 1:1 正方形出图;prompt 必须强绑定主题ã€ç”»é¢ç²¾è‡´ã€å¼ºè¡¨çŽ°åŠ›å¹¶ä¸€çœ¼ä½“çŽ°ä¸»é¢˜ï¼Œä¸å†è¦æ±‚“画é¢å¹²å‡€â€æˆ–“适åˆä½œä¸ºå¡ç‰Œæ£‹ç›˜åº•图â€ã€‚è¿è¡Œæ€å¿…é¡»æŠŠèƒŒæ™¯å›¾ä½œä¸ºæ£‹ç›˜å†…éƒ¨é™æ€åº•图使用,ä¸èƒ½é™çº§æˆæ•´é¡µæ°›å›´èƒŒæ™¯ï¼›å¡ç‰Œæ¶ˆé™¤åŽäº§ç”Ÿçš„空ä½å’Œæ‹–拽æºä½åº”露出该棋盘底图。å¡é¢èƒŒé¢èƒŒæ™¯ v1 使用默认å ä½å›¾ï¼Œä¸ä½œä¸ºåˆ›ä½œè€…é…ç½®é¡¹ã€‚è§„åˆ™å‚æ•°ä¸å¼€æ”¾ç¼–辑:4 关棋盘å‡ä¸º `6x6`;第 1 关目标 15ã€5 分钟ã€ä»… `1x2`,第 2 关目标 20ã€5 分钟ã€è§£é” `1x2/1x3`,第 3 关目标 30ã€7 分钟ã€è§£é” `1x2/1x3/2x2`,第 4 关目标 35ã€10 分钟ã€è§£é” `1x2/1x3/2x2/2x3`;防死局å‘牌和åŠé”定规则å‡ç”±åŽç«¯è§„则集固定。 -ç´ æç”Ÿæˆä½¿ç”¨æ‹¼æ¶ˆæ¶ˆä¸“用编排,但必须å¤ç”¨ `platform-image`ã€VectorEngine `gpt-image-2`ã€OSSã€`asset_object`ã€æ¢ç­¾å’Œå¤±è´¥å®¡è®¡ã€‚ç´ æç›®æ ‡æ˜¯ 4 å¼  `1024x1536` 竖版工作表,æ¯å¼ åŽå°æŒ‰ `4 列 x 6 行` è£åˆ‡ï¼Œæ¯æ ¼ `256x256`ï¼›æœåŠ¡ç«¯ä»Žå·¥ä½œè¡¨åˆ‡å‡ºæ€»è®¡ 95 个 1x1 å¡ç‰Œç¢Žç‰‡ï¼Œå†åˆæˆä¸€å¼  `10x10 / 2560x2560` 最终 atlas。å¤åˆå›¾æ¡ˆç»„总数固定为 35ï¼Œå½¢çŠ¶é…æ¯”固定为 `1x2=23`ã€`1x3=5`ã€`2x2=4`ã€`2x3=3`。æœåŠ¡ç«¯å…ˆé¢„æŽ’æ¯ä¸ªå¤åˆå›¾æ¡ˆç»„çš„ sheet å¸ƒå±€ã€æœ€ç»ˆ atlas åæ ‡å’Œå½¢çŠ¶ï¼Œå†æŒ‰åæ ‡åˆ‡æˆ 1x1 å¡ç‰Œç¢Žç‰‡ä½œä¸ºè¿è¡Œæ€ç´ æï¼›sheet 生图 prompt åªèƒ½è¦æ±‚å¤åˆå›¾æ¡ˆç»„坿Œ‰åŽå° 4x6 å‡ç­‰åˆ‡æˆ 1x1 方形å°ä»½ï¼Œä¸èƒ½è®©æ¨¡åž‹åœ¨å°å›¾æ¡ˆä¸Šç»˜åˆ¶åˆ‡åˆ†çº¿ã€è¾¹æ¡†ã€ç½‘格线ã€ç¼–å·æˆ–è£åˆ‡å‚考线。当å‰åªæœ‰å•关,åŒå…³å†…å¤åˆå›¾æ¡ˆä¸é‡å¤ã€‚è‰ç¨¿ç¼–译和å‘布都必须使用 api-server å·²æŒä¹…化的真实 atlas / card assets,拒ç»ç¼ºå¤±ã€ç©ºå¯¹è±¡é”®æˆ– `placeholder` å ä½ç´ æï¼Œä¸å…许 `spacetime-client` 或 SpacetimeDB ä¾§åˆæˆä¸´æ—¶ç´ æç»•过平å°å›¾ç‰‡åº•座。 +ç´ æç”Ÿæˆä½¿ç”¨æ‹¼æ¶ˆæ¶ˆä¸“用编排,但必须å¤ç”¨ `platform-image`ã€VectorEngine `gpt-image-2`ã€OSSã€`asset_object`ã€æ¢ç­¾å’Œå¤±è´¥å®¡è®¡ã€‚ç´ æç›®æ ‡æ˜¯ 4 å¼  `1024x1536` 竖版工作表,æ¯å¼ åŽå°æŒ‰ `4 列 x 6 行` è£åˆ‡ï¼Œæ¯æ ¼ `256x256`ï¼›æœåŠ¡ç«¯ä»Žå·¥ä½œè¡¨åˆ‡å‡ºæ€»è®¡ 95 个 1x1 å¡ç‰Œç¢Žç‰‡ï¼Œå†åˆæˆä¸€å¼  `10x10 / 2560x2560` 最终 atlas。å¤åˆå›¾æ¡ˆç»„总数固定为 35ï¼Œå½¢çŠ¶é…æ¯”固定为 `1x2=23`ã€`1x3=5`ã€`2x2=4`ã€`2x3=3`。æœåŠ¡ç«¯å…ˆé¢„æŽ’æ¯ä¸ªå¤åˆå›¾æ¡ˆç»„çš„ sheet å¸ƒå±€ã€æœ€ç»ˆ atlas åæ ‡å’Œå½¢çŠ¶ï¼Œå†æŒ‰åæ ‡åˆ‡æˆ 1x1 å¡ç‰Œç¢Žç‰‡ä½œä¸ºè¿è¡Œæ€ç´ æï¼›sheet 生图 prompt åªèƒ½è¦æ±‚å¤åˆå›¾æ¡ˆç»„坿Œ‰åŽå° 4x6 å‡ç­‰åˆ‡æˆ 1x1 方形å°ä»½ï¼Œä¸èƒ½è®©æ¨¡åž‹åœ¨å°å›¾æ¡ˆä¸Šç»˜åˆ¶åˆ‡åˆ†çº¿ã€è¾¹æ¡†ã€ç½‘格线ã€ç¼–å·æˆ–è£åˆ‡å‚考线。4 å…³å¤ç”¨åŒä¸€å¥— atlas,根æ®å…³å¡è§£é”形状和目标数选择å¯ç”¨å›¾æ¡ˆç»„ï¼›åŒå…³å†…å¤åˆå›¾æ¡ˆä¸é‡å¤ã€‚è‰ç¨¿ç¼–译和å‘布都必须使用 api-server å·²æŒä¹…化的真实 atlas / card assets,拒ç»ç¼ºå¤±ã€ç©ºå¯¹è±¡é”®æˆ– `placeholder` å ä½ç´ æï¼Œä¸å…许 `spacetime-client` 或 SpacetimeDB ä¾§åˆæˆä¸´æ—¶ç´ æç»•过平å°å›¾ç‰‡åº•座。 è¿è¡Œæ€è§„则: -1. å•关固定为 `6x6 / 35次消除`。 -2. æ¯å±€å›ºå®š 10 分钟;超时åªåˆ¤å½“å‰å…³å¤±è´¥ï¼Œå¯é‡è¯•当å‰å…³ã€‚ -3. 当å‰å…³ç›´æŽ¥å‡ºçް `1x2`ã€`1x3`ã€`2x2` å’Œ `2x3`。 -4. å¼€å±€æ£‹ç›˜éšæœºé“ºæ»¡å¹¶ä¿è¯è‡³å°‘一步å¯è§£ï¼›è¡¥ç‰ŒåŽä¹Ÿå¿…须由åŽç«¯ä¿è¯è‡³å°‘一步å¯è§£ã€‚ -5. 顶部å¡ç‰Œå‡†å¤‡åŒºæŒ‰çºµåˆ—è¡¥ä½ï¼ŒæŸåˆ—有空格时该列å¡ç‰Œä»Žé¡¶éƒ¨ä¸‹è½ã€‚ +1. 4 关棋盘å‡å›ºå®šä¸º `6x6`。 +2. 第 1 / 2 / 3 / 4 关目标分别为 `15 / 20 / 30 / 35` æ¬¡æ¶ˆé™¤ï¼Œé™æ—¶åˆ†åˆ«ä¸º `5 / 5 / 7 / 10` 分钟;超时åªåˆ¤å½“å‰å…³å¤±è´¥ï¼Œå¯é‡è¯•当å‰å…³ã€‚ +3. 第 1 关仅出现 `1x2`;第 2 关出现 `1x2`ã€`1x3`;第 3 关出现 `1x2`ã€`1x3`ã€`2x2`;第 4 关出现 `1x2`ã€`1x3`ã€`2x2`ã€`2x3`。 +4. å¼€å±€åªæ”¾å…¥æœ¬å…³ç›®æ ‡æ¶ˆé™¤æ•°å¯¹åº”的全部å¡ç‰Œï¼Œæ£‹ç›˜æ”¾ä¸ä¸‹çš„牌进入顶部准备区,牌ä¸è¶³æ£‹ç›˜æ ¼æ•°æ—¶ç©ºæ ¼ä¿ç•™ï¼›å¼€å±€ä»ä¿è¯è‡³å°‘一步å¯è§£ã€‚ +5. 顶部å¡ç‰Œå‡†å¤‡åŒºæŒ‰çºµåˆ—è¡¥ä½ï¼ŒæŸåˆ—有空格时该列å¡ç‰Œä»Žé¡¶éƒ¨ä¸‹è½ï¼›é¡¶éƒ¨æ²¡æœ‰æ–°ç‰Œæ—¶ç©ºæ ¼ç•™åœ¨åœºä¸Šå¹¶éœ²å‡ºèƒŒæ™¯å›¾ã€‚胜利æ¡ä»¶æ°¸è¿œæ˜¯æ¶ˆé™¤å®Œæœ¬å…³å…¨éƒ¨å¡ç‰Œï¼Œè¾¾åˆ°ç›®æ ‡æ¶ˆé™¤æ•°ä¸”棋盘与顶部准备区都没有剩余å¡ç‰ŒåŽæ‰è¿›å…¥ä¸‹ä¸€å…³ã€‚ 6. éž 2 格消除时,补牌ä¸å¾—ç ´å已完æˆå±€éƒ¨ï¼›åªæœ‰çŽ©å®¶ä¸»åŠ¨äº¤æ¢æˆ–æ’žå…¥æ‰å…许打散åŠé”定拼接组。 7. æ­£å¼ runtime åªæ¶ˆè´¹åŽç«¯ snapshot 与 action 结果;å‰ç«¯è´Ÿè´£å¼€å±€ç¿»è½¬ã€æ‹–æ‹½ã€æŽ‰è½ã€æ¶ˆé™¤å’Œå¼¹å±‚动画。 - æ‹–æ‹½æ‰‹æ„Ÿå¿…é¡»å¯¹é½æ‹¼å›¾æ¨¡æ¿ï¼šå¼€å±€å°å¡ç‰‡åªç¿»è½¬ä¸€æ¬¡ï¼Œäº¤æ¢è½ä½ä¸å¾—釿–°ç¿»ç‰Œï¼›æŒ‰ä½åŽå¯è§å¡ç‰‡ç«‹å³è·Ÿéšé¼ æ ‡æˆ–手指,æºä½ç½®å³æ—¶ç•™å‡ºç©ºæ§½ï¼›æ”¾ä¸‹æ—¶è¢«æ›¿æ¢å¡ç‰‡è¦å¿«é€Ÿé£žå‘对应空ä½ï¼›å·²å®Œæˆå±€éƒ¨æ‹¼æŽ¥ç»„è¦ä»¥è¿žç»­æ•´ä½“呈现并å¯ä½œä¸ºæ•´ç»„拖起。拖拽浮层必须挂到页é¢çº§ `document.body` portal,é¿å…å¹³å°å£³å±‚ transform 让 `position: fixed` å’Œ `clientX/clientY` åæ ‡ç³»é”™ä½ã€‚ + æ‹–æ‹½æ‰‹æ„Ÿå¿…é¡»å¯¹é½æ‹¼å›¾æ¨¡æ¿ï¼šå¼€å±€å°å¡ç‰‡åªç¿»è½¬ä¸€æ¬¡ï¼Œäº¤æ¢è½ä½ä¸å¾—釿–°ç¿»ç‰Œï¼›æŒ‰ä½åŽå¯è§å¡ç‰‡ç«‹å³è·Ÿéšé¼ æ ‡æˆ–手指,æºä½ç½®å³æ—¶ç•™å‡ºç©ºæ§½ï¼›æ”¾ä¸‹æ—¶è¢«æ›¿æ¢å¡ç‰‡è¦å¿«é€Ÿé£žå‘对应空ä½ï¼›å·²å®Œæˆå±€éƒ¨æ‹¼æŽ¥ç»„è¦ä»¥è¿žç»­æ•´ä½“呈现并å¯ä½œä¸ºæ•´ç»„拖起。åŠé”定局部拼接组按åŒç»„å¡ç‰Œâ€œç´ æå标相邻且棋盘格相邻â€çš„连通å—识别,`1x3`ã€`2x2`ã€`2x3` 的转角或多行局部拼åˆä¹Ÿå¿…须整体写入åŒä¸€ä¸ª `lockedGroupId`,ä¸èƒ½åªé”ä½çº¿æ€§æŽ’åºåŽç›¸é‚»çš„一段。拖拽浮层必须挂到页é¢çº§ `document.body` portal,é¿å…å¹³å°å£³å±‚ transform 让 `position: fixed` å’Œ `clientX/clientY` åæ ‡ç³»é”™ä½ï¼›æ•´ç»„拖起期间活动组åªèƒ½ç”± portal ghost 展示å¡å›¾ï¼Œæ£‹ç›˜æ ¼å­å±‚标记为拖拽æºä½å¹¶ä¿æŒé€æ˜Žç©ºæ§½ï¼Œé”定组覆盖层ä¸å¾—继续渲染正在拖的组。整组拖拽è½ç‚¹å¿…须先以å‰ç«¯å½“å‰ board snapshot 校验整组平移åŽçš„æ‰€æœ‰æ ¼å­ä»åœ¨æ£‹ç›˜å†…,越界时åªå›žå¼¹ä¸æäº¤ `swap`ï¼›åŽç«¯ä»ä¿ç•™æœ€ç»ˆè§„则è£å†³ã€‚ 8. æ­£å¼ `published` run 的终æ€äº‹ä»¶ä½¿ç”¨ `run-finished` å’Œ `level-failed`,事件结果 JSON è‡³å°‘åŒ…å« `status`ã€`level`ã€`clears`ã€`clearDelta` å’Œ `elapsedMs`,供基础统计与排障回读。 新增阶段为 `puzzle-clear-workspace`ã€`puzzle-clear-generating`ã€`puzzle-clear-result` å’Œ `puzzle-clear-runtime`;路由为 `/creation/puzzle-clear`ã€`/creation/puzzle-clear/generating`ã€`/creation/puzzle-clear/result` 与 `/runtime/puzzle-clear`。API 命å空间为 `/api/creation/puzzle-clear/*` 与 `/api/runtime/puzzle-clear/*`。验è¯å‘½ä»¤è§ `docs/prd/ã€çŽ©æ³•åˆ›ä½œã€‘æ‹¼æ¶ˆæ¶ˆçŽ©æ³•æ¨¡æ¿PRD-2026-05-30.md` 与 `docs/technical/ã€çŽ©æ³•åˆ›ä½œã€‘æ‹¼æ¶ˆæ¶ˆçŽ©æ³•æ¨¡æ¿æŠ€æœ¯æ–¹æ¡ˆ-2026-05-30.md`。 diff --git a/server-rs/crates/api-server/src/puzzle_clear.rs b/server-rs/crates/api-server/src/puzzle_clear.rs index 1221df47..0aff7483 100644 --- a/server-rs/crates/api-server/src/puzzle_clear.rs +++ b/server-rs/crates/api-server/src/puzzle_clear.rs @@ -25,6 +25,8 @@ use shared_kernel::{build_prefixed_uuid_id, format_timestamp_micros}; use spacetime_client::SpacetimeClientError; use std::{ collections::BTreeMap, + env, fs, + path::{Path as FsPath, PathBuf}, time::{SystemTime, UNIX_EPOCH}, }; @@ -38,8 +40,8 @@ use crate::{ }, http_error::AppError, openai_image_generation::{ - DownloadedOpenAiImage, build_openai_image_http_client, create_openai_image_generation, - require_openai_image_settings, + DownloadedOpenAiImage, build_openai_image_http_client, build_openai_image_request_body, + create_openai_image_generation, require_openai_image_settings, }, request_context::RequestContext, state::AppState, @@ -599,6 +601,721 @@ struct PuzzleClearGeneratedSheet { image: DownloadedOpenAiImage, } +#[derive(Clone, Debug)] +struct PuzzleClearImageDebugRun { + root: PathBuf, + run_id: String, +} + +impl PuzzleClearImageDebugRun { + fn record_spec( + &self, + sheet_specs: &[PuzzleClearAtlasSheetSpec], + groups: &[PuzzleClearPatternGroup], + ) { + let sheets = sheet_specs + .iter() + .map(|spec| { + json!({ + "sheetId": spec.sheet_id, + "layout": spec.layout.iter().map(|row| row.to_vec()).collect::>(), + "layoutPrompt": spec.layout_prompt, + }) + }) + .collect::>(); + let groups = groups + .iter() + .map(|group| { + json!({ + "groupId": group.group_id.as_str(), + "shape": group.shape.as_str(), + "width": group.width, + "height": group.height, + "atlasX": group.atlas_x, + "atlasY": group.atlas_y, + "atlasWidth": group.atlas_width, + "atlasHeight": group.atlas_height, + }) + }) + .collect::>(); + self.write_json( + "specs/puzzle-clear-debug-spec.json", + &json!({ + "cellSize": PUZZLE_CLEAR_ATLAS_CELL_SIZE, + "sheetColumns": PUZZLE_CLEAR_SHEET_COLUMNS, + "sheetRows": PUZZLE_CLEAR_SHEET_ROWS, + "finalAtlasColumns": PUZZLE_CLEAR_FINAL_ATLAS_COLUMNS, + "finalAtlasRows": PUZZLE_CLEAR_FINAL_ATLAS_ROWS, + "sheets": sheets, + "groups": groups, + }), + "记录拼消消调试规格失败", + ); + } + + fn record_board_background_request(&self, prompt: &str) { + self.write_text( + "prompts/board-background.txt", + prompt, + "记录拼消消底图 prompt 失败", + ); + self.write_json( + "requests/board-background.json", + &json!({ + "endpoint": "/v1/images/generations", + "body": build_openai_image_request_body( + prompt, + Some("æ–‡å­—ã€æ°´å°ã€æŒ‰é’®ã€æ•™ç¨‹æµ®å±‚ã€æ˜Žæ˜¾ç½‘æ ¼"), + PUZZLE_CLEAR_BOARD_BACKGROUND_GENERATION_SIZE, + 1, + &[], + ), + }), + "记录拼消消底图 request 失败", + ); + } + + fn record_board_background_image( + &self, + task_id: &str, + actual_prompt: Option<&str>, + image: &DownloadedOpenAiImage, + ) { + let extension = puzzle_clear_debug_image_extension(image); + self.write_bytes( + format!("background/board-background.{extension}"), + image.bytes.as_slice(), + "记录拼消消底图图片失败", + ); + self.write_json( + "responses/board-background.json", + &json!({ + "taskId": task_id, + "actualPrompt": actual_prompt, + "image": { + "mimeType": image.mime_type.as_str(), + "extension": image.extension.as_str(), + "byteLength": image.bytes.len(), + }, + }), + "记录拼消消底图 response 摘è¦å¤±è´¥", + ); + } + + fn record_board_background_error(&self, error: &AppError) { + self.write_json( + "responses/board-background.error.json", + &puzzle_clear_debug_error_json(error), + "记录拼消消底图错误失败", + ); + } + + fn record_sheet_request( + &self, + sheet_spec: &PuzzleClearAtlasSheetSpec, + attempt_index: usize, + prompt: &str, + ) { + let attempt_id = puzzle_clear_debug_attempt_id(attempt_index); + self.write_text( + format!("prompts/{}.txt", sheet_spec.sheet_id), + prompt, + "记录拼消消 sheet prompt 失败", + ); + self.write_json( + format!("requests/{}-{attempt_id}.json", sheet_spec.sheet_id), + &json!({ + "endpoint": "/v1/images/generations", + "sheetId": sheet_spec.sheet_id, + "attempt": attempt_index + 1, + "body": build_openai_image_request_body( + prompt, + Some(PUZZLE_CLEAR_ATLAS_NEGATIVE_PROMPT), + PUZZLE_CLEAR_ATLAS_GENERATION_SIZE, + 1, + &[], + ), + }), + "记录拼消消 sheet request 失败", + ); + } + + fn record_sheet_generation_error( + &self, + sheet_spec: &PuzzleClearAtlasSheetSpec, + attempt_index: usize, + error: &AppError, + ) { + let attempt_id = puzzle_clear_debug_attempt_id(attempt_index); + self.write_json( + format!("responses/{}-{attempt_id}.error.json", sheet_spec.sheet_id), + &json!({ + "sheetId": sheet_spec.sheet_id, + "attempt": attempt_index + 1, + "stage": "generation", + "error": puzzle_clear_debug_error_json(error), + }), + "记录拼消消 sheet 生æˆé”™è¯¯å¤±è´¥", + ); + } + + fn record_sheet_attempt_image( + &self, + sheet_spec: &PuzzleClearAtlasSheetSpec, + attempt_index: usize, + task_id: &str, + actual_prompt: Option<&str>, + image: &DownloadedOpenAiImage, + ) { + let attempt_id = puzzle_clear_debug_attempt_id(attempt_index); + let extension = puzzle_clear_debug_image_extension(image); + self.write_bytes( + format!("sheets/{}-{attempt_id}.{extension}", sheet_spec.sheet_id), + image.bytes.as_slice(), + "记录拼消消 sheet 原图失败", + ); + self.write_json( + format!("responses/{}-{attempt_id}.json", sheet_spec.sheet_id), + &json!({ + "sheetId": sheet_spec.sheet_id, + "attempt": attempt_index + 1, + "taskId": task_id, + "actualPrompt": actual_prompt, + "image": { + "mimeType": image.mime_type.as_str(), + "extension": image.extension.as_str(), + "byteLength": image.bytes.len(), + }, + }), + "记录拼消消 sheet response 摘è¦å¤±è´¥", + ); + } + + fn record_sheet_quality( + &self, + sheet_spec: &PuzzleClearAtlasSheetSpec, + attempt_index: usize, + task_id: &str, + image: &DownloadedOpenAiImage, + quality_error: Option<&AppError>, + ) { + let attempt_id = puzzle_clear_debug_attempt_id(attempt_index); + match build_puzzle_clear_sheet_quality_debug_report( + sheet_spec, + attempt_index, + task_id, + image, + quality_error, + ) { + Ok(report) => { + self.write_json( + format!("reports/{}-{attempt_id}.quality.json", sheet_spec.sheet_id), + &report, + "记录拼消消 sheet è´¨é‡æŠ¥å‘Šå¤±è´¥", + ); + } + Err(error) => self.write_json( + format!("reports/{}-{attempt_id}.quality-error.json", sheet_spec.sheet_id), + &puzzle_clear_debug_error_json(&error), + "记录拼消消 sheet è´¨é‡æŠ¥å‘Šé”™è¯¯å¤±è´¥", + ), + } + self.write_sheet_cells(sheet_spec, attempt_index, image); + } + + fn record_sheet_accepted( + &self, + sheet_spec: &PuzzleClearAtlasSheetSpec, + task_id: &str, + image: &DownloadedOpenAiImage, + ) { + let extension = puzzle_clear_debug_image_extension(image); + self.write_bytes( + format!("accepted/{}.{extension}", sheet_spec.sheet_id), + image.bytes.as_slice(), + "记录拼消消 accepted sheet 失败", + ); + self.write_json( + format!("accepted/{}.json", sheet_spec.sheet_id), + &json!({ + "sheetId": sheet_spec.sheet_id, + "taskId": task_id, + "accepted": true, + "image": { + "mimeType": image.mime_type.as_str(), + "extension": image.extension.as_str(), + "byteLength": image.bytes.len(), + }, + }), + "记录拼消消 accepted sheet 摘è¦å¤±è´¥", + ); + } + + fn record_atlas_image( + &self, + image: &DownloadedOpenAiImage, + generated_sheets: &[PuzzleClearGeneratedSheet], + ) { + self.write_bytes( + "atlas/puzzle-clear-atlas.png", + image.bytes.as_slice(), + "记录拼消消最终 atlas 失败", + ); + self.write_json( + "atlas/puzzle-clear-atlas.json", + &json!({ + "image": { + "mimeType": image.mime_type.as_str(), + "extension": image.extension.as_str(), + "byteLength": image.bytes.len(), + }, + "acceptedSheets": generated_sheets + .iter() + .map(|sheet| json!({ + "sheetId": sheet.spec.sheet_id, + "taskId": sheet.task_id.as_str(), + })) + .collect::>(), + }), + "记录拼消消最终 atlas 摘è¦å¤±è´¥", + ); + } + + fn record_summary_success( + &self, + session_id: &str, + profile_id: &str, + sheet_count: usize, + card_count: usize, + ) { + self.write_text( + "summary.md", + format!( + concat!( + "# 拼消消 runtime 生图调试\n\n", + "- runId: `{}`\n", + "- sessionId: `{}`\n", + "- profileId: `{}`\n", + "- status: `ready`\n", + "- acceptedSheets: `{}`\n", + "- cardCount: `{}`\n\n", + "关键查看顺åºï¼š\n\n", + "1. `prompts/` å’Œ `requests/`:真实请求内容。\n", + "2. `sheets/`ï¼šæ¯æ¬¡ attempt 的原始 sheet。\n", + "3. `reports/*.quality.json`ï¼šæ¯æ ¼è´¨é‡æŒ‡æ ‡å’Œ hard/advisory findings。\n", + "4. `cells//contact-sheet.png`:4x6 è£åˆ‡æ€»è§ˆã€‚\n", + "5. `accepted/`:最终通过门ç¦çš„ sheet。\n", + "6. `atlas/puzzle-clear-atlas.png`:最终 atlas 预览。\n" + ), + self.run_id, session_id, profile_id, sheet_count, card_count + ), + "记录拼消消调试 summary 失败", + ); + } + + fn write_sheet_cells( + &self, + sheet_spec: &PuzzleClearAtlasSheetSpec, + attempt_index: usize, + image: &DownloadedOpenAiImage, + ) { + let attempt_id = puzzle_clear_debug_attempt_id(attempt_index); + let result = (|| -> Result<(), AppError> { + let source = image::load_from_memory(image.bytes.as_slice()).map_err(|error| { + AppError::from_status(StatusCode::BAD_GATEWAY).with_details(json!({ + "provider": PUZZLE_CLEAR_CREATION_PROVIDER, + "message": format!("拼消消素æ {} 调试è£åˆ‡è§£ç å¤±è´¥ï¼š{error}", sheet_spec.sheet_id), + })) + })?; + let source_width = source.width(); + let source_height = source.height(); + let mut contact = image::RgbaImage::from_pixel( + PUZZLE_CLEAR_ATLAS_CELL_SIZE * PUZZLE_CLEAR_SHEET_COLUMNS, + PUZZLE_CLEAR_ATLAS_CELL_SIZE * PUZZLE_CLEAR_SHEET_ROWS, + image::Rgba([255, 255, 255, 0]), + ); + for row in 0..PUZZLE_CLEAR_SHEET_ROWS { + for col in 0..PUZZLE_CLEAR_SHEET_COLUMNS { + let group_id = sheet_spec.layout[row as usize][col as usize]; + let bounds = + puzzle_clear_sheet_cell_bounds(row, col, source_width, source_height); + let cropped = source + .crop_imm(bounds.x0, bounds.y0, bounds.width(), bounds.height()) + .resize_exact( + PUZZLE_CLEAR_ATLAS_CELL_SIZE, + PUZZLE_CLEAR_ATLAS_CELL_SIZE, + image::imageops::FilterType::Lanczos3, + ) + .to_rgba8(); + let mut cursor = std::io::Cursor::new(Vec::new()); + image::DynamicImage::ImageRgba8(cropped.clone()) + .write_to(&mut cursor, image::ImageFormat::Png) + .map_err(|error| { + AppError::from_status(StatusCode::BAD_GATEWAY).with_details(json!({ + "provider": PUZZLE_CLEAR_CREATION_PROVIDER, + "message": format!("拼消消素æ {} 调试è£åˆ‡å†™å…¥å¤±è´¥ï¼š{error}", sheet_spec.sheet_id), + })) + })?; + self.write_bytes( + format!( + "cells/{}-{attempt_id}/r{:02}-c{:02}-{}.png", + sheet_spec.sheet_id, + row + 1, + col + 1, + sanitize_puzzle_clear_debug_segment(group_id, "cell"), + ), + cursor.into_inner().as_slice(), + "记录拼消消 sheet è£åˆ‡æ ¼å¤±è´¥", + ); + image::imageops::overlay( + &mut contact, + &cropped, + i64::from(col * PUZZLE_CLEAR_ATLAS_CELL_SIZE), + i64::from(row * PUZZLE_CLEAR_ATLAS_CELL_SIZE), + ); + } + } + let mut cursor = std::io::Cursor::new(Vec::new()); + image::DynamicImage::ImageRgba8(contact) + .write_to(&mut cursor, image::ImageFormat::Png) + .map_err(|error| { + AppError::from_status(StatusCode::BAD_GATEWAY).with_details(json!({ + "provider": PUZZLE_CLEAR_CREATION_PROVIDER, + "message": format!("拼消消素æ {} 调试 contact sheet 写入失败:{error}", sheet_spec.sheet_id), + })) + })?; + self.write_bytes( + format!("cells/{}-{attempt_id}/contact-sheet.png", sheet_spec.sheet_id), + cursor.into_inner().as_slice(), + "记录拼消消 sheet contact sheet 失败", + ); + Ok(()) + })(); + if let Err(error) = result { + self.write_json( + format!("cells/{}-{attempt_id}/error.json", sheet_spec.sheet_id), + &puzzle_clear_debug_error_json(&error), + "记录拼消消 sheet è£åˆ‡é”™è¯¯å¤±è´¥", + ); + } + } + + fn write_text( + &self, + relative_path: impl AsRef, + content: impl AsRef, + action: &'static str, + ) { + self.write_result(action, || { + let target = self.root.join(relative_path.as_ref()); + if let Some(parent) = target.parent() { + fs::create_dir_all(parent)?; + } + fs::write(target, content.as_ref().as_bytes()) + }); + } + + fn write_json(&self, relative_path: impl AsRef, value: &Value, action: &'static str) { + self.write_result(action, || { + let target = self.root.join(relative_path.as_ref()); + if let Some(parent) = target.parent() { + fs::create_dir_all(parent)?; + } + let mut bytes = serde_json::to_vec_pretty(value) + .map_err(|error| std::io::Error::new(std::io::ErrorKind::Other, error))?; + bytes.push(b'\n'); + fs::write(target, bytes) + }); + } + + fn write_bytes( + &self, + relative_path: impl AsRef, + bytes: &[u8], + action: &'static str, + ) { + self.write_result(action, || { + let target = self.root.join(relative_path.as_ref()); + if let Some(parent) = target.parent() { + fs::create_dir_all(parent)?; + } + fs::write(target, bytes) + }); + } + + fn write_result( + &self, + action: &'static str, + operation: impl FnOnce() -> std::io::Result<()>, + ) { + if let Err(error) = operation() { + tracing::warn!( + provider = PUZZLE_CLEAR_CREATION_PROVIDER, + run_id = self.run_id, + debug_path = %self.root.display(), + error = %error, + action, + "拼消消本地生图调试包写入失败,已忽略" + ); + } + } +} + +fn maybe_create_puzzle_clear_image_debug_run( + session_id: &str, + profile_id: &str, + theme_prompt: &str, +) -> Option { + if !puzzle_clear_image_debug_enabled() { + return None; + } + let base_dir = puzzle_clear_image_debug_runs_dir(); + let run_id = env::var("PUZZLE_CLEAR_IMAGE_DEBUG_RUN_ID") + .ok() + .map(|value| value.trim().to_string()) + .filter(|value| !value.is_empty()) + .unwrap_or_else(|| { + format!( + "runtime-{}-{}-{}", + sanitize_puzzle_clear_debug_segment(session_id, "session"), + sanitize_puzzle_clear_debug_segment(profile_id, "profile"), + current_utc_micros() + ) + }); + let run_id = sanitize_puzzle_clear_debug_segment(run_id.as_str(), "runtime"); + let root = base_dir.join(run_id.as_str()); + if let Err(error) = fs::create_dir_all(&root) { + tracing::warn!( + provider = PUZZLE_CLEAR_CREATION_PROVIDER, + debug_path = %root.display(), + error = %error, + "拼消消本地生图调试包åˆå§‹åŒ–失败,已关闭本次记录" + ); + return None; + } + let debug_run = PuzzleClearImageDebugRun { root, run_id }; + debug_run.write_json( + "manifest.json", + &json!({ + "runId": debug_run.run_id.as_str(), + "source": "api-server-runtime", + "playType": "puzzle-clear", + "sessionId": session_id, + "profileId": profile_id, + "themePrompt": theme_prompt, + "createdAt": format_timestamp_micros(current_utc_micros()), + "env": { + "PUZZLE_CLEAR_IMAGE_DEBUG_ENABLED": "1", + "PUZZLE_CLEAR_IMAGE_DEBUG_DIR": puzzle_clear_image_debug_runs_dir().display().to_string(), + }, + }), + "记录拼消消调试 manifest 失败", + ); + if let Err(error) = fs::write( + puzzle_clear_image_debug_runs_dir().join("latest-runtime.txt"), + debug_run.root.to_string_lossy().as_bytes(), + ) { + tracing::warn!( + provider = PUZZLE_CLEAR_CREATION_PROVIDER, + error = %error, + "拼消消本地生图调试包 latest-runtime.txt 写入失败,已忽略" + ); + } + tracing::info!( + provider = PUZZLE_CLEAR_CREATION_PROVIDER, + run_id = debug_run.run_id.as_str(), + debug_path = %debug_run.root.display(), + "拼消消本地生图调试包已开å¯" + ); + Some(debug_run) +} + +fn puzzle_clear_image_debug_enabled() -> bool { + env::var("PUZZLE_CLEAR_IMAGE_DEBUG_ENABLED") + .ok() + .map(|value| { + matches!( + value.trim().to_ascii_lowercase().as_str(), + "1" | "true" | "yes" | "on" + ) + }) + .unwrap_or(false) +} + +fn puzzle_clear_image_debug_runs_dir() -> PathBuf { + env::var("PUZZLE_CLEAR_IMAGE_DEBUG_DIR") + .ok() + .map(|value| value.trim().to_string()) + .filter(|value| !value.is_empty()) + .map(PathBuf::from) + .unwrap_or_else(|| { + puzzle_clear_repo_root() + .join(".app") + .join("puzzle-clear-image-debug") + .join("runs") + }) +} + +fn puzzle_clear_repo_root() -> PathBuf { + let current_dir = env::current_dir().unwrap_or_else(|_| PathBuf::from(".")); + for candidate in current_dir.ancestors() { + if candidate.join("server-rs").is_dir() && candidate.join("package.json").is_file() { + return candidate.to_path_buf(); + } + } + current_dir +} + +fn puzzle_clear_debug_attempt_id(attempt_index: usize) -> String { + format!("attempt-{:02}", attempt_index + 1) +} + +fn puzzle_clear_debug_image_extension(image: &DownloadedOpenAiImage) -> String { + sanitize_puzzle_clear_debug_segment( + image + .extension + .trim() + .trim_start_matches('.') + .to_ascii_lowercase() + .as_str(), + "png", + ) +} + +fn sanitize_puzzle_clear_debug_segment(raw: &str, fallback: &str) -> String { + let mut value = String::new(); + for character in raw.chars() { + if character.is_ascii_alphanumeric() || matches!(character, '-' | '_' | '.') { + value.push(character); + } else { + value.push('-'); + } + } + let value = value.trim_matches('-').trim_matches('.').to_string(); + if value.is_empty() { + fallback.to_string() + } else { + value + } +} + +fn puzzle_clear_debug_error_json(error: &AppError) -> Value { + json!({ + "statusCode": error.status_code().as_u16(), + "code": error.code(), + "message": error.body_text(), + "details": error.details(), + }) +} + +fn build_puzzle_clear_sheet_quality_debug_report( + sheet_spec: &PuzzleClearAtlasSheetSpec, + attempt_index: usize, + task_id: &str, + image: &DownloadedOpenAiImage, + quality_error: Option<&AppError>, +) -> Result { + let source = image::load_from_memory(image.bytes.as_slice()).map_err(|error| { + AppError::from_status(StatusCode::BAD_GATEWAY).with_details(json!({ + "provider": PUZZLE_CLEAR_CREATION_PROVIDER, + "message": format!("拼消消素æ {} è°ƒè¯•è´¨é‡æŠ¥å‘Šè§£ç å¤±è´¥ï¼š{error}", sheet_spec.sheet_id), + })) + })?; + let source_width = source.width(); + let source_height = source.height(); + let mut hard_findings = Vec::new(); + let mut advisory_findings = Vec::new(); + let mut cells = Vec::new(); + for row in 0..PUZZLE_CLEAR_SHEET_ROWS { + for col in 0..PUZZLE_CLEAR_SHEET_COLUMNS { + let group_id = sheet_spec.layout[row as usize][col as usize]; + let bounds = puzzle_clear_sheet_cell_bounds(row, col, source_width, source_height); + let quality = + analyze_puzzle_clear_sheet_cell_quality(&source, sheet_spec, row, col, bounds); + let cell_label = format!("第{}行第{}列", row + 1, col + 1); + let mut cell_findings = Vec::new(); + let mut cell_advisory_findings = Vec::new(); + + if group_id == PUZZLE_CLEAR_SHEET_UNUSED_CELL { + if quality.foreground_ratio > PUZZLE_CLEAR_SHEET_BLANK_MAX_FOREGROUND_RATIO { + let finding = format!("{cell_label} 空白格有主体"); + hard_findings.push(finding.clone()); + cell_findings.push(finding); + } + } else if group_id != PUZZLE_CLEAR_SHEET_FILLER_CELL { + if quality.foreground_ratio < PUZZLE_CLEAR_SHEET_MIN_FOREGROUND_RATIO { + let finding = format!("{cell_label} 主体过少"); + hard_findings.push(finding.clone()); + cell_findings.push(finding); + } + if quality.strongest_internal_seam_ratio + > PUZZLE_CLEAR_SHEET_INTERNAL_SEAM_RATIO_THRESHOLD + { + let finding = format!("{cell_label} 啿 ¼å†…部疑似拼接线"); + hard_findings.push(finding.clone()); + cell_findings.push(finding); + } + if quality.exposed_edge_count >= 2 + && quality.strongest_edge_ratio + > PUZZLE_CLEAR_SHEET_STRONG_EDGE_RATIO_THRESHOLD + { + let finding = format!("{cell_label} 主体贴到ä¸åŒå›¾æ¡ˆè¾¹ç•Œ"); + advisory_findings.push(finding.clone()); + cell_advisory_findings.push(finding); + } + } + + cells.push(json!({ + "row": row + 1, + "col": col + 1, + "groupId": group_id, + "discarded": is_puzzle_clear_sheet_discarded_cell(group_id), + "bounds": { + "x0": bounds.x0, + "y0": bounds.y0, + "x1": bounds.x1, + "y1": bounds.y1, + }, + "foregroundRatio": quality.foreground_ratio, + "exposedEdgeCount": quality.exposed_edge_count, + "strongestEdgeRatio": quality.strongest_edge_ratio, + "strongestInternalSeamRatio": quality.strongest_internal_seam_ratio, + "hardFindings": cell_findings, + "advisoryFindings": cell_advisory_findings, + })); + } + } + + Ok(json!({ + "sheetId": sheet_spec.sheet_id, + "attempt": attempt_index + 1, + "taskId": task_id, + "accepted": quality_error.is_none(), + "image": { + "width": source_width, + "height": source_height, + "mimeType": image.mime_type.as_str(), + "extension": image.extension.as_str(), + "byteLength": image.bytes.len(), + }, + "thresholds": { + "foregroundDiff": PUZZLE_CLEAR_SHEET_FOREGROUND_DIFF_THRESHOLD, + "minForegroundRatio": PUZZLE_CLEAR_SHEET_MIN_FOREGROUND_RATIO, + "blankMaxForegroundRatio": PUZZLE_CLEAR_SHEET_BLANK_MAX_FOREGROUND_RATIO, + "edgeRatio": PUZZLE_CLEAR_SHEET_EDGE_RATIO_THRESHOLD, + "strongEdgeRatio": PUZZLE_CLEAR_SHEET_STRONG_EDGE_RATIO_THRESHOLD, + "internalSeamDiff": PUZZLE_CLEAR_SHEET_INTERNAL_SEAM_DIFF_THRESHOLD, + "internalSeamRatio": PUZZLE_CLEAR_SHEET_INTERNAL_SEAM_RATIO_THRESHOLD, + "internalSeamSideContrast": PUZZLE_CLEAR_SHEET_INTERNAL_SEAM_SIDE_CONTRAST_THRESHOLD, + "internalSeamSideTextureMax": PUZZLE_CLEAR_SHEET_INTERNAL_SEAM_SIDE_TEXTURE_MAX, + }, + "hardFindings": hard_findings, + "advisoryFindings": advisory_findings, + "qualityError": quality_error.map(puzzle_clear_debug_error_json), + "cells": cells, + })) +} + async fn maybe_prepare_puzzle_clear_assets_inner( state: &AppState, request_context: &RequestContext, @@ -633,8 +1350,13 @@ async fn maybe_prepare_puzzle_clear_assets_inner( .map(ToString::to_string) .unwrap_or_else(|| { build_prefixed_uuid_id(module_puzzle_clear::PUZZLE_CLEAR_PROFILE_ID_PREFIX) - }); + }); payload.profile_id = Some(profile_id.clone()); + let image_debug_run = maybe_create_puzzle_clear_image_debug_run( + session_id, + profile_id.as_str(), + payload.theme_prompt.as_deref().unwrap_or_default(), + ); if payload.generate_board_background.unwrap_or(false) && payload @@ -654,6 +1376,7 @@ async fn maybe_prepare_puzzle_clear_assets_inner( owner_user_id, profile_id.as_str(), board_background_prompt.unwrap_or(theme_prompt), + image_debug_run.as_ref(), ) .await?; payload.board_background_asset = Some(background_asset); @@ -683,6 +1406,9 @@ async fn maybe_prepare_puzzle_clear_assets_inner( .map(|group| (group.group_id.clone(), group)) .collect::>(); let sheet_specs = puzzle_clear_atlas_sheet_specs(); + if let Some(debug_run) = image_debug_run.as_ref() { + debug_run.record_spec(&sheet_specs, &groups); + } let settings = require_openai_image_settings(state) .map(|settings| { settings.with_external_api_audit_context( @@ -710,6 +1436,9 @@ async fn maybe_prepare_puzzle_clear_assets_inner( sheet_spec.sheet_id, attempt_index + 1 ); + if let Some(debug_run) = image_debug_run.as_ref() { + debug_run.record_sheet_request(&sheet_spec, attempt_index, sheet_prompt.as_str()); + } let generated = match create_openai_image_generation( &http_client, &settings, @@ -727,6 +1456,9 @@ async fn maybe_prepare_puzzle_clear_assets_inner( if attempt_index + 1 < PUZZLE_CLEAR_SHEET_GENERATION_MAX_ATTEMPTS && is_retryable_puzzle_clear_sheet_generation_error(&error) => { + if let Some(debug_run) = image_debug_run.as_ref() { + debug_run.record_sheet_generation_error(&sheet_spec, attempt_index, &error); + } tracing::warn!( provider = PUZZLE_CLEAR_CREATION_PROVIDER, sheet_id = sheet_spec.sheet_id, @@ -737,6 +1469,9 @@ async fn maybe_prepare_puzzle_clear_assets_inner( continue; } Err(error) => { + if let Some(debug_run) = image_debug_run.as_ref() { + debug_run.record_sheet_generation_error(&sheet_spec, attempt_index, &error); + } return Err(puzzle_clear_error_response( request_context, PUZZLE_CLEAR_CREATION_PROVIDER, @@ -745,6 +1480,7 @@ async fn maybe_prepare_puzzle_clear_assets_inner( } }; let task_id = generated.task_id; + let actual_prompt = generated.actual_prompt; let image = generated.images.into_iter().next().ok_or_else(|| { puzzle_clear_error_response( request_context, @@ -755,8 +1491,30 @@ async fn maybe_prepare_puzzle_clear_assets_inner( })), ) })?; - match validate_puzzle_clear_sheet_quality(&image, &sheet_spec) { + if let Some(debug_run) = image_debug_run.as_ref() { + debug_run.record_sheet_attempt_image( + &sheet_spec, + attempt_index, + task_id.as_str(), + actual_prompt.as_deref(), + &image, + ); + } + let quality_result = validate_puzzle_clear_sheet_quality(&image, &sheet_spec); + if let Some(debug_run) = image_debug_run.as_ref() { + debug_run.record_sheet_quality( + &sheet_spec, + attempt_index, + task_id.as_str(), + &image, + quality_result.as_ref().err(), + ); + } + match quality_result { Ok(()) => { + if let Some(debug_run) = image_debug_run.as_ref() { + debug_run.record_sheet_accepted(&sheet_spec, task_id.as_str(), &image); + } accepted_sheet = Some(PuzzleClearGeneratedSheet { spec: sheet_spec, prompt: sheet_prompt.clone(), @@ -821,6 +1579,9 @@ async fn maybe_prepare_puzzle_clear_assets_inner( compose_puzzle_clear_final_atlas(&slices, &groups_by_id).map_err(|error| { puzzle_clear_error_response(request_context, PUZZLE_CLEAR_CREATION_PROVIDER, error) })?; + if let Some(debug_run) = image_debug_run.as_ref() { + debug_run.record_atlas_image(&atlas_image, &generated_sheets); + } let atlas_prompt = generated_sheets .iter() .map(|sheet| format!("{}:\n{}", sheet.spec.sheet_id, sheet.prompt)) @@ -864,6 +1625,14 @@ async fn maybe_prepare_puzzle_clear_assets_inner( payload.atlas_asset = Some(atlas_asset); payload.pattern_groups = Some(groups); payload.card_assets = Some(card_assets); + if let Some(debug_run) = image_debug_run.as_ref() { + debug_run.record_summary_success( + session_id, + profile_id.as_str(), + generated_sheets.len(), + payload.card_assets.as_ref().map_or(0, Vec::len), + ); + } tracing::info!( provider = PUZZLE_CLEAR_CREATION_PROVIDER, session_id, @@ -1054,8 +1823,12 @@ async fn generate_and_persist_puzzle_clear_board_background( owner_user_id: &str, profile_id: &str, theme_prompt: &str, + image_debug_run: Option<&PuzzleClearImageDebugRun>, ) -> Result { let prompt = build_puzzle_clear_board_background_prompt(theme_prompt); + if let Some(debug_run) = image_debug_run { + debug_run.record_board_background_request(prompt.as_str()); + } let settings = require_openai_image_settings(state) .map(|settings| { settings.with_external_api_audit_context( @@ -1082,9 +1855,13 @@ async fn generate_and_persist_puzzle_clear_board_background( ) .await .map_err(|error| { + if let Some(debug_run) = image_debug_run { + debug_run.record_board_background_error(&error); + } puzzle_clear_error_response(request_context, PUZZLE_CLEAR_CREATION_PROVIDER, error) })?; let task_id = generated.task_id; + let actual_prompt = generated.actual_prompt; let image = generated.images.into_iter().next().ok_or_else(|| { puzzle_clear_error_response( request_context, @@ -1095,6 +1872,9 @@ async fn generate_and_persist_puzzle_clear_board_background( })), ) })?; + if let Some(debug_run) = image_debug_run { + debug_run.record_board_background_image(task_id.as_str(), actual_prompt.as_deref(), &image); + } persist_puzzle_clear_generated_image_asset( state, owner_user_id, @@ -2140,7 +2920,11 @@ fn map_puzzle_clear_client_error(error: SpacetimeClientError) -> AppError { if value.contains("å‘布需è¦") || value.contains("ä¸èƒ½ä¸ºç©º") || value.contains("å¿…é¡»") - || value.contains("æ— æƒ") => + || value.contains("æ— æƒ") + || value.contains("puzzle-clear åæ ‡æ— æ•ˆ") + || value.contains("puzzle-clear ç›®æ ‡æ ¼å­æ²¡æœ‰å¡ç‰Œ") + || value.contains("puzzle-clear å½“å‰ run ä¸åœ¨ playing 状æ€") + || value.contains("puzzle-clear 当å‰å…³å¡å·²ç»è¶…æ—¶") => { StatusCode::BAD_REQUEST } diff --git a/server-rs/crates/module-puzzle-clear/src/application.rs b/server-rs/crates/module-puzzle-clear/src/application.rs index 29c7e312..e3f8c25c 100644 --- a/server-rs/crates/module-puzzle-clear/src/application.rs +++ b/server-rs/crates/module-puzzle-clear/src/application.rs @@ -3,25 +3,55 @@ use std::collections::{BTreeSet, HashMap, VecDeque}; use shared_kernel::normalize_required_string; use crate::{ - PUZZLE_CLEAR_LEVEL_DURATION_SECONDS, PuzzleClearBoard, PuzzleClearCard, PuzzleClearCell, - PuzzleClearDeck, PuzzleClearElimination, PuzzleClearError, PuzzleClearLevelConfig, - PuzzleClearMove, PuzzleClearOrientation, PuzzleClearPatternGroup, PuzzleClearRunSnapshot, - PuzzleClearRunStatus, PuzzleClearShapeKind, PuzzleClearShapeQuota, + PuzzleClearBoard, PuzzleClearCard, PuzzleClearCell, PuzzleClearDeck, PuzzleClearElimination, + PuzzleClearError, PuzzleClearLevelConfig, PuzzleClearMove, PuzzleClearOrientation, + PuzzleClearPatternGroup, PuzzleClearRunSnapshot, PuzzleClearRunStatus, PuzzleClearShapeKind, + PuzzleClearShapeQuota, }; pub fn puzzle_clear_level_configs() -> Vec { - vec![PuzzleClearLevelConfig { - level_index: 1, - board_size: 6, - target_clears: 35, - duration_seconds: PUZZLE_CLEAR_LEVEL_DURATION_SECONDS, - unlocked_shapes: vec![ - PuzzleClearShapeKind::OneByTwo, - PuzzleClearShapeKind::OneByThree, - PuzzleClearShapeKind::TwoByTwo, - PuzzleClearShapeKind::TwoByThree, - ], - }] + vec![ + PuzzleClearLevelConfig { + level_index: 1, + board_size: 6, + target_clears: 15, + duration_seconds: 300, + unlocked_shapes: vec![PuzzleClearShapeKind::OneByTwo], + }, + PuzzleClearLevelConfig { + level_index: 2, + board_size: 6, + target_clears: 20, + duration_seconds: 300, + unlocked_shapes: vec![ + PuzzleClearShapeKind::OneByTwo, + PuzzleClearShapeKind::OneByThree, + ], + }, + PuzzleClearLevelConfig { + level_index: 3, + board_size: 6, + target_clears: 30, + duration_seconds: 420, + unlocked_shapes: vec![ + PuzzleClearShapeKind::OneByTwo, + PuzzleClearShapeKind::OneByThree, + PuzzleClearShapeKind::TwoByTwo, + ], + }, + PuzzleClearLevelConfig { + level_index: 4, + board_size: 6, + target_clears: 35, + duration_seconds: 600, + unlocked_shapes: vec![ + PuzzleClearShapeKind::OneByTwo, + PuzzleClearShapeKind::OneByThree, + PuzzleClearShapeKind::TwoByTwo, + PuzzleClearShapeKind::TwoByThree, + ], + }, + ] } pub fn puzzle_clear_shape_quotas() -> Vec { @@ -114,7 +144,7 @@ pub fn create_puzzle_clear_board( return Err(PuzzleClearError::InvalidLevel); } let total = (level.board_size * level.board_size) as usize; - if cards.len() < total { + if cards.is_empty() { return Err(PuzzleClearError::EmptyDeck); } let mut rng = DeterministicRng::new(seed, &format!("level-{}", level.level_index)); @@ -125,10 +155,12 @@ pub fn create_puzzle_clear_board( for row in 0..level.board_size { for col in 0..level.board_size { let index = (row * level.board_size + col) as usize; + let empty_slots = total.saturating_sub(selected.len()); + let card_index = index.checked_sub(empty_slots); cells.push(PuzzleClearCell { row, col, - card: selected.get(index).cloned(), + card: card_index.and_then(|card_index| selected.get(card_index).cloned()), locked_group_id: None, }); } @@ -202,7 +234,7 @@ pub fn apply_puzzle_clear_swap( .into_iter() .find(|config| config.level_index == next.level_index) .ok_or(PuzzleClearError::InvalidLevel)?; - if next.clears_done >= level.target_clears && !has_remaining_cards(&next.board) { + if next.clears_done >= level.target_clears && !has_remaining_cards_in_run(&next) { next.status = if next.level_index >= max_puzzle_clear_level_index() { PuzzleClearRunStatus::Finished } else { @@ -401,8 +433,13 @@ fn ensure_not_expired(run: &PuzzleClearRunSnapshot, now_ms: u64) -> Result<(), P } fn is_level_expired(run: &PuzzleClearRunSnapshot, now_ms: u64) -> bool { + let duration_seconds = puzzle_clear_level_configs() + .into_iter() + .find(|config| config.level_index == run.level_index) + .map(|config| config.duration_seconds) + .unwrap_or(600); now_ms.saturating_sub(run.level_started_at_ms) - > u64::from(PUZZLE_CLEAR_LEVEL_DURATION_SECONDS) * 1000 + > u64::from(duration_seconds) * 1000 } fn validate_board(board: &PuzzleClearBoard) -> Result<(), PuzzleClearError> { @@ -421,6 +458,15 @@ fn has_remaining_cards(board: &PuzzleClearBoard) -> bool { board.cells.iter().any(|cell| cell.card.is_some()) } +fn has_remaining_cards_in_run(run: &PuzzleClearRunSnapshot) -> bool { + has_remaining_cards(&run.board) + || run + .deck + .ready_columns + .iter() + .any(|column| !column.is_empty()) +} + fn ensure_board_has_playable_move(board: &mut PuzzleClearBoard) -> Result<(), PuzzleClearError> { if find_eliminations(board).is_empty() && has_playable_move(board) { return Ok(()); @@ -490,15 +536,7 @@ fn find_local_completed_groups(board: &PuzzleClearBoard) -> Vec Vec bool { + if entries.len() < 2 { + return false; + } + let mut visited = vec![false; entries.len()]; + let mut stack = vec![0usize]; + visited[0] = true; + + while let Some(index) = stack.pop() { + let current = &entries[index]; + for (candidate_index, candidate) in entries.iter().enumerate() { + if visited[candidate_index] { + continue; + } + if manhattan_part_distance(¤t.2, &candidate.2) == 1 + && are_neighbors(current.0, current.1, candidate.0, candidate.1) + { + visited[candidate_index] = true; + stack.push(candidate_index); + } + } + } + + visited.into_iter().all(|is_visited| is_visited) +} + fn clear_locked_group(board: &mut PuzzleClearBoard, group_id: &str) { for cell in &mut board.cells { if cell.locked_group_id.as_deref() == Some(group_id) { @@ -1177,14 +1241,40 @@ mod tests { use super::*; #[test] - fn fixed_level_config_uses_single_six_by_six_level() { + fn fixed_level_config_uses_four_six_by_six_levels() { let configs = puzzle_clear_level_configs(); - assert_eq!(configs.len(), 1); - assert_eq!(configs[0].board_size, 6); - assert_eq!(configs[0].target_clears, 35); + assert_eq!(configs.len(), 4); + assert!(configs.iter().all(|config| config.board_size == 6)); assert_eq!( - configs[0].unlocked_shapes, + configs + .iter() + .map(|config| ( + config.level_index, + config.target_clears, + config.duration_seconds + )) + .collect::>(), + vec![(1, 15, 300), (2, 20, 300), (3, 30, 420), (4, 35, 600)] + ); + assert_eq!(configs[0].unlocked_shapes, vec![PuzzleClearShapeKind::OneByTwo]); + assert_eq!( + configs[1].unlocked_shapes, + vec![ + PuzzleClearShapeKind::OneByTwo, + PuzzleClearShapeKind::OneByThree, + ] + ); + assert_eq!( + configs[2].unlocked_shapes, + vec![ + PuzzleClearShapeKind::OneByTwo, + PuzzleClearShapeKind::OneByThree, + PuzzleClearShapeKind::TwoByTwo, + ] + ); + assert_eq!( + configs[3].unlocked_shapes, vec![ PuzzleClearShapeKind::OneByTwo, PuzzleClearShapeKind::OneByThree, @@ -1192,7 +1282,6 @@ mod tests { PuzzleClearShapeKind::TwoByThree, ] ); - assert!(configs.iter().all(|config| config.duration_seconds == 600)); } #[test] @@ -1250,6 +1339,23 @@ mod tests { assert!(has_playable_move(&board)); } + #[test] + fn first_level_board_uses_exact_target_cards_and_leaves_empty_cells() { + let groups = plan_puzzle_clear_pattern_groups(64).expect("atlas should plan"); + let cards = build_cards_from_groups(&groups, "/generated-puzzle-clear") + .into_iter() + .filter(|card| card.shape == PuzzleClearShapeKind::OneByTwo) + .take(30) + .collect::>(); + let board = create_puzzle_clear_board(&puzzle_clear_level_configs()[0], "seed-a", cards) + .expect("board should create with empty cells"); + + assert_eq!(board.cells.iter().filter(|cell| cell.card.is_some()).count(), 30); + assert_eq!(board.cells.iter().filter(|cell| cell.card.is_none()).count(), 6); + assert!(find_eliminations(&board).is_empty()); + assert!(has_playable_move(&board)); + } + #[test] fn one_by_two_neighbors_are_not_half_locked() { let board = board_from_cards( @@ -1350,7 +1456,7 @@ mod tests { } #[test] - fn reaching_target_clears_without_empty_board_keeps_playing() { + fn reaching_target_clears_does_not_complete_level_with_remaining_cards() { let board = board_from_cards( 3, vec![ @@ -1376,7 +1482,7 @@ mod tests { 100, ) .expect("run should start"); - run.clears_done = 4; + run.clears_done = 14; let next = apply_puzzle_clear_swap( &run, PuzzleClearMove { @@ -1389,11 +1495,57 @@ mod tests { ) .expect("swap should resolve"); - assert_eq!(next.clears_done, 5); + assert_eq!(next.clears_done, 15); assert_eq!(next.status, PuzzleClearRunStatus::Playing); + assert!(next.finished_at_ms.is_none()); assert!(next.board.cells.iter().any(|cell| cell.card.is_some())); } + #[test] + fn reaching_target_clears_completes_level_after_all_cards_are_removed() { + let board = board_from_cards( + 3, + vec![ + Some(card("play", 0, 0)), + None, + None, + None, + Some(card("play", 1, 0)), + None, + None, + None, + None, + ], + ); + let mut run = start_puzzle_clear_run( + "run-target-empty".to_string(), + "user-1".to_string(), + "profile-1".to_string(), + board, + PuzzleClearDeck { + ready_columns: vec![vec![], vec![], vec![]], + }, + 100, + ) + .expect("run should start"); + run.clears_done = 14; + let next = apply_puzzle_clear_swap( + &run, + PuzzleClearMove { + from_row: 1, + from_col: 1, + to_row: 0, + to_col: 1, + }, + 200, + ) + .expect("swap should resolve"); + + assert_eq!(next.clears_done, 15); + assert_eq!(next.status, PuzzleClearRunStatus::LevelCleared); + assert!(next.board.cells.iter().all(|cell| cell.card.is_none())); + } + #[test] fn refill_keeps_locked_partial_group_in_place() { let mut board = board_from_cards( @@ -1737,6 +1889,57 @@ mod tests { ); } + #[test] + fn two_by_two_l_shaped_partial_group_locks_as_one_group() { + let board = board_from_cards( + 3, + vec![ + Some(card_shape("block", PuzzleClearShapeKind::TwoByTwo, 0, 0)), + Some(card_shape("block", PuzzleClearShapeKind::TwoByTwo, 1, 0)), + Some(card("noise-a", 0, 0)), + Some(card_shape("block", PuzzleClearShapeKind::TwoByTwo, 0, 1)), + Some(card("noise-b", 0, 0)), + Some(card("play", 1, 0)), + Some(card("noise-d", 0, 0)), + Some(card("play", 0, 0)), + Some(card("noise-c", 0, 0)), + ], + ); + let run = start_puzzle_clear_run( + "run-2x2-partial".to_string(), + "user-1".to_string(), + "profile-1".to_string(), + board, + PuzzleClearDeck { + ready_columns: vec![vec![], vec![], vec![]], + }, + 100, + ) + .expect("run should start"); + + let locked = apply_puzzle_clear_swap( + &run, + PuzzleClearMove { + from_row: 1, + from_col: 1, + to_row: 2, + to_col: 0, + }, + 200, + ) + .expect("non-clear swap should lock partial group"); + + let block_locks = locked + .board + .cells + .iter() + .filter(|cell| cell.card.as_ref().is_some_and(|card| card.group_id == "block")) + .map(|cell| cell.locked_group_id.as_deref()) + .collect::>(); + assert_eq!(block_locks, vec![Some("block"), Some("block"), Some("block")]); + assert_eq!(locked.clears_done, 0); + } + #[test] fn timeout_fails_only_current_level_and_retry_restarts_it() { let board = board_from_cards( diff --git a/server-rs/crates/spacetime-module/src/puzzle_clear.rs b/server-rs/crates/spacetime-module/src/puzzle_clear.rs index ce917767..3091fda7 100644 --- a/server-rs/crates/spacetime-module/src/puzzle_clear.rs +++ b/server-rs/crates/spacetime-module/src/puzzle_clear.rs @@ -6,8 +6,8 @@ pub use types::*; use crate::{ProcedureContext, ReducerContext, SpacetimeType, Table, Timestamp, json}; use module_puzzle_clear::{ - PUZZLE_CLEAR_LEVEL_DURATION_SECONDS, PuzzleClearBoard, PuzzleClearCard, PuzzleClearDeck, - PuzzleClearMove, PuzzleClearRunSnapshot as DomainRunSnapshot, advance_puzzle_clear_level, + PuzzleClearBoard, PuzzleClearCard, PuzzleClearDeck, PuzzleClearMove, + PuzzleClearRunSnapshot as DomainRunSnapshot, advance_puzzle_clear_level, apply_puzzle_clear_swap, create_puzzle_clear_board, fail_puzzle_clear_level_on_timeout, parse_puzzle_clear_orientation, parse_puzzle_clear_shape_kind, puzzle_clear_level_configs, retry_puzzle_clear_level, start_puzzle_clear_run, @@ -976,6 +976,7 @@ fn build_level_board_and_deck( .collect::>(), seed, level.target_clears as usize, + &level.unlocked_shapes, ); let board = create_puzzle_clear_board(&level, seed, allowed.clone()) .map_err(|error| error.to_string())?; @@ -991,6 +992,7 @@ fn ordered_level_cards( cards: Vec, seed: &str, target_groups: usize, + unlocked_shapes: &[module_puzzle_clear::PuzzleClearShapeKind], ) -> Vec { let mut groups: BTreeMap> = BTreeMap::new(); for card in cards { @@ -1008,9 +1010,40 @@ fn ordered_level_cards( .unwrap_or_default(); left_key.cmp(&right_key) }); - grouped + let mut selected = Vec::new(); + let mut selected_group_ids = std::collections::BTreeSet::new(); + for shape in unlocked_shapes { + if selected.len() >= target_groups { + break; + } + let Some(group) = grouped.iter().find(|group| { + group + .first() + .is_some_and(|card| card.shape == *shape && !selected_group_ids.contains(&card.group_id)) + }) else { + continue; + }; + if let Some(first) = group.first() { + selected_group_ids.insert(first.group_id.clone()); + selected.push(group.clone()); + } + } + for group in grouped { + if selected.len() >= target_groups { + break; + } + let Some(first) = group.first() else { + continue; + }; + if selected_group_ids.contains(&first.group_id) { + continue; + } + selected_group_ids.insert(first.group_id.clone()); + selected.push(group); + } + + selected .into_iter() - .take(target_groups) .flat_map(|mut group| { group.sort_by_key(|card| (card.part_y, card.part_x)); group @@ -1061,7 +1094,7 @@ fn build_runtime_snapshot( level_index: snapshot.level_index, clears_done: snapshot.clears_done, target_clears: level.target_clears, - level_duration_seconds: PUZZLE_CLEAR_LEVEL_DURATION_SECONDS, + level_duration_seconds: level.duration_seconds, level_started_at_ms: snapshot.level_started_at_ms, board: PuzzleClearBoardSnapshot { rows: snapshot.board.rows, diff --git a/src/components/platform-entry/PlatformEntryFlowShellImpl.tsx b/src/components/platform-entry/PlatformEntryFlowShellImpl.tsx index 16d24b6c..88a8c502 100644 --- a/src/components/platform-entry/PlatformEntryFlowShellImpl.tsx +++ b/src/components/platform-entry/PlatformEntryFlowShellImpl.tsx @@ -10399,17 +10399,52 @@ export function PlatformEntryFlowShellImpl({ setSelectionStage, ]); - const publishPuzzleClearDraft = useCallback(async () => { + const publishPuzzleClearDraft = useCallback(async (metadata?: { + workTitle: string; + workDescription: string; + }) => { const profileId = puzzleClearWork?.summary.profileId?.trim(); if (!profileId) { setPuzzleClearError('拼消消è‰ç¨¿å°šæœªç”Ÿæˆå¯å‘布作å“。'); setSelectionStage('puzzle-clear-result'); - return; + return false; } setIsPuzzleClearBusy(true); setPuzzleClearError(null); try { + let currentWork = puzzleClearWork; + const normalizedTitle = metadata?.workTitle.trim() ?? ''; + const normalizedDescription = metadata?.workDescription.trim() ?? ''; + if (metadata && normalizedTitle) { + const sessionId = + puzzleClearSession?.sessionId?.trim() || + puzzleClearWork?.summary.sourceSessionId?.trim() || + ''; + if (!sessionId) { + setPuzzleClearError('拼消消è‰ç¨¿ç¼ºå°‘会è¯ä¿¡æ¯ï¼Œæš‚时无法ä¿å­˜å‘布资料。'); + setSelectionStage('puzzle-clear-result'); + return false; + } + const updateResponse = await puzzleClearClient.executeAction(sessionId, { + actionType: 'update-work-meta', + profileId, + workTitle: normalizedTitle, + workDescription: normalizedDescription, + }); + currentWork = updateResponse.work ?? currentWork; + setPuzzleClearSession(updateResponse.session); + if (currentWork) { + setPuzzleClearWork(currentWork); + writeCreationUrlState( + buildPuzzleClearCreationUrlState({ + session: updateResponse.session, + work: currentWork, + }), + ); + } + } + const response = await puzzleClearClient.publishWork(profileId); setPuzzleClearWork(response.item); setPuzzleClearWorks((current) => [ @@ -10433,17 +10468,20 @@ export function PlatformEntryFlowShellImpl({ publicWorkCode, stage: 'work-detail', }); + return true; } catch (error) { setPuzzleClearError( resolveRpgCreationErrorMessage(error, 'å‘布拼消消作å“失败。'), ); setSelectionStage('puzzle-clear-result'); + return false; } finally { setIsPuzzleClearBusy(false); } }, [ openPublishShareModal, - puzzleClearWork?.summary.profileId, + puzzleClearSession?.sessionId, + puzzleClearWork, refreshPuzzleClearGallery, refreshPuzzleClearShelf, setSelectionStage, diff --git a/src/components/puzzle-clear-creation/PuzzleClearWorkspace.test.tsx b/src/components/puzzle-clear-creation/PuzzleClearWorkspace.test.tsx index 3425ddac..d72a28b2 100644 --- a/src/components/puzzle-clear-creation/PuzzleClearWorkspace.test.tsx +++ b/src/components/puzzle-clear-creation/PuzzleClearWorkspace.test.tsx @@ -76,7 +76,7 @@ beforeEach(() => { vi.mocked(readPuzzleReferenceImageAsDataUrl).mockReset(); }); -test('å·¥ä½œå°æäº¤ç»“æž„åŒ–è¡¨å•ä¸Žåº•å›¾æ§½ä½ payload', async () => { +test('工作å°åªæäº¤æ¸¸æˆå†…容é…ç½®ä¸Žåº•å›¾æ§½ä½ payload', async () => { const response = createSessionResponse(); const onSubmitted = vi.fn(); vi.mocked(puzzleClearClient.createSession).mockResolvedValue(response); @@ -91,19 +91,17 @@ test('å·¥ä½œå°æäº¤ç»“æž„åŒ–è¡¨å•ä¸Žåº•å›¾æ§½ä½ payload', async () => { />, ); - fireEvent.change(screen.getByLabelText('ä½œå“æ ‡é¢˜'), { - target: { value: ' 星港拼消消 ' }, - }); - fireEvent.change(screen.getByLabelText('简介'), { - target: { value: ' 霓虹星港主题 ' }, - }); - fireEvent.change(screen.getByLabelText('主题è¯'), { + expect(screen.getByText('拼消消创作')).not.toBeNull(); + expect(screen.queryByLabelText('ä½œå“æ ‡é¢˜')).toBeNull(); + expect(screen.queryByLabelText('简介')).toBeNull(); + + fireEvent.change(screen.getByLabelText('å¡ç‰Œç´ æä¸»é¢˜'), { target: { value: ' 霓虹星港 ' }, }); - fireEvent.change(screen.getByLabelText('场地底图'), { + fireEvent.change(screen.getByLabelText('ç”»é¢æè¿°'), { target: { value: '星港中央棋盘底图' }, }); - fireEvent.change(screen.getByLabelText('上传底图'), { + fireEvent.change(screen.getByLabelText('上传背景图'), { target: { files: [ new File(['fake-image'], 'board.png', { @@ -122,8 +120,8 @@ test('å·¥ä½œå°æäº¤ç»“æž„åŒ–è¡¨å•ä¸Žåº•å›¾æ§½ä½ payload', async () => { await waitFor(() => expect(puzzleClearClient.createSession).toHaveBeenCalledWith({ templateId: 'puzzle-clear', - workTitle: '星港拼消消', - workDescription: '霓虹星港主题', + workTitle: '霓虹星港拼消消', + workDescription: '', themePrompt: '霓虹星港', boardBackgroundPrompt: '星港中央棋盘底图', generateBoardBackground: false, @@ -138,7 +136,7 @@ test('å·¥ä½œå°æäº¤ç»“æž„åŒ–è¡¨å•ä¸Žåº•å›¾æ§½ä½ payload', async () => { response, expect.objectContaining({ templateId: 'puzzle-clear', - workTitle: '星港拼消消', + workTitle: '霓虹星港拼消消', themePrompt: '霓虹星港', }), ); @@ -155,7 +153,29 @@ test('工作å°ä¸æ¸²æŸ“èŠå¤©å¼ Agent 输入', () => { expect(screen.queryByText(/å‘逿¶ˆæ¯|èŠå¤©|对è¯|输入想法/u)).toBeNull(); }); -test('关闭 AI 生æˆåº•图且未上传底图时ä¸å…许æäº¤', async () => { +test('背景图上传区ä¸å†å¥—å¤–å±‚ä¿¡æ¯æ¡†', () => { + const { container } = render( + , + ); + + const uploadInput = screen.getByLabelText('上传背景图', { + selector: 'input', + }); + const uploadCard = uploadInput.closest('.puzzle-image-upload-card'); + + expect(uploadCard).not.toBeNull(); + expect(uploadCard?.closest('.platform-subpanel')).toBeNull(); + expect(container.querySelector('.puzzle-image-upload-card')).toBeTruthy(); + expect(screen.getByText('背景图')).toBeTruthy(); + expect(screen.getByText('上传图片/å¡«å†™ç”»é¢æè¿°')).toBeTruthy(); + expect(screen.queryByText('中央底图')).toBeNull(); + expect(screen.queryByLabelText('场地底图画é¢')).toBeNull(); +}); + +test('工作å°ä¿ç•™èƒŒæ™¯å›¾å‘½åå’Œç”»é¢æè¿°è¾“å…¥', () => { render( { />, ); - fireEvent.change(screen.getByLabelText('ä½œå“æ ‡é¢˜'), { - target: { value: '星港拼消消' }, - }); - fireEvent.change(screen.getByLabelText('主题è¯'), { + expect(screen.getByText('背景图')).toBeTruthy(); + expect(screen.getByLabelText('ç”»é¢æè¿°')).toBeTruthy(); + expect(screen.queryByText('上传图åƒ')).toBeNull(); +}); + +test('工作å°å¯ä»…用主题进入 AI ç”Ÿæˆæµç¨‹', async () => { + const response = createSessionResponse(); + vi.mocked(puzzleClearClient.createSession).mockResolvedValue(response); + + render( + , + ); + + fireEvent.change(screen.getByLabelText('å¡ç‰Œç´ æä¸»é¢˜'), { target: { value: '霓虹星港' }, }); - fireEvent.click(screen.getByRole('checkbox', { name: 'AI 生æˆåº•图' })); expect( (screen.getByRole('button', { name: '生æˆ' }) as HTMLButtonElement).disabled, - ).toBe(true); + ).toBe(false); fireEvent.click(screen.getByRole('button', { name: '生æˆ' })); await waitFor(() => - expect(puzzleClearClient.createSession).not.toHaveBeenCalled(), + expect(puzzleClearClient.createSession).toHaveBeenCalledWith( + expect.objectContaining({ + themePrompt: '霓虹星港', + boardBackgroundPrompt: '', + generateBoardBackground: true, + boardBackgroundAsset: null, + }), + ), ); }); @@ -194,13 +233,10 @@ test('å·¥ä½œå°æ”¯æŒåŽŸç”Ÿè¡¨å•æäº¤ç”Ÿæˆ', async () => { />, ); - fireEvent.change(screen.getByLabelText('ä½œå“æ ‡é¢˜'), { - target: { value: '星港拼消消' }, - }); - fireEvent.change(screen.getByLabelText('主题è¯'), { + fireEvent.change(screen.getByLabelText('å¡ç‰Œç´ æä¸»é¢˜'), { target: { value: '霓虹星港' }, }); - fireEvent.change(screen.getByLabelText('场地底图'), { + fireEvent.change(screen.getByLabelText('ç”»é¢æè¿°'), { target: { value: '星港中央棋盘底图' }, }); @@ -216,7 +252,7 @@ test('å·¥ä½œå°æ”¯æŒåŽŸç”Ÿè¡¨å•æäº¤ç”Ÿæˆ', async () => { response, expect.objectContaining({ templateId: 'puzzle-clear', - workTitle: '星港拼消消', + workTitle: '霓虹星港拼消消', }), ); }); diff --git a/src/components/puzzle-clear-creation/PuzzleClearWorkspace.tsx b/src/components/puzzle-clear-creation/PuzzleClearWorkspace.tsx index 75734d04..f9fa8a30 100644 --- a/src/components/puzzle-clear-creation/PuzzleClearWorkspace.tsx +++ b/src/components/puzzle-clear-creation/PuzzleClearWorkspace.tsx @@ -21,8 +21,6 @@ type PuzzleClearWorkspaceProps = { }; type PuzzleClearWorkspaceFormState = { - workTitle: string; - workDescription: string; themePrompt: string; boardBackgroundPrompt: string; boardBackgroundAsset: PuzzleClearImageAsset | null; @@ -31,8 +29,6 @@ type PuzzleClearWorkspaceFormState = { }; const DEFAULT_FORM_STATE: PuzzleClearWorkspaceFormState = { - workTitle: '', - workDescription: '', themePrompt: '', boardBackgroundPrompt: '', boardBackgroundAsset: null, @@ -56,6 +52,15 @@ function buildLocalBoardBackgroundAsset( }; } +function derivePuzzleClearDraftTitle(themePrompt: string) { + const normalizedTheme = themePrompt.trim(); + if (!normalizedTheme) { + return '拼消消è‰ç¨¿'; + } + const suffix = normalizedTheme.endsWith('拼消消') ? '' : '拼消消'; + return `${normalizedTheme}${suffix}`.slice(0, 30); +} + export function PuzzleClearWorkspace({ isBusy = false, error = null, @@ -78,12 +83,8 @@ export function PuzzleClearWorkspace({ const canSubmit = useMemo( () => - Boolean( - formState.workTitle.trim() && - formState.themePrompt.trim() && - hasBoardBackgroundInput, - ), - [formState.themePrompt, formState.workTitle, hasBoardBackgroundInput], + Boolean(formState.themePrompt.trim() && hasBoardBackgroundInput), + [formState.themePrompt, hasBoardBackgroundInput], ); const handleSubmit = async () => { @@ -107,8 +108,8 @@ export function PuzzleClearWorkspace({ : null); const payload: PuzzleClearWorkspaceCreateRequest = { templateId: 'puzzle-clear', - workTitle: formState.workTitle.trim(), - workDescription: formState.workDescription.trim(), + workTitle: derivePuzzleClearDraftTitle(formState.themePrompt), + workDescription: '', themePrompt: formState.themePrompt.trim(), boardBackgroundPrompt: formState.boardBackgroundPrompt.trim(), generateBoardBackground: formState.generateBoardBackground, @@ -133,125 +134,76 @@ export function PuzzleClearWorkspace({ }} className="platform-remap-surface mx-auto flex h-full min-h-0 w-full max-w-5xl flex-col px-3 pb-3 pt-3 sm:px-4 sm:pt-4" > -
+
-
-
- +
+
+

+ 拼消消创作 +

+ + BETA + +
+
-