diff --git a/.hermes/shared-memory/decision-log.md b/.hermes/shared-memory/decision-log.md index 68d8cd56..9e13545d 100644 --- a/.hermes/shared-memory/decision-log.md +++ b/.hermes/shared-memory/decision-log.md @@ -16,10 +16,10 @@ --- -## 2026-06-03 拼消消收敛为单关 6x6 与 4-sheet 素材策略 +## 2026-06-03 拼消消收敛为 4 关 6x6 与 4-sheet 素材策略 - 背景:最初 4 关 / 135 次消除 / 单张大 atlas 方案生图数量和空间一致性成本过高,真实 image2 结果容易被布局提示词诱导成带文字、边框或编号的说明图,不适合运行态 1x1 切片。 -- 决策:拼消消运行态收敛为单关 `6x6 / 35 次消除 / 600 秒`,直接解锁 `1x2`、`1x3`、`2x2`、`2x3`;素材生成改为 4 张 `1024x1536` 竖版 sheet,每张按 `4x6`、每格 `256x256` 切片,再由服务端合成 `10x10 / 2560x2560` 最终 atlas。形状配比固定为 `1x2=23`、`1x3=5`、`2x2=4`、`2x3=3`,总计 35 个复合图案组和 95 个 1x1 卡牌切片。 +- 决策:拼消消运行态收敛为 4 关 `6x6` 渐进规则:第 1 关目标 15、300 秒、仅 `1x2`;第 2 关目标 20、300 秒、解锁 `1x2/1x3`;第 3 关目标 30、420 秒、解锁 `1x2/1x3/2x2`;第 4 关目标 35、600 秒、解锁全部 `1x2/1x3/2x2/2x3`。目标消除数就是本关实际复合图案组总数,胜利条件永远是消除完本关全部卡牌;棋盘放不下的牌进入顶部准备区,牌不足棋盘格数时保留空格并露出背景图。素材生成仍使用 4 张 `1024x1536` 竖版 sheet,每张按 `4x6`、每格 `256x256` 切片,再由服务端合成 `10x10 / 2560x2560` 最终 atlas。形状配比固定为 `1x2=23`、`1x3=5`、`2x2=4`、`2x3=3`,总计 35 个复合图案组和 95 个 1x1 卡牌切片。 - 影响范围:`module-puzzle-clear` 关卡与图案组规划、api-server 拼消消素材生成编排、前端草稿试玩本地 runtime、结果页 atlas 预览、拼消消 PRD / 技术方案 / 平台链路文档。 - 验证方式:`cargo test -p module-puzzle-clear --manifest-path server-rs/Cargo.toml`、`cargo test -p api-server puzzle_clear --manifest-path server-rs/Cargo.toml -- --nocapture`、`npm run test -- src/services/puzzle-clear/puzzleClearLocalRuntime.test.ts`、`npm run test -- src/components/puzzle-clear-result/PuzzleClearResultView.test.tsx src/components/puzzle-clear-runtime/PuzzleClearRuntimeShell.test.tsx`。 - 关联文档:`docs/prd/【玩法创作】拼消消玩法模板PRD-2026-05-30.md`、`docs/technical/【玩法创作】拼消消玩法模板技术方案-2026-05-30.md`、`docs/【玩法创作】平台入口与玩法链路-2026-05-15.md`、`docs/【后端架构】server-rs与SpacetimeDB数据契约-2026-05-15.md`。 @@ -28,7 +28,7 @@ - 背景:拼消消以拼图交换手感为基础,但核心规则从“拼完整单图过关”变为“拼成多个复合图案组后逐个消除”,同时需要顶部补牌、防死局、半锁定局部拼接组和正式统计,不能继续复用拼图运行态规则本体。 - 决策:`puzzle-clear` 作为独立玩法域接入,公开作品码前缀固定为 `PC-`;创作链路采用表单 / 图片输入工作台 -> 独立生成页 -> 结果页 -> 试玩 -> 发布 -> 统一作品详情 -> 正式 runtime。领域规则落在 `module-puzzle-clear`,SpacetimeDB 新增 `puzzle_clear_*` 表 / procedure / view,并接入统一 `public_work_gallery_entry` / `public_work_detail_entry`;前端只表现后端 snapshot/action 结果,不把胜负、补牌或消除裁决做成前端事实源。 -- 补充约束:草稿编译和发布都必须拒绝缺失或 `placeholder` atlas / card assets,不允许后端 facade 或 SpacetimeDB 合成临时素材;当前单关正式 runtime 终态事件使用 `run-finished`、`level-failed`,并写入包含 `status`、`level`、`clears`、`clearDelta`、`elapsedMs` 的结果 JSON。 +- 补充约束:草稿编译和发布都必须拒绝缺失或 `placeholder` atlas / card assets,不允许后端 facade 或 SpacetimeDB 合成临时素材;正式 runtime 终态事件使用 `run-finished`、`level-failed`,并写入包含 `status`、`level`、`clears`、`clearDelta`、`elapsedMs` 的结果 JSON。 - 补充约束:拼消消结果页草稿试玩使用前端本地 `runtimeMode=draft` snapshot,不调用 `/api/runtime/puzzle-clear/runs`,不写正式 run 统计;公开详情和推荐流正式运行继续走后端 `/api/runtime/puzzle-clear/*`,客户端需要区分创作详情 `/api/creation/puzzle-clear/works/{profileId}` 与公开运行态详情 `/api/runtime/puzzle-clear/works/{profileId}`。 - 影响范围:`CONTEXT.md`、拼消消 PRD / 技术方案、平台玩法链路文档、`shared-contracts` / `packages/shared`、`api-server`、`spacetime-module`、`spacetime-client`、作品架 / 广场 / 统一作品详情 / runtime 前端分流。 - 验证方式:PRD 和技术方案必须覆盖资产槽位、素材工作表风险、切片验证、恢复语义、API 命名空间和验证命令;实现侧至少运行 `npm run spacetime:generate`、`npm run check:spacetime-schema`、`npm run check:spacetime-runtime-access`、`npm run check:server-rs-ddd`、`npm run typecheck`、`npm run check:encoding`、相关前端测试和 `cargo test -p module-puzzle-clear --manifest-path server-rs/Cargo.toml`。 diff --git a/.hermes/shared-memory/pitfalls.md b/.hermes/shared-memory/pitfalls.md index f5fd1ae3..4163c3ac 100644 --- a/.hermes/shared-memory/pitfalls.md +++ b/.hermes/shared-memory/pitfalls.md @@ -120,6 +120,22 @@ - 验证:`npm run test -- src/components/puzzle-clear-runtime/PuzzleClearRuntimeShell.test.tsx`,浏览器里确认局部拼合会闪、完整消除会放大淡出、补牌在淡出后段才开始掉落。 - 关联:`src/components/puzzle-clear-runtime/PuzzleClearRuntimeShell.tsx`、`src/index.css`、`src/components/puzzle-clear-runtime/PuzzleClearRuntimeShell.test.tsx`。 +## 拼消消补牌下落叠层不能比动画提前卸载 + +- 现象:小牌补牌下落时看起来只是闪一下就切到目标格,下滑过程不连贯,途中还会闪出白色底。 +- 原因:补牌叠层的清理时间只按消除动画固定时长计算,没有覆盖每张卡自己的下落延迟、下落距离时长和收尾缓冲;同时下落叠层曾带 `bg-white` / `border-white` 外壳,动画起点透明度和提亮滤镜会把外壳闪成白块。 +- 处理:下落卡片按距离写入 `--puzzle-clear-drop-duration`,清理定时器取 `delay + duration + settle buffer` 的最大值;CSS 下落动画使用 `translate3d`、轻微过冲和回弹,叠层保持透明、无边框、无亮度提升,只渲染真实卡片图。 +- 验证:`npm run test -- src/components/puzzle-clear-runtime/PuzzleClearRuntimeShell.test.tsx src/index.test.ts`;浏览器里完整消除后新补入小牌应有连续下滑和轻微落位感,过程中不应出现白色闪块。 +- 关联:`src/components/puzzle-clear-runtime/PuzzleClearRuntimeShell.tsx`、`src/index.css`、`src/index.test.ts`、`docs/technical/【玩法创作】拼消消玩法模板技术方案-2026-05-30.md`。 + +## 拼消消替换飞行层要飞目标卡且不能带白底 + +- 现象:按下卡牌、拖动覆盖另一张卡并完成替换时,被替换的小卡在新位置先闪一下白底,然后才显示正确图像。 +- 原因:替换飞行层如果复用被拖动的源卡,并且容器本身带 `background: white` / 白色边框,就会和拖拽 ghost 同向叠在目标格附近;真正应该回到源空位的目标卡没有覆盖源位空槽,视觉上就会露出白壳或白底。 +- 处理:拖拽 ghost 只负责源卡落到目标格;`swapFlight` 必须使用被覆盖的 `target.card`,从目标格飞回源空位。飞行层容器保持透明、无边框,阴影只加在真实卡图上。 +- 验证:`npm run test -- src/components/puzzle-clear-runtime/PuzzleClearRuntimeShell.test.tsx src/index.test.ts` 覆盖目标卡图片进入 `puzzle-clear-swap-flight`,并断言 `.puzzle-clear-swap-flight` 不再出现白底或白边。 +- 关联:`src/components/puzzle-clear-runtime/PuzzleClearRuntimeShell.tsx`、`src/index.css`、`src/components/puzzle-clear-runtime/PuzzleClearRuntimeShell.test.tsx`、`src/index.test.ts`。 + ## 首页推荐分流参数不能条件性调用 hook - 现象:桌面首页或移动首页在 HMR、断点切换或重新渲染后直接报 React hook 顺序错误,页面停在“正在加载内容”。 @@ -1825,6 +1841,22 @@ - 验证:浏览器拖拽时能看到跟手 ghost、源位空槽、落点飞入和整组拼接层;`src/components/puzzle-clear-runtime/PuzzleClearRuntimeShell.test.tsx` 应覆盖这些行为。 - 关联:`src/components/puzzle-clear-runtime/PuzzleClearRuntimeShell.tsx`、`src/components/puzzle-clear-runtime/PuzzleClearRuntimeShell.test.tsx`、`src/index.css`。 +## 拼消消整组拖拽越界要前端回弹,不要发无效 swap + +- 现象:正式拼消消 runtime 玩到已有局部拼接组后,把整组拖到棋盘边缘外侧,前端弹出“puzzle-clear 坐标无效”,后端日志里对应 `/api/runtime/puzzle-clear/runs/{runId}/swap`。 +- 原因:后端 `move_locked_group` 会按整组平移后的每个格子做边界校验;前端早期只校验松手目标格存在,没有校验整组的其它格子是否会越界,因此会把后端必然拒绝的动作发出去。 +- 处理:拖动带 `lockedGroupId` 的拼接组时,前端先用当前 board snapshot 计算 `rowDelta/colDelta`,确认组内所有格子平移后仍在棋盘内;越界时只播放回弹,不调用 `onSwapCards`。后端继续保留最终规则裁决;`puzzle-clear 坐标无效`、缺卡、非 playing、超时这类玩法动作校验错误应映射为 400,避免误判为网关问题。 +- 验证:`npm run test -- src/components/puzzle-clear-runtime/PuzzleClearRuntimeShell.test.tsx` 覆盖“已拼接局部拖到会越界的位置时只回弹不提交后端动作”;`cargo check -p api-server --manifest-path server-rs/Cargo.toml` 通过。 +- 关联:`src/components/puzzle-clear-runtime/PuzzleClearRuntimeShell.tsx`、`src/components/puzzle-clear-runtime/PuzzleClearRuntimeShell.test.tsx`、`server-rs/crates/module-puzzle-clear/src/application.rs`、`server-rs/crates/api-server/src/puzzle_clear.rs`。 + +## 拼消消多行局部拼合要按连通块整体锁定和拖起 + +- 现象:`2x2` 或 `2x3` 图案拼成 L 形、转角或多行局部后,玩家拖起已拼合组时会看到有一张卡没有被拿起,仍留在格子层里,并被格子白底或锁定组覆盖层压住。 +- 原因:半锁定局部拼接组如果只按 `partY/partX` 排序后的线性相邻关系判断,会漏掉 2D 形状里的分叉 / 转角连通块;前端如果只隐藏起点和线性邻格,也会让未写入同一 `lockedGroupId` 的卡继续按普通格子层渲染。 +- 处理:本地草稿 runtime 和 Rust `module-puzzle-clear` 都要按同组卡牌“素材坐标相邻且棋盘格相邻”的连通块识别半锁定组,`1x3`、`2x2`、`2x3` 的局部拼合必须整体写入同一个 `lockedGroupId`。前端拖起锁定组时,活动组覆盖层不再渲染;组内所有源格标记为 `drag-group-source`,格子层不渲染卡图,只保留透明空槽,卡图只出现在 `document.body` portal 里的 drag ghost。 +- 验证:`npm run test -- src/components/puzzle-clear-runtime/PuzzleClearRuntimeShell.test.tsx src/services/puzzle-clear/puzzleClearLocalRuntime.test.ts`、`cargo test -p module-puzzle-clear --manifest-path server-rs/Cargo.toml`。 +- 关联:`src/components/puzzle-clear-runtime/PuzzleClearRuntimeShell.tsx`、`src/services/puzzle-clear/puzzleClearLocalRuntime.ts`、`server-rs/crates/module-puzzle-clear/src/application.rs`、`src/index.css`。 + ## 拼消消空格位必须允许落位,不能当成不可交互死格 - 现象:运行到某一关后,棋盘里出现空格位,用户能看见空洞但拖不进去,也点不动。 diff --git a/docs/prd/【玩法创作】拼消消玩法模板PRD-2026-05-30.md b/docs/prd/【玩法创作】拼消消玩法模板PRD-2026-05-30.md index 6adfb9a7..8c6e1a41 100644 --- a/docs/prd/【玩法创作】拼消消玩法模板PRD-2026-05-30.md +++ b/docs/prd/【玩法创作】拼消消玩法模板PRD-2026-05-30.md @@ -17,8 +17,8 @@ - 工作台模式:表单 / 图片输入创作工作台。 - 创作链路:入口 -> 工作台 -> 生成页 -> 结果页 -> 试玩 -> 发布 -> 运行态。 - 单图资产槽位: - - `board-background` / `ui-background` / `中央场地底图` / `boardBackgroundPrompt` 优先、空值时回退 `themePrompt`,并支持用户上传图 / 写回 `draft.boardBackgroundAsset`、`draft.boardBackgroundPrompt`、`work.boardBackgroundAsset` 与 `work.boardBackgroundPrompt` / 允许历史图 / 允许 AI 重绘。 - - 中央场地底图的字段名沿用平台表面口径,实际作用是玩家逐步消除清空中央棋盘后慢慢看到的主题目标图;AI 生成尺寸必须与中央棋盘一致,使用 1:1 正方形画面。prompt 必须强绑定主题、画面精致、强表现力并一眼体现主题,带来探索、揭开全貌和追求目标完成的感受;不得继续要求“画面干净”或“适合作为卡牌棋盘底图”。 + - `board-background` / `ui-background` / `背景图` / `boardBackgroundPrompt` 优先、空值时回退 `themePrompt`,并支持用户上传图片或填写画面描述生图 / 写回 `draft.boardBackgroundAsset`、`draft.boardBackgroundPrompt`、`work.boardBackgroundAsset` 与 `work.boardBackgroundPrompt` / 允许历史图 / 允许 AI 重绘。 + - 背景图实际作用是玩家逐步消除清空中央棋盘后慢慢看到的主题目标图;AI 生成尺寸必须与中央棋盘一致,使用 1:1 正方形画面。prompt 必须强绑定主题、画面精致、强表现力并一眼体现主题,带来探索、揭开全貌和追求目标完成的感受;不得继续要求“画面干净”或“适合作为卡牌棋盘底图”。 - 系列素材槽位: - `batchId=puzzle-clear-pattern-atlas-v1`。 - `sheetSpec`:4 张素材工作表,每张 `1024x1536` 竖版,后台按 `4 列 x 6 行` 裁切,每个 1x1 单元为 `256x256`;服务端再把切片合成一张 `10x10 / 2560x2560` 最终 atlas。复合图案组总数为 `35`,形状配比 `1x2=23`、`1x3=5`、`2x2=4`、`2x3=3`,总计 `95` 个 1x1 卡牌切片。 @@ -36,12 +36,12 @@ | 字段 | 契约字段 | 默认值 | 校验 | 落库 | | --- | --- | --- | --- | --- | -| 作品标题 | `workTitle` | 空 | 必填,1-30 字 | session draft / work profile | -| 简介 | `workDescription` | 空 | 0-120 字 | session draft / work profile | -| 主题词 | `themePrompt` | 空 | 必填,1-80 字 | 生成 prompt 与草稿 | -| 场地底图主题词 | `boardBackgroundPrompt` | 空 | 0-80 字;为空时底图生成回退 `themePrompt` | session draft / work profile / 主题目标图生成 prompt | -| 中央场地底图 | `boardBackgroundAsset` | 空 | 上传或 AI 生成至少一种 | 单图资产槽位 | -| AI 生成底图 | `generateBoardBackground` | `true` | boolean | 生成编排参数 | +| 卡牌素材主题 | `themePrompt` | 空 | 必填,1-80 字 | 生成 prompt 与草稿;工作台内部派生草稿占位标题,不向用户展示作品标题输入 | +| 画面描述 | `boardBackgroundPrompt` | 空 | 0-80 字;为空时背景图生成回退 `themePrompt` | session draft / work profile / 主题目标图生成 prompt | +| 背景图 | `boardBackgroundAsset` | 空 | 上传图片或 AI 生成至少一种 | 单图资产槽位 | +| AI 生成背景图 | `generateBoardBackground` | `true` | boolean | 生成编排参数 | + +作品标题 `workTitle` 与简介 `workDescription` 不属于工作台游戏内容配置;发布前检查环节必须让用户填写 / 修改标题与简介,保存为 `update-work-meta` 后再发布。工作台阶段只展示玩法标题、卡牌素材主题和背景图配置;背景图可以上传图片,也可以填写画面描述后使用 AI 生成,不再在同一界面混排发布元信息。 规则参数不开放创作者编辑:棋盘尺寸、倒计时、消除次数、形状解锁、防死局发牌和半锁定规则固定。 @@ -49,20 +49,24 @@ | 关卡 | 棋盘 | 目标消除 | 倒计时 | 解锁形状 | | --- | --- | --- | --- | --- | -| 1 | 6x6 | 35 | 10 分钟 | 1x2、1x3、2x2、2x3 | +| 1 | 6x6 | 15 | 5 分钟 | 1x2 | +| 2 | 6x6 | 20 | 5 分钟 | 1x2、1x3 | +| 3 | 6x6 | 30 | 7 分钟 | 1x2、1x3、2x2 | +| 4 | 6x6 | 35 | 10 分钟 | 1x2、1x3、2x2、2x3 | -- 开局每个小格子从背面翻向正面。 +- 开局只放入本关目标消除数对应的全部卡牌;棋盘放不下的牌进入顶部准备区,牌不足棋盘格数时空格保留。 +- 每个有卡牌的小格子从背面翻向正面。 - 可消除图由横向或纵向复合图案组组成,最小消除单位为两张图拼接。 - 完成一个复合图案组后,该组所有 1x1 卡牌碎片消除。 -- 消除后空位按列由顶部卡牌准备区下落补齐。 +- 消除后空位按列由顶部卡牌准备区下落补齐;若顶部没有新牌,则空格留在场上并露出背景图。 - 每次补牌至少保证掉落卡中有一张可以与场上剩余某张卡拼接,防止死局。 - 非 2 格消除时,若场上已有局部完成的半锁定拼接组,补牌不得破坏它。 - 半锁定拼接组可整体拖动;玩家用外部单格撞入组内某格时,只交换该格,组其余部分保留,组状态退回半完成。 -- 超时只判当前关失败,可重试当前关;完成 35 次目标并清空当前棋盘后整局完成。 +- 超时只判当前关失败,可重试当前关;胜利条件永远是消除完本关全部卡牌,达到目标消除数且棋盘与顶部准备区都没有剩余卡牌后进入下一关,完成第 4 关全部卡牌后整局完成。 ## 结果页 -结果页展示:素材 atlas、中央场地底图、发布状态、试玩入口和失败重试。结果页不写功能说明类文案,不开放规则编辑器,不新增排行榜配置。 +结果页展示:素材 atlas、背景图、发布状态、试玩入口和失败重试。点击发布时弹出发布前检查面板,收集作品标题和简介并保存作品信息后再发布。结果页不写功能说明类文案,不开放规则编辑器,不新增排行榜配置。 ## 统计 diff --git a/docs/technical/【玩法创作】拼消消玩法模板技术方案-2026-05-30.md b/docs/technical/【玩法创作】拼消消玩法模板技术方案-2026-05-30.md index b66efa99..6bdb65ed 100644 --- a/docs/technical/【玩法创作】拼消消玩法模板技术方案-2026-05-30.md +++ b/docs/technical/【玩法创作】拼消消玩法模板技术方案-2026-05-30.md @@ -28,17 +28,19 @@ | 形状 | 数量 | 单组单元数 | 解锁 | | --- | ---: | ---: | --- | | 1x2 | 23 | 2 | 第 1 关 | -| 1x3 | 5 | 3 | 第 1 关 | -| 2x2 | 4 | 4 | 第 1 关 | -| 2x3 | 3 | 6 | 第 1 关 | +| 1x3 | 5 | 3 | 第 2 关 | +| 2x2 | 4 | 4 | 第 3 关 | +| 2x3 | 3 | 6 | 第 4 关 | 流程: ```text -主题词 / 场地底图主题词 / 用户底图 -> 4 张 sheet 坐标规划 -> gpt-image-2 生成素材工作表 -> 按 4x6 裁切 1x1 -> 合成最终 atlas -> atlas 与卡牌切片持久化 -> OSS / asset_object / bind -> session draft 回写 +卡牌素材主题 / 背景图画面描述 / 用户背景图 -> 4 张 sheet 坐标规划 -> gpt-image-2 生成素材工作表 -> 按 4x6 裁切 1x1 -> 合成最终 atlas -> atlas 与卡牌切片持久化 -> OSS / asset_object / bind -> session draft 回写 ``` -中央场地底图的 prompt 来源固定为:若用户填写 `boardBackgroundPrompt`,AI 生成底图只读取该字段;若该字段为空,才回退读取 `themePrompt`。用户直接上传底图资产时不再用主题词重写该资产,只执行平台资产持久化与换签。中央场地底图在运行态不是普通棋盘衬底,而是玩家逐渐消除卡牌后露出的主题目标图;生成请求使用与中央棋盘一致的 1:1 正方形尺寸,prompt 必须强调探索、揭开全貌、追求完成目标、精致主题主视觉和强主题表现,不写“画面干净”或“适合作为卡牌棋盘底图”。 +背景图的 prompt 来源固定为:若用户填写 `boardBackgroundPrompt`,AI 生成背景图只读取该字段;若该字段为空,才回退读取 `themePrompt`。用户直接上传背景图资产时不再用主题词重写该资产,只执行平台资产持久化与换签。背景图在运行态不是整页氛围背景,而是玩家逐渐消除卡牌后露出的主题目标图;生成请求使用与中央棋盘一致的 1:1 正方形尺寸,prompt 必须强调探索、揭开全貌、追求完成目标、精致主题主视觉和强主题表现,不写“画面干净”或“适合作为卡牌棋盘底图”。 + +工作台 UI 对齐拼图创作流程:左上角保留返回按钮,下方展示大标题“拼消消创作”;工作台只收集游戏内容配置,包括卡牌素材主题和背景图配置。背景图配置通过同一个单图槽位表达“上传图片 / 填写画面描述”两种输入方式,槽位标题显示为“背景图”,描述输入显示为“画面描述”,工作台内容区不再套额外外层信息框。作品标题与简介属于发布元信息,不在工作台出现;前端创建草稿时可按主题派生内部占位标题,结果页点击发布时弹出发布前检查面板,先通过 `update-work-meta` 保存标题 / 简介,再调用发布接口。 ### 素材工作表风险与切片验证 @@ -65,13 +67,13 @@ `module-puzzle-clear` 已固定以下规则: -- 关卡配置:单关 `6x6/35`,600 秒。 +- 关卡配置:4 关,棋盘均为 `6x6`;第 1 关目标 15、300 秒、仅 `1x2`;第 2 关目标 20、300 秒、解锁 `1x2/1x3`;第 3 关目标 30、420 秒、解锁 `1x2/1x3/2x2`;第 4 关目标 35、600 秒、解锁全部 `1x2/1x3/2x2/2x3`。 - 图案组配比:`1x2=23`、`1x3=5`、`2x2=4`、`2x3=3`。 -- 开局随机铺满并保证至少一步可解。 -- 补牌按列重力下落;补牌后仍保证至少一步可解。 +- 开局只放入本关目标消除数对应的全部卡牌;棋盘放不下的牌进入顶部准备区,牌不足棋盘格数时空格保留;开局仍保证至少一步可解。 +- 补牌按列重力下落;顶部没有新牌时空格留在场上并露出背景图;补牌后若场上仍有卡牌则保证至少一步可解。 - 完整图案组消除并清空对应格。 - 半锁定拼接组只由玩家主动交换 / 撞入打散,补牌不破坏。 -- 超时失败只作用于当前单关,可重试;完成 35 次消除目标并清空棋盘后整局完成。 +- 超时失败只作用于当前关,可重试;胜利条件永远是消除完本关全部卡牌,达到目标消除数且棋盘与顶部准备区都没有剩余卡牌后进入下一关,第 4 关全部卡牌消除后整局完成。 ## API 命名空间 @@ -94,7 +96,7 @@ api-server 路由熔断使用 SpacetimeDB 创作入口配置 `puzzle-clear`, 正式 `published` run 记录开局、全局完成、当前关失败、耗时和消除统计。runtime action 返回的终态事件包括: -- `run-finished`:第 1 关完成并结束整局,结果 JSON 至少包含 `status`、`level`、`clears`、`clearDelta`、`elapsedMs`。 +- `run-finished`:第 4 关完成并结束整局,结果 JSON 至少包含 `status`、`level`、`clears`、`clearDelta`、`elapsedMs`。 - `level-failed`:当前关超时失败,结果 JSON 至少包含 `status`、`level`、`clears`、`clearDelta`、`elapsedMs`。 草稿试玩只消费同一份 snapshot/action 结果做表现,不写正式统计。 @@ -108,7 +110,7 @@ api-server 路由熔断使用 SpacetimeDB 创作入口配置 `puzzle-clear`, - `puzzle-clear-result` -> `/creation/puzzle-clear/result` - `puzzle-clear-runtime` -> `/runtime/puzzle-clear` -runtime 移动端优先,首屏结构为顶部倒计时 / 单关铭牌、顶部列准备区、棋盘、失败 / 完成弹层。棋盘主网格、半锁定组覆盖层和消除 / 掉落覆盖层统一使用 1.5px 格间距。动画包括开场翻转、局部正确拼合高光、完整消除放大淡出和列补牌延迟下落,不再有下一关切换。消除和补牌动画只能作为当前后端 snapshot 的表现层覆盖;已有场上卡片因重力下沉后的最终格不得被旧消除坐标或掉落覆盖层隐藏,避免出现“下方空位但上方卡片未下落”的视觉假象;新补入卡牌应等完整消除淡出进入尾段后再播放下落反馈。 +runtime 移动端优先,首屏结构为顶部倒计时 / 关卡铭牌、顶部列准备区、棋盘、失败 / 完成弹层。棋盘主网格、半锁定组覆盖层和消除 / 掉落覆盖层统一使用 1.5px 格间距。动画包括开场翻转、局部正确拼合高光、完整消除放大淡出、列补牌延迟下落和关卡完成后的下一关切换。消除和补牌动画只能作为当前后端 snapshot 的表现层覆盖;已有场上卡片因重力下沉后的最终格不得被旧消除坐标或掉落覆盖层隐藏,避免出现“下方空位但上方卡片未下落”的视觉假象;新补入卡牌应等完整消除淡出进入尾段后再播放下落反馈。列补牌下落的过渡层生命周期必须覆盖 `delay + duration + settle buffer`,并按下落距离延长动画时长,避免叠层在延迟后刚出现就被卸载;下落叠层不得带白色背景、白色边框或提亮滤镜,卡片图本身负责视觉主体,避免下滑时白闪。拖拽覆盖替换时,拖动卡由拖拽 ghost 落到目标格,被覆盖的目标卡才使用替换飞行层回到源空位;替换飞行层同样不得带白底、白边或白色外壳,避免目标卡在新位置先闪白再显示。 ## 验证计划 diff --git a/docs/【玩法创作】平台入口与玩法链路-2026-05-15.md b/docs/【玩法创作】平台入口与玩法链路-2026-05-15.md index 3a6fb116..e0f6b8a2 100644 --- a/docs/【玩法创作】平台入口与玩法链路-2026-05-15.md +++ b/docs/【玩法创作】平台入口与玩法链路-2026-05-15.md @@ -193,20 +193,20 @@ RPG / 拼图等运行态存档仍以 `/api/profile/save-archives` 的后端列 创作入口 -> 轻表单工作台 -> 生成过程页 -> 结果页 -> 试玩 -> 发布 -> 统一作品详情 -> 正式运行态 ``` -工作台字段固定为作品标题、简介、主题词、场地底图主题词 `boardBackgroundPrompt`、中央场地底图槽位、是否 AI 生成底图。中央场地底图必须复用 `CreativeImageInputPanel`,支持上传、历史图和 AI 重绘;若用户填写 `boardBackgroundPrompt`,AI 生成底图只读取该字段,字段为空时才回退读取 `themePrompt`;用户上传底图时不再用主题词重写该资产。中央场地底图的字段名保留平台口径,但实际语义是玩家逐步消除清空棋盘后露出的主题目标图,生成尺寸必须与中央棋盘一致,按 1:1 正方形出图;prompt 必须强绑定主题、画面精致、强表现力并一眼体现主题,不再要求“画面干净”或“适合作为卡牌棋盘底图”。运行态必须把中央场地底图作为棋盘内部静态底图使用,不能降级成整页氛围背景;卡牌消除后产生的空位和拖拽源位应露出该棋盘底图。卡面背面背景 v1 使用默认占位图,不作为创作者配置项。规则参数不开放编辑:单关 `6x6`、每局 10 分钟、35 次目标消除、形状解锁、防死局发牌和半锁定规则均由后端规则集固定。 +工作台字段固定为卡牌素材主题 `themePrompt`、背景图画面描述 `boardBackgroundPrompt`、背景图槽位和是否 AI 生成背景图;作品标题与简介不在工作台填写,发布前检查环节保存为作品信息后再发布。背景图必须复用 `CreativeImageInputPanel`,支持上传、历史图和 AI 重绘;工作台界面用同一槽位表达“上传图片 / 填写画面描述”两种输入方式,槽位标题显示为“背景图”,描述输入显示为“画面描述”,内容区不再套额外外层信息框。若用户填写 `boardBackgroundPrompt`,AI 生成背景图只读取该字段,字段为空时才回退读取 `themePrompt`;用户上传背景图时不再用主题词重写该资产。背景图的实际语义是玩家逐步消除清空棋盘后露出的主题目标图,生成尺寸必须与中央棋盘一致,按 1:1 正方形出图;prompt 必须强绑定主题、画面精致、强表现力并一眼体现主题,不再要求“画面干净”或“适合作为卡牌棋盘底图”。运行态必须把背景图作为棋盘内部静态底图使用,不能降级成整页氛围背景;卡牌消除后产生的空位和拖拽源位应露出该棋盘底图。卡面背面背景 v1 使用默认占位图,不作为创作者配置项。规则参数不开放编辑:4 关棋盘均为 `6x6`;第 1 关目标 15、5 分钟、仅 `1x2`,第 2 关目标 20、5 分钟、解锁 `1x2/1x3`,第 3 关目标 30、7 分钟、解锁 `1x2/1x3/2x2`,第 4 关目标 35、10 分钟、解锁 `1x2/1x3/2x2/2x3`;防死局发牌和半锁定规则均由后端规则集固定。 -素材生成使用拼消消专用编排,但必须复用 `platform-image`、VectorEngine `gpt-image-2`、OSS、`asset_object`、换签和失败审计。素材目标是 4 张 `1024x1536` 竖版工作表,每张后台按 `4 列 x 6 行` 裁切,每格 `256x256`;服务端从工作表切出总计 95 个 1x1 卡牌碎片,再合成一张 `10x10 / 2560x2560` 最终 atlas。复合图案组总数固定为 35,形状配比固定为 `1x2=23`、`1x3=5`、`2x2=4`、`2x3=3`。服务端先预排每个复合图案组的 sheet 布局、最终 atlas 坐标和形状,再按坐标切成 1x1 卡牌碎片作为运行态素材;sheet 生图 prompt 只能要求复合图案组可按后台 4x6 均等切成 1x1 方形小份,不能让模型在小图案上绘制切分线、边框、网格线、编号或裁切参考线。当前只有单关,同关内复合图案不重复。草稿编译和发布都必须使用 api-server 已持久化的真实 atlas / card assets,拒绝缺失、空对象键或 `placeholder` 占位素材,不允许 `spacetime-client` 或 SpacetimeDB 侧合成临时素材绕过平台图片底座。 +素材生成使用拼消消专用编排,但必须复用 `platform-image`、VectorEngine `gpt-image-2`、OSS、`asset_object`、换签和失败审计。素材目标是 4 张 `1024x1536` 竖版工作表,每张后台按 `4 列 x 6 行` 裁切,每格 `256x256`;服务端从工作表切出总计 95 个 1x1 卡牌碎片,再合成一张 `10x10 / 2560x2560` 最终 atlas。复合图案组总数固定为 35,形状配比固定为 `1x2=23`、`1x3=5`、`2x2=4`、`2x3=3`。服务端先预排每个复合图案组的 sheet 布局、最终 atlas 坐标和形状,再按坐标切成 1x1 卡牌碎片作为运行态素材;sheet 生图 prompt 只能要求复合图案组可按后台 4x6 均等切成 1x1 方形小份,不能让模型在小图案上绘制切分线、边框、网格线、编号或裁切参考线。4 关复用同一套 atlas,根据关卡解锁形状和目标数选择可用图案组;同关内复合图案不重复。草稿编译和发布都必须使用 api-server 已持久化的真实 atlas / card assets,拒绝缺失、空对象键或 `placeholder` 占位素材,不允许 `spacetime-client` 或 SpacetimeDB 侧合成临时素材绕过平台图片底座。 运行态规则: -1. 单关固定为 `6x6 / 35次消除`。 -2. 每局固定 10 分钟;超时只判当前关失败,可重试当前关。 -3. 当前关直接出现 `1x2`、`1x3`、`2x2` 和 `2x3`。 -4. 开局棋盘随机铺满并保证至少一步可解;补牌后也必须由后端保证至少一步可解。 -5. 顶部卡牌准备区按纵列补位,某列有空格时该列卡牌从顶部下落。 +1. 4 关棋盘均固定为 `6x6`。 +2. 第 1 / 2 / 3 / 4 关目标分别为 `15 / 20 / 30 / 35` 次消除,限时分别为 `5 / 5 / 7 / 10` 分钟;超时只判当前关失败,可重试当前关。 +3. 第 1 关仅出现 `1x2`;第 2 关出现 `1x2`、`1x3`;第 3 关出现 `1x2`、`1x3`、`2x2`;第 4 关出现 `1x2`、`1x3`、`2x2`、`2x3`。 +4. 开局只放入本关目标消除数对应的全部卡牌,棋盘放不下的牌进入顶部准备区,牌不足棋盘格数时空格保留;开局仍保证至少一步可解。 +5. 顶部卡牌准备区按纵列补位,某列有空格时该列卡牌从顶部下落;顶部没有新牌时空格留在场上并露出背景图。胜利条件永远是消除完本关全部卡牌,达到目标消除数且棋盘与顶部准备区都没有剩余卡牌后才进入下一关。 6. 非 2 格消除时,补牌不得破坏已完成局部;只有玩家主动交换或撞入才允许打散半锁定拼接组。 7. 正式 runtime 只消费后端 snapshot 与 action 结果;前端负责开局翻转、拖拽、掉落、消除和弹层动画。 - 拖拽手感必须对齐拼图模板:开局小卡片只翻转一次,交换落位不得重新翻牌;按住后可见卡片立即跟随鼠标或手指,源位置即时留出空槽;放下时被替换卡片要快速飞向对应空位;已完成局部拼接组要以连续整体呈现并可作为整组拖起。拖拽浮层必须挂到页面级 `document.body` portal,避免平台壳层 transform 让 `position: fixed` 和 `clientX/clientY` 坐标系错位。 + 拖拽手感必须对齐拼图模板:开局小卡片只翻转一次,交换落位不得重新翻牌;按住后可见卡片立即跟随鼠标或手指,源位置即时留出空槽;放下时被替换卡片要快速飞向对应空位;已完成局部拼接组要以连续整体呈现并可作为整组拖起。半锁定局部拼接组按同组卡牌“素材坐标相邻且棋盘格相邻”的连通块识别,`1x3`、`2x2`、`2x3` 的转角或多行局部拼合也必须整体写入同一个 `lockedGroupId`,不能只锁住线性排序后相邻的一段。拖拽浮层必须挂到页面级 `document.body` portal,避免平台壳层 transform 让 `position: fixed` 和 `clientX/clientY` 坐标系错位;整组拖起期间活动组只能由 portal ghost 展示卡图,棋盘格子层标记为拖拽源位并保持透明空槽,锁定组覆盖层不得继续渲染正在拖的组。整组拖拽落点必须先以前端当前 board snapshot 校验整组平移后的所有格子仍在棋盘内,越界时只回弹不提交 `swap`;后端仍保留最终规则裁决。 8. 正式 `published` run 的终态事件使用 `run-finished` 和 `level-failed`,事件结果 JSON 至少包含 `status`、`level`、`clears`、`clearDelta` 和 `elapsedMs`,供基础统计与排障回读。 新增阶段为 `puzzle-clear-workspace`、`puzzle-clear-generating`、`puzzle-clear-result` 和 `puzzle-clear-runtime`;路由为 `/creation/puzzle-clear`、`/creation/puzzle-clear/generating`、`/creation/puzzle-clear/result` 与 `/runtime/puzzle-clear`。API 命名空间为 `/api/creation/puzzle-clear/*` 与 `/api/runtime/puzzle-clear/*`。验证命令见 `docs/prd/【玩法创作】拼消消玩法模板PRD-2026-05-30.md` 与 `docs/technical/【玩法创作】拼消消玩法模板技术方案-2026-05-30.md`。 diff --git a/server-rs/crates/api-server/src/puzzle_clear.rs b/server-rs/crates/api-server/src/puzzle_clear.rs index 1221df47..0aff7483 100644 --- a/server-rs/crates/api-server/src/puzzle_clear.rs +++ b/server-rs/crates/api-server/src/puzzle_clear.rs @@ -25,6 +25,8 @@ use shared_kernel::{build_prefixed_uuid_id, format_timestamp_micros}; use spacetime_client::SpacetimeClientError; use std::{ collections::BTreeMap, + env, fs, + path::{Path as FsPath, PathBuf}, time::{SystemTime, UNIX_EPOCH}, }; @@ -38,8 +40,8 @@ use crate::{ }, http_error::AppError, openai_image_generation::{ - DownloadedOpenAiImage, build_openai_image_http_client, create_openai_image_generation, - require_openai_image_settings, + DownloadedOpenAiImage, build_openai_image_http_client, build_openai_image_request_body, + create_openai_image_generation, require_openai_image_settings, }, request_context::RequestContext, state::AppState, @@ -599,6 +601,721 @@ struct PuzzleClearGeneratedSheet { image: DownloadedOpenAiImage, } +#[derive(Clone, Debug)] +struct PuzzleClearImageDebugRun { + root: PathBuf, + run_id: String, +} + +impl PuzzleClearImageDebugRun { + fn record_spec( + &self, + sheet_specs: &[PuzzleClearAtlasSheetSpec], + groups: &[PuzzleClearPatternGroup], + ) { + let sheets = sheet_specs + .iter() + .map(|spec| { + json!({ + "sheetId": spec.sheet_id, + "layout": spec.layout.iter().map(|row| row.to_vec()).collect::>(), + "layoutPrompt": spec.layout_prompt, + }) + }) + .collect::>(); + let groups = groups + .iter() + .map(|group| { + json!({ + "groupId": group.group_id.as_str(), + "shape": group.shape.as_str(), + "width": group.width, + "height": group.height, + "atlasX": group.atlas_x, + "atlasY": group.atlas_y, + "atlasWidth": group.atlas_width, + "atlasHeight": group.atlas_height, + }) + }) + .collect::>(); + self.write_json( + "specs/puzzle-clear-debug-spec.json", + &json!({ + "cellSize": PUZZLE_CLEAR_ATLAS_CELL_SIZE, + "sheetColumns": PUZZLE_CLEAR_SHEET_COLUMNS, + "sheetRows": PUZZLE_CLEAR_SHEET_ROWS, + "finalAtlasColumns": PUZZLE_CLEAR_FINAL_ATLAS_COLUMNS, + "finalAtlasRows": PUZZLE_CLEAR_FINAL_ATLAS_ROWS, + "sheets": sheets, + "groups": groups, + }), + "记录拼消消调试规格失败", + ); + } + + fn record_board_background_request(&self, prompt: &str) { + self.write_text( + "prompts/board-background.txt", + prompt, + "记录拼消消底图 prompt 失败", + ); + self.write_json( + "requests/board-background.json", + &json!({ + "endpoint": "/v1/images/generations", + "body": build_openai_image_request_body( + prompt, + Some("文字、水印、按钮、教程浮层、明显网格"), + PUZZLE_CLEAR_BOARD_BACKGROUND_GENERATION_SIZE, + 1, + &[], + ), + }), + "记录拼消消底图 request 失败", + ); + } + + fn record_board_background_image( + &self, + task_id: &str, + actual_prompt: Option<&str>, + image: &DownloadedOpenAiImage, + ) { + let extension = puzzle_clear_debug_image_extension(image); + self.write_bytes( + format!("background/board-background.{extension}"), + image.bytes.as_slice(), + "记录拼消消底图图片失败", + ); + self.write_json( + "responses/board-background.json", + &json!({ + "taskId": task_id, + "actualPrompt": actual_prompt, + "image": { + "mimeType": image.mime_type.as_str(), + "extension": image.extension.as_str(), + "byteLength": image.bytes.len(), + }, + }), + "记录拼消消底图 response 摘要失败", + ); + } + + fn record_board_background_error(&self, error: &AppError) { + self.write_json( + "responses/board-background.error.json", + &puzzle_clear_debug_error_json(error), + "记录拼消消底图错误失败", + ); + } + + fn record_sheet_request( + &self, + sheet_spec: &PuzzleClearAtlasSheetSpec, + attempt_index: usize, + prompt: &str, + ) { + let attempt_id = puzzle_clear_debug_attempt_id(attempt_index); + self.write_text( + format!("prompts/{}.txt", sheet_spec.sheet_id), + prompt, + "记录拼消消 sheet prompt 失败", + ); + self.write_json( + format!("requests/{}-{attempt_id}.json", sheet_spec.sheet_id), + &json!({ + "endpoint": "/v1/images/generations", + "sheetId": sheet_spec.sheet_id, + "attempt": attempt_index + 1, + "body": build_openai_image_request_body( + prompt, + Some(PUZZLE_CLEAR_ATLAS_NEGATIVE_PROMPT), + PUZZLE_CLEAR_ATLAS_GENERATION_SIZE, + 1, + &[], + ), + }), + "记录拼消消 sheet request 失败", + ); + } + + fn record_sheet_generation_error( + &self, + sheet_spec: &PuzzleClearAtlasSheetSpec, + attempt_index: usize, + error: &AppError, + ) { + let attempt_id = puzzle_clear_debug_attempt_id(attempt_index); + self.write_json( + format!("responses/{}-{attempt_id}.error.json", sheet_spec.sheet_id), + &json!({ + "sheetId": sheet_spec.sheet_id, + "attempt": attempt_index + 1, + "stage": "generation", + "error": puzzle_clear_debug_error_json(error), + }), + "记录拼消消 sheet 生成错误失败", + ); + } + + fn record_sheet_attempt_image( + &self, + sheet_spec: &PuzzleClearAtlasSheetSpec, + attempt_index: usize, + task_id: &str, + actual_prompt: Option<&str>, + image: &DownloadedOpenAiImage, + ) { + let attempt_id = puzzle_clear_debug_attempt_id(attempt_index); + let extension = puzzle_clear_debug_image_extension(image); + self.write_bytes( + format!("sheets/{}-{attempt_id}.{extension}", sheet_spec.sheet_id), + image.bytes.as_slice(), + "记录拼消消 sheet 原图失败", + ); + self.write_json( + format!("responses/{}-{attempt_id}.json", sheet_spec.sheet_id), + &json!({ + "sheetId": sheet_spec.sheet_id, + "attempt": attempt_index + 1, + "taskId": task_id, + "actualPrompt": actual_prompt, + "image": { + "mimeType": image.mime_type.as_str(), + "extension": image.extension.as_str(), + "byteLength": image.bytes.len(), + }, + }), + "记录拼消消 sheet response 摘要失败", + ); + } + + fn record_sheet_quality( + &self, + sheet_spec: &PuzzleClearAtlasSheetSpec, + attempt_index: usize, + task_id: &str, + image: &DownloadedOpenAiImage, + quality_error: Option<&AppError>, + ) { + let attempt_id = puzzle_clear_debug_attempt_id(attempt_index); + match build_puzzle_clear_sheet_quality_debug_report( + sheet_spec, + attempt_index, + task_id, + image, + quality_error, + ) { + Ok(report) => { + self.write_json( + format!("reports/{}-{attempt_id}.quality.json", sheet_spec.sheet_id), + &report, + "记录拼消消 sheet 质量报告失败", + ); + } + Err(error) => self.write_json( + format!("reports/{}-{attempt_id}.quality-error.json", sheet_spec.sheet_id), + &puzzle_clear_debug_error_json(&error), + "记录拼消消 sheet 质量报告错误失败", + ), + } + self.write_sheet_cells(sheet_spec, attempt_index, image); + } + + fn record_sheet_accepted( + &self, + sheet_spec: &PuzzleClearAtlasSheetSpec, + task_id: &str, + image: &DownloadedOpenAiImage, + ) { + let extension = puzzle_clear_debug_image_extension(image); + self.write_bytes( + format!("accepted/{}.{extension}", sheet_spec.sheet_id), + image.bytes.as_slice(), + "记录拼消消 accepted sheet 失败", + ); + self.write_json( + format!("accepted/{}.json", sheet_spec.sheet_id), + &json!({ + "sheetId": sheet_spec.sheet_id, + "taskId": task_id, + "accepted": true, + "image": { + "mimeType": image.mime_type.as_str(), + "extension": image.extension.as_str(), + "byteLength": image.bytes.len(), + }, + }), + "记录拼消消 accepted sheet 摘要失败", + ); + } + + fn record_atlas_image( + &self, + image: &DownloadedOpenAiImage, + generated_sheets: &[PuzzleClearGeneratedSheet], + ) { + self.write_bytes( + "atlas/puzzle-clear-atlas.png", + image.bytes.as_slice(), + "记录拼消消最终 atlas 失败", + ); + self.write_json( + "atlas/puzzle-clear-atlas.json", + &json!({ + "image": { + "mimeType": image.mime_type.as_str(), + "extension": image.extension.as_str(), + "byteLength": image.bytes.len(), + }, + "acceptedSheets": generated_sheets + .iter() + .map(|sheet| json!({ + "sheetId": sheet.spec.sheet_id, + "taskId": sheet.task_id.as_str(), + })) + .collect::>(), + }), + "记录拼消消最终 atlas 摘要失败", + ); + } + + fn record_summary_success( + &self, + session_id: &str, + profile_id: &str, + sheet_count: usize, + card_count: usize, + ) { + self.write_text( + "summary.md", + format!( + concat!( + "# 拼消消 runtime 生图调试\n\n", + "- runId: `{}`\n", + "- sessionId: `{}`\n", + "- profileId: `{}`\n", + "- status: `ready`\n", + "- acceptedSheets: `{}`\n", + "- cardCount: `{}`\n\n", + "关键查看顺序:\n\n", + "1. `prompts/` 和 `requests/`:真实请求内容。\n", + "2. `sheets/`:每次 attempt 的原始 sheet。\n", + "3. `reports/*.quality.json`:每格质量指标和 hard/advisory findings。\n", + "4. `cells//contact-sheet.png`:4x6 裁切总览。\n", + "5. `accepted/`:最终通过门禁的 sheet。\n", + "6. `atlas/puzzle-clear-atlas.png`:最终 atlas 预览。\n" + ), + self.run_id, session_id, profile_id, sheet_count, card_count + ), + "记录拼消消调试 summary 失败", + ); + } + + fn write_sheet_cells( + &self, + sheet_spec: &PuzzleClearAtlasSheetSpec, + attempt_index: usize, + image: &DownloadedOpenAiImage, + ) { + let attempt_id = puzzle_clear_debug_attempt_id(attempt_index); + let result = (|| -> Result<(), AppError> { + let source = image::load_from_memory(image.bytes.as_slice()).map_err(|error| { + AppError::from_status(StatusCode::BAD_GATEWAY).with_details(json!({ + "provider": PUZZLE_CLEAR_CREATION_PROVIDER, + "message": format!("拼消消素材 {} 调试裁切解码失败:{error}", sheet_spec.sheet_id), + })) + })?; + let source_width = source.width(); + let source_height = source.height(); + let mut contact = image::RgbaImage::from_pixel( + PUZZLE_CLEAR_ATLAS_CELL_SIZE * PUZZLE_CLEAR_SHEET_COLUMNS, + PUZZLE_CLEAR_ATLAS_CELL_SIZE * PUZZLE_CLEAR_SHEET_ROWS, + image::Rgba([255, 255, 255, 0]), + ); + for row in 0..PUZZLE_CLEAR_SHEET_ROWS { + for col in 0..PUZZLE_CLEAR_SHEET_COLUMNS { + let group_id = sheet_spec.layout[row as usize][col as usize]; + let bounds = + puzzle_clear_sheet_cell_bounds(row, col, source_width, source_height); + let cropped = source + .crop_imm(bounds.x0, bounds.y0, bounds.width(), bounds.height()) + .resize_exact( + PUZZLE_CLEAR_ATLAS_CELL_SIZE, + PUZZLE_CLEAR_ATLAS_CELL_SIZE, + image::imageops::FilterType::Lanczos3, + ) + .to_rgba8(); + let mut cursor = std::io::Cursor::new(Vec::new()); + image::DynamicImage::ImageRgba8(cropped.clone()) + .write_to(&mut cursor, image::ImageFormat::Png) + .map_err(|error| { + AppError::from_status(StatusCode::BAD_GATEWAY).with_details(json!({ + "provider": PUZZLE_CLEAR_CREATION_PROVIDER, + "message": format!("拼消消素材 {} 调试裁切写入失败:{error}", sheet_spec.sheet_id), + })) + })?; + self.write_bytes( + format!( + "cells/{}-{attempt_id}/r{:02}-c{:02}-{}.png", + sheet_spec.sheet_id, + row + 1, + col + 1, + sanitize_puzzle_clear_debug_segment(group_id, "cell"), + ), + cursor.into_inner().as_slice(), + "记录拼消消 sheet 裁切格失败", + ); + image::imageops::overlay( + &mut contact, + &cropped, + i64::from(col * PUZZLE_CLEAR_ATLAS_CELL_SIZE), + i64::from(row * PUZZLE_CLEAR_ATLAS_CELL_SIZE), + ); + } + } + let mut cursor = std::io::Cursor::new(Vec::new()); + image::DynamicImage::ImageRgba8(contact) + .write_to(&mut cursor, image::ImageFormat::Png) + .map_err(|error| { + AppError::from_status(StatusCode::BAD_GATEWAY).with_details(json!({ + "provider": PUZZLE_CLEAR_CREATION_PROVIDER, + "message": format!("拼消消素材 {} 调试 contact sheet 写入失败:{error}", sheet_spec.sheet_id), + })) + })?; + self.write_bytes( + format!("cells/{}-{attempt_id}/contact-sheet.png", sheet_spec.sheet_id), + cursor.into_inner().as_slice(), + "记录拼消消 sheet contact sheet 失败", + ); + Ok(()) + })(); + if let Err(error) = result { + self.write_json( + format!("cells/{}-{attempt_id}/error.json", sheet_spec.sheet_id), + &puzzle_clear_debug_error_json(&error), + "记录拼消消 sheet 裁切错误失败", + ); + } + } + + fn write_text( + &self, + relative_path: impl AsRef, + content: impl AsRef, + action: &'static str, + ) { + self.write_result(action, || { + let target = self.root.join(relative_path.as_ref()); + if let Some(parent) = target.parent() { + fs::create_dir_all(parent)?; + } + fs::write(target, content.as_ref().as_bytes()) + }); + } + + fn write_json(&self, relative_path: impl AsRef, value: &Value, action: &'static str) { + self.write_result(action, || { + let target = self.root.join(relative_path.as_ref()); + if let Some(parent) = target.parent() { + fs::create_dir_all(parent)?; + } + let mut bytes = serde_json::to_vec_pretty(value) + .map_err(|error| std::io::Error::new(std::io::ErrorKind::Other, error))?; + bytes.push(b'\n'); + fs::write(target, bytes) + }); + } + + fn write_bytes( + &self, + relative_path: impl AsRef, + bytes: &[u8], + action: &'static str, + ) { + self.write_result(action, || { + let target = self.root.join(relative_path.as_ref()); + if let Some(parent) = target.parent() { + fs::create_dir_all(parent)?; + } + fs::write(target, bytes) + }); + } + + fn write_result( + &self, + action: &'static str, + operation: impl FnOnce() -> std::io::Result<()>, + ) { + if let Err(error) = operation() { + tracing::warn!( + provider = PUZZLE_CLEAR_CREATION_PROVIDER, + run_id = self.run_id, + debug_path = %self.root.display(), + error = %error, + action, + "拼消消本地生图调试包写入失败,已忽略" + ); + } + } +} + +fn maybe_create_puzzle_clear_image_debug_run( + session_id: &str, + profile_id: &str, + theme_prompt: &str, +) -> Option { + if !puzzle_clear_image_debug_enabled() { + return None; + } + let base_dir = puzzle_clear_image_debug_runs_dir(); + let run_id = env::var("PUZZLE_CLEAR_IMAGE_DEBUG_RUN_ID") + .ok() + .map(|value| value.trim().to_string()) + .filter(|value| !value.is_empty()) + .unwrap_or_else(|| { + format!( + "runtime-{}-{}-{}", + sanitize_puzzle_clear_debug_segment(session_id, "session"), + sanitize_puzzle_clear_debug_segment(profile_id, "profile"), + current_utc_micros() + ) + }); + let run_id = sanitize_puzzle_clear_debug_segment(run_id.as_str(), "runtime"); + let root = base_dir.join(run_id.as_str()); + if let Err(error) = fs::create_dir_all(&root) { + tracing::warn!( + provider = PUZZLE_CLEAR_CREATION_PROVIDER, + debug_path = %root.display(), + error = %error, + "拼消消本地生图调试包初始化失败,已关闭本次记录" + ); + return None; + } + let debug_run = PuzzleClearImageDebugRun { root, run_id }; + debug_run.write_json( + "manifest.json", + &json!({ + "runId": debug_run.run_id.as_str(), + "source": "api-server-runtime", + "playType": "puzzle-clear", + "sessionId": session_id, + "profileId": profile_id, + "themePrompt": theme_prompt, + "createdAt": format_timestamp_micros(current_utc_micros()), + "env": { + "PUZZLE_CLEAR_IMAGE_DEBUG_ENABLED": "1", + "PUZZLE_CLEAR_IMAGE_DEBUG_DIR": puzzle_clear_image_debug_runs_dir().display().to_string(), + }, + }), + "记录拼消消调试 manifest 失败", + ); + if let Err(error) = fs::write( + puzzle_clear_image_debug_runs_dir().join("latest-runtime.txt"), + debug_run.root.to_string_lossy().as_bytes(), + ) { + tracing::warn!( + provider = PUZZLE_CLEAR_CREATION_PROVIDER, + error = %error, + "拼消消本地生图调试包 latest-runtime.txt 写入失败,已忽略" + ); + } + tracing::info!( + provider = PUZZLE_CLEAR_CREATION_PROVIDER, + run_id = debug_run.run_id.as_str(), + debug_path = %debug_run.root.display(), + "拼消消本地生图调试包已开启" + ); + Some(debug_run) +} + +fn puzzle_clear_image_debug_enabled() -> bool { + env::var("PUZZLE_CLEAR_IMAGE_DEBUG_ENABLED") + .ok() + .map(|value| { + matches!( + value.trim().to_ascii_lowercase().as_str(), + "1" | "true" | "yes" | "on" + ) + }) + .unwrap_or(false) +} + +fn puzzle_clear_image_debug_runs_dir() -> PathBuf { + env::var("PUZZLE_CLEAR_IMAGE_DEBUG_DIR") + .ok() + .map(|value| value.trim().to_string()) + .filter(|value| !value.is_empty()) + .map(PathBuf::from) + .unwrap_or_else(|| { + puzzle_clear_repo_root() + .join(".app") + .join("puzzle-clear-image-debug") + .join("runs") + }) +} + +fn puzzle_clear_repo_root() -> PathBuf { + let current_dir = env::current_dir().unwrap_or_else(|_| PathBuf::from(".")); + for candidate in current_dir.ancestors() { + if candidate.join("server-rs").is_dir() && candidate.join("package.json").is_file() { + return candidate.to_path_buf(); + } + } + current_dir +} + +fn puzzle_clear_debug_attempt_id(attempt_index: usize) -> String { + format!("attempt-{:02}", attempt_index + 1) +} + +fn puzzle_clear_debug_image_extension(image: &DownloadedOpenAiImage) -> String { + sanitize_puzzle_clear_debug_segment( + image + .extension + .trim() + .trim_start_matches('.') + .to_ascii_lowercase() + .as_str(), + "png", + ) +} + +fn sanitize_puzzle_clear_debug_segment(raw: &str, fallback: &str) -> String { + let mut value = String::new(); + for character in raw.chars() { + if character.is_ascii_alphanumeric() || matches!(character, '-' | '_' | '.') { + value.push(character); + } else { + value.push('-'); + } + } + let value = value.trim_matches('-').trim_matches('.').to_string(); + if value.is_empty() { + fallback.to_string() + } else { + value + } +} + +fn puzzle_clear_debug_error_json(error: &AppError) -> Value { + json!({ + "statusCode": error.status_code().as_u16(), + "code": error.code(), + "message": error.body_text(), + "details": error.details(), + }) +} + +fn build_puzzle_clear_sheet_quality_debug_report( + sheet_spec: &PuzzleClearAtlasSheetSpec, + attempt_index: usize, + task_id: &str, + image: &DownloadedOpenAiImage, + quality_error: Option<&AppError>, +) -> Result { + let source = image::load_from_memory(image.bytes.as_slice()).map_err(|error| { + AppError::from_status(StatusCode::BAD_GATEWAY).with_details(json!({ + "provider": PUZZLE_CLEAR_CREATION_PROVIDER, + "message": format!("拼消消素材 {} 调试质量报告解码失败:{error}", sheet_spec.sheet_id), + })) + })?; + let source_width = source.width(); + let source_height = source.height(); + let mut hard_findings = Vec::new(); + let mut advisory_findings = Vec::new(); + let mut cells = Vec::new(); + for row in 0..PUZZLE_CLEAR_SHEET_ROWS { + for col in 0..PUZZLE_CLEAR_SHEET_COLUMNS { + let group_id = sheet_spec.layout[row as usize][col as usize]; + let bounds = puzzle_clear_sheet_cell_bounds(row, col, source_width, source_height); + let quality = + analyze_puzzle_clear_sheet_cell_quality(&source, sheet_spec, row, col, bounds); + let cell_label = format!("第{}行第{}列", row + 1, col + 1); + let mut cell_findings = Vec::new(); + let mut cell_advisory_findings = Vec::new(); + + if group_id == PUZZLE_CLEAR_SHEET_UNUSED_CELL { + if quality.foreground_ratio > PUZZLE_CLEAR_SHEET_BLANK_MAX_FOREGROUND_RATIO { + let finding = format!("{cell_label} 空白格有主体"); + hard_findings.push(finding.clone()); + cell_findings.push(finding); + } + } else if group_id != PUZZLE_CLEAR_SHEET_FILLER_CELL { + if quality.foreground_ratio < PUZZLE_CLEAR_SHEET_MIN_FOREGROUND_RATIO { + let finding = format!("{cell_label} 主体过少"); + hard_findings.push(finding.clone()); + cell_findings.push(finding); + } + if quality.strongest_internal_seam_ratio + > PUZZLE_CLEAR_SHEET_INTERNAL_SEAM_RATIO_THRESHOLD + { + let finding = format!("{cell_label} 单格内部疑似拼接线"); + hard_findings.push(finding.clone()); + cell_findings.push(finding); + } + if quality.exposed_edge_count >= 2 + && quality.strongest_edge_ratio + > PUZZLE_CLEAR_SHEET_STRONG_EDGE_RATIO_THRESHOLD + { + let finding = format!("{cell_label} 主体贴到不同图案边界"); + advisory_findings.push(finding.clone()); + cell_advisory_findings.push(finding); + } + } + + cells.push(json!({ + "row": row + 1, + "col": col + 1, + "groupId": group_id, + "discarded": is_puzzle_clear_sheet_discarded_cell(group_id), + "bounds": { + "x0": bounds.x0, + "y0": bounds.y0, + "x1": bounds.x1, + "y1": bounds.y1, + }, + "foregroundRatio": quality.foreground_ratio, + "exposedEdgeCount": quality.exposed_edge_count, + "strongestEdgeRatio": quality.strongest_edge_ratio, + "strongestInternalSeamRatio": quality.strongest_internal_seam_ratio, + "hardFindings": cell_findings, + "advisoryFindings": cell_advisory_findings, + })); + } + } + + Ok(json!({ + "sheetId": sheet_spec.sheet_id, + "attempt": attempt_index + 1, + "taskId": task_id, + "accepted": quality_error.is_none(), + "image": { + "width": source_width, + "height": source_height, + "mimeType": image.mime_type.as_str(), + "extension": image.extension.as_str(), + "byteLength": image.bytes.len(), + }, + "thresholds": { + "foregroundDiff": PUZZLE_CLEAR_SHEET_FOREGROUND_DIFF_THRESHOLD, + "minForegroundRatio": PUZZLE_CLEAR_SHEET_MIN_FOREGROUND_RATIO, + "blankMaxForegroundRatio": PUZZLE_CLEAR_SHEET_BLANK_MAX_FOREGROUND_RATIO, + "edgeRatio": PUZZLE_CLEAR_SHEET_EDGE_RATIO_THRESHOLD, + "strongEdgeRatio": PUZZLE_CLEAR_SHEET_STRONG_EDGE_RATIO_THRESHOLD, + "internalSeamDiff": PUZZLE_CLEAR_SHEET_INTERNAL_SEAM_DIFF_THRESHOLD, + "internalSeamRatio": PUZZLE_CLEAR_SHEET_INTERNAL_SEAM_RATIO_THRESHOLD, + "internalSeamSideContrast": PUZZLE_CLEAR_SHEET_INTERNAL_SEAM_SIDE_CONTRAST_THRESHOLD, + "internalSeamSideTextureMax": PUZZLE_CLEAR_SHEET_INTERNAL_SEAM_SIDE_TEXTURE_MAX, + }, + "hardFindings": hard_findings, + "advisoryFindings": advisory_findings, + "qualityError": quality_error.map(puzzle_clear_debug_error_json), + "cells": cells, + })) +} + async fn maybe_prepare_puzzle_clear_assets_inner( state: &AppState, request_context: &RequestContext, @@ -633,8 +1350,13 @@ async fn maybe_prepare_puzzle_clear_assets_inner( .map(ToString::to_string) .unwrap_or_else(|| { build_prefixed_uuid_id(module_puzzle_clear::PUZZLE_CLEAR_PROFILE_ID_PREFIX) - }); + }); payload.profile_id = Some(profile_id.clone()); + let image_debug_run = maybe_create_puzzle_clear_image_debug_run( + session_id, + profile_id.as_str(), + payload.theme_prompt.as_deref().unwrap_or_default(), + ); if payload.generate_board_background.unwrap_or(false) && payload @@ -654,6 +1376,7 @@ async fn maybe_prepare_puzzle_clear_assets_inner( owner_user_id, profile_id.as_str(), board_background_prompt.unwrap_or(theme_prompt), + image_debug_run.as_ref(), ) .await?; payload.board_background_asset = Some(background_asset); @@ -683,6 +1406,9 @@ async fn maybe_prepare_puzzle_clear_assets_inner( .map(|group| (group.group_id.clone(), group)) .collect::>(); let sheet_specs = puzzle_clear_atlas_sheet_specs(); + if let Some(debug_run) = image_debug_run.as_ref() { + debug_run.record_spec(&sheet_specs, &groups); + } let settings = require_openai_image_settings(state) .map(|settings| { settings.with_external_api_audit_context( @@ -710,6 +1436,9 @@ async fn maybe_prepare_puzzle_clear_assets_inner( sheet_spec.sheet_id, attempt_index + 1 ); + if let Some(debug_run) = image_debug_run.as_ref() { + debug_run.record_sheet_request(&sheet_spec, attempt_index, sheet_prompt.as_str()); + } let generated = match create_openai_image_generation( &http_client, &settings, @@ -727,6 +1456,9 @@ async fn maybe_prepare_puzzle_clear_assets_inner( if attempt_index + 1 < PUZZLE_CLEAR_SHEET_GENERATION_MAX_ATTEMPTS && is_retryable_puzzle_clear_sheet_generation_error(&error) => { + if let Some(debug_run) = image_debug_run.as_ref() { + debug_run.record_sheet_generation_error(&sheet_spec, attempt_index, &error); + } tracing::warn!( provider = PUZZLE_CLEAR_CREATION_PROVIDER, sheet_id = sheet_spec.sheet_id, @@ -737,6 +1469,9 @@ async fn maybe_prepare_puzzle_clear_assets_inner( continue; } Err(error) => { + if let Some(debug_run) = image_debug_run.as_ref() { + debug_run.record_sheet_generation_error(&sheet_spec, attempt_index, &error); + } return Err(puzzle_clear_error_response( request_context, PUZZLE_CLEAR_CREATION_PROVIDER, @@ -745,6 +1480,7 @@ async fn maybe_prepare_puzzle_clear_assets_inner( } }; let task_id = generated.task_id; + let actual_prompt = generated.actual_prompt; let image = generated.images.into_iter().next().ok_or_else(|| { puzzle_clear_error_response( request_context, @@ -755,8 +1491,30 @@ async fn maybe_prepare_puzzle_clear_assets_inner( })), ) })?; - match validate_puzzle_clear_sheet_quality(&image, &sheet_spec) { + if let Some(debug_run) = image_debug_run.as_ref() { + debug_run.record_sheet_attempt_image( + &sheet_spec, + attempt_index, + task_id.as_str(), + actual_prompt.as_deref(), + &image, + ); + } + let quality_result = validate_puzzle_clear_sheet_quality(&image, &sheet_spec); + if let Some(debug_run) = image_debug_run.as_ref() { + debug_run.record_sheet_quality( + &sheet_spec, + attempt_index, + task_id.as_str(), + &image, + quality_result.as_ref().err(), + ); + } + match quality_result { Ok(()) => { + if let Some(debug_run) = image_debug_run.as_ref() { + debug_run.record_sheet_accepted(&sheet_spec, task_id.as_str(), &image); + } accepted_sheet = Some(PuzzleClearGeneratedSheet { spec: sheet_spec, prompt: sheet_prompt.clone(), @@ -821,6 +1579,9 @@ async fn maybe_prepare_puzzle_clear_assets_inner( compose_puzzle_clear_final_atlas(&slices, &groups_by_id).map_err(|error| { puzzle_clear_error_response(request_context, PUZZLE_CLEAR_CREATION_PROVIDER, error) })?; + if let Some(debug_run) = image_debug_run.as_ref() { + debug_run.record_atlas_image(&atlas_image, &generated_sheets); + } let atlas_prompt = generated_sheets .iter() .map(|sheet| format!("{}:\n{}", sheet.spec.sheet_id, sheet.prompt)) @@ -864,6 +1625,14 @@ async fn maybe_prepare_puzzle_clear_assets_inner( payload.atlas_asset = Some(atlas_asset); payload.pattern_groups = Some(groups); payload.card_assets = Some(card_assets); + if let Some(debug_run) = image_debug_run.as_ref() { + debug_run.record_summary_success( + session_id, + profile_id.as_str(), + generated_sheets.len(), + payload.card_assets.as_ref().map_or(0, Vec::len), + ); + } tracing::info!( provider = PUZZLE_CLEAR_CREATION_PROVIDER, session_id, @@ -1054,8 +1823,12 @@ async fn generate_and_persist_puzzle_clear_board_background( owner_user_id: &str, profile_id: &str, theme_prompt: &str, + image_debug_run: Option<&PuzzleClearImageDebugRun>, ) -> Result { let prompt = build_puzzle_clear_board_background_prompt(theme_prompt); + if let Some(debug_run) = image_debug_run { + debug_run.record_board_background_request(prompt.as_str()); + } let settings = require_openai_image_settings(state) .map(|settings| { settings.with_external_api_audit_context( @@ -1082,9 +1855,13 @@ async fn generate_and_persist_puzzle_clear_board_background( ) .await .map_err(|error| { + if let Some(debug_run) = image_debug_run { + debug_run.record_board_background_error(&error); + } puzzle_clear_error_response(request_context, PUZZLE_CLEAR_CREATION_PROVIDER, error) })?; let task_id = generated.task_id; + let actual_prompt = generated.actual_prompt; let image = generated.images.into_iter().next().ok_or_else(|| { puzzle_clear_error_response( request_context, @@ -1095,6 +1872,9 @@ async fn generate_and_persist_puzzle_clear_board_background( })), ) })?; + if let Some(debug_run) = image_debug_run { + debug_run.record_board_background_image(task_id.as_str(), actual_prompt.as_deref(), &image); + } persist_puzzle_clear_generated_image_asset( state, owner_user_id, @@ -2140,7 +2920,11 @@ fn map_puzzle_clear_client_error(error: SpacetimeClientError) -> AppError { if value.contains("发布需要") || value.contains("不能为空") || value.contains("必须") - || value.contains("无权") => + || value.contains("无权") + || value.contains("puzzle-clear 坐标无效") + || value.contains("puzzle-clear 目标格子没有卡牌") + || value.contains("puzzle-clear 当前 run 不在 playing 状态") + || value.contains("puzzle-clear 当前关卡已经超时") => { StatusCode::BAD_REQUEST } diff --git a/server-rs/crates/module-puzzle-clear/src/application.rs b/server-rs/crates/module-puzzle-clear/src/application.rs index 29c7e312..e3f8c25c 100644 --- a/server-rs/crates/module-puzzle-clear/src/application.rs +++ b/server-rs/crates/module-puzzle-clear/src/application.rs @@ -3,25 +3,55 @@ use std::collections::{BTreeSet, HashMap, VecDeque}; use shared_kernel::normalize_required_string; use crate::{ - PUZZLE_CLEAR_LEVEL_DURATION_SECONDS, PuzzleClearBoard, PuzzleClearCard, PuzzleClearCell, - PuzzleClearDeck, PuzzleClearElimination, PuzzleClearError, PuzzleClearLevelConfig, - PuzzleClearMove, PuzzleClearOrientation, PuzzleClearPatternGroup, PuzzleClearRunSnapshot, - PuzzleClearRunStatus, PuzzleClearShapeKind, PuzzleClearShapeQuota, + PuzzleClearBoard, PuzzleClearCard, PuzzleClearCell, PuzzleClearDeck, PuzzleClearElimination, + PuzzleClearError, PuzzleClearLevelConfig, PuzzleClearMove, PuzzleClearOrientation, + PuzzleClearPatternGroup, PuzzleClearRunSnapshot, PuzzleClearRunStatus, PuzzleClearShapeKind, + PuzzleClearShapeQuota, }; pub fn puzzle_clear_level_configs() -> Vec { - vec![PuzzleClearLevelConfig { - level_index: 1, - board_size: 6, - target_clears: 35, - duration_seconds: PUZZLE_CLEAR_LEVEL_DURATION_SECONDS, - unlocked_shapes: vec![ - PuzzleClearShapeKind::OneByTwo, - PuzzleClearShapeKind::OneByThree, - PuzzleClearShapeKind::TwoByTwo, - PuzzleClearShapeKind::TwoByThree, - ], - }] + vec![ + PuzzleClearLevelConfig { + level_index: 1, + board_size: 6, + target_clears: 15, + duration_seconds: 300, + unlocked_shapes: vec![PuzzleClearShapeKind::OneByTwo], + }, + PuzzleClearLevelConfig { + level_index: 2, + board_size: 6, + target_clears: 20, + duration_seconds: 300, + unlocked_shapes: vec![ + PuzzleClearShapeKind::OneByTwo, + PuzzleClearShapeKind::OneByThree, + ], + }, + PuzzleClearLevelConfig { + level_index: 3, + board_size: 6, + target_clears: 30, + duration_seconds: 420, + unlocked_shapes: vec![ + PuzzleClearShapeKind::OneByTwo, + PuzzleClearShapeKind::OneByThree, + PuzzleClearShapeKind::TwoByTwo, + ], + }, + PuzzleClearLevelConfig { + level_index: 4, + board_size: 6, + target_clears: 35, + duration_seconds: 600, + unlocked_shapes: vec![ + PuzzleClearShapeKind::OneByTwo, + PuzzleClearShapeKind::OneByThree, + PuzzleClearShapeKind::TwoByTwo, + PuzzleClearShapeKind::TwoByThree, + ], + }, + ] } pub fn puzzle_clear_shape_quotas() -> Vec { @@ -114,7 +144,7 @@ pub fn create_puzzle_clear_board( return Err(PuzzleClearError::InvalidLevel); } let total = (level.board_size * level.board_size) as usize; - if cards.len() < total { + if cards.is_empty() { return Err(PuzzleClearError::EmptyDeck); } let mut rng = DeterministicRng::new(seed, &format!("level-{}", level.level_index)); @@ -125,10 +155,12 @@ pub fn create_puzzle_clear_board( for row in 0..level.board_size { for col in 0..level.board_size { let index = (row * level.board_size + col) as usize; + let empty_slots = total.saturating_sub(selected.len()); + let card_index = index.checked_sub(empty_slots); cells.push(PuzzleClearCell { row, col, - card: selected.get(index).cloned(), + card: card_index.and_then(|card_index| selected.get(card_index).cloned()), locked_group_id: None, }); } @@ -202,7 +234,7 @@ pub fn apply_puzzle_clear_swap( .into_iter() .find(|config| config.level_index == next.level_index) .ok_or(PuzzleClearError::InvalidLevel)?; - if next.clears_done >= level.target_clears && !has_remaining_cards(&next.board) { + if next.clears_done >= level.target_clears && !has_remaining_cards_in_run(&next) { next.status = if next.level_index >= max_puzzle_clear_level_index() { PuzzleClearRunStatus::Finished } else { @@ -401,8 +433,13 @@ fn ensure_not_expired(run: &PuzzleClearRunSnapshot, now_ms: u64) -> Result<(), P } fn is_level_expired(run: &PuzzleClearRunSnapshot, now_ms: u64) -> bool { + let duration_seconds = puzzle_clear_level_configs() + .into_iter() + .find(|config| config.level_index == run.level_index) + .map(|config| config.duration_seconds) + .unwrap_or(600); now_ms.saturating_sub(run.level_started_at_ms) - > u64::from(PUZZLE_CLEAR_LEVEL_DURATION_SECONDS) * 1000 + > u64::from(duration_seconds) * 1000 } fn validate_board(board: &PuzzleClearBoard) -> Result<(), PuzzleClearError> { @@ -421,6 +458,15 @@ fn has_remaining_cards(board: &PuzzleClearBoard) -> bool { board.cells.iter().any(|cell| cell.card.is_some()) } +fn has_remaining_cards_in_run(run: &PuzzleClearRunSnapshot) -> bool { + has_remaining_cards(&run.board) + || run + .deck + .ready_columns + .iter() + .any(|column| !column.is_empty()) +} + fn ensure_board_has_playable_move(board: &mut PuzzleClearBoard) -> Result<(), PuzzleClearError> { if find_eliminations(board).is_empty() && has_playable_move(board) { return Ok(()); @@ -490,15 +536,7 @@ fn find_local_completed_groups(board: &PuzzleClearBoard) -> Vec Vec bool { + if entries.len() < 2 { + return false; + } + let mut visited = vec![false; entries.len()]; + let mut stack = vec![0usize]; + visited[0] = true; + + while let Some(index) = stack.pop() { + let current = &entries[index]; + for (candidate_index, candidate) in entries.iter().enumerate() { + if visited[candidate_index] { + continue; + } + if manhattan_part_distance(¤t.2, &candidate.2) == 1 + && are_neighbors(current.0, current.1, candidate.0, candidate.1) + { + visited[candidate_index] = true; + stack.push(candidate_index); + } + } + } + + visited.into_iter().all(|is_visited| is_visited) +} + fn clear_locked_group(board: &mut PuzzleClearBoard, group_id: &str) { for cell in &mut board.cells { if cell.locked_group_id.as_deref() == Some(group_id) { @@ -1177,14 +1241,40 @@ mod tests { use super::*; #[test] - fn fixed_level_config_uses_single_six_by_six_level() { + fn fixed_level_config_uses_four_six_by_six_levels() { let configs = puzzle_clear_level_configs(); - assert_eq!(configs.len(), 1); - assert_eq!(configs[0].board_size, 6); - assert_eq!(configs[0].target_clears, 35); + assert_eq!(configs.len(), 4); + assert!(configs.iter().all(|config| config.board_size == 6)); assert_eq!( - configs[0].unlocked_shapes, + configs + .iter() + .map(|config| ( + config.level_index, + config.target_clears, + config.duration_seconds + )) + .collect::>(), + vec![(1, 15, 300), (2, 20, 300), (3, 30, 420), (4, 35, 600)] + ); + assert_eq!(configs[0].unlocked_shapes, vec![PuzzleClearShapeKind::OneByTwo]); + assert_eq!( + configs[1].unlocked_shapes, + vec![ + PuzzleClearShapeKind::OneByTwo, + PuzzleClearShapeKind::OneByThree, + ] + ); + assert_eq!( + configs[2].unlocked_shapes, + vec![ + PuzzleClearShapeKind::OneByTwo, + PuzzleClearShapeKind::OneByThree, + PuzzleClearShapeKind::TwoByTwo, + ] + ); + assert_eq!( + configs[3].unlocked_shapes, vec![ PuzzleClearShapeKind::OneByTwo, PuzzleClearShapeKind::OneByThree, @@ -1192,7 +1282,6 @@ mod tests { PuzzleClearShapeKind::TwoByThree, ] ); - assert!(configs.iter().all(|config| config.duration_seconds == 600)); } #[test] @@ -1250,6 +1339,23 @@ mod tests { assert!(has_playable_move(&board)); } + #[test] + fn first_level_board_uses_exact_target_cards_and_leaves_empty_cells() { + let groups = plan_puzzle_clear_pattern_groups(64).expect("atlas should plan"); + let cards = build_cards_from_groups(&groups, "/generated-puzzle-clear") + .into_iter() + .filter(|card| card.shape == PuzzleClearShapeKind::OneByTwo) + .take(30) + .collect::>(); + let board = create_puzzle_clear_board(&puzzle_clear_level_configs()[0], "seed-a", cards) + .expect("board should create with empty cells"); + + assert_eq!(board.cells.iter().filter(|cell| cell.card.is_some()).count(), 30); + assert_eq!(board.cells.iter().filter(|cell| cell.card.is_none()).count(), 6); + assert!(find_eliminations(&board).is_empty()); + assert!(has_playable_move(&board)); + } + #[test] fn one_by_two_neighbors_are_not_half_locked() { let board = board_from_cards( @@ -1350,7 +1456,7 @@ mod tests { } #[test] - fn reaching_target_clears_without_empty_board_keeps_playing() { + fn reaching_target_clears_does_not_complete_level_with_remaining_cards() { let board = board_from_cards( 3, vec![ @@ -1376,7 +1482,7 @@ mod tests { 100, ) .expect("run should start"); - run.clears_done = 4; + run.clears_done = 14; let next = apply_puzzle_clear_swap( &run, PuzzleClearMove { @@ -1389,11 +1495,57 @@ mod tests { ) .expect("swap should resolve"); - assert_eq!(next.clears_done, 5); + assert_eq!(next.clears_done, 15); assert_eq!(next.status, PuzzleClearRunStatus::Playing); + assert!(next.finished_at_ms.is_none()); assert!(next.board.cells.iter().any(|cell| cell.card.is_some())); } + #[test] + fn reaching_target_clears_completes_level_after_all_cards_are_removed() { + let board = board_from_cards( + 3, + vec![ + Some(card("play", 0, 0)), + None, + None, + None, + Some(card("play", 1, 0)), + None, + None, + None, + None, + ], + ); + let mut run = start_puzzle_clear_run( + "run-target-empty".to_string(), + "user-1".to_string(), + "profile-1".to_string(), + board, + PuzzleClearDeck { + ready_columns: vec![vec![], vec![], vec![]], + }, + 100, + ) + .expect("run should start"); + run.clears_done = 14; + let next = apply_puzzle_clear_swap( + &run, + PuzzleClearMove { + from_row: 1, + from_col: 1, + to_row: 0, + to_col: 1, + }, + 200, + ) + .expect("swap should resolve"); + + assert_eq!(next.clears_done, 15); + assert_eq!(next.status, PuzzleClearRunStatus::LevelCleared); + assert!(next.board.cells.iter().all(|cell| cell.card.is_none())); + } + #[test] fn refill_keeps_locked_partial_group_in_place() { let mut board = board_from_cards( @@ -1737,6 +1889,57 @@ mod tests { ); } + #[test] + fn two_by_two_l_shaped_partial_group_locks_as_one_group() { + let board = board_from_cards( + 3, + vec![ + Some(card_shape("block", PuzzleClearShapeKind::TwoByTwo, 0, 0)), + Some(card_shape("block", PuzzleClearShapeKind::TwoByTwo, 1, 0)), + Some(card("noise-a", 0, 0)), + Some(card_shape("block", PuzzleClearShapeKind::TwoByTwo, 0, 1)), + Some(card("noise-b", 0, 0)), + Some(card("play", 1, 0)), + Some(card("noise-d", 0, 0)), + Some(card("play", 0, 0)), + Some(card("noise-c", 0, 0)), + ], + ); + let run = start_puzzle_clear_run( + "run-2x2-partial".to_string(), + "user-1".to_string(), + "profile-1".to_string(), + board, + PuzzleClearDeck { + ready_columns: vec![vec![], vec![], vec![]], + }, + 100, + ) + .expect("run should start"); + + let locked = apply_puzzle_clear_swap( + &run, + PuzzleClearMove { + from_row: 1, + from_col: 1, + to_row: 2, + to_col: 0, + }, + 200, + ) + .expect("non-clear swap should lock partial group"); + + let block_locks = locked + .board + .cells + .iter() + .filter(|cell| cell.card.as_ref().is_some_and(|card| card.group_id == "block")) + .map(|cell| cell.locked_group_id.as_deref()) + .collect::>(); + assert_eq!(block_locks, vec![Some("block"), Some("block"), Some("block")]); + assert_eq!(locked.clears_done, 0); + } + #[test] fn timeout_fails_only_current_level_and_retry_restarts_it() { let board = board_from_cards( diff --git a/server-rs/crates/spacetime-module/src/puzzle_clear.rs b/server-rs/crates/spacetime-module/src/puzzle_clear.rs index ce917767..3091fda7 100644 --- a/server-rs/crates/spacetime-module/src/puzzle_clear.rs +++ b/server-rs/crates/spacetime-module/src/puzzle_clear.rs @@ -6,8 +6,8 @@ pub use types::*; use crate::{ProcedureContext, ReducerContext, SpacetimeType, Table, Timestamp, json}; use module_puzzle_clear::{ - PUZZLE_CLEAR_LEVEL_DURATION_SECONDS, PuzzleClearBoard, PuzzleClearCard, PuzzleClearDeck, - PuzzleClearMove, PuzzleClearRunSnapshot as DomainRunSnapshot, advance_puzzle_clear_level, + PuzzleClearBoard, PuzzleClearCard, PuzzleClearDeck, PuzzleClearMove, + PuzzleClearRunSnapshot as DomainRunSnapshot, advance_puzzle_clear_level, apply_puzzle_clear_swap, create_puzzle_clear_board, fail_puzzle_clear_level_on_timeout, parse_puzzle_clear_orientation, parse_puzzle_clear_shape_kind, puzzle_clear_level_configs, retry_puzzle_clear_level, start_puzzle_clear_run, @@ -976,6 +976,7 @@ fn build_level_board_and_deck( .collect::>(), seed, level.target_clears as usize, + &level.unlocked_shapes, ); let board = create_puzzle_clear_board(&level, seed, allowed.clone()) .map_err(|error| error.to_string())?; @@ -991,6 +992,7 @@ fn ordered_level_cards( cards: Vec, seed: &str, target_groups: usize, + unlocked_shapes: &[module_puzzle_clear::PuzzleClearShapeKind], ) -> Vec { let mut groups: BTreeMap> = BTreeMap::new(); for card in cards { @@ -1008,9 +1010,40 @@ fn ordered_level_cards( .unwrap_or_default(); left_key.cmp(&right_key) }); - grouped + let mut selected = Vec::new(); + let mut selected_group_ids = std::collections::BTreeSet::new(); + for shape in unlocked_shapes { + if selected.len() >= target_groups { + break; + } + let Some(group) = grouped.iter().find(|group| { + group + .first() + .is_some_and(|card| card.shape == *shape && !selected_group_ids.contains(&card.group_id)) + }) else { + continue; + }; + if let Some(first) = group.first() { + selected_group_ids.insert(first.group_id.clone()); + selected.push(group.clone()); + } + } + for group in grouped { + if selected.len() >= target_groups { + break; + } + let Some(first) = group.first() else { + continue; + }; + if selected_group_ids.contains(&first.group_id) { + continue; + } + selected_group_ids.insert(first.group_id.clone()); + selected.push(group); + } + + selected .into_iter() - .take(target_groups) .flat_map(|mut group| { group.sort_by_key(|card| (card.part_y, card.part_x)); group @@ -1061,7 +1094,7 @@ fn build_runtime_snapshot( level_index: snapshot.level_index, clears_done: snapshot.clears_done, target_clears: level.target_clears, - level_duration_seconds: PUZZLE_CLEAR_LEVEL_DURATION_SECONDS, + level_duration_seconds: level.duration_seconds, level_started_at_ms: snapshot.level_started_at_ms, board: PuzzleClearBoardSnapshot { rows: snapshot.board.rows, diff --git a/src/components/platform-entry/PlatformEntryFlowShellImpl.tsx b/src/components/platform-entry/PlatformEntryFlowShellImpl.tsx index 16d24b6c..88a8c502 100644 --- a/src/components/platform-entry/PlatformEntryFlowShellImpl.tsx +++ b/src/components/platform-entry/PlatformEntryFlowShellImpl.tsx @@ -10399,17 +10399,52 @@ export function PlatformEntryFlowShellImpl({ setSelectionStage, ]); - const publishPuzzleClearDraft = useCallback(async () => { + const publishPuzzleClearDraft = useCallback(async (metadata?: { + workTitle: string; + workDescription: string; + }) => { const profileId = puzzleClearWork?.summary.profileId?.trim(); if (!profileId) { setPuzzleClearError('拼消消草稿尚未生成可发布作品。'); setSelectionStage('puzzle-clear-result'); - return; + return false; } setIsPuzzleClearBusy(true); setPuzzleClearError(null); try { + let currentWork = puzzleClearWork; + const normalizedTitle = metadata?.workTitle.trim() ?? ''; + const normalizedDescription = metadata?.workDescription.trim() ?? ''; + if (metadata && normalizedTitle) { + const sessionId = + puzzleClearSession?.sessionId?.trim() || + puzzleClearWork?.summary.sourceSessionId?.trim() || + ''; + if (!sessionId) { + setPuzzleClearError('拼消消草稿缺少会话信息,暂时无法保存发布资料。'); + setSelectionStage('puzzle-clear-result'); + return false; + } + const updateResponse = await puzzleClearClient.executeAction(sessionId, { + actionType: 'update-work-meta', + profileId, + workTitle: normalizedTitle, + workDescription: normalizedDescription, + }); + currentWork = updateResponse.work ?? currentWork; + setPuzzleClearSession(updateResponse.session); + if (currentWork) { + setPuzzleClearWork(currentWork); + writeCreationUrlState( + buildPuzzleClearCreationUrlState({ + session: updateResponse.session, + work: currentWork, + }), + ); + } + } + const response = await puzzleClearClient.publishWork(profileId); setPuzzleClearWork(response.item); setPuzzleClearWorks((current) => [ @@ -10433,17 +10468,20 @@ export function PlatformEntryFlowShellImpl({ publicWorkCode, stage: 'work-detail', }); + return true; } catch (error) { setPuzzleClearError( resolveRpgCreationErrorMessage(error, '发布拼消消作品失败。'), ); setSelectionStage('puzzle-clear-result'); + return false; } finally { setIsPuzzleClearBusy(false); } }, [ openPublishShareModal, - puzzleClearWork?.summary.profileId, + puzzleClearSession?.sessionId, + puzzleClearWork, refreshPuzzleClearGallery, refreshPuzzleClearShelf, setSelectionStage, diff --git a/src/components/puzzle-clear-creation/PuzzleClearWorkspace.test.tsx b/src/components/puzzle-clear-creation/PuzzleClearWorkspace.test.tsx index 3425ddac..d72a28b2 100644 --- a/src/components/puzzle-clear-creation/PuzzleClearWorkspace.test.tsx +++ b/src/components/puzzle-clear-creation/PuzzleClearWorkspace.test.tsx @@ -76,7 +76,7 @@ beforeEach(() => { vi.mocked(readPuzzleReferenceImageAsDataUrl).mockReset(); }); -test('工作台提交结构化表单与底图槽位 payload', async () => { +test('工作台只提交游戏内容配置与底图槽位 payload', async () => { const response = createSessionResponse(); const onSubmitted = vi.fn(); vi.mocked(puzzleClearClient.createSession).mockResolvedValue(response); @@ -91,19 +91,17 @@ test('工作台提交结构化表单与底图槽位 payload', async () => { />, ); - fireEvent.change(screen.getByLabelText('作品标题'), { - target: { value: ' 星港拼消消 ' }, - }); - fireEvent.change(screen.getByLabelText('简介'), { - target: { value: ' 霓虹星港主题 ' }, - }); - fireEvent.change(screen.getByLabelText('主题词'), { + expect(screen.getByText('拼消消创作')).not.toBeNull(); + expect(screen.queryByLabelText('作品标题')).toBeNull(); + expect(screen.queryByLabelText('简介')).toBeNull(); + + fireEvent.change(screen.getByLabelText('卡牌素材主题'), { target: { value: ' 霓虹星港 ' }, }); - fireEvent.change(screen.getByLabelText('场地底图'), { + fireEvent.change(screen.getByLabelText('画面描述'), { target: { value: '星港中央棋盘底图' }, }); - fireEvent.change(screen.getByLabelText('上传底图'), { + fireEvent.change(screen.getByLabelText('上传背景图'), { target: { files: [ new File(['fake-image'], 'board.png', { @@ -122,8 +120,8 @@ test('工作台提交结构化表单与底图槽位 payload', async () => { await waitFor(() => expect(puzzleClearClient.createSession).toHaveBeenCalledWith({ templateId: 'puzzle-clear', - workTitle: '星港拼消消', - workDescription: '霓虹星港主题', + workTitle: '霓虹星港拼消消', + workDescription: '', themePrompt: '霓虹星港', boardBackgroundPrompt: '星港中央棋盘底图', generateBoardBackground: false, @@ -138,7 +136,7 @@ test('工作台提交结构化表单与底图槽位 payload', async () => { response, expect.objectContaining({ templateId: 'puzzle-clear', - workTitle: '星港拼消消', + workTitle: '霓虹星港拼消消', themePrompt: '霓虹星港', }), ); @@ -155,7 +153,29 @@ test('工作台不渲染聊天式 Agent 输入', () => { expect(screen.queryByText(/发送消息|聊天|对话|输入想法/u)).toBeNull(); }); -test('关闭 AI 生成底图且未上传底图时不允许提交', async () => { +test('背景图上传区不再套外层信息框', () => { + const { container } = render( + , + ); + + const uploadInput = screen.getByLabelText('上传背景图', { + selector: 'input', + }); + const uploadCard = uploadInput.closest('.puzzle-image-upload-card'); + + expect(uploadCard).not.toBeNull(); + expect(uploadCard?.closest('.platform-subpanel')).toBeNull(); + expect(container.querySelector('.puzzle-image-upload-card')).toBeTruthy(); + expect(screen.getByText('背景图')).toBeTruthy(); + expect(screen.getByText('上传图片/填写画面描述')).toBeTruthy(); + expect(screen.queryByText('中央底图')).toBeNull(); + expect(screen.queryByLabelText('场地底图画面')).toBeNull(); +}); + +test('工作台保留背景图命名和画面描述输入', () => { render( { />, ); - fireEvent.change(screen.getByLabelText('作品标题'), { - target: { value: '星港拼消消' }, - }); - fireEvent.change(screen.getByLabelText('主题词'), { + expect(screen.getByText('背景图')).toBeTruthy(); + expect(screen.getByLabelText('画面描述')).toBeTruthy(); + expect(screen.queryByText('上传图像')).toBeNull(); +}); + +test('工作台可仅用主题进入 AI 生成流程', async () => { + const response = createSessionResponse(); + vi.mocked(puzzleClearClient.createSession).mockResolvedValue(response); + + render( + , + ); + + fireEvent.change(screen.getByLabelText('卡牌素材主题'), { target: { value: '霓虹星港' }, }); - fireEvent.click(screen.getByRole('checkbox', { name: 'AI 生成底图' })); expect( (screen.getByRole('button', { name: '生成' }) as HTMLButtonElement).disabled, - ).toBe(true); + ).toBe(false); fireEvent.click(screen.getByRole('button', { name: '生成' })); await waitFor(() => - expect(puzzleClearClient.createSession).not.toHaveBeenCalled(), + expect(puzzleClearClient.createSession).toHaveBeenCalledWith( + expect.objectContaining({ + themePrompt: '霓虹星港', + boardBackgroundPrompt: '', + generateBoardBackground: true, + boardBackgroundAsset: null, + }), + ), ); }); @@ -194,13 +233,10 @@ test('工作台支持原生表单提交生成', async () => { />, ); - fireEvent.change(screen.getByLabelText('作品标题'), { - target: { value: '星港拼消消' }, - }); - fireEvent.change(screen.getByLabelText('主题词'), { + fireEvent.change(screen.getByLabelText('卡牌素材主题'), { target: { value: '霓虹星港' }, }); - fireEvent.change(screen.getByLabelText('场地底图'), { + fireEvent.change(screen.getByLabelText('画面描述'), { target: { value: '星港中央棋盘底图' }, }); @@ -216,7 +252,7 @@ test('工作台支持原生表单提交生成', async () => { response, expect.objectContaining({ templateId: 'puzzle-clear', - workTitle: '星港拼消消', + workTitle: '霓虹星港拼消消', }), ); }); diff --git a/src/components/puzzle-clear-creation/PuzzleClearWorkspace.tsx b/src/components/puzzle-clear-creation/PuzzleClearWorkspace.tsx index 75734d04..f9fa8a30 100644 --- a/src/components/puzzle-clear-creation/PuzzleClearWorkspace.tsx +++ b/src/components/puzzle-clear-creation/PuzzleClearWorkspace.tsx @@ -21,8 +21,6 @@ type PuzzleClearWorkspaceProps = { }; type PuzzleClearWorkspaceFormState = { - workTitle: string; - workDescription: string; themePrompt: string; boardBackgroundPrompt: string; boardBackgroundAsset: PuzzleClearImageAsset | null; @@ -31,8 +29,6 @@ type PuzzleClearWorkspaceFormState = { }; const DEFAULT_FORM_STATE: PuzzleClearWorkspaceFormState = { - workTitle: '', - workDescription: '', themePrompt: '', boardBackgroundPrompt: '', boardBackgroundAsset: null, @@ -56,6 +52,15 @@ function buildLocalBoardBackgroundAsset( }; } +function derivePuzzleClearDraftTitle(themePrompt: string) { + const normalizedTheme = themePrompt.trim(); + if (!normalizedTheme) { + return '拼消消草稿'; + } + const suffix = normalizedTheme.endsWith('拼消消') ? '' : '拼消消'; + return `${normalizedTheme}${suffix}`.slice(0, 30); +} + export function PuzzleClearWorkspace({ isBusy = false, error = null, @@ -78,12 +83,8 @@ export function PuzzleClearWorkspace({ const canSubmit = useMemo( () => - Boolean( - formState.workTitle.trim() && - formState.themePrompt.trim() && - hasBoardBackgroundInput, - ), - [formState.themePrompt, formState.workTitle, hasBoardBackgroundInput], + Boolean(formState.themePrompt.trim() && hasBoardBackgroundInput), + [formState.themePrompt, hasBoardBackgroundInput], ); const handleSubmit = async () => { @@ -107,8 +108,8 @@ export function PuzzleClearWorkspace({ : null); const payload: PuzzleClearWorkspaceCreateRequest = { templateId: 'puzzle-clear', - workTitle: formState.workTitle.trim(), - workDescription: formState.workDescription.trim(), + workTitle: derivePuzzleClearDraftTitle(formState.themePrompt), + workDescription: '', themePrompt: formState.themePrompt.trim(), boardBackgroundPrompt: formState.boardBackgroundPrompt.trim(), generateBoardBackground: formState.generateBoardBackground, @@ -133,125 +134,76 @@ export function PuzzleClearWorkspace({ }} className="platform-remap-surface mx-auto flex h-full min-h-0 w-full max-w-5xl flex-col px-3 pb-3 pt-3 sm:px-4 sm:pt-4" > -
+
-
-
- +
+
+

+ 拼消消创作 +

+ + BETA + +
+
-