fix: 完善拼消消模板运行规则

This commit is contained in:
2026-06-11 00:50:18 +08:00
parent c98c3de96d
commit 21ac5642e8
19 changed files with 1952 additions and 317 deletions

View File

@@ -16,10 +16,10 @@
--- ---
## 2026-06-03 拼消消收敛为关 6x6 与 4-sheet 素材策略 ## 2026-06-03 拼消消收敛为 4 关 6x6 与 4-sheet 素材策略
- 背景:最初 4 关 / 135 次消除 / 单张大 atlas 方案生图数量和空间一致性成本过高,真实 image2 结果容易被布局提示词诱导成带文字、边框或编号的说明图,不适合运行态 1x1 切片。 - 背景:最初 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 / 技术方案 / 平台链路文档。 - 影响范围:`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` - 验证方式:`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` - 关联文档:`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 结果,不把胜负、补牌或消除裁决做成前端事实源。 - 决策:`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}` - 补充约束:拼消消结果页草稿试玩使用前端本地 `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 前端分流。 - 影响范围:`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` - 验证方式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`

View File

@@ -120,6 +120,22 @@
- 验证:`npm run test -- src/components/puzzle-clear-runtime/PuzzleClearRuntimeShell.test.tsx`,浏览器里确认局部拼合会闪、完整消除会放大淡出、补牌在淡出后段才开始掉落。 - 验证:`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` - 关联:`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 ## 首页推荐分流参数不能条件性调用 hook
- 现象:桌面首页或移动首页在 HMR、断点切换或重新渲染后直接报 React hook 顺序错误,页面停在“正在加载内容”。 - 现象:桌面首页或移动首页在 HMR、断点切换或重新渲染后直接报 React hook 顺序错误,页面停在“正在加载内容”。
@@ -1825,6 +1841,22 @@
- 验证:浏览器拖拽时能看到跟手 ghost、源位空槽、落点飞入和整组拼接层`src/components/puzzle-clear-runtime/PuzzleClearRuntimeShell.test.tsx` 应覆盖这些行为。 - 验证:浏览器拖拽时能看到跟手 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` - 关联:`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`
## 拼消消空格位必须允许落位,不能当成不可交互死格 ## 拼消消空格位必须允许落位,不能当成不可交互死格
- 现象:运行到某一关后,棋盘里出现空格位,用户能看见空洞但拖不进去,也点不动。 - 现象:运行到某一关后,棋盘里出现空格位,用户能看见空洞但拖不进去,也点不动。

View File

@@ -17,8 +17,8 @@
- 工作台模式:表单 / 图片输入创作工作台。 - 工作台模式:表单 / 图片输入创作工作台。
- 创作链路:入口 -> 工作台 -> 生成页 -> 结果页 -> 试玩 -> 发布 -> 运行态。 - 创作链路:入口 -> 工作台 -> 生成页 -> 结果页 -> 试玩 -> 发布 -> 运行态。
- 单图资产槽位: - 单图资产槽位:
- `board-background` / `ui-background` / `中央场地底图` / `boardBackgroundPrompt` 优先、空值时回退 `themePrompt`,并支持用户上传图 / 写回 `draft.boardBackgroundAsset``draft.boardBackgroundPrompt``work.boardBackgroundAsset``work.boardBackgroundPrompt` / 允许历史图 / 允许 AI 重绘。 - `board-background` / `ui-background` / `背景图` / `boardBackgroundPrompt` 优先、空值时回退 `themePrompt`,并支持用户上传图片或填写画面描述生图 / 写回 `draft.boardBackgroundAsset``draft.boardBackgroundPrompt``work.boardBackgroundAsset``work.boardBackgroundPrompt` / 允许历史图 / 允许 AI 重绘。
- 中央场地底图的字段名沿用平台表面口径,实际作用是玩家逐步消除清空中央棋盘后慢慢看到的主题目标图AI 生成尺寸必须与中央棋盘一致,使用 1:1 正方形画面。prompt 必须强绑定主题、画面精致、强表现力并一眼体现主题,带来探索、揭开全貌和追求目标完成的感受;不得继续要求“画面干净”或“适合作为卡牌棋盘底图”。 - 背景图实际作用是玩家逐步消除清空中央棋盘后慢慢看到的主题目标图AI 生成尺寸必须与中央棋盘一致,使用 1:1 正方形画面。prompt 必须强绑定主题、画面精致、强表现力并一眼体现主题,带来探索、揭开全貌和追求目标完成的感受;不得继续要求“画面干净”或“适合作为卡牌棋盘底图”。
- 系列素材槽位: - 系列素材槽位:
- `batchId=puzzle-clear-pattern-atlas-v1` - `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 卡牌切片。 - `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 | | 卡牌素材主题 | `themePrompt` | 空 | 必填1-80 字 | 生成 prompt 与草稿;工作台内部派生草稿占位标题,不向用户展示作品标题输入 |
| 简介 | `workDescription` | 空 | 0-120 字 | session draft / work profile | | 画面描述 | `boardBackgroundPrompt` | 空 | 0-80 字;为空时背景图生成回退 `themePrompt` | session draft / work profile / 主题目标图生成 prompt |
| 主题词 | `themePrompt` | 空 | 必填1-80 字 | 生成 prompt 与草稿 | | 背景图 | `boardBackgroundAsset` | 空 | 上传图片或 AI 生成至少一种 | 单图资产槽位 |
| 场地底图主题词 | `boardBackgroundPrompt` | 空 | 0-80 字;为空时底图生成回退 `themePrompt` | session draft / work profile / 主题目标图生成 prompt | | AI 生成背景图 | `generateBoardBackground` | `true` | boolean | 生成编排参数 |
| 中央场地底图 | `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 卡牌碎片消除。 - 完成一个复合图案组后,该组所有 1x1 卡牌碎片消除。
- 消除后空位按列由顶部卡牌准备区下落补齐。 - 消除后空位按列由顶部卡牌准备区下落补齐;若顶部没有新牌,则空格留在场上并露出背景图
- 每次补牌至少保证掉落卡中有一张可以与场上剩余某张卡拼接,防止死局。 - 每次补牌至少保证掉落卡中有一张可以与场上剩余某张卡拼接,防止死局。
- 非 2 格消除时,若场上已有局部完成的半锁定拼接组,补牌不得破坏它。 - 非 2 格消除时,若场上已有局部完成的半锁定拼接组,补牌不得破坏它。
- 半锁定拼接组可整体拖动;玩家用外部单格撞入组内某格时,只交换该格,组其余部分保留,组状态退回半完成。 - 半锁定拼接组可整体拖动;玩家用外部单格撞入组内某格时,只交换该格,组其余部分保留,组状态退回半完成。
- 超时只判当前关失败,可重试当前关;完成 35 次目标并清空当前棋盘后整局完成。 - 超时只判当前关失败,可重试当前关;胜利条件永远是消除完本关全部卡牌,达到目标消除数且棋盘与顶部准备区都没有剩余卡牌后进入下一关,完成第 4 关全部卡牌后整局完成。
## 结果页 ## 结果页
结果页展示:素材 atlas、中央场地底图、发布状态、试玩入口和失败重试。结果页不写功能说明类文案,不开放规则编辑器,不新增排行榜配置。 结果页展示:素材 atlas、背景图、发布状态、试玩入口和失败重试。点击发布时弹出发布前检查面板,收集作品标题和简介并保存作品信息后再发布。结果页不写功能说明类文案,不开放规则编辑器,不新增排行榜配置。
## 统计 ## 统计

View File

@@ -28,17 +28,19 @@
| 形状 | 数量 | 单组单元数 | 解锁 | | 形状 | 数量 | 单组单元数 | 解锁 |
| --- | ---: | ---: | --- | | --- | ---: | ---: | --- |
| 1x2 | 23 | 2 | 第 1 关 | | 1x2 | 23 | 2 | 第 1 关 |
| 1x3 | 5 | 3 | 第 1 关 | | 1x3 | 5 | 3 | 第 2 关 |
| 2x2 | 4 | 4 | 第 1 关 | | 2x2 | 4 | 4 | 第 3 关 |
| 2x3 | 3 | 6 | 第 1 关 | | 2x3 | 3 | 6 | 第 4 关 |
流程: 流程:
```text ```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` 已固定以下规则: `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` - 图案组配比:`1x2=23``1x3=5``2x2=4``2x3=3`
- 开局随机铺满并保证至少一步可解。 - 开局只放入本关目标消除数对应的全部卡牌;棋盘放不下的牌进入顶部准备区,牌不足棋盘格数时空格保留;开局仍保证至少一步可解。
- 补牌按列重力下落;补牌后仍保证至少一步可解。 - 补牌按列重力下落;顶部没有新牌时空格留在场上并露出背景图;补牌后若场上仍有卡牌则保证至少一步可解。
- 完整图案组消除并清空对应格。 - 完整图案组消除并清空对应格。
- 半锁定拼接组只由玩家主动交换 / 撞入打散,补牌不破坏。 - 半锁定拼接组只由玩家主动交换 / 撞入打散,补牌不破坏。
- 超时失败只作用于当前关,可重试;完成 35 次消除目标并清空棋盘后整局完成。 - 超时失败只作用于当前关,可重试;胜利条件永远是消除完本关全部卡牌,达到目标消除数且棋盘与顶部准备区都没有剩余卡牌后进入下一关,第 4 关全部卡牌消除后整局完成。
## API 命名空间 ## API 命名空间
@@ -94,7 +96,7 @@ api-server 路由熔断使用 SpacetimeDB 创作入口配置 `puzzle-clear`
正式 `published` run 记录开局、全局完成、当前关失败、耗时和消除统计。runtime action 返回的终态事件包括: 正式 `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` - `level-failed`:当前关超时失败,结果 JSON 至少包含 `status``level``clears``clearDelta``elapsedMs`
草稿试玩只消费同一份 snapshot/action 结果做表现,不写正式统计。 草稿试玩只消费同一份 snapshot/action 结果做表现,不写正式统计。
@@ -108,7 +110,7 @@ api-server 路由熔断使用 SpacetimeDB 创作入口配置 `puzzle-clear`
- `puzzle-clear-result` -> `/creation/puzzle-clear/result` - `puzzle-clear-result` -> `/creation/puzzle-clear/result`
- `puzzle-clear-runtime` -> `/runtime/puzzle-clear` - `puzzle-clear-runtime` -> `/runtime/puzzle-clear`
runtime 移动端优先,首屏结构为顶部倒计时 / 关铭牌、顶部列准备区、棋盘、失败 / 完成弹层。棋盘主网格、半锁定组覆盖层和消除 / 掉落覆盖层统一使用 1.5px 格间距。动画包括开场翻转、局部正确拼合高光、完整消除放大淡出列补牌延迟下落,不再有下一关切换。消除和补牌动画只能作为当前后端 snapshot 的表现层覆盖;已有场上卡片因重力下沉后的最终格不得被旧消除坐标或掉落覆盖层隐藏,避免出现“下方空位但上方卡片未下落”的视觉假象;新补入卡牌应等完整消除淡出进入尾段后再播放下落反馈。 runtime 移动端优先,首屏结构为顶部倒计时 / 关铭牌、顶部列准备区、棋盘、失败 / 完成弹层。棋盘主网格、半锁定组覆盖层和消除 / 掉落覆盖层统一使用 1.5px 格间距。动画包括开场翻转、局部正确拼合高光、完整消除放大淡出列补牌延迟下落和关卡完成后的下一关切换。消除和补牌动画只能作为当前后端 snapshot 的表现层覆盖;已有场上卡片因重力下沉后的最终格不得被旧消除坐标或掉落覆盖层隐藏,避免出现“下方空位但上方卡片未下落”的视觉假象;新补入卡牌应等完整消除淡出进入尾段后再播放下落反馈。列补牌下落的过渡层生命周期必须覆盖 `delay + duration + settle buffer`,并按下落距离延长动画时长,避免叠层在延迟后刚出现就被卸载;下落叠层不得带白色背景、白色边框或提亮滤镜,卡片图本身负责视觉主体,避免下滑时白闪。拖拽覆盖替换时,拖动卡由拖拽 ghost 落到目标格,被覆盖的目标卡才使用替换飞行层回到源空位;替换飞行层同样不得带白底、白边或白色外壳,避免目标卡在新位置先闪白再显示。
## 验证计划 ## 验证计划

View File

@@ -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次消除` 1. 4 关棋盘均固定为 `6x6`
2. 每局固定 10 分钟;超时只判当前关失败,可重试当前关。 2. 第 1 / 2 / 3 / 4 关目标分别为 `15 / 20 / 30 / 35` 次消除,限时分别为 `5 / 5 / 7 / 10` 分钟;超时只判当前关失败,可重试当前关。
3. 当前关直接出现 `1x2``1x3``2x2``2x3` 3. 第 1 关仅出现 `1x2`;第 2 关出现 `1x2``1x3`;第 3 关出现 `1x2``1x3``2x2`;第 4 关出现 `1x2``1x3``2x2``2x3`
4. 开局棋盘随机铺满并保证至少一步可解;补牌后也必须由后端保证至少一步可解。 4. 开局只放入本关目标消除数对应的全部卡牌,棋盘放不下的牌进入顶部准备区,牌不足棋盘格数时空格保留;开局仍保证至少一步可解。
5. 顶部卡牌准备区按纵列补位,某列有空格时该列卡牌从顶部下落。 5. 顶部卡牌准备区按纵列补位,某列有空格时该列卡牌从顶部下落;顶部没有新牌时空格留在场上并露出背景图。胜利条件永远是消除完本关全部卡牌,达到目标消除数且棋盘与顶部准备区都没有剩余卡牌后才进入下一关
6. 非 2 格消除时,补牌不得破坏已完成局部;只有玩家主动交换或撞入才允许打散半锁定拼接组。 6. 非 2 格消除时,补牌不得破坏已完成局部;只有玩家主动交换或撞入才允许打散半锁定拼接组。
7. 正式 runtime 只消费后端 snapshot 与 action 结果;前端负责开局翻转、拖拽、掉落、消除和弹层动画。 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`,供基础统计与排障回读。 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` 新增阶段为 `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`

View File

@@ -25,6 +25,8 @@ use shared_kernel::{build_prefixed_uuid_id, format_timestamp_micros};
use spacetime_client::SpacetimeClientError; use spacetime_client::SpacetimeClientError;
use std::{ use std::{
collections::BTreeMap, collections::BTreeMap,
env, fs,
path::{Path as FsPath, PathBuf},
time::{SystemTime, UNIX_EPOCH}, time::{SystemTime, UNIX_EPOCH},
}; };
@@ -38,8 +40,8 @@ use crate::{
}, },
http_error::AppError, http_error::AppError,
openai_image_generation::{ openai_image_generation::{
DownloadedOpenAiImage, build_openai_image_http_client, create_openai_image_generation, DownloadedOpenAiImage, build_openai_image_http_client, build_openai_image_request_body,
require_openai_image_settings, create_openai_image_generation, require_openai_image_settings,
}, },
request_context::RequestContext, request_context::RequestContext,
state::AppState, state::AppState,
@@ -599,6 +601,721 @@ struct PuzzleClearGeneratedSheet {
image: DownloadedOpenAiImage, 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::<Vec<_>>(),
"layoutPrompt": spec.layout_prompt,
})
})
.collect::<Vec<_>>();
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::<Vec<_>>();
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::<Vec<_>>(),
}),
"记录拼消消最终 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/<sheet-attempt>/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<FsPath>,
content: impl AsRef<str>,
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<FsPath>, 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<FsPath>,
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<PuzzleClearImageDebugRun> {
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<Value, 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 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( async fn maybe_prepare_puzzle_clear_assets_inner(
state: &AppState, state: &AppState,
request_context: &RequestContext, request_context: &RequestContext,
@@ -633,8 +1350,13 @@ async fn maybe_prepare_puzzle_clear_assets_inner(
.map(ToString::to_string) .map(ToString::to_string)
.unwrap_or_else(|| { .unwrap_or_else(|| {
build_prefixed_uuid_id(module_puzzle_clear::PUZZLE_CLEAR_PROFILE_ID_PREFIX) build_prefixed_uuid_id(module_puzzle_clear::PUZZLE_CLEAR_PROFILE_ID_PREFIX)
}); });
payload.profile_id = Some(profile_id.clone()); 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) if payload.generate_board_background.unwrap_or(false)
&& payload && payload
@@ -654,6 +1376,7 @@ async fn maybe_prepare_puzzle_clear_assets_inner(
owner_user_id, owner_user_id,
profile_id.as_str(), profile_id.as_str(),
board_background_prompt.unwrap_or(theme_prompt), board_background_prompt.unwrap_or(theme_prompt),
image_debug_run.as_ref(),
) )
.await?; .await?;
payload.board_background_asset = Some(background_asset); 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)) .map(|group| (group.group_id.clone(), group))
.collect::<BTreeMap<_, _>>(); .collect::<BTreeMap<_, _>>();
let sheet_specs = puzzle_clear_atlas_sheet_specs(); 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) let settings = require_openai_image_settings(state)
.map(|settings| { .map(|settings| {
settings.with_external_api_audit_context( settings.with_external_api_audit_context(
@@ -710,6 +1436,9 @@ async fn maybe_prepare_puzzle_clear_assets_inner(
sheet_spec.sheet_id, sheet_spec.sheet_id,
attempt_index + 1 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( let generated = match create_openai_image_generation(
&http_client, &http_client,
&settings, &settings,
@@ -727,6 +1456,9 @@ async fn maybe_prepare_puzzle_clear_assets_inner(
if attempt_index + 1 < PUZZLE_CLEAR_SHEET_GENERATION_MAX_ATTEMPTS if attempt_index + 1 < PUZZLE_CLEAR_SHEET_GENERATION_MAX_ATTEMPTS
&& is_retryable_puzzle_clear_sheet_generation_error(&error) => && 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!( tracing::warn!(
provider = PUZZLE_CLEAR_CREATION_PROVIDER, provider = PUZZLE_CLEAR_CREATION_PROVIDER,
sheet_id = sheet_spec.sheet_id, sheet_id = sheet_spec.sheet_id,
@@ -737,6 +1469,9 @@ async fn maybe_prepare_puzzle_clear_assets_inner(
continue; continue;
} }
Err(error) => { 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( return Err(puzzle_clear_error_response(
request_context, request_context,
PUZZLE_CLEAR_CREATION_PROVIDER, PUZZLE_CLEAR_CREATION_PROVIDER,
@@ -745,6 +1480,7 @@ async fn maybe_prepare_puzzle_clear_assets_inner(
} }
}; };
let task_id = generated.task_id; let task_id = generated.task_id;
let actual_prompt = generated.actual_prompt;
let image = generated.images.into_iter().next().ok_or_else(|| { let image = generated.images.into_iter().next().ok_or_else(|| {
puzzle_clear_error_response( puzzle_clear_error_response(
request_context, 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(()) => { 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 { accepted_sheet = Some(PuzzleClearGeneratedSheet {
spec: sheet_spec, spec: sheet_spec,
prompt: sheet_prompt.clone(), 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| { compose_puzzle_clear_final_atlas(&slices, &groups_by_id).map_err(|error| {
puzzle_clear_error_response(request_context, PUZZLE_CLEAR_CREATION_PROVIDER, 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 let atlas_prompt = generated_sheets
.iter() .iter()
.map(|sheet| format!("{}:\n{}", sheet.spec.sheet_id, sheet.prompt)) .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.atlas_asset = Some(atlas_asset);
payload.pattern_groups = Some(groups); payload.pattern_groups = Some(groups);
payload.card_assets = Some(card_assets); 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!( tracing::info!(
provider = PUZZLE_CLEAR_CREATION_PROVIDER, provider = PUZZLE_CLEAR_CREATION_PROVIDER,
session_id, session_id,
@@ -1054,8 +1823,12 @@ async fn generate_and_persist_puzzle_clear_board_background(
owner_user_id: &str, owner_user_id: &str,
profile_id: &str, profile_id: &str,
theme_prompt: &str, theme_prompt: &str,
image_debug_run: Option<&PuzzleClearImageDebugRun>,
) -> Result<PuzzleClearImageAsset, Response> { ) -> Result<PuzzleClearImageAsset, Response> {
let prompt = build_puzzle_clear_board_background_prompt(theme_prompt); 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) let settings = require_openai_image_settings(state)
.map(|settings| { .map(|settings| {
settings.with_external_api_audit_context( settings.with_external_api_audit_context(
@@ -1082,9 +1855,13 @@ async fn generate_and_persist_puzzle_clear_board_background(
) )
.await .await
.map_err(|error| { .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) puzzle_clear_error_response(request_context, PUZZLE_CLEAR_CREATION_PROVIDER, error)
})?; })?;
let task_id = generated.task_id; let task_id = generated.task_id;
let actual_prompt = generated.actual_prompt;
let image = generated.images.into_iter().next().ok_or_else(|| { let image = generated.images.into_iter().next().ok_or_else(|| {
puzzle_clear_error_response( puzzle_clear_error_response(
request_context, 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( persist_puzzle_clear_generated_image_asset(
state, state,
owner_user_id, owner_user_id,
@@ -2140,7 +2920,11 @@ fn map_puzzle_clear_client_error(error: SpacetimeClientError) -> AppError {
if value.contains("发布需要") if value.contains("发布需要")
|| value.contains("不能为空") || 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 StatusCode::BAD_REQUEST
} }

View File

@@ -3,25 +3,55 @@ use std::collections::{BTreeSet, HashMap, VecDeque};
use shared_kernel::normalize_required_string; use shared_kernel::normalize_required_string;
use crate::{ use crate::{
PUZZLE_CLEAR_LEVEL_DURATION_SECONDS, PuzzleClearBoard, PuzzleClearCard, PuzzleClearCell, PuzzleClearBoard, PuzzleClearCard, PuzzleClearCell, PuzzleClearDeck, PuzzleClearElimination,
PuzzleClearDeck, PuzzleClearElimination, PuzzleClearError, PuzzleClearLevelConfig, PuzzleClearError, PuzzleClearLevelConfig, PuzzleClearMove, PuzzleClearOrientation,
PuzzleClearMove, PuzzleClearOrientation, PuzzleClearPatternGroup, PuzzleClearRunSnapshot, PuzzleClearPatternGroup, PuzzleClearRunSnapshot, PuzzleClearRunStatus, PuzzleClearShapeKind,
PuzzleClearRunStatus, PuzzleClearShapeKind, PuzzleClearShapeQuota, PuzzleClearShapeQuota,
}; };
pub fn puzzle_clear_level_configs() -> Vec<PuzzleClearLevelConfig> { pub fn puzzle_clear_level_configs() -> Vec<PuzzleClearLevelConfig> {
vec![PuzzleClearLevelConfig { vec![
level_index: 1, PuzzleClearLevelConfig {
board_size: 6, level_index: 1,
target_clears: 35, board_size: 6,
duration_seconds: PUZZLE_CLEAR_LEVEL_DURATION_SECONDS, target_clears: 15,
unlocked_shapes: vec![ duration_seconds: 300,
PuzzleClearShapeKind::OneByTwo, unlocked_shapes: vec![PuzzleClearShapeKind::OneByTwo],
PuzzleClearShapeKind::OneByThree, },
PuzzleClearShapeKind::TwoByTwo, PuzzleClearLevelConfig {
PuzzleClearShapeKind::TwoByThree, 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<PuzzleClearShapeQuota> { pub fn puzzle_clear_shape_quotas() -> Vec<PuzzleClearShapeQuota> {
@@ -114,7 +144,7 @@ pub fn create_puzzle_clear_board(
return Err(PuzzleClearError::InvalidLevel); return Err(PuzzleClearError::InvalidLevel);
} }
let total = (level.board_size * level.board_size) as usize; let total = (level.board_size * level.board_size) as usize;
if cards.len() < total { if cards.is_empty() {
return Err(PuzzleClearError::EmptyDeck); return Err(PuzzleClearError::EmptyDeck);
} }
let mut rng = DeterministicRng::new(seed, &format!("level-{}", level.level_index)); 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 row in 0..level.board_size {
for col in 0..level.board_size { for col in 0..level.board_size {
let index = (row * level.board_size + col) as usize; 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 { cells.push(PuzzleClearCell {
row, row,
col, col,
card: selected.get(index).cloned(), card: card_index.and_then(|card_index| selected.get(card_index).cloned()),
locked_group_id: None, locked_group_id: None,
}); });
} }
@@ -202,7 +234,7 @@ pub fn apply_puzzle_clear_swap(
.into_iter() .into_iter()
.find(|config| config.level_index == next.level_index) .find(|config| config.level_index == next.level_index)
.ok_or(PuzzleClearError::InvalidLevel)?; .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() { next.status = if next.level_index >= max_puzzle_clear_level_index() {
PuzzleClearRunStatus::Finished PuzzleClearRunStatus::Finished
} else { } 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 { 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) 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> { 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()) 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> { fn ensure_board_has_playable_move(board: &mut PuzzleClearBoard) -> Result<(), PuzzleClearError> {
if find_eliminations(board).is_empty() && has_playable_move(board) { if find_eliminations(board).is_empty() && has_playable_move(board) {
return Ok(()); return Ok(());
@@ -490,15 +536,7 @@ fn find_local_completed_groups(board: &PuzzleClearBoard) -> Vec<PuzzleClearElimi
if entries.len() < 2 || first.shape == PuzzleClearShapeKind::OneByTwo { if entries.len() < 2 || first.shape == PuzzleClearShapeKind::OneByTwo {
return None; return None;
} }
let mut ordered = entries.clone(); is_connected_partial_group(&entries).then(|| PuzzleClearElimination {
ordered.sort_by_key(|(_, _, card)| (card.part_y, card.part_x));
let adjacent = ordered.windows(2).all(|pair| {
let a = &pair[0].2;
let b = &pair[1].2;
manhattan_part_distance(a, b) == 1
&& are_neighbors(pair[0].0, pair[0].1, pair[1].0, pair[1].1)
});
adjacent.then(|| PuzzleClearElimination {
group_id, group_id,
positions: entries positions: entries
.into_iter() .into_iter()
@@ -509,6 +547,32 @@ fn find_local_completed_groups(board: &PuzzleClearBoard) -> Vec<PuzzleClearElimi
.collect() .collect()
} }
fn is_connected_partial_group(entries: &[(u32, u32, PuzzleClearCard)]) -> 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(&current.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) { fn clear_locked_group(board: &mut PuzzleClearBoard, group_id: &str) {
for cell in &mut board.cells { for cell in &mut board.cells {
if cell.locked_group_id.as_deref() == Some(group_id) { if cell.locked_group_id.as_deref() == Some(group_id) {
@@ -1177,14 +1241,40 @@ mod tests {
use super::*; use super::*;
#[test] #[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(); let configs = puzzle_clear_level_configs();
assert_eq!(configs.len(), 1); assert_eq!(configs.len(), 4);
assert_eq!(configs[0].board_size, 6); assert!(configs.iter().all(|config| config.board_size == 6));
assert_eq!(configs[0].target_clears, 35);
assert_eq!( assert_eq!(
configs[0].unlocked_shapes, configs
.iter()
.map(|config| (
config.level_index,
config.target_clears,
config.duration_seconds
))
.collect::<Vec<_>>(),
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![ vec![
PuzzleClearShapeKind::OneByTwo, PuzzleClearShapeKind::OneByTwo,
PuzzleClearShapeKind::OneByThree, PuzzleClearShapeKind::OneByThree,
@@ -1192,7 +1282,6 @@ mod tests {
PuzzleClearShapeKind::TwoByThree, PuzzleClearShapeKind::TwoByThree,
] ]
); );
assert!(configs.iter().all(|config| config.duration_seconds == 600));
} }
#[test] #[test]
@@ -1250,6 +1339,23 @@ mod tests {
assert!(has_playable_move(&board)); 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::<Vec<_>>();
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] #[test]
fn one_by_two_neighbors_are_not_half_locked() { fn one_by_two_neighbors_are_not_half_locked() {
let board = board_from_cards( let board = board_from_cards(
@@ -1350,7 +1456,7 @@ mod tests {
} }
#[test] #[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( let board = board_from_cards(
3, 3,
vec![ vec![
@@ -1376,7 +1482,7 @@ mod tests {
100, 100,
) )
.expect("run should start"); .expect("run should start");
run.clears_done = 4; run.clears_done = 14;
let next = apply_puzzle_clear_swap( let next = apply_puzzle_clear_swap(
&run, &run,
PuzzleClearMove { PuzzleClearMove {
@@ -1389,11 +1495,57 @@ mod tests {
) )
.expect("swap should resolve"); .expect("swap should resolve");
assert_eq!(next.clears_done, 5); assert_eq!(next.clears_done, 15);
assert_eq!(next.status, PuzzleClearRunStatus::Playing); assert_eq!(next.status, PuzzleClearRunStatus::Playing);
assert!(next.finished_at_ms.is_none());
assert!(next.board.cells.iter().any(|cell| cell.card.is_some())); 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] #[test]
fn refill_keeps_locked_partial_group_in_place() { fn refill_keeps_locked_partial_group_in_place() {
let mut board = board_from_cards( 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::<Vec<_>>();
assert_eq!(block_locks, vec![Some("block"), Some("block"), Some("block")]);
assert_eq!(locked.clears_done, 0);
}
#[test] #[test]
fn timeout_fails_only_current_level_and_retry_restarts_it() { fn timeout_fails_only_current_level_and_retry_restarts_it() {
let board = board_from_cards( let board = board_from_cards(

View File

@@ -6,8 +6,8 @@ pub use types::*;
use crate::{ProcedureContext, ReducerContext, SpacetimeType, Table, Timestamp, json}; use crate::{ProcedureContext, ReducerContext, SpacetimeType, Table, Timestamp, json};
use module_puzzle_clear::{ use module_puzzle_clear::{
PUZZLE_CLEAR_LEVEL_DURATION_SECONDS, PuzzleClearBoard, PuzzleClearCard, PuzzleClearDeck, PuzzleClearBoard, PuzzleClearCard, PuzzleClearDeck, PuzzleClearMove,
PuzzleClearMove, PuzzleClearRunSnapshot as DomainRunSnapshot, advance_puzzle_clear_level, PuzzleClearRunSnapshot as DomainRunSnapshot, advance_puzzle_clear_level,
apply_puzzle_clear_swap, create_puzzle_clear_board, fail_puzzle_clear_level_on_timeout, 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, parse_puzzle_clear_orientation, parse_puzzle_clear_shape_kind, puzzle_clear_level_configs,
retry_puzzle_clear_level, start_puzzle_clear_run, retry_puzzle_clear_level, start_puzzle_clear_run,
@@ -976,6 +976,7 @@ fn build_level_board_and_deck(
.collect::<Vec<_>>(), .collect::<Vec<_>>(),
seed, seed,
level.target_clears as usize, level.target_clears as usize,
&level.unlocked_shapes,
); );
let board = create_puzzle_clear_board(&level, seed, allowed.clone()) let board = create_puzzle_clear_board(&level, seed, allowed.clone())
.map_err(|error| error.to_string())?; .map_err(|error| error.to_string())?;
@@ -991,6 +992,7 @@ fn ordered_level_cards(
cards: Vec<PuzzleClearCard>, cards: Vec<PuzzleClearCard>,
seed: &str, seed: &str,
target_groups: usize, target_groups: usize,
unlocked_shapes: &[module_puzzle_clear::PuzzleClearShapeKind],
) -> Vec<PuzzleClearCard> { ) -> Vec<PuzzleClearCard> {
let mut groups: BTreeMap<String, Vec<PuzzleClearCard>> = BTreeMap::new(); let mut groups: BTreeMap<String, Vec<PuzzleClearCard>> = BTreeMap::new();
for card in cards { for card in cards {
@@ -1008,9 +1010,40 @@ fn ordered_level_cards(
.unwrap_or_default(); .unwrap_or_default();
left_key.cmp(&right_key) 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() .into_iter()
.take(target_groups)
.flat_map(|mut group| { .flat_map(|mut group| {
group.sort_by_key(|card| (card.part_y, card.part_x)); group.sort_by_key(|card| (card.part_y, card.part_x));
group group
@@ -1061,7 +1094,7 @@ fn build_runtime_snapshot(
level_index: snapshot.level_index, level_index: snapshot.level_index,
clears_done: snapshot.clears_done, clears_done: snapshot.clears_done,
target_clears: level.target_clears, 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, level_started_at_ms: snapshot.level_started_at_ms,
board: PuzzleClearBoardSnapshot { board: PuzzleClearBoardSnapshot {
rows: snapshot.board.rows, rows: snapshot.board.rows,

View File

@@ -10399,17 +10399,52 @@ export function PlatformEntryFlowShellImpl({
setSelectionStage, setSelectionStage,
]); ]);
const publishPuzzleClearDraft = useCallback(async () => { const publishPuzzleClearDraft = useCallback(async (metadata?: {
workTitle: string;
workDescription: string;
}) => {
const profileId = puzzleClearWork?.summary.profileId?.trim(); const profileId = puzzleClearWork?.summary.profileId?.trim();
if (!profileId) { if (!profileId) {
setPuzzleClearError('拼消消草稿尚未生成可发布作品。'); setPuzzleClearError('拼消消草稿尚未生成可发布作品。');
setSelectionStage('puzzle-clear-result'); setSelectionStage('puzzle-clear-result');
return; return false;
} }
setIsPuzzleClearBusy(true); setIsPuzzleClearBusy(true);
setPuzzleClearError(null); setPuzzleClearError(null);
try { 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); const response = await puzzleClearClient.publishWork(profileId);
setPuzzleClearWork(response.item); setPuzzleClearWork(response.item);
setPuzzleClearWorks((current) => [ setPuzzleClearWorks((current) => [
@@ -10433,17 +10468,20 @@ export function PlatformEntryFlowShellImpl({
publicWorkCode, publicWorkCode,
stage: 'work-detail', stage: 'work-detail',
}); });
return true;
} catch (error) { } catch (error) {
setPuzzleClearError( setPuzzleClearError(
resolveRpgCreationErrorMessage(error, '发布拼消消作品失败。'), resolveRpgCreationErrorMessage(error, '发布拼消消作品失败。'),
); );
setSelectionStage('puzzle-clear-result'); setSelectionStage('puzzle-clear-result');
return false;
} finally { } finally {
setIsPuzzleClearBusy(false); setIsPuzzleClearBusy(false);
} }
}, [ }, [
openPublishShareModal, openPublishShareModal,
puzzleClearWork?.summary.profileId, puzzleClearSession?.sessionId,
puzzleClearWork,
refreshPuzzleClearGallery, refreshPuzzleClearGallery,
refreshPuzzleClearShelf, refreshPuzzleClearShelf,
setSelectionStage, setSelectionStage,

View File

@@ -76,7 +76,7 @@ beforeEach(() => {
vi.mocked(readPuzzleReferenceImageAsDataUrl).mockReset(); vi.mocked(readPuzzleReferenceImageAsDataUrl).mockReset();
}); });
test('工作台提交结构化表单与底图槽位 payload', async () => { test('工作台提交游戏内容配置与底图槽位 payload', async () => {
const response = createSessionResponse(); const response = createSessionResponse();
const onSubmitted = vi.fn(); const onSubmitted = vi.fn();
vi.mocked(puzzleClearClient.createSession).mockResolvedValue(response); vi.mocked(puzzleClearClient.createSession).mockResolvedValue(response);
@@ -91,19 +91,17 @@ test('工作台提交结构化表单与底图槽位 payload', async () => {
/>, />,
); );
fireEvent.change(screen.getByLabelText('作品标题'), { expect(screen.getByText('拼消消创作')).not.toBeNull();
target: { value: ' 星港拼消消 ' }, expect(screen.queryByLabelText('作品标题')).toBeNull();
}); expect(screen.queryByLabelText('简介')).toBeNull();
fireEvent.change(screen.getByLabelText('简介'), {
target: { value: ' 霓虹星港主题 ' }, fireEvent.change(screen.getByLabelText('卡牌素材主题'), {
});
fireEvent.change(screen.getByLabelText('主题词'), {
target: { value: ' 霓虹星港 ' }, target: { value: ' 霓虹星港 ' },
}); });
fireEvent.change(screen.getByLabelText('场地底图'), { fireEvent.change(screen.getByLabelText('画面描述'), {
target: { value: '星港中央棋盘底图' }, target: { value: '星港中央棋盘底图' },
}); });
fireEvent.change(screen.getByLabelText('上传图'), { fireEvent.change(screen.getByLabelText('上传背景图'), {
target: { target: {
files: [ files: [
new File(['fake-image'], 'board.png', { new File(['fake-image'], 'board.png', {
@@ -122,8 +120,8 @@ test('工作台提交结构化表单与底图槽位 payload', async () => {
await waitFor(() => await waitFor(() =>
expect(puzzleClearClient.createSession).toHaveBeenCalledWith({ expect(puzzleClearClient.createSession).toHaveBeenCalledWith({
templateId: 'puzzle-clear', templateId: 'puzzle-clear',
workTitle: '星港拼消消', workTitle: '霓虹星港拼消消',
workDescription: '霓虹星港主题', workDescription: '',
themePrompt: '霓虹星港', themePrompt: '霓虹星港',
boardBackgroundPrompt: '星港中央棋盘底图', boardBackgroundPrompt: '星港中央棋盘底图',
generateBoardBackground: false, generateBoardBackground: false,
@@ -138,7 +136,7 @@ test('工作台提交结构化表单与底图槽位 payload', async () => {
response, response,
expect.objectContaining({ expect.objectContaining({
templateId: 'puzzle-clear', templateId: 'puzzle-clear',
workTitle: '星港拼消消', workTitle: '霓虹星港拼消消',
themePrompt: '霓虹星港', themePrompt: '霓虹星港',
}), }),
); );
@@ -155,7 +153,29 @@ test('工作台不渲染聊天式 Agent 输入', () => {
expect(screen.queryByText(/|||/u)).toBeNull(); expect(screen.queryByText(/|||/u)).toBeNull();
}); });
test('关闭 AI 生成底图且未上传底图时不允许提交', async () => { test('背景图上传区不再套外层信息框', () => {
const { container } = render(
<PuzzleClearWorkspace
onBack={vi.fn()}
onSubmitted={vi.fn()}
/>,
);
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( render(
<PuzzleClearWorkspace <PuzzleClearWorkspace
onBack={vi.fn()} onBack={vi.fn()}
@@ -163,22 +183,41 @@ test('关闭 AI 生成底图且未上传底图时不允许提交', async () => {
/>, />,
); );
fireEvent.change(screen.getByLabelText('作品标题'), { expect(screen.getByText('背景图')).toBeTruthy();
target: { value: '星港拼消消' }, expect(screen.getByLabelText('画面描述')).toBeTruthy();
}); expect(screen.queryByText('上传图像')).toBeNull();
fireEvent.change(screen.getByLabelText('主题词'), { });
test('工作台可仅用主题进入 AI 生成流程', async () => {
const response = createSessionResponse();
vi.mocked(puzzleClearClient.createSession).mockResolvedValue(response);
render(
<PuzzleClearWorkspace
onBack={vi.fn()}
onSubmitted={vi.fn()}
/>,
);
fireEvent.change(screen.getByLabelText('卡牌素材主题'), {
target: { value: '霓虹星港' }, target: { value: '霓虹星港' },
}); });
fireEvent.click(screen.getByRole('checkbox', { name: 'AI 生成底图' }));
expect( expect(
(screen.getByRole('button', { name: '生成' }) as HTMLButtonElement).disabled, (screen.getByRole('button', { name: '生成' }) as HTMLButtonElement).disabled,
).toBe(true); ).toBe(false);
fireEvent.click(screen.getByRole('button', { name: '生成' })); fireEvent.click(screen.getByRole('button', { name: '生成' }));
await waitFor(() => 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('作品标题'), { fireEvent.change(screen.getByLabelText('卡牌素材主题'), {
target: { value: '星港拼消消' },
});
fireEvent.change(screen.getByLabelText('主题词'), {
target: { value: '霓虹星港' }, target: { value: '霓虹星港' },
}); });
fireEvent.change(screen.getByLabelText('场地底图'), { fireEvent.change(screen.getByLabelText('画面描述'), {
target: { value: '星港中央棋盘底图' }, target: { value: '星港中央棋盘底图' },
}); });
@@ -216,7 +252,7 @@ test('工作台支持原生表单提交生成', async () => {
response, response,
expect.objectContaining({ expect.objectContaining({
templateId: 'puzzle-clear', templateId: 'puzzle-clear',
workTitle: '星港拼消消', workTitle: '霓虹星港拼消消',
}), }),
); );
}); });

View File

@@ -21,8 +21,6 @@ type PuzzleClearWorkspaceProps = {
}; };
type PuzzleClearWorkspaceFormState = { type PuzzleClearWorkspaceFormState = {
workTitle: string;
workDescription: string;
themePrompt: string; themePrompt: string;
boardBackgroundPrompt: string; boardBackgroundPrompt: string;
boardBackgroundAsset: PuzzleClearImageAsset | null; boardBackgroundAsset: PuzzleClearImageAsset | null;
@@ -31,8 +29,6 @@ type PuzzleClearWorkspaceFormState = {
}; };
const DEFAULT_FORM_STATE: PuzzleClearWorkspaceFormState = { const DEFAULT_FORM_STATE: PuzzleClearWorkspaceFormState = {
workTitle: '',
workDescription: '',
themePrompt: '', themePrompt: '',
boardBackgroundPrompt: '', boardBackgroundPrompt: '',
boardBackgroundAsset: null, 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({ export function PuzzleClearWorkspace({
isBusy = false, isBusy = false,
error = null, error = null,
@@ -78,12 +83,8 @@ export function PuzzleClearWorkspace({
const canSubmit = useMemo( const canSubmit = useMemo(
() => () =>
Boolean( Boolean(formState.themePrompt.trim() && hasBoardBackgroundInput),
formState.workTitle.trim() && [formState.themePrompt, hasBoardBackgroundInput],
formState.themePrompt.trim() &&
hasBoardBackgroundInput,
),
[formState.themePrompt, formState.workTitle, hasBoardBackgroundInput],
); );
const handleSubmit = async () => { const handleSubmit = async () => {
@@ -107,8 +108,8 @@ export function PuzzleClearWorkspace({
: null); : null);
const payload: PuzzleClearWorkspaceCreateRequest = { const payload: PuzzleClearWorkspaceCreateRequest = {
templateId: 'puzzle-clear', templateId: 'puzzle-clear',
workTitle: formState.workTitle.trim(), workTitle: derivePuzzleClearDraftTitle(formState.themePrompt),
workDescription: formState.workDescription.trim(), workDescription: '',
themePrompt: formState.themePrompt.trim(), themePrompt: formState.themePrompt.trim(),
boardBackgroundPrompt: formState.boardBackgroundPrompt.trim(), boardBackgroundPrompt: formState.boardBackgroundPrompt.trim(),
generateBoardBackground: formState.generateBoardBackground, 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" 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"
> >
<div className="mb-3 flex items-center justify-between gap-3"> <div className="mb-3 flex shrink-0 items-center justify-between gap-3 sm:mb-4">
<button <button
type="button" type="button"
onClick={onBack} onClick={onBack}
className="platform-button platform-button--ghost min-h-0 px-3 py-2 text-sm" className="platform-button platform-button--ghost min-h-0 px-3 py-1.5 text-[11px]"
> >
<ArrowLeft className="h-4 w-4" /> <span className="inline-flex items-center gap-1.5">
<ArrowLeft className="h-3.5 w-3.5" />
</span>
</button> </button>
</div> </div>
<div className="grid min-h-0 flex-1 gap-3 lg:grid-cols-[minmax(0,0.88fr)_minmax(0,1.12fr)]"> <div className="mb-4 shrink-0 sm:mb-5">
<section className="platform-subpanel flex min-h-0 flex-col gap-3 overflow-y-auto rounded-[1.25rem] p-4"> <div className="flex flex-wrap items-center gap-2">
<label className="block"> <h1 className="m-0 text-3xl font-black leading-none tracking-normal text-[var(--platform-text-strong)] sm:text-6xl">
<span className="text-xs font-bold tracking-[0.18em] text-[var(--platform-text-soft)]">
</h1>
</span> <span className="rounded-full border border-emerald-200 bg-emerald-50 px-3 py-1 text-[11px] font-black text-emerald-700">
<input BETA
value={formState.workTitle} </span>
maxLength={32} </div>
disabled={isBusy || isSubmitting} </div>
onChange={(event) =>
setFormState((current) => ({
...current,
workTitle: event.target.value,
}))
}
className="mt-2 w-full rounded-[1rem] border border-[var(--platform-subpanel-border)] bg-white/90 px-3 py-3 text-sm font-semibold text-[var(--platform-text-strong)] outline-none"
/>
</label>
<label className="block"> <section className="flex min-h-0 flex-1 flex-col gap-4 overflow-y-auto px-1 pb-1">
<span className="text-xs font-bold tracking-[0.18em] text-[var(--platform-text-soft)]"> <label className="block">
<span className="mb-2 block text-sm font-black text-[var(--platform-text-strong)]">
</span>
<textarea </span>
value={formState.workDescription} <input
maxLength={120} value={formState.themePrompt}
disabled={isBusy || isSubmitting} maxLength={80}
rows={4} disabled={isBusy || isSubmitting}
onChange={(event) => onChange={(event) =>
setFormState((current) => ({ setFormState((current) => ({
...current, ...current,
workDescription: event.target.value, themePrompt: event.target.value,
})) }))
} }
className="mt-2 w-full resize-none rounded-[1rem] border border-[var(--platform-subpanel-border)] bg-white/90 px-3 py-3 text-sm leading-6 text-[var(--platform-text-strong)] outline-none" className="w-full rounded-[1.05rem] border border-[var(--platform-subpanel-border)] bg-white/90 px-4 py-3 text-base font-semibold text-[var(--platform-text-strong)] outline-none"
/> />
</label> </label>
<label className="block"> <div className="min-h-0 min-w-0">
<span className="text-xs font-bold tracking-[0.18em] text-[var(--platform-text-soft)]">
</span>
<input
value={formState.themePrompt}
maxLength={80}
disabled={isBusy || isSubmitting}
onChange={(event) =>
setFormState((current) => ({
...current,
themePrompt: event.target.value,
}))
}
className="mt-2 w-full rounded-[1rem] border border-[var(--platform-subpanel-border)] bg-white/90 px-3 py-3 text-sm font-semibold text-[var(--platform-text-strong)] outline-none"
/>
</label>
<label className="flex items-center justify-between gap-3 rounded-[1rem] border border-[var(--platform-subpanel-border)] bg-white/72 px-3 py-3">
<span className="text-sm font-bold text-[var(--platform-text-strong)]">
AI
</span>
<input
type="checkbox"
checked={formState.generateBoardBackground}
disabled={isBusy || isSubmitting}
onChange={(event) =>
setFormState((current) => ({
...current,
generateBoardBackground: event.target.checked,
}))
}
className="h-5 w-5 accent-[var(--platform-accent)]"
/>
</label>
{localError || error ? (
<div className="platform-banner platform-banner--danger rounded-2xl text-sm leading-6">
{localError ?? error}
</div>
) : null}
</section>
<div className="flex min-h-[28rem] min-w-0 flex-col">
<CreativeImageInputPanel <CreativeImageInputPanel
fillHeight={false}
disabled={isBusy || isSubmitting} disabled={isBusy || isSubmitting}
isSubmitting={isSubmitting} isSubmitting={isSubmitting}
uploadedImageSrc={formState.boardBackgroundImageSrc} uploadedImageSrc={formState.boardBackgroundImageSrc}
uploadedImageAlt="场地底图" uploadedImageAlt="背景图"
mainImageInputId="puzzle-clear-board-background" mainImageInputId="puzzle-clear-board-background"
promptTextareaId="puzzle-clear-board-background-prompt" promptTextareaId="puzzle-clear-board-background-prompt"
prompt={formState.boardBackgroundPrompt} prompt={formState.boardBackgroundPrompt}
promptLabel="场地底图" promptLabel="画面描述"
promptRows={5} promptRows={4}
aiRedraw={formState.generateBoardBackground} aiRedraw={formState.generateBoardBackground}
promptReferenceImages={[]} promptReferenceImages={[]}
showSubmitButton={false} showSubmitButton={false}
submitLabel="生成" submitLabel="生成"
submitDisabled={!canSubmit || isSubmitting || isBusy} submitDisabled={!canSubmit || isSubmitting || isBusy}
labels={{ labels={{
imageField: '中央底图', imageField: '背景图',
uploadImage: '上传图', uploadImage: '上传背景图',
replaceImage: '替换图', replaceImage: '替换背景图',
emptyImageHint: '上传图', emptyImageHint: '上传图片/填写画面描述',
removeImage: '移除图', removeImage: '移除背景图',
removeImageConfirmTitle: '移除图', removeImageConfirmTitle: '移除背景图',
removeImageConfirmBody: '移除后将使用主题词生成中央场地底图。', removeImageConfirmBody: '移除后将使用画面描述生成背景图。',
promptReferenceUpload: '上传参考图', promptReferenceUpload: '上传参考图',
promptReferencePreviewAlt: '场地底图参考', promptReferencePreviewAlt: '背景图参考',
closePromptReferencePreview: '关闭预览', closePromptReferencePreview: '关闭预览',
}} }}
onMainImageFileSelect={(file) => { onMainImageFileSelect={(file) => {
@@ -273,7 +225,7 @@ export function PuzzleClearWorkspace({
setLocalError( setLocalError(
caughtError instanceof Error caughtError instanceof Error
? caughtError.message ? caughtError.message
: '图读取失败。', : '背景图读取失败。',
); );
}); });
}} }}
@@ -282,6 +234,7 @@ export function PuzzleClearWorkspace({
...current, ...current,
boardBackgroundImageSrc: '', boardBackgroundImageSrc: '',
boardBackgroundAsset: null, boardBackgroundAsset: null,
generateBoardBackground: true,
})); }));
}} }}
onAiRedrawChange={(value) => onAiRedrawChange={(value) =>
@@ -299,7 +252,13 @@ export function PuzzleClearWorkspace({
onSubmit={handleSubmit} onSubmit={handleSubmit}
/> />
</div> </div>
</div>
{localError || error ? (
<div className="platform-banner platform-banner--danger rounded-2xl text-sm leading-6">
{localError ?? error}
</div>
) : null}
</section>
<div className="mt-auto flex justify-end gap-2 pb-[max(0.25rem,env(safe-area-inset-bottom))] pt-3"> <div className="mt-auto flex justify-end gap-2 pb-[max(0.25rem,env(safe-area-inset-bottom))] pt-3">
<button <button

View File

@@ -130,7 +130,7 @@ function createProfile(
}; };
} }
test('结果页展示 atlas、中央底图与卡牌预览,并触发试玩、发布和图集重试', async () => { test('结果页展示 atlas、背景图与卡牌预览,并触发试玩、发布和图集重试', async () => {
const onStartTestRun = vi.fn(); const onStartTestRun = vi.fn();
const onPublish = vi.fn(); const onPublish = vi.fn();
const onRegenerateAtlas = vi.fn(); const onRegenerateAtlas = vi.fn();
@@ -146,7 +146,7 @@ test('结果页展示 atlas、中央底图与卡牌预览并触发试玩、
/>, />,
); );
expect(screen.getByAltText('场地底图').getAttribute('src')).toBe( expect(screen.getByAltText('背景图').getAttribute('src')).toBe(
'/board-background.png', '/board-background.png',
); );
expect(screen.getByAltText('素材图集').getAttribute('src')).toBe('/atlas.png'); expect(screen.getByAltText('素材图集').getAttribute('src')).toBe('/atlas.png');
@@ -158,13 +158,51 @@ test('结果页展示 atlas、中央底图与卡牌预览并触发试玩、
await act(async () => { await act(async () => {
fireEvent.click(screen.getByRole('button', { name: //u })); fireEvent.click(screen.getByRole('button', { name: //u }));
}); });
expect(screen.getByRole('dialog', { name: '发布前检查' })).not.toBeNull();
fireEvent.change(screen.getByLabelText('作品标题'), {
target: { value: ' 发布标题 ' },
});
fireEvent.change(screen.getByLabelText('简介'), {
target: { value: ' 发布简介 ' },
});
await act(async () => {
fireEvent.click(screen.getAllByRole('button', { name: //u }).at(-1)!);
});
fireEvent.click(screen.getByRole('button', { name: //u })); fireEvent.click(screen.getByRole('button', { name: //u }));
expect(onStartTestRun).toHaveBeenCalledTimes(1); expect(onStartTestRun).toHaveBeenCalledTimes(1);
expect(onPublish).toHaveBeenCalledTimes(1); expect(onPublish).toHaveBeenCalledWith({
workTitle: '发布标题',
workDescription: '发布简介',
});
expect(onRegenerateAtlas).toHaveBeenCalledTimes(1); expect(onRegenerateAtlas).toHaveBeenCalledTimes(1);
}); });
test('结果页发布前检查要求填写作品标题', async () => {
const onPublish = vi.fn();
render(
<PuzzleClearResultView
profile={createProfile()}
onBack={vi.fn()}
onEdit={vi.fn()}
onStartTestRun={vi.fn()}
onPublish={onPublish}
onRegenerateAtlas={vi.fn()}
/>,
);
fireEvent.click(screen.getByRole('button', { name: //u }));
fireEvent.change(screen.getByLabelText('作品标题'), {
target: { value: ' ' },
});
await act(async () => {
fireEvent.click(screen.getAllByRole('button', { name: //u }).at(-1)!);
});
expect(screen.getByText('请填写作品标题。')).not.toBeNull();
expect(onPublish).not.toHaveBeenCalled();
});
test('结果页在素材未发布就绪时禁用发布,且不写入规则说明文案', () => { test('结果页在素材未发布就绪时禁用发布,且不写入规则说明文案', () => {
render( render(
<PuzzleClearResultView <PuzzleClearResultView

View File

@@ -7,6 +7,11 @@ import type {
} from '../../../packages/shared/src/contracts/puzzleClear'; } from '../../../packages/shared/src/contracts/puzzleClear';
import { ResolvedAssetImage } from '../ResolvedAssetImage'; import { ResolvedAssetImage } from '../ResolvedAssetImage';
export type PuzzleClearPublishMeta = {
workTitle: string;
workDescription: string;
};
type PuzzleClearResultViewProps = { type PuzzleClearResultViewProps = {
profile: PuzzleClearDraftResponse | PuzzleClearWorkProfileResponse; profile: PuzzleClearDraftResponse | PuzzleClearWorkProfileResponse;
isBusy?: boolean; isBusy?: boolean;
@@ -14,7 +19,7 @@ type PuzzleClearResultViewProps = {
onBack: () => void; onBack: () => void;
onEdit: () => void; onEdit: () => void;
onStartTestRun: () => void; onStartTestRun: () => void;
onPublish: () => void; onPublish: (metadata: PuzzleClearPublishMeta) => boolean | Promise<boolean | void> | void;
onRegenerateAtlas: () => void; onRegenerateAtlas: () => void;
}; };
@@ -39,6 +44,10 @@ export function PuzzleClearResultView({
onRegenerateAtlas, onRegenerateAtlas,
}: PuzzleClearResultViewProps) { }: PuzzleClearResultViewProps) {
const [isPublishing, setIsPublishing] = useState(false); const [isPublishing, setIsPublishing] = useState(false);
const [publishMeta, setPublishMeta] = useState<PuzzleClearPublishMeta | null>(
null,
);
const [publishMetaError, setPublishMetaError] = useState<string | null>(null);
const isWorkProfile = isPuzzleClearWorkProfile(profile); const isWorkProfile = isPuzzleClearWorkProfile(profile);
const draft = getDraft(profile); const draft = getDraft(profile);
const summary = isWorkProfile ? profile.summary : null; const summary = isWorkProfile ? profile.summary : null;
@@ -54,10 +63,34 @@ export function PuzzleClearResultView({
const previewCards = cardAssets.slice(0, 24); const previewCards = cardAssets.slice(0, 24);
const canPublish = Boolean(isWorkProfile && summary?.publishReady); const canPublish = Boolean(isWorkProfile && summary?.publishReady);
const openPublishDialog = () => {
if (!canPublish || isBusy) {
return;
}
setPublishMeta({
workTitle: title.slice(0, 30),
workDescription: description.slice(0, 120),
});
setPublishMetaError(null);
};
const handlePublish = async () => { const handlePublish = async () => {
const normalizedMeta = {
workTitle: publishMeta?.workTitle.trim() ?? '',
workDescription: publishMeta?.workDescription.trim() ?? '',
};
if (!normalizedMeta.workTitle) {
setPublishMetaError('请填写作品标题。');
return;
}
setIsPublishing(true); setIsPublishing(true);
try { try {
await Promise.resolve(onPublish()); const result = await Promise.resolve(onPublish(normalizedMeta));
if (result !== false) {
setPublishMeta(null);
setPublishMetaError(null);
}
} finally { } finally {
setIsPublishing(false); setIsPublishing(false);
} }
@@ -101,12 +134,12 @@ export function PuzzleClearResultView({
{boardBackgroundAsset?.imageSrc ? ( {boardBackgroundAsset?.imageSrc ? (
<ResolvedAssetImage <ResolvedAssetImage
src={boardBackgroundAsset.imageSrc} src={boardBackgroundAsset.imageSrc}
alt="场地底图" alt="背景图"
className="aspect-[9/16] h-full w-full object-cover" className="aspect-[9/16] h-full w-full object-cover"
/> />
) : ( ) : (
<div className="grid aspect-[9/16] place-items-center text-sm text-[var(--platform-text-soft)]"> <div className="grid aspect-[9/16] place-items-center text-sm text-[var(--platform-text-soft)]">
</div> </div>
)} )}
</div> </div>
@@ -193,7 +226,7 @@ export function PuzzleClearResultView({
</button> </button>
<button <button
type="button" type="button"
onClick={handlePublish} onClick={openPublishDialog}
disabled={isBusy || isPublishing || !canPublish} disabled={isBusy || isPublishing || !canPublish}
className="platform-button platform-button--secondary min-h-11 justify-center gap-2 px-4 py-3" className="platform-button platform-button--secondary min-h-11 justify-center gap-2 px-4 py-3"
> >
@@ -216,6 +249,102 @@ export function PuzzleClearResultView({
</section> </section>
</aside> </aside>
</div> </div>
{publishMeta ? (
<div className="platform-modal-backdrop fixed inset-0 z-[80] flex items-center justify-center px-4 py-6">
<div
role="dialog"
aria-modal="true"
aria-labelledby="puzzle-clear-publish-meta-title"
className="platform-modal-shell platform-remap-surface w-full max-w-md rounded-[1.35rem] p-5 shadow-[0_24px_70px_rgba(15,23,42,0.22)]"
>
<div
id="puzzle-clear-publish-meta-title"
className="text-lg font-black text-[var(--platform-text-strong)]"
>
</div>
<div className="mt-4 grid gap-3">
<label className="block">
<span className="mb-2 block text-sm font-black text-[var(--platform-text-strong)]">
</span>
<input
value={publishMeta.workTitle}
maxLength={30}
disabled={isBusy || isPublishing}
onChange={(event) => {
setPublishMeta((current) =>
current
? {
...current,
workTitle: event.target.value,
}
: current,
);
setPublishMetaError(null);
}}
className="w-full rounded-[1rem] border border-[var(--platform-subpanel-border)] bg-white/90 px-4 py-3 text-sm font-semibold text-[var(--platform-text-strong)] outline-none"
/>
</label>
<label className="block">
<span className="mb-2 block text-sm font-black text-[var(--platform-text-strong)]">
</span>
<textarea
value={publishMeta.workDescription}
maxLength={120}
rows={4}
disabled={isBusy || isPublishing}
onChange={(event) =>
setPublishMeta((current) =>
current
? {
...current,
workDescription: event.target.value,
}
: current,
)
}
className="w-full resize-none rounded-[1rem] border border-[var(--platform-subpanel-border)] bg-white/90 px-4 py-3 text-sm leading-6 text-[var(--platform-text-strong)] outline-none"
/>
</label>
</div>
{publishMetaError ? (
<div className="platform-banner platform-banner--danger mt-3 rounded-2xl text-sm leading-6">
{publishMetaError}
</div>
) : null}
<div className="mt-5 grid grid-cols-2 gap-3">
<button
type="button"
disabled={isBusy || isPublishing}
onClick={() => setPublishMeta(null)}
className="platform-button platform-button--secondary justify-center"
>
</button>
<button
type="button"
disabled={isBusy || isPublishing}
onClick={() => {
void handlePublish();
}}
className={`platform-button platform-button--primary justify-center gap-2 ${
isBusy || isPublishing ? 'cursor-not-allowed opacity-55' : ''
}`}
>
{isPublishing ? (
<Loader2 className="h-4 w-4 animate-spin" />
) : (
<Send className="h-4 w-4" />
)}
</button>
</div>
</div>
</div>
) : null}
</div> </div>
); );
} }

View File

@@ -862,6 +862,38 @@ test('按住已拼接局部时会拿起整个拼接组', () => {
expect(ghost.getAttribute('style') ?? '').toContain('width: 120px'); expect(ghost.getAttribute('style') ?? '').toContain('width: 120px');
}); });
test('已拼接局部拖到会越界的位置时只回弹不提交后端动作', async () => {
const onSwapCards = vi.fn().mockResolvedValue(undefined);
render(
<PuzzleClearRuntimeShell
profile={createProfile()}
run={createRun()}
onSwapCards={onSwapCards}
onRetryLevel={vi.fn()}
onNextLevel={vi.fn()}
onTimeUp={vi.fn()}
/>,
);
const from = screen.getByLabelText('卡片 1-1');
const invalidTarget = screen.getByLabelText('卡片 1-3');
fireEvent.pointerDown(from, { pointerId: 1, clientX: 18, clientY: 18 });
fireEvent.pointerMove(from, { pointerId: 1, clientX: 120, clientY: 20 });
fireEvent.pointerUp(invalidTarget, {
pointerId: 1,
clientX: 170,
clientY: 20,
});
expect(screen.getByTestId('puzzle-clear-drag-ghost').className).toContain(
'puzzle-clear-drag-ghost--returning',
);
await Promise.resolve();
expect(onSwapCards).not.toHaveBeenCalled();
});
test('按住已拼接局部时组内其他格子也会一起进入空槽状态', () => { test('按住已拼接局部时组内其他格子也会一起进入空槽状态', () => {
render( render(
<PuzzleClearRuntimeShell <PuzzleClearRuntimeShell
@@ -882,8 +914,68 @@ test('按住已拼接局部时组内其他格子也会一起进入空槽状态',
expect(from.className).toContain('puzzle-clear-card--drag-origin-empty'); expect(from.className).toContain('puzzle-clear-card--drag-origin-empty');
expect(neighbor.className).toContain('puzzle-clear-card--drag-group-empty'); expect(neighbor.className).toContain('puzzle-clear-card--drag-group-empty');
expect(from.getAttribute('data-puzzle-clear-layer')).toBe('drag-group-source');
expect(neighbor.getAttribute('data-puzzle-clear-layer')).toBe('drag-group-source');
expect(from.querySelector('img')).toBeNull(); expect(from.querySelector('img')).toBeNull();
expect(neighbor.querySelector('img')).toBeNull(); expect(neighbor.querySelector('img')).toBeNull();
expect(
screen
.getByTestId('puzzle-clear-board')
.querySelector('[data-testid="puzzle-clear-locked-group-visual"]'),
).toBeNull();
expect(screen.getByTestId('puzzle-clear-drag-ghost').querySelectorAll('img')).toHaveLength(2);
});
test('拖起多格拼合组时格子层不再残留被遮罩压住的单卡', () => {
const run = cloneRunWithBoard(createRun(), {
rows: 3,
cols: 3,
cells: [
{ row: 0, col: 0, card: createCard(70, 0, 0), lockedGroupId: 'block' },
{ row: 0, col: 1, card: createCard(70, 1, 0), lockedGroupId: 'block' },
{ row: 0, col: 2, card: createCard(72, 0, 0), lockedGroupId: null },
{ row: 1, col: 0, card: createCard(70, 0, 1), lockedGroupId: 'block' },
{ row: 1, col: 1, card: createCard(73, 0, 0), lockedGroupId: null },
{ row: 1, col: 2, card: createCard(74, 0, 0), lockedGroupId: null },
{ row: 2, col: 0, card: createCard(75, 0, 0), lockedGroupId: null },
{ row: 2, col: 1, card: createCard(76, 0, 0), lockedGroupId: null },
{ row: 2, col: 2, card: createCard(77, 0, 0), lockedGroupId: null },
],
});
render(
<PuzzleClearRuntimeShell
profile={createProfile()}
run={run}
onSwapCards={vi.fn()}
onRetryLevel={vi.fn()}
onNextLevel={vi.fn()}
onTimeUp={vi.fn()}
/>,
);
const from = screen.getByLabelText('卡片 1-1');
fireEvent.pointerDown(from, { pointerId: 1, clientX: 18, clientY: 18 });
fireEvent.pointerMove(from, { pointerId: 1, clientX: 82, clientY: 44 });
const groupCells = [
screen.getByLabelText('卡片 1-1'),
screen.getByLabelText('卡片 1-2'),
screen.getByLabelText('卡片 2-1'),
];
for (const cell of groupCells) {
expect(cell.getAttribute('data-puzzle-clear-layer')).toBe('drag-group-source');
expect(cell.querySelector('img')).toBeNull();
expect(cell.className).toMatch(
/puzzle-clear-card--(?:drag-origin-empty|drag-group-empty)/u,
);
}
expect(
screen
.getByTestId('puzzle-clear-board')
.querySelector('[data-testid="puzzle-clear-locked-group-visual"]'),
).toBeNull();
expect(screen.getByTestId('puzzle-clear-drag-ghost').querySelectorAll('img')).toHaveLength(3);
}); });
test('放下后会播放替换落点动画', async () => { test('放下后会播放替换落点动画', async () => {
@@ -946,13 +1038,15 @@ test('放下后被替换的卡片会快速飞回空位', async () => {
fireEvent.pointerMove(from, { pointerId: 1, clientX: 68, clientY: 20 }); fireEvent.pointerMove(from, { pointerId: 1, clientX: 68, clientY: 20 });
fireEvent.pointerUp(to, { pointerId: 1, clientX: 112, clientY: 20 }); fireEvent.pointerUp(to, { pointerId: 1, clientX: 112, clientY: 20 });
expect(screen.getByTestId('puzzle-clear-swap-flight')).toBeTruthy(); const flight = screen.getByTestId('puzzle-clear-swap-flight');
expect(screen.getByTestId('puzzle-clear-swap-flight').className).toContain( expect(flight).toBeTruthy();
expect(flight.className).toContain(
'puzzle-clear-swap-flight--incoming', 'puzzle-clear-swap-flight--incoming',
); );
expect(screen.getByTestId('puzzle-clear-swap-flight').getAttribute('style') ?? '').toContain( expect(flight.getAttribute('style') ?? '').toContain(
'--puzzle-clear-flight-delta-x', '--puzzle-clear-flight-delta-x',
); );
expect(flight.querySelector('img')?.getAttribute('src')).toBe('/cards/1-1-0.png');
resolveSwapPromise(); resolveSwapPromise();
await waitFor(() => await waitFor(() =>
@@ -1021,6 +1115,14 @@ test('消除成功时会播放消除和掉落过渡动画', async () => {
.querySelector('.puzzle-clear-transition-piece--drop') .querySelector('.puzzle-clear-transition-piece--drop')
?.getAttribute('style') ?? '', ?.getAttribute('style') ?? '',
).toContain('--puzzle-clear-drop-delay: 520ms'); ).toContain('--puzzle-clear-drop-delay: 520ms');
const dropPiece = screen
.getByTestId('puzzle-clear-board')
.querySelector<HTMLElement>('.puzzle-clear-transition-piece--drop');
expect(dropPiece?.getAttribute('style') ?? '').toContain(
'--puzzle-clear-drop-duration:',
);
expect(dropPiece?.className ?? '').not.toContain('bg-white');
expect(dropPiece?.className ?? '').not.toContain('border-white');
}); });
test('正确局部拼合但未消除时会高光提醒拼合组', async () => { test('正确局部拼合但未消除时会高光提醒拼合组', async () => {

View File

@@ -83,6 +83,8 @@ type PuzzleClearBoardPreviewState = {
swapFeedback: PuzzleClearSwapFeedbackState | null; swapFeedback: PuzzleClearSwapFeedbackState | null;
}; };
type PuzzleClearCardLayerState = 'empty' | 'card' | 'locked-group' | 'drag-group-source';
type PuzzleClearSwapFeedbackState = { type PuzzleClearSwapFeedbackState = {
from: PuzzleClearGridPosition; from: PuzzleClearGridPosition;
to: PuzzleClearGridPosition; to: PuzzleClearGridPosition;
@@ -98,6 +100,7 @@ type PuzzleClearSwapFlightState = {
type PuzzleClearClearTransitionState = { type PuzzleClearClearTransitionState = {
transitionKey: number; transitionKey: number;
durationMs: number;
clearedCells: Array<{ clearedCells: Array<{
row: number; row: number;
col: number; col: number;
@@ -109,6 +112,7 @@ type PuzzleClearClearTransitionState = {
card: PuzzleClearCardAsset; card: PuzzleClearCardAsset;
distance: number; distance: number;
delayMs: number; delayMs: number;
durationMs: number;
hideBoardCell: boolean; hideBoardCell: boolean;
}>; }>;
}; };
@@ -123,6 +127,9 @@ type PuzzleClearLockedGroupViewModel = PuzzleClearDragGroupState;
const PUZZLE_CLEAR_CLEAR_TRANSITION_MS = 1120; const PUZZLE_CLEAR_CLEAR_TRANSITION_MS = 1120;
const PUZZLE_CLEAR_REFILL_DROP_DELAY_MS = 520; const PUZZLE_CLEAR_REFILL_DROP_DELAY_MS = 520;
const PUZZLE_CLEAR_DROP_STAGGER_MAX_MS = 180; const PUZZLE_CLEAR_DROP_STAGGER_MAX_MS = 180;
const PUZZLE_CLEAR_DROP_BASE_DURATION_MS = 620;
const PUZZLE_CLEAR_DROP_DISTANCE_DURATION_MS = 72;
const PUZZLE_CLEAR_DROP_SETTLE_BUFFER_MS = 120;
const PUZZLE_CLEAR_MERGE_HIGHLIGHT_MS = 760; const PUZZLE_CLEAR_MERGE_HIGHLIGHT_MS = 760;
function getRun( function getRun(
@@ -282,6 +289,29 @@ function cloneDragGroupState(
}; };
} }
function canPlacePuzzleClearDraggedGroup(
group: PuzzleClearDragGroupState | null | undefined,
board: PuzzleClearRuntimeSnapshotResponse['board'] | null | undefined,
from: PuzzleClearGridPosition,
to: PuzzleClearGridPosition,
) {
if (!group || !board) {
return true;
}
const rowDelta = to.row - from.row;
const colDelta = to.col - from.col;
return group.cells.every((entry) => {
const nextRow = entry.row + rowDelta;
const nextCol = entry.col + colDelta;
return (
nextRow >= 0 &&
nextCol >= 0 &&
nextRow < board.rows &&
nextCol < board.cols
);
});
}
function buildPuzzleClearLockedGroupSignatures( function buildPuzzleClearLockedGroupSignatures(
run: PuzzleClearRuntimeSnapshotResponse, run: PuzzleClearRuntimeSnapshotResponse,
) { ) {
@@ -391,6 +421,9 @@ function buildPuzzleClearClearTransition(
col: cell.col, col: cell.col,
card: cell.card!, card: cell.card!,
distance: movedDistance, distance: movedDistance,
durationMs:
PUZZLE_CLEAR_DROP_BASE_DURATION_MS +
Math.max(0, movedDistance - 1) * PUZZLE_CLEAR_DROP_DISTANCE_DURATION_MS,
delayMs: delayMs:
PUZZLE_CLEAR_REFILL_DROP_DELAY_MS + PUZZLE_CLEAR_REFILL_DROP_DELAY_MS +
Math.min( Math.min(
@@ -409,6 +442,7 @@ function buildPuzzleClearClearTransition(
card: PuzzleClearCardAsset; card: PuzzleClearCardAsset;
distance: number; distance: number;
delayMs: number; delayMs: number;
durationMs: number;
hideBoardCell: boolean; hideBoardCell: boolean;
} => cell !== null, } => cell !== null,
) )
@@ -425,6 +459,10 @@ function buildPuzzleClearClearTransition(
return { return {
transitionKey: Date.now(), transitionKey: Date.now(),
durationMs: Math.max(
PUZZLE_CLEAR_CLEAR_TRANSITION_MS,
...dropCells.map((cell) => cell.delayMs + cell.durationMs + PUZZLE_CLEAR_DROP_SETTLE_BUFFER_MS),
),
clearedCells, clearedCells,
dropCells, dropCells,
}; };
@@ -676,7 +714,7 @@ export function PuzzleClearRuntimeShell({
clearTransitionTimerRef.current = window.setTimeout(() => { clearTransitionTimerRef.current = window.setTimeout(() => {
clearTransitionTimerRef.current = null; clearTransitionTimerRef.current = null;
setClearTransition(null); setClearTransition(null);
}, PUZZLE_CLEAR_CLEAR_TRANSITION_MS); }, transition.durationMs);
return; return;
} }
@@ -931,7 +969,15 @@ export function PuzzleClearRuntimeShell({
const target = cells.get( const target = cells.get(
getCellKey(releaseTarget.row, releaseTarget.col), getCellKey(releaseTarget.row, releaseTarget.col),
); );
if (!target) { if (
!target ||
!canPlacePuzzleClearDraggedGroup(
dragState?.group,
board,
origin,
releaseTarget,
)
) {
setSelectedCell(null); setSelectedCell(null);
setDragState((current) => setDragState((current) =>
current current
@@ -960,10 +1006,6 @@ export function PuzzleClearRuntimeShell({
readElementRect( readElementRect(
cellElementRefMap.current.get(getCellKey(origin.row, origin.col)), cellElementRefMap.current.get(getCellKey(origin.row, origin.col)),
); );
const sourceCard =
dragState?.card ??
cells.get(getCellKey(origin.row, origin.col))?.card ??
null;
setDragState((current) => setDragState((current) =>
current current
? { ? {
@@ -976,11 +1018,11 @@ export function PuzzleClearRuntimeShell({
} }
: current, : current,
); );
if (targetRect && sourceRect && sourceCard) { if (targetRect && sourceRect && target.card) {
setSwapFlight({ setSwapFlight({
card: sourceCard, card: target.card,
fromRect: sourceRect, fromRect: targetRect,
toRect: targetRect, toRect: sourceRect,
flightKey: Date.now(), flightKey: Date.now(),
}); });
} }
@@ -1102,6 +1144,13 @@ export function PuzzleClearRuntimeShell({
const isDraggedGroupCell = draggedGroupCellKeys.has( const isDraggedGroupCell = draggedGroupCellKeys.has(
getCellKey(row, col), getCellKey(row, col),
); );
const cardLayerState: PuzzleClearCardLayerState = isDraggedGroupCell
? 'drag-group-source'
: cell?.lockedGroupId
? 'locked-group'
: card
? 'card'
: 'empty';
const selected = const selected =
selectedCell?.row === row && selectedCell?.col === col; selectedCell?.row === row && selectedCell?.col === col;
const isSwapFeedbackCell = const isSwapFeedbackCell =
@@ -1153,6 +1202,8 @@ export function PuzzleClearRuntimeShell({
data-puzzle-clear-cell="true" data-puzzle-clear-cell="true"
data-puzzle-clear-row={row} data-puzzle-clear-row={row}
data-puzzle-clear-col={col} data-puzzle-clear-col={col}
data-puzzle-clear-layer={cardLayerState}
data-puzzle-clear-locked-group-id={cell?.lockedGroupId ?? undefined}
draggable={false} draggable={false}
onDragStart={(event) => event.preventDefault()} onDragStart={(event) => event.preventDefault()}
className={`puzzle-clear-card relative z-10 grid min-h-0 touch-none select-none place-items-center overflow-hidden rounded-[0.6rem] border transition ${ className={`puzzle-clear-card relative z-10 grid min-h-0 touch-none select-none place-items-center overflow-hidden rounded-[0.6rem] border transition ${
@@ -1195,13 +1246,10 @@ export function PuzzleClearRuntimeShell({
style={style} style={style}
aria-label={`卡片 ${row + 1}-${col + 1}`} aria-label={`卡片 ${row + 1}-${col + 1}`}
> >
{card && !isDraggedGroupCell ? ( {cardLayerState === 'card' ? (
<span className="absolute inset-1 rounded-[0.48rem] bg-[var(--puzzle-clear-card-tone)] opacity-45" /> <span className="absolute inset-1 rounded-[0.48rem] bg-[var(--puzzle-clear-card-tone)] opacity-45" />
) : null} ) : null}
{card?.imageSrc && {cardLayerState === 'card' && card?.imageSrc ? (
!isDragOrigin &&
!isDraggedGroupCell &&
!cell?.lockedGroupId ? (
<ResolvedAssetImage <ResolvedAssetImage
src={card.imageSrc} src={card.imageSrc}
alt="" alt=""
@@ -1210,7 +1258,7 @@ export function PuzzleClearRuntimeShell({
className="pointer-events-none relative z-10 h-full w-full select-none rounded-[0.48rem] object-cover" className="pointer-events-none relative z-10 h-full w-full select-none rounded-[0.48rem] object-cover"
/> />
) : null} ) : null}
{isDragOrigin ? ( {isDraggedGroupCell ? (
<span className="relative z-10 h-full w-full rounded-[0.48rem] border border-dashed border-white/28 bg-white/8" /> <span className="relative z-10 h-full w-full rounded-[0.48rem] border border-dashed border-white/28 bg-white/8" />
) : null} ) : null}
</button> </button>
@@ -1303,11 +1351,12 @@ export function PuzzleClearRuntimeShell({
{clearTransition.dropCells.map((cell) => ( {clearTransition.dropCells.map((cell) => (
<div <div
key={`drop:${clearTransition.transitionKey}:${cell.row}:${cell.col}:${cell.card.cardId}`} key={`drop:${clearTransition.transitionKey}:${cell.row}:${cell.col}:${cell.card.cardId}`}
className="puzzle-clear-transition-piece puzzle-clear-transition-piece--drop overflow-hidden rounded-[0.6rem] border border-white/72 bg-white/70" className="puzzle-clear-transition-piece puzzle-clear-transition-piece--drop overflow-hidden rounded-[0.6rem]"
style={{ style={{
gridColumn: cell.col + 1, gridColumn: cell.col + 1,
gridRow: cell.row + 1, gridRow: cell.row + 1,
'--puzzle-clear-drop-delay': `${cell.delayMs}ms`, '--puzzle-clear-drop-delay': `${cell.delayMs}ms`,
'--puzzle-clear-drop-duration': `${cell.durationMs}ms`,
'--puzzle-clear-drop-start-y': `calc(-120% - ${Math.max( '--puzzle-clear-drop-start-y': `calc(-120% - ${Math.max(
0, 0,
cell.distance - 1, cell.distance - 1,

View File

@@ -250,19 +250,30 @@ body {
@keyframes puzzle-clear-transition-drop { @keyframes puzzle-clear-transition-drop {
0% { 0% {
opacity: 0; opacity: 0;
transform: translateY(var(--puzzle-clear-drop-start-y, -120%)) scale(0.96); transform: translate3d(0, var(--puzzle-clear-drop-start-y, -120%), 0)
filter: saturate(1.08) brightness(1.03); scale(0.985);
filter: saturate(1.04) brightness(1);
} }
55% { 10% {
opacity: 1; opacity: 1;
transform: translateY(10%) scale(1.02); }
filter: saturate(1.03) brightness(1.01);
72% {
opacity: 1;
transform: translate3d(0, 7%, 0) scale(1);
filter: saturate(1.02) brightness(1);
}
88% {
opacity: 1;
transform: translate3d(0, -2.5%, 0) scale(1);
filter: saturate(1) brightness(1);
} }
100% { 100% {
opacity: 1; opacity: 1;
transform: translateY(0) scale(1); transform: translate3d(0, 0, 0) scale(1);
filter: saturate(1) brightness(1); filter: saturate(1) brightness(1);
} }
} }
@@ -317,7 +328,9 @@ body {
box-shadow: none; box-shadow: none;
} }
.puzzle-clear-card--drag-group-empty img,
.puzzle-clear-card--drag-group-empty > span:first-child { .puzzle-clear-card--drag-group-empty > span:first-child {
visibility: hidden;
opacity: 0; opacity: 0;
} }
@@ -327,7 +340,9 @@ body {
box-shadow: none; box-shadow: none;
} }
.puzzle-clear-card--drag-origin-empty img,
.puzzle-clear-card--drag-origin-empty > span:first-child { .puzzle-clear-card--drag-origin-empty > span:first-child {
visibility: hidden;
opacity: 0; opacity: 0;
} }
@@ -349,10 +364,16 @@ body {
.puzzle-clear-swap-flight { .puzzle-clear-swap-flight {
overflow: hidden; overflow: hidden;
border: 1px solid rgba(255, 255, 255, 0.88); border: 0;
border-radius: 0.6rem; border-radius: 0.6rem;
background: white; background: transparent;
box-shadow: 0 14px 34px rgba(15, 23, 42, 0.18); box-shadow: 0 12px 24px rgba(15, 23, 42, 0.12);
backface-visibility: hidden;
contain: paint;
}
.puzzle-clear-swap-flight img {
box-shadow: 0 8px 18px rgba(15, 23, 42, 0.12);
} }
.puzzle-clear-swap-flight--incoming { .puzzle-clear-swap-flight--incoming {
@@ -434,10 +455,20 @@ body {
} }
.puzzle-clear-transition-piece--drop { .puzzle-clear-transition-piece--drop {
animation: puzzle-clear-transition-drop 460ms cubic-bezier(0.18, 0.82, 0.24, 1) animation: puzzle-clear-transition-drop var(--puzzle-clear-drop-duration, 680ms)
both; cubic-bezier(0.16, 0.82, 0.22, 1) both;
animation-delay: var(--puzzle-clear-drop-delay, 0ms); animation-delay: var(--puzzle-clear-drop-delay, 0ms);
background: transparent;
border: 0;
will-change: transform, opacity, filter; will-change: transform, opacity, filter;
transform: translate3d(0, var(--puzzle-clear-drop-start-y, -120%), 0)
scale(0.985);
backface-visibility: hidden;
contain: paint;
}
.puzzle-clear-transition-piece--drop img {
box-shadow: 0 8px 18px rgba(15, 23, 42, 0.12);
} }
@keyframes puzzle-clear-card-swap-feedback { @keyframes puzzle-clear-card-swap-feedback {

View File

@@ -101,3 +101,32 @@ describe('index stylesheet creation agent hero contrast', () => {
expect(hintBlock).toContain('rgba(255, 255, 255, 0.72) !important'); expect(hintBlock).toContain('rgba(255, 255, 255, 0.72) !important');
}); });
}); });
describe('index stylesheet puzzle clear drop animation', () => {
it('keeps refill card drops transparent and driven by per-card duration', () => {
const css = readIndexCss();
const dropBlock = getCssBlock(css, '.puzzle-clear-transition-piece--drop');
expect(dropBlock).toContain(
'animation: puzzle-clear-transition-drop var(--puzzle-clear-drop-duration, 680ms)',
);
expect(dropBlock).toContain('background: transparent;');
expect(dropBlock).toContain('border: 0;');
expect(dropBlock).toContain('translate3d(0, var(--puzzle-clear-drop-start-y, -120%), 0)');
const keyframesBlock = getCssBlock(css, '@keyframes puzzle-clear-transition-drop');
expect(keyframesBlock).toContain('translate3d(0, 7%, 0)');
expect(keyframesBlock).toContain('translate3d(0, -2.5%, 0)');
expect(keyframesBlock).not.toContain('brightness(1.03)');
});
it('keeps swap replacement flights transparent instead of flashing a white shell', () => {
const css = readIndexCss();
const swapFlightBlock = getCssBlock(css, '.puzzle-clear-swap-flight');
expect(swapFlightBlock).toContain('background: transparent;');
expect(swapFlightBlock).toContain('border: 0;');
expect(swapFlightBlock).not.toContain('background: white;');
expect(swapFlightBlock).not.toContain('rgba(255, 255, 255');
});
});

View File

@@ -98,6 +98,33 @@ function createDraftWork(): PuzzleClearWorkProfileResponse {
return createDraftWorkWithCardCount(70); return createDraftWorkWithCardCount(70);
} }
function createDraftWorkWithAllShapes(): PuzzleClearWorkProfileResponse {
const work = createDraftWorkWithCardCount(70);
const extraCards = [
createCustomCard('tri-a', 'tri-a-0', 0, 0, '1x3'),
createCustomCard('tri-a', 'tri-a-1', 1, 0, '1x3'),
createCustomCard('tri-a', 'tri-a-2', 2, 0, '1x3'),
createCustomCard('block-a', 'block-a-0-0', 0, 0, '2x2'),
createCustomCard('block-a', 'block-a-1-0', 1, 0, '2x2'),
createCustomCard('block-a', 'block-a-0-1', 0, 1, '2x2'),
createCustomCard('block-a', 'block-a-1-1', 1, 1, '2x2'),
createCustomCard('wide-a', 'wide-a-0-0', 0, 0, '2x3'),
createCustomCard('wide-a', 'wide-a-1-0', 1, 0, '2x3'),
createCustomCard('wide-a', 'wide-a-2-0', 2, 0, '2x3'),
createCustomCard('wide-a', 'wide-a-0-1', 0, 1, '2x3'),
createCustomCard('wide-a', 'wide-a-1-1', 1, 1, '2x3'),
createCustomCard('wide-a', 'wide-a-2-1', 2, 1, '2x3'),
];
return {
...work,
draft: {
...work.draft,
cardAssets: [...work.draft.cardAssets, ...extraCards],
},
cardAssets: [...work.cardAssets, ...extraCards],
};
}
function createDraftWorkWithCardCount(cardCount: number): PuzzleClearWorkProfileResponse { function createDraftWorkWithCardCount(cardCount: number): PuzzleClearWorkProfileResponse {
const atlasAsset = { const atlasAsset = {
assetId: 'atlas-1', assetId: 'atlas-1',
@@ -159,12 +186,13 @@ test('草稿试玩创建本地运行态,不要求作品先发布', () => {
expect(run.status).toBe('playing'); expect(run.status).toBe('playing');
expect(run.board.rows).toBe(6); expect(run.board.rows).toBe(6);
expect(run.board.cols).toBe(6); expect(run.board.cols).toBe(6);
expect(run.targetClears).toBe(35); expect(run.targetClears).toBe(15);
expect(run.levelDurationSeconds).toBe(300);
expect(run.readyColumns).toHaveLength(run.board.cols); expect(run.readyColumns).toHaveLength(run.board.cols);
expect(isPuzzleClearLocalRuntimeSnapshot(run)).toBe(true); expect(isPuzzleClearLocalRuntimeSnapshot(run)).toBe(true);
}); });
test('本地草稿运行态支持交换、重试、单关推进和超时失败', () => { test('本地草稿运行态支持交换、重试、推进和超时失败', () => {
const work = createDraftWork(); const work = createDraftWork();
const run = createPuzzleClearLocalRuntimeSnapshot(work); const run = createPuzzleClearLocalRuntimeSnapshot(work);
const preparedRun = { const preparedRun = {
@@ -210,33 +238,66 @@ test('本地草稿运行态支持交换、重试、单关推进和超时失败',
expect(retried.levelIndex).toBe(failed.levelIndex); expect(retried.levelIndex).toBe(failed.levelIndex);
const next = advancePuzzleClearLocalLevel(retried, work); const next = advancePuzzleClearLocalLevel(retried, work);
expect(next.levelIndex).toBe(1); expect(next.levelIndex).toBe(2);
expect(next.status).toBe('playing'); expect(next.status).toBe('playing');
expect(next.targetClears).toBe(35); expect(next.targetClears).toBe(20);
expect(next.levelDurationSeconds).toBe(300);
}); });
test('本地运行态固定使用 6x6 棋盘和 35 次消除目标', () => { test('本地运行态固定使用 4 关 6x6 棋盘和逐关目标', () => {
const work = createDraftWork(); const work = createDraftWork();
const run = createPuzzleClearLocalRuntimeSnapshot(work, 4); const levels = [1, 2, 3, 4].map((levelIndex) =>
createPuzzleClearLocalRuntimeSnapshot(work, levelIndex),
);
expect(run.levelIndex).toBe(1); expect(
expect(run.board.rows).toBe(6); levels.map((run) => ({
expect(run.board.cols).toBe(6); levelIndex: run.levelIndex,
expect(run.targetClears).toBe(35); rows: run.board.rows,
cols: run.board.cols,
targetClears: run.targetClears,
duration: run.levelDurationSeconds,
})),
).toEqual([
{ levelIndex: 1, rows: 6, cols: 6, targetClears: 15, duration: 300 },
{ levelIndex: 2, rows: 6, cols: 6, targetClears: 20, duration: 300 },
{ levelIndex: 3, rows: 6, cols: 6, targetClears: 30, duration: 420 },
{ levelIndex: 4, rows: 6, cols: 6, targetClears: 35, duration: 600 },
]);
}); });
test('本地草稿只会保留单关目标所需的 35 个图案组,剩余卡进入顶部准备区', () => { test('本地草稿第一关只使用刚好 15 组 1x2 图案,牌不足棋盘时留出空格', () => {
const work = createDraftWorkWithCardCount(80); const work = createDraftWorkWithCardCount(80);
const run = createPuzzleClearLocalRuntimeSnapshot(work); const run = createPuzzleClearLocalRuntimeSnapshot(work);
const boardCardCount = run.board.rows * run.board.cols; const boardCardCount = run.board.cells.filter((cell) => cell.card).length;
const emptyCellCount = run.board.cells.filter((cell) => !cell.card).length;
const reserveCardCount = run.readyColumns.flat().length; const reserveCardCount = run.readyColumns.flat().length;
expect(run.board.cells.every((cell) => cell.card?.shape === '1x2')).toBe(true); expect(
expect(boardCardCount).toBe(36); run.board.cells.every((cell) => !cell.card || cell.card.shape === '1x2'),
expect(reserveCardCount).toBe(34); ).toBe(true);
expect(boardCardCount).toBe(30);
expect(emptyCellCount).toBe(6);
expect(reserveCardCount).toBe(0);
expect(run.readyColumns).toHaveLength(run.board.cols); expect(run.readyColumns).toHaveLength(run.board.cols);
}); });
test('本地草稿按关卡逐步解锁消除形状', () => {
const work = createDraftWorkWithAllShapes();
const levelShapeSets = [1, 2, 3, 4].map((levelIndex) => {
const run = createPuzzleClearLocalRuntimeSnapshot(work, levelIndex);
return new Set(
[...run.board.cells.map((cell) => cell.card?.shape), ...run.readyColumns.flat().map((card) => card.shape)]
.filter(Boolean),
);
});
expect([...levelShapeSets[0]!].sort()).toEqual(['1x2']);
expect([...levelShapeSets[1]!].sort()).toEqual(['1x2', '1x3']);
expect([...levelShapeSets[2]!].sort()).toEqual(['1x2', '1x3', '2x2']);
expect([...levelShapeSets[3]!].sort()).toEqual(['1x2', '1x3', '2x2', '2x3']);
});
test('本地草稿开局不会直接摆出已完成的图案组', () => { test('本地草稿开局不会直接摆出已完成的图案组', () => {
const work = createDraftWorkWithCardCount(80); const work = createDraftWorkWithCardCount(80);
const run = createPuzzleClearLocalRuntimeSnapshot(work); const run = createPuzzleClearLocalRuntimeSnapshot(work);
@@ -282,6 +343,44 @@ test('本地草稿第一关不会把 1x2 当成半锁定拼接组留在场上',
expect(movedNearButIncomplete.clearsDone).toBe(0); expect(movedNearButIncomplete.clearsDone).toBe(0);
}); });
test('本地草稿会把 2x2 的 L 形局部拼合整体锁住', () => {
const work = createDraftWork();
const run = createPuzzleClearLocalRuntimeSnapshot(work);
const preparedRun = {
...run,
board: {
rows: 3,
cols: 3,
cells: [
{ row: 0, col: 0, card: createCustomCard('block', 'block-0-0', 0, 0, '2x2'), lockedGroupId: null },
{ row: 0, col: 1, card: createCustomCard('block', 'block-1-0', 1, 0, '2x2'), lockedGroupId: null },
{ row: 0, col: 2, card: createCustomCard('noise-a', 'noise-a', 0, 0), lockedGroupId: null },
{ row: 1, col: 0, card: createCustomCard('block', 'block-0-1', 0, 1, '2x2'), lockedGroupId: null },
{ row: 1, col: 1, card: createCustomCard('noise-b', 'noise-b', 0, 0), lockedGroupId: null },
{ row: 1, col: 2, card: createCustomCard('noise-c', 'noise-c', 0, 0), lockedGroupId: null },
{ row: 2, col: 0, card: createCustomCard('noise-d', 'noise-d', 0, 0), lockedGroupId: null },
{ row: 2, col: 1, card: createCustomCard('play', 'play-0', 0, 0), lockedGroupId: null },
{ row: 2, col: 2, card: createCustomCard('play', 'play-1', 1, 0), lockedGroupId: null },
],
},
readyColumns: [[], [], []],
};
const next = swapPuzzleClearLocalCards(preparedRun, {
fromRow: 2,
fromCol: 1,
toRow: 2,
toCol: 2,
});
expect(
next.board.cells
.filter((cell) => cell.card?.groupId === 'block')
.map((cell) => cell.lockedGroupId),
).toEqual(['block', 'block', 'block']);
expect(next.clearsDone).toBe(0);
});
test('本地草稿补位时会从其它准备列借牌避免出现空格子', () => { test('本地草稿补位时会从其它准备列借牌避免出现空格子', () => {
const work = createDraftWork(); const work = createDraftWork();
const run = createPuzzleClearLocalRuntimeSnapshot(work); const run = createPuzzleClearLocalRuntimeSnapshot(work);
@@ -415,7 +514,7 @@ test('本地草稿交换成完整 1x2 图案后会消除并从顶部准备区补
expect(next.readyColumns[1]).toHaveLength(0); expect(next.readyColumns[1]).toHaveLength(0);
}); });
test('本地草稿达到当前关目标但场上仍有牌时不会提前完成', () => { test('本地草稿达到当前关目标但仍有牌时不会完成当前关', () => {
const work = createDraftWork(); const work = createDraftWork();
const run = createPuzzleClearLocalRuntimeSnapshot(work); const run = createPuzzleClearLocalRuntimeSnapshot(work);
const firstPart = createCustomCard('pair-b', 'pair-b-0', 0, 0); const firstPart = createCustomCard('pair-b', 'pair-b-0', 0, 0);
@@ -465,11 +564,12 @@ test('本地草稿达到当前关目标但场上仍有卡牌时不会提前完
expect(next.clearsDone).toBe(1); expect(next.clearsDone).toBe(1);
expect(next.status).toBe('playing'); expect(next.status).toBe('playing');
expect(next.finishedAtMs).toBeNull(); expect(next.finishedAtMs).toBeNull();
expect(next.board.cells.some((cell) => cell.card)).toBe(true);
}); });
test('本地草稿只有在达到目标且清空棋盘后才进入关卡完成状态', () => { test('本地草稿最后一关达到目标后进入整局完成状态', () => {
const work = createDraftWork(); const work = createDraftWork();
const run = createPuzzleClearLocalRuntimeSnapshot(work); const run = createPuzzleClearLocalRuntimeSnapshot(work, 4);
const firstPart = createCustomCard('pair-c', 'pair-c-0', 0, 0); const firstPart = createCustomCard('pair-c', 'pair-c-0', 0, 0);
const secondPart = createCustomCard('pair-c', 'pair-c-1', 1, 0); const secondPart = createCustomCard('pair-c', 'pair-c-1', 1, 0);
const preparedRun = { const preparedRun = {

View File

@@ -6,18 +6,36 @@ import type {
PuzzleClearWorkProfileResponse, PuzzleClearWorkProfileResponse,
} from '../../../packages/shared/src/contracts/puzzleClear'; } from '../../../packages/shared/src/contracts/puzzleClear';
const PUZZLE_CLEAR_LOCAL_RUNTIME_DURATION_SECONDS = 600;
const PUZZLE_CLEAR_LOCAL_MAX_LEVEL = 1;
const PUZZLE_CLEAR_LEVEL_CONFIGS = [ const PUZZLE_CLEAR_LEVEL_CONFIGS = [
{ size: 6, targetClears: 35 }, {
size: 6,
targetClears: 15,
durationSeconds: 300,
unlockedShapes: ['1x2'],
},
{
size: 6,
targetClears: 20,
durationSeconds: 300,
unlockedShapes: ['1x2', '1x3'],
},
{
size: 6,
targetClears: 30,
durationSeconds: 420,
unlockedShapes: ['1x2', '1x3', '2x2'],
},
{
size: 6,
targetClears: 35,
durationSeconds: 600,
unlockedShapes: ['1x2', '1x3', '2x2', '2x3'],
},
] as const; ] as const;
type PuzzleClearLevelConfig = (typeof PUZZLE_CLEAR_LEVEL_CONFIGS)[number]; type PuzzleClearLevelConfig = (typeof PUZZLE_CLEAR_LEVEL_CONFIGS)[number];
const PUZZLE_CLEAR_LOCAL_MAX_LEVEL = PUZZLE_CLEAR_LEVEL_CONFIGS.length;
const PUZZLE_CLEAR_FALLBACK_LEVEL_CONFIG: PuzzleClearLevelConfig = const PUZZLE_CLEAR_FALLBACK_LEVEL_CONFIG: PuzzleClearLevelConfig =
PUZZLE_CLEAR_LEVEL_CONFIGS[PUZZLE_CLEAR_LEVEL_CONFIGS.length - 1]!; PUZZLE_CLEAR_LEVEL_CONFIGS[PUZZLE_CLEAR_LEVEL_CONFIGS.length - 1]!;
const PUZZLE_CLEAR_LEVEL_UNLOCKED_SHAPES: readonly PuzzleClearCardAsset['shape'][][] =
[
['1x2', '1x3', '2x2', '2x3'],
];
type PuzzleClearLocalRuntimeBoardCell = { type PuzzleClearLocalRuntimeBoardCell = {
row: number; row: number;
@@ -60,14 +78,7 @@ function resolvePuzzleClearLevelConfig(
function resolvePuzzleClearLevelUnlockedShapes( function resolvePuzzleClearLevelUnlockedShapes(
levelIndex: number, levelIndex: number,
): readonly PuzzleClearCardAsset['shape'][] { ): readonly PuzzleClearCardAsset['shape'][] {
const normalizedLevelIndex = Math.min( return resolvePuzzleClearLevelConfig(levelIndex).unlockedShapes;
PUZZLE_CLEAR_LEVEL_UNLOCKED_SHAPES.length - 1,
Math.max(0, levelIndex - 1),
);
return (
PUZZLE_CLEAR_LEVEL_UNLOCKED_SHAPES[normalizedLevelIndex] ??
PUZZLE_CLEAR_LEVEL_UNLOCKED_SHAPES[PUZZLE_CLEAR_LEVEL_UNLOCKED_SHAPES.length - 1]!
);
} }
function stablePuzzleClearGroupKey(seed: string, groupId: string) { function stablePuzzleClearGroupKey(seed: string, groupId: string) {
@@ -84,8 +95,9 @@ function buildPuzzleClearLevelCards(
levelIndex: number, levelIndex: number,
) { ) {
const cards = work.cardAssets.length > 0 ? work.cardAssets : work.draft.cardAssets; const cards = work.cardAssets.length > 0 ? work.cardAssets : work.draft.cardAssets;
const unlockedShapes = new Set(resolvePuzzleClearLevelUnlockedShapes(levelIndex)); const levelConfig = resolvePuzzleClearLevelConfig(levelIndex);
const targetGroups = resolvePuzzleClearLevelConfig(levelIndex).targetClears; const unlockedShapes = new Set(levelConfig.unlockedShapes);
const targetGroups = levelConfig.targetClears;
const groupedCards = new Map<string, PuzzleClearCardAsset[]>(); const groupedCards = new Map<string, PuzzleClearCardAsset[]>();
for (const card of cards) { for (const card of cards) {
@@ -97,7 +109,7 @@ function buildPuzzleClearLevelCards(
groupedCards.set(card.groupId, entries); groupedCards.set(card.groupId, entries);
} }
return [...groupedCards.entries()] const sortedGroups = [...groupedCards.entries()]
.map(([groupId, entries]) => ({ .map(([groupId, entries]) => ({
groupId, groupId,
cards: entries.sort((left, right) => left.partY - right.partY || left.partX - right.partX), cards: entries.sort((left, right) => left.partY - right.partY || left.partX - right.partX),
@@ -109,9 +121,33 @@ function buildPuzzleClearLevelCards(
return left.groupId.localeCompare(right.groupId); return left.groupId.localeCompare(right.groupId);
} }
return leftKey < rightKey ? -1 : 1; return leftKey < rightKey ? -1 : 1;
}) });
.slice(0, targetGroups) const selectedGroups: typeof sortedGroups = [];
.flatMap((group) => group.cards); const selectedGroupIds = new Set<string>();
for (const shape of levelConfig.unlockedShapes) {
const candidate = sortedGroups.find(
(group) =>
group.cards[0]?.shape === shape && !selectedGroupIds.has(group.groupId),
);
if (candidate && selectedGroups.length < targetGroups) {
selectedGroups.push(candidate);
selectedGroupIds.add(candidate.groupId);
}
}
for (const group of sortedGroups) {
if (selectedGroups.length >= targetGroups) {
break;
}
if (selectedGroupIds.has(group.groupId)) {
continue;
}
selectedGroups.push(group);
selectedGroupIds.add(group.groupId);
}
return selectedGroups.flatMap((group) => group.cards);
} }
function shufflePuzzleClearLocalCards( function shufflePuzzleClearLocalCards(
@@ -141,17 +177,20 @@ function buildLocalRuntimeBoard(
) { ) {
const size = resolvePuzzleClearLevelConfig(levelIndex).size; const size = resolvePuzzleClearLevelConfig(levelIndex).size;
const visibleCards = cards.slice(0, size * size); const visibleCards = cards.slice(0, size * size);
const totalCells = size * size;
for (let attempt = 0; attempt < 128; attempt += 1) { for (let attempt = 0; attempt < 128; attempt += 1) {
const attemptSeed = `${seed}:level-${levelIndex}:attempt-${attempt}`; const attemptSeed = `${seed}:level-${levelIndex}:attempt-${attempt}`;
const shuffledCards = shufflePuzzleClearLocalCards(visibleCards, attemptSeed); const shuffledCards = shufflePuzzleClearLocalCards(visibleCards, attemptSeed);
const emptySlots = Math.max(0, totalCells - shuffledCards.length);
const boardCells: PuzzleClearLocalRuntimeBoardCell[] = []; const boardCells: PuzzleClearLocalRuntimeBoardCell[] = [];
let index = 0; let index = 0;
for (let row = 0; row < size; row += 1) { for (let row = 0; row < size; row += 1) {
for (let col = 0; col < size; col += 1) { for (let col = 0; col < size; col += 1) {
const cardIndex = index - emptySlots;
boardCells.push({ boardCells.push({
row, row,
col, col,
card: cloneCard(shuffledCards[index] ?? null), card: cardIndex >= 0 ? cloneCard(shuffledCards[cardIndex] ?? null) : null,
lockedGroupId: null, lockedGroupId: null,
}); });
index += 1; index += 1;
@@ -175,10 +214,12 @@ function buildLocalRuntimeBoard(
cells: Array.from({ length: size * size }, (_, index) => { cells: Array.from({ length: size * size }, (_, index) => {
const row = Math.floor(index / size); const row = Math.floor(index / size);
const col = index % size; const col = index % size;
const emptySlots = Math.max(0, size * size - visibleCards.length);
const cardIndex = index - emptySlots;
return { return {
row, row,
col, col,
card: cloneCard(visibleCards[index] ?? null), card: cardIndex >= 0 ? cloneCard(visibleCards[cardIndex] ?? null) : null,
lockedGroupId: null, lockedGroupId: null,
}; };
}), }),
@@ -257,6 +298,33 @@ function partPositionKey(row: number, col: number, partX: number, partY: number)
return `${row}:${col}:${partX}:${partY}`; return `${row}:${col}:${partX}:${partY}`;
} }
function isPuzzleClearConnectedPartialGroup(
entries: Array<{ row: number; col: number; card: PuzzleClearCardAsset }>,
) {
if (entries.length < 2) {
return false;
}
const visited = new Set<number>([0]);
const queue = [0];
while (queue.length > 0) {
const index = queue.shift()!;
const current = entries[index]!;
entries.forEach((candidate, candidateIndex) => {
if (visited.has(candidateIndex)) {
return;
}
if (
getPartDistance(current.card, candidate.card) === 1 &&
areNeighborCells(current, candidate)
) {
visited.add(candidateIndex);
queue.push(candidateIndex);
}
});
}
return visited.size === entries.length;
}
function getCardAt(board: PuzzleClearBoardSnapshot, row: number, col: number) { function getCardAt(board: PuzzleClearBoardSnapshot, row: number, col: number) {
return findCell(board, row, col)?.card ?? null; return findCell(board, row, col)?.card ?? null;
} }
@@ -331,18 +399,7 @@ function findPuzzleClearLocalCompletedPartialGroups(
if (!first || first.shape === '1x2' || entries.length < 2) { if (!first || first.shape === '1x2' || entries.length < 2) {
continue; continue;
} }
const ordered = [...entries].sort( if (isPuzzleClearConnectedPartialGroup(entries)) {
(left, right) =>
left.card.partY - right.card.partY || left.card.partX - right.card.partX,
);
const adjacent = ordered.slice(1).every((entry, index) => {
const previous = ordered[index]!;
return (
getPartDistance(previous.card, entry.card) === 1 &&
areNeighborCells(previous, entry)
);
});
if (adjacent) {
groups.push({ groups.push({
groupId, groupId,
positions: entries.map((entry) => ({ row: entry.row, col: entry.col })), positions: entries.map((entry) => ({ row: entry.row, col: entry.col })),
@@ -508,6 +565,15 @@ function hasPuzzleClearLocalRemainingCards(board: PuzzleClearBoardSnapshot) {
return board.cells.some((cell) => Boolean(cell.card)); return board.cells.some((cell) => Boolean(cell.card));
} }
function hasPuzzleClearLocalRemainingCardsInRun(
run: PuzzleClearRuntimeSnapshotResponse,
) {
return (
hasPuzzleClearLocalRemainingCards(run.board) ||
run.readyColumns.some((column) => column.length > 0)
);
}
function hasPuzzleClearLocalPotentialMove(board: PuzzleClearBoardSnapshot) { function hasPuzzleClearLocalPotentialMove(board: PuzzleClearBoardSnapshot) {
if (findPuzzleClearLocalEliminations(board).length > 0) { if (findPuzzleClearLocalEliminations(board).length > 0) {
return false; return false;
@@ -774,7 +840,7 @@ export function createPuzzleClearLocalRuntimeSnapshot(
levelIndex: normalizedLevelIndex, levelIndex: normalizedLevelIndex,
clearsDone: 0, clearsDone: 0,
targetClears: levelConfig.targetClears, targetClears: levelConfig.targetClears,
levelDurationSeconds: PUZZLE_CLEAR_LOCAL_RUNTIME_DURATION_SECONDS, levelDurationSeconds: levelConfig.durationSeconds,
levelStartedAtMs: Date.now(), levelStartedAtMs: Date.now(),
board, board,
readyColumns, readyColumns,
@@ -818,7 +884,7 @@ export function swapPuzzleClearLocalCards(
markPuzzleClearLocalCompletedGroups(next.board); markPuzzleClearLocalCompletedGroups(next.board);
if ( if (
next.clearsDone >= next.targetClears && next.clearsDone >= next.targetClears &&
!hasPuzzleClearLocalRemainingCards(next.board) !hasPuzzleClearLocalRemainingCardsInRun(next)
) { ) {
next.status = next.status =
next.levelIndex >= PUZZLE_CLEAR_LOCAL_MAX_LEVEL ? 'finished' : 'level_cleared'; next.levelIndex >= PUZZLE_CLEAR_LOCAL_MAX_LEVEL ? 'finished' : 'level_cleared';