diff --git a/.hermes/shared-memory/decision-log.md b/.hermes/shared-memory/decision-log.md
index f1bca0e8..0ca4927b 100644
--- a/.hermes/shared-memory/decision-log.md
+++ b/.hermes/shared-memory/decision-log.md
@@ -16,6 +16,23 @@
---
+## 2026-06-03 拼消消收敛为单关 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 卡牌切片。
+- 影响范围:`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`。
+
+## 2026-05-30 拼消消按独立玩法公开闭环接入
+
+- 背景:拼消消以拼图交换手感为基础,但核心规则从“拼完整单图过关”变为“拼成多个复合图案组后逐个消除”,同时需要顶部补牌、防死局、半锁定局部拼接组和正式统计,不能继续复用拼图运行态规则本体。
+- 决策:`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。
+- 补充约束:拼消消结果页草稿试玩使用前端本地 `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`。
+- 关联文档:`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`。
## 2026-06-05 Server-Provision 全程在目标部署 agent 执行且不安装构建链
- 背景:`Genarrative-Server-Provision` 的 `DEPLOY_TARGET=development` 语义是部署到 dev 服务器,不是构建机 dry-run。旧流水线把 development 映射到 `linux && genarrative-build`,还先在 build 节点准备 `provision-tools/` 再 stash 给后续阶段,导致真实 dev 初始化可能跑到 Jenkins controller / build 节点;脚本还安装 clang / lld / pkg-config / OpenSSL headers / sccache 等构建链依赖,超出了服务器初始化职责。
@@ -1280,3 +1297,11 @@
- 影响范围:`server-rs/crates/api-server/src/state.rs`、`server-rs/crates/module-auth/src/lib.rs`、`server-rs/crates/spacetime-module/src/auth/procedures.rs`、`server-rs/crates/spacetime-client/src/auth.rs`、对应生成 bindings。
- 验证方式:`cargo check -p module-auth --manifest-path server-rs/Cargo.toml`、`cargo check -p api-server --manifest-path server-rs/Cargo.toml`、`cargo test -p module-auth password --manifest-path server-rs/Cargo.toml -- --nocapture`、`npm run check:spacetime-schema`、`npm run check:encoding`、`cargo test -p api-server spacetime_unavailable_router_returns_service_unavailable_for_requests --manifest-path server-rs/Cargo.toml -- --nocapture`。
- 关联文档:`docs/【后端架构】server-rs与SpacetimeDB数据契约-2026-05-15.md`、`docs/【开发运维】本地开发验证与生产运维-2026-05-15.md`。
+
+## 2026-05-31 拼消消底图 prompt 与 atlas 切片提示词收口
+
+- 背景:拼消消生成资产检查时,用户需要区分主题词、场地底图主题词和复合图 atlas prompt 的职责;若小图案显式画出切分线或边框,运行态 1x1 切片会显得像错误素材。
+- 决策:`boardBackgroundPrompt` 成为中央场地底图的优先 prompt 来源,只有该字段为空时才回退读取 `themePrompt`;用户上传底图时只执行平台资产持久化和换签,不用主题词重写上传资产。复合图 atlas prompt 只描述“可被服务端按等大 1x1 方格切分”,禁止模型在图案上绘制切分线、边框、网格线或裁切参考线。
+- 影响范围:拼消消工作台 payload、`shared-contracts` / `packages/shared` 契约、api-server 生成编排、SpacetimeDB session/work snapshot、文档与生成进度展示。
+- 验证方式:`npm run spacetime:generate`、`npm run check:encoding`、`npm run check:server-rs-ddd`、`cargo test -p module-puzzle-clear`、`cargo test -p spacetime-client puzzle_clear -- --nocapture`、`npm run test -- src/components/puzzle-clear-creation/PuzzleClearWorkspace.test.tsx src/services/miniGameDraftGenerationProgress.test.ts src/routing/appPageRoutes.test.ts src/services/publicWorkCode.test.ts`。
+- 关联文档:`docs/prd/【玩法创作】拼消消玩法模板PRD-2026-05-30.md`、`docs/technical/【玩法创作】拼消消玩法模板技术方案-2026-05-30.md`、`docs/【玩法创作】平台入口与玩法链路-2026-05-15.md`。
diff --git a/.hermes/shared-memory/pitfalls.md b/.hermes/shared-memory/pitfalls.md
index feb5b666..ebdffc89 100644
--- a/.hermes/shared-memory/pitfalls.md
+++ b/.hermes/shared-memory/pitfalls.md
@@ -1,4 +1,4 @@
-# 踩坑与排障记录
+# 踩坑与排障记录
> 用途:记录已验证、未来很可能再次遇到的问题。每条都应包含现象、原因、处理方式和验证方式。
@@ -48,12 +48,12 @@
- 验证:`cargo test -p platform-image --manifest-path server-rs/Cargo.toml vector_engine_image_edit_retries_send_timeout_once_and_succeeds`、`cargo check -p api-server --manifest-path server-rs/Cargo.toml`;查询 `tracking_event` 时失败记录应能看到触发者 `user_id` 和可用的 `profile_id`。
- 关联:`server-rs/crates/platform-image/src/vector_engine/client.rs`、`server-rs/crates/api-server/src/external_api_audit.rs`、`server-rs/crates/api-server/src/openai_image_generation.rs`、`docs/【开发运维】本地开发验证与生产运维-2026-05-15.md`。
-## “我的”页每日任务卡不要硬编码进度
+## “我的”页每日任务卡不要硬编码进度,也不要跨日保留旧状态
- 现象:用户完成或领取每日任务后,任务中心弹窗里的任务状态已经变化,但“我的”页卡片仍显示 `0 / 1` 和“去完成”。
-- 原因:卡片首版只写了静态展示文案,没有读取 `/api/profile/tasks` 返回的 `ProfileTaskCenterResponse`,领取接口返回的新 `center` 也只用于弹窗。
-- 处理:进入“我的”页时读取任务中心,卡片用当前可操作任务或已领取任务派生奖励、进度条和操作状态;`claimRpgProfileTaskReward(...)` 成功后用响应里的 `center` 覆盖本地任务中心。
-- 验证:`npm run test -- src/components/rpg-entry/RpgEntryHomeView.recharge.test.tsx` 应覆盖卡片从后端任务摘要显示 `1 / 1`,领取后显示已完成。
+- 原因:卡片首版只写了静态展示文案,没有读取 `/api/profile/tasks` 返回的 `ProfileTaskCenterResponse`,领取接口返回的新 `center` 也只用于弹窗;后来虽然后端按北京时间 0 点切换业务日,但前端停留在“我的”页时不会跨日刷新,可能继续展示上一日已领取状态。
+- 处理:进入“我的”页时读取任务中心,卡片用当前可操作任务或已领取任务派生奖励、进度条和操作状态;`claimRpgProfileTaskReward(...)` 成功后用响应里的 `center` 覆盖本地任务中心;停留在“我的”页跨过北京时间 0 点时,先非阻断 refresh 登录态写入新业务日 `daily_login`,再重拉任务中心。
+- 验证:`npm run test -- src/components/rpg-entry/RpgEntryHomeView.recharge.test.tsx` 应覆盖卡片从后端任务摘要显示 `1 / 1`、领取后显示已完成,以及北京时间 0 点自动 refresh 后重拉任务中心。
- 关联:`src/components/rpg-entry/RpgEntryHomeView.tsx`、`src/components/rpg-entry/RpgEntryHomeView.recharge.test.tsx`、`docs/【项目基线】当前产品与工程约束-2026-05-15.md`。
## “我的”页不要恢复旧的填邀请码次级按钮
@@ -104,6 +104,30 @@
- 验证:`npm test -- src/components/rpg-entry/RpgEntryFlowShell.agent.interaction.test.tsx -t \"puzzle draft generation auto starts trial and runtime back opens draft result\"`,确认 `window.location.pathname === '/runtime/puzzle'` 且 `window.location.search` 同时包含 `runtimeProfileId` 和 `runtimeSessionId`。
- 关联:`src/components/platform-entry/PlatformEntryFlowShellImpl.tsx`、`src/services/puzzleRuntimeUrlState.ts`、`src/routing/appPageRoutes.ts`、`docs/【玩法创作】平台入口与玩法链路-2026-05-15.md`。
+## 拼消消草稿试玩不能只测 swap 回调
+
+- 现象:拼消消结果页和 runtime shell 的单测都能通过,但真实页面里卡片只是交换,完全不会消除,顶部准备区还会因为已知的卡背占位路径显示坏图。
+- 原因:草稿试玩走的是前端本地 runtime,早期测试只覆盖了 `onSwapCards` 回调和局部状态,没有验证完整的消除、重力补牌、关卡完成和资源兜底链路;同时顶部卡背对 `puzzle-clear-card-back.webp` 这类已知缺失资源没有前置回退。
+- 处理:草稿试玩的回归测试必须覆盖“交换 -> 完整图案消除 -> 补牌 -> 关卡完成”闭环,并在组件测试里验证真实点击/拖拽序列;顶部准备区卡背遇到已知占位路径时直接回退到 `puzzle.webp` 这类可用参考图,不等图片加载失败后再兜底。
+- 验证:`npm run test -- src/services/puzzle-clear/puzzleClearLocalRuntime.test.ts src/components/puzzle-clear-runtime/PuzzleClearRuntimeShell.test.tsx` 通过,浏览器 smoke 页实测可完成一次消除并弹出“本关完成”。
+- 关联:`src/services/puzzle-clear/puzzleClearLocalRuntime.ts`、`src/services/puzzle-clear/puzzleClearLocalRuntime.test.ts`、`src/components/puzzle-clear-runtime/PuzzleClearRuntimeShell.tsx`、`src/components/puzzle-clear-runtime/PuzzleClearRuntimeShell.test.tsx`。
+
+## 拼消消消除过渡不能隐藏已有卡片的最终下沉格
+
+- 现象:消除补牌过程中偶尔看起来下方有空位,但同列上方卡片没有落下来。
+- 原因:后端和本地 runtime 的重力补牌已经把已有卡片压到底;真正的问题在前端过渡层。消除动画曾按旧消除坐标隐藏棋盘格,掉落动画也曾隐藏所有 drop 目标格。当某个旧卡下沉到刚被消除的格子时,最终 snapshot 里的真实卡片会被隐藏,视觉上像补牌没有落下。
+- 处理:消除 / 掉落覆盖层只负责动画表现,不再隐藏已有场上卡片的最终格;只有从顶部准备区新补入、前一帧棋盘不存在的卡片,才允许临时隐藏底层目标格来配合下落动画。
+- 验证:`npm run test -- src/components/puzzle-clear-runtime/PuzzleClearRuntimeShell.test.tsx -t "已有卡片因重力下沉时目标格不被过渡状态隐藏成空位"`,并保留领域侧 `cargo test -p module-puzzle-clear refill --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`、`docs/technical/【玩法创作】拼消消玩法模板技术方案-2026-05-30.md`。
+
+## 拼消消完整消除反馈不要让补牌抢帧
+
+- 现象:玩家正确拼完整组后,卡片几乎瞬间消失,顶部补牌马上出现或下落,导致“拼对了”的确认反馈很弱。
+- 原因:前端一收到新 snapshot 就同时播放消除和掉落叠层,旧消除动画时长较短;新补入卡牌的下落延迟接近 0ms,视觉上会抢在消除反馈之前开始。
+- 处理:局部正确拼合但未消除时只给锁定组做一次高光;完整消除时让旧卡片在消除叠层中短暂放大展示再淡出;新补入卡牌的下落延迟到淡出尾段,并继续只隐藏新补入目标格,不隐藏已有场上卡片下沉后的最终格。
+- 验证:`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`。
+
## 首页推荐分流参数不能条件性调用 hook
- 现象:桌面首页或移动首页在 HMR、断点切换或重新渲染后直接报 React hook 顺序错误,页面停在“正在加载内容”。
@@ -135,6 +159,14 @@
- 验证:后台保存两条以上公告后,点击底部加号进入创作入口页应自动轮播这些后台配置项;`CustomWorldCreationHub` 相关测试应断言标题来自后端配置。
- 关联:`src/components/custom-world-home/CustomWorldCreationStartCard.tsx`、`server-rs/crates/module-runtime/src/application.rs`、`apps/admin-web/src/pages/AdminCreationEntrySwitchPage.tsx`。
+## 创作入口 banner 默认图片路径必须真实存在
+
+- 现象:创作页顶部 banner 返回旧结构化 `eventBanner` 时,前端 `
` 请求 `/branding/taonier-logo-spiral-reference-concepts/taonier-spiral-bouncy-clay.png`,但 `public/` 下没有该文件,导致 banner 背景图加载失败。
+- 原因:旧库 `event_banners_json=None` 时,读取层把旧单条结构化 banner 当成 `eventBanners` 优先数组下发;同时旧结构化默认 `coverImageSrc` 指向已经不存在的品牌素材路径。
+- 处理:`module-runtime` 在 `event_banners_json` 缺失或不可解析时回到默认公告数组;默认 HTML 公告和旧结构化默认 `coverImageSrc` 都引用 `public/` 下真实存在的 `/creation-type-references/puzzle.webp`。
+- 验证:`cargo test -p module-runtime creation_entry_event_banners_none_returns_default_announcements --manifest-path server-rs/Cargo.toml`;重启本地 `api-server` 后 `GET /api/creation-entry/config` 的 `eventBanners[0]` 不再指向缺失的 `/branding/taonier-logo-spiral-reference-concepts/taonier-spiral-bouncy-clay.png`。
+- 关联:`server-rs/crates/module-runtime/src/application.rs`、`server-rs/crates/module-runtime/src/domain.rs`、`docs/【玩法创作】平台入口与玩法链路-2026-05-15.md`。
+
## 移动端草稿卡不要长按选中文字
- 现象:移动端草稿页长按作品卡标题或摘要时触发系统文字选区,容易误触并打断作品架操作。
@@ -1018,6 +1050,14 @@
- 验证:`cargo test -p api-server phone_auth_sms_provider_errors_keep_upstream_http_semantics --manifest-path server-rs/Cargo.toml`,真实 provider 频控时接口不再返回 `500`。
- 关联:`server-rs/crates/module-auth/src/errors.rs`、`server-rs/crates/api-server/src/phone_auth.rs`、`docs/technical/PHONE_SMS_PROVIDER_ERROR_HTTP_MAPPING_FIX_2026-05-08.md`。
+
+## 本地短信 smoke 先确认 SMS provider
+
+- 现象:浏览器里短信验证码发送成功,但提交 `123456` 仍然报验证码错误,或者短信登录后又回到未登录态。
+- 原因:当前运行中的 `api-server` 如果读取到 `.env.local` 里的 `SMS_AUTH_PROVIDER=aliyun`,就会走真实短信 provider 口径;这时 mock 验证码 `123456` 不会被接受。之前本地调试时常见的误判是把 `.env.local` 改成 mock 了,但没有重启 `npm run dev`,或者旧的 `scripts/dev.mjs` 进程还在沿用旧环境。
+- 处理:本地只做 UI / 账号链路 smoke 时,把 `.env.local` 显式设为 `SMS_AUTH_PROVIDER=mock` 且配置 `SMS_AUTH_MOCK_VERIFY_CODE=123456`,然后重启 `npm run dev` 或 `npm run dev:api-server`。要做真实短信联调时,再切回 `SMS_AUTH_PROVIDER=aliyun` 并重启。
+- 验证:`POST /api/auth/phone/send-code` 应返回 `providerRequestId=mock-request-id`;`POST /api/auth/phone/login` 用 `123456` 应返回 `200` 且 `user.loginMethod=phone`。浏览器侧短信登录成功后,会先进入邀请码弹窗或我的页面,不应再提示“验证码错误”。
+- 关联:`scripts/dev-utils.mjs`、`scripts/dev-utils.test.ts`、`scripts/dev.mjs`、`server-rs/crates/api-server/src/config.rs`。
## 手机验证码登录成功后又瞬间回到未登录
- 现象:手机号验证码登录先成功,随后 UI 又闪回“未登录”,登录弹窗可能重新出现。
@@ -1601,10 +1641,18 @@
- 现象:拼图生成页已经收到 VectorEngine 图片编辑失败并进入重试态,但用户返回草稿 Tab 后,同一草稿仍显示“生成中”;连续触发多个拼图生成时,失败后还可能只剩一条新增草稿,或者只看到标题为“第1关”的半成品空壳;抓大鹅后台失败时也可能没有任何通知,点击草稿又像重新开始生成。
- 原因:前端失败 notice 只更新生成页局部状态,pending 作品架条目在失败时被清掉或被非 `generating` 状态误映射为 `ready`;后端作品摘要也可能短暂仍是 `generationStatus=generating`。如果失败消息没有写入 notice,用户离开生成页后不会弹出 `PlatformErrorDialog`;如果打开草稿只看持久化 `generating`,就会绕过失败态恢复。
-- 处理:失败时按 session 保留 pending 作品架条目并标记 `failed`,失败 notice 保存错误消息并触发带来源的 `PlatformErrorDialog`;拼图契约没有 `failed` 枚举,pending 拼图映射为 `idle`,同时用本地失败 notice 覆盖持久化生成中状态和旧的“正在生成”摘要。点击失败草稿应优先用 notice / 后端 session / fallback payload 组装失败生成页,不能重新从 0 秒启动新进度;拼图失败半成品没有有效 `workTitle` 时,作品架标题回退为“拼图草稿”。
+- 处理:失败时按 session 保留 pending 作品架条目并标记 `failed`,失败 notice 保存错误消息并触发带来源的 `PlatformErrorDialog`;拼图契约没有 `failed` 枚举,pending 拼图映射为 `idle`,同时用本地失败 notice 覆盖持久化生成中状态和旧的“正在生成”摘要。点击失败草稿应优先用 notice / 后端 session / fallback payload 组装失败生成页,不能重新从 0 秒启动新进度;失败页点击重新生成必须优先复用当前 `sessionId` 执行编译 action,不得因存在表单缓存 payload 就调用 create-session。拼图失败半成品没有有效 `workTitle` 时,作品架标题回退为“拼图草稿”。
- 验证:`node node_modules/vitest/vitest.mjs run src/components/rpg-entry/RpgEntryFlowShell.agent.interaction.test.tsx -t "failed parallel puzzle|background match3d"`。
- 关联:`src/components/platform-entry/PlatformEntryFlowShellImpl.tsx`、`src/components/custom-world-home/creationWorkShelf.ts`、`docs/【玩法创作】平台入口与玩法链路-2026-05-15.md`。
+## 生成失败重试不要走新建草稿
+
+- 现象:拼图或抓大鹅生成失败后,在失败页点击“重新生成”,作品架里多出一份新的草稿,原失败草稿仍留在列表里。
+- 原因:重试 handler 曾优先读取缓存的表单 payload 并调用 create-session 路径;失败草稿按 session 留在作品架是正确行为,于是重试动作额外创建了第二份草稿。
+- 处理:只要当前失败页还能恢复到原 `sessionId`,重试就走该 session 的 compile action;只有没有可恢复 session 时,才允许用表单 payload 重新创建草稿。
+- 验证:`npm run test -- src/components/rpg-entry/RpgEntryFlowShell.agent.interaction.test.tsx -t "failed .* draft retry reuses current session"`。
+- 关联:`src/components/platform-entry/PlatformEntryFlowShellImpl.tsx`、`src/components/rpg-entry/RpgEntryFlowShell.agent.interaction.test.tsx`、`docs/【玩法创作】平台入口与玩法链路-2026-05-15.md`。
+
## 汪汪声浪草稿试玩不要写正式 run
- 现象:如果草稿结果页试玩和发布后 runtime 共用同一写成绩路径,未发布或未确认资源的草稿试玩会污染正式单局、排行榜和作品统计。
@@ -1837,6 +1885,132 @@
- 验证:`npx vitest run src/components/rpg-entry/RpgEntryHomeView.recharge.test.tsx` 覆盖访客态纵向滑动不弹登录且触发下一条推荐。
- 关联:`src/components/rpg-entry/RpgEntryHomeView.tsx`、`src/components/rpg-entry/RpgEntryHomeView.recharge.test.tsx`。
+## Windows junction worktree 下 Vitest 定向路径失败先切真实路径
+
+- 现象:在 `C:\Users\...\ .codex\worktrees\...` 这类 junction 工作区运行 `npm run test -- src/...` 时,Vitest 可能报 `Failed to load url C:/Users/... (resolved id: F:/DevWorktrees/...)`,同一测试文件明明存在却被判定找不到。
+- 原因:Vite / Vitest 在 Windows 下会把测试入口 realpath 到真实 worktree 路径;如果命令从 junction 路径传入相对文件参数,入口路径和 resolved id 可能跨盘符不一致。
+- 处理:前端定向测试优先从 `Get-Item | Format-List Target` 显示的真实路径运行,例如 `F:\DevWorktrees\codex\worktrees\f584\Genarrative`;不要把这类文件加载失败误判成组件或路由断言失败。
+- 验证:同一命令从真实路径执行应正常收集并运行测试,例如 `npm run test -- src/components/puzzle-clear-runtime/PuzzleClearRuntimeShell.test.tsx`。
+- 关联:`src/components/puzzle-clear-creation/PuzzleClearWorkspace.test.tsx`、`src/components/puzzle-clear-result/PuzzleClearResultView.test.tsx`、`src/components/puzzle-clear-runtime/PuzzleClearRuntimeShell.test.tsx`、`src/routing/appPageRoutes.test.ts`。
+
+## 拼消消草稿试玩要和正式 runtime 分流
+
+- 现象:拼消消结果页点击“试玩”后如果仍然调用 `/api/runtime/puzzle-clear/runs`,草稿试玩会被正式 run 规则和统计约束卡住,公开作品又可能和草稿恢复串台。
+- 原因:拼消消既有草稿生成 / 结果页 / 发布闭环,也有正式公开 runtime;如果把结果页试玩和公开运行态复用同一个后端 startRun 入口,`work detail` 读取路径和统计口径都会混在一起。
+- 处理:结果页试玩改走前端本地 `runtimeMode=draft` snapshot,只用于草稿试玩和关卡切换,不写正式 run;公开详情和推荐流进入正式 runtime 时才走后端 `/api/runtime/puzzle-clear/*`。客户端读取作品详情时也要区分创作详情 `/api/creation/puzzle-clear/works/{profileId}` 与公开运行态详情 `/api/runtime/puzzle-clear/works/{profileId}`。
+- 验证:点击拼消消结果页的试玩按钮,不应再请求 `/api/runtime/puzzle-clear/runs`;公开详情入口仍应能读取后端运行态详情。
+- 关联:`src/components/platform-entry/PlatformEntryFlowShellImpl.tsx`、`src/services/puzzle-clear/puzzleClearClient.ts`、`src/services/puzzle-clear/puzzleClearLocalRuntime.ts`、`docs/prd/【玩法创作】拼消消玩法模板PRD-2026-05-30.md`。
+
+## 拼消消 runtime 必须继承拼图模板的原生交互基线
+
+- 现象:拼消消卡片在浏览器里会出现原生图片拖拽 / 下载手柄,或窗口拉伸后棋盘和卡片被拉成矩形。
+- 原因:拼消消 runtime 早期只继承了“交换 / 消除”的业务逻辑,没有完整继承拼图模板在基础交互上的防护:`touch-none`、`select-none`、`aspect-square`、`draggable={false}`、`onDragStart(event.preventDefault())`、`-webkit-user-drag: none`。
+- 处理:棋盘容器必须保持正方形约束,卡片按钮和内层 `
` 都要显式禁用浏览器原生拖拽,样式层也要补 `user-select: none` 与 `-webkit-user-drag: none`,不能只靠业务指针逻辑。
+- 验证:浏览器中检查棋盘 `getBoundingClientRect().width === height`,卡片图片 `draggable="false"` 且 `-webkit-user-drag` 为 `none`;真实拖拽只应进入交换逻辑,不应触发原生图片拖拽。
+- 关联:`src/components/puzzle-clear-runtime/PuzzleClearRuntimeShell.tsx`、`src/index.css`、`src/components/puzzle-runtime/PuzzleRuntimeShell.tsx`、`src/components/puzzle-clear-runtime/PuzzleClearRuntimeShell.test.tsx`。
+
+## 拼消消拖拽浮层要挂到页面级 portal
+
+- 现象:拼消消拖拽时图片看起来没有贴在鼠标或手指上,尤其是平台壳层本身带有 transform 时更明显。
+- 原因:拖拽 ghost 用了 `position: fixed`,但如果还挂在会被 transform 的局部容器里,浏览器会把 fixed 当成相对该祖先定位;`clientX/clientY` 读到的是视口坐标,两个坐标系一混就会出现肉眼可见的偏移。
+- 处理:拖拽浮层必须通过 portal 挂到 `document.body` 这一层,再继续使用 `clientX/clientY - pointerOffset` 计算 left/top;不要把 ghost 留在平台壳或任何会参与 transform 的容器里。
+- 验证:`npm run test -- src/components/puzzle-clear-runtime/PuzzleClearRuntimeShell.test.tsx` 应断言拖拽浮层父节点是 `document.body`,且 left/top 与按下点偏移一致。
+- 关联:`src/components/puzzle-clear-runtime/PuzzleClearRuntimeShell.tsx`、`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`。
+
+## 拼消消空格位必须允许落位,不能当成不可交互死格
+
+- 现象:运行到某一关后,棋盘里出现空格位,用户能看见空洞但拖不进去,也点不动。
+- 原因:空格位被前端交互或后端裁决误当成“无效目标”,只保留了交换逻辑,没有把“源卡落入空位、源位清空”当成合法移动。
+- 处理:空格位必须保留 button 交互态和落点命中逻辑;前端拖拽 / 点击落到空格时直接提交移动,后端和本地 runtime 都要把源卡移动到目标格并清空源格,不再走失败交换。
+- 验证:`npm run test -- src/components/puzzle-clear-runtime/PuzzleClearRuntimeShell.test.tsx`、`npm run test -- src/services/puzzle-clear/puzzleClearLocalRuntime.test.ts`、`cargo test -p module-puzzle-clear --manifest-path server-rs/Cargo.toml player_move_can_drop_card_into_empty_target_cell -- --nocapture`。
+- 关联:`src/components/puzzle-clear-runtime/PuzzleClearRuntimeShell.tsx`、`src/services/puzzle-clear/puzzleClearLocalRuntime.ts`、`server-rs/crates/module-puzzle-clear/src/application.rs`。
+
+## 拼消消空位落卡后必须立即补位,不能把空洞留成真空格
+
+- 现象:卡牌成功落进空格后,源位仍然留空,玩家会误以为那个格子坏掉了。
+- 原因:移动逻辑只处理了“落到空位”,没有在未消除时同步走一遍重力补位,所以源列会短暂或永久留下空洞。
+- 处理:只要移动后棋盘存在空位,就立即走补位和可解性修复;这样源位会从顶部准备区补卡,不会留下不可交互空洞。
+- 验证:`npm run test -- src/services/puzzle-clear/puzzleClearLocalRuntime.test.ts`、`cargo test -p module-puzzle-clear --manifest-path server-rs/Cargo.toml player_move_can_drop_card_into_empty_target_cell -- --nocapture`。
+- 关联:`src/services/puzzle-clear/puzzleClearLocalRuntime.ts`、`server-rs/crates/module-puzzle-clear/src/application.rs`。
+
+## 拼消消素材错位先查 sheet 质量门禁
+
+- 现象:一张卡牌切片里同时出现两个或多个错位图案,或空白格、相邻编号区域里混入其他图案碎片。
+- 原因:provider 生成的 `1024x1536 / 4x6` 工作表可能违反视觉契约;旧流程只校验布局元数据和切片数量,无法发现图像内容已经主体缺失或污染空白格。边界贴边检测容易把正常铺满主体误判成跨格污染,不能作为高可靠硬门禁。
+- 处理:先强化 atlas prompt,要求每个 `256x256` 单元独立查看时只能包含一个主体或同一主体单一局部;服务端在 sheet 切片前做像素级质量门禁,硬拦截非空格前景占比过低和空白格污染,严重多边非同组边界贴边只记录 warning 供排查,不直接让创作失败。硬门禁失败的 sheet 最多尝试 4 次,仍失败则拒绝持久化脏 atlas。
+- 追加处理:照片式微场景素材必须把每个 `256x256` 单元收束为一张完整的单场景照片裁片;同编号连续格表示同一视觉家族,不是随机独立小图,要求共享同一场景锚点、主色和道具语言。禁止单格内部出现两张照片、两个不同场景、拼接线、内部竖切、内部横切或左右 / 上下两块不同背景;质量门禁只在单格内部强色差直线贯穿大部分高度或宽度,且两侧都像低纹理人工平铺色块时,按“单格内部疑似拼接线”硬失败并重试 sheet,避免把窗框、桌沿、地平线等自然场景强边缘误杀。
+- 追加处理:sheet 生成时如果 VectorEngine 返回 `retryable=true` 的 `502`、`504`、`429` 或请求超时,例如 nginx HTML `502 Bad Gateway`,不要立刻把草稿置为 failed,应消耗同一 sheet 的下一次 attempt;仍失败再回写失败状态。
+- 追加处理:`sheet-03` 原本唯一空白格容易被模型画入主题主体,导致第 6 行第 4 列反复报“空白格有主体”并消耗多次 image2 请求。该格改为 `FILL` 补位格,允许生成主题小图但服务端切片、atlas 合成和运行态全部丢弃;前端拼消消 action 等待窗口同步提高到 40 分钟,避免上游单图慢返回时用户侧 20 分钟超时。
+- 验证:`cargo test -p api-server puzzle_clear --manifest-path server-rs/Cargo.toml -- --nocapture`、`cargo check -p api-server --manifest-path server-rs/Cargo.toml`。
+- 关联:`server-rs/crates/api-server/src/puzzle_clear.rs`、`docs/technical/【玩法创作】拼消消玩法模板技术方案-2026-05-30.md`。
+
+## 拼消消锁定组覆盖层必须锚定在棋盘本身
+
+- 现象:消除或补牌过程中,局部完成的组图偶尔会看起来从格子里“飘出去”,并且大小会随着窗口和外层面板变化而异常拉伸。
+- 原因:锁定组视觉层用了 `absolute inset-0`,但棋盘容器本身不是 `position: relative`,于是覆盖层实际锚到了更外层的运行态面板,`gridColumn` / `gridRow` 只能在错误坐标系里排版。
+- 处理:棋盘容器必须显式 `relative`,让锁定组覆盖层、拖拽鬼影和格子坐标都在同一正方形棋盘坐标系内排版;不要把这类覆盖层锚到外层 `section` 或整页容器。
+- 验证:浏览器里棋盘 `getBoundingClientRect()` 和锁定组覆盖层应共享同一块正方形区域,窗口缩放后组图不应再出现越界或被拉伸的现象;`PuzzleClearRuntimeShell.test.tsx` 需要断言棋盘 class 包含 `relative`。
+- 关联:`src/components/puzzle-clear-runtime/PuzzleClearRuntimeShell.tsx`、`src/components/puzzle-clear-runtime/PuzzleClearRuntimeShell.test.tsx`。
+
+## 拼消消中央场地底图必须挂在棋盘内部
+
+- 现象:创作阶段选择了中央场地底图,但运行态消除卡片后只看到浅色格子或空点,看不到底图。
+- 原因:底图被渲染成整页氛围背景,并被页面渐变、棋盘面板和格子 `bg-white/78` 遮住;棋盘内部没有静态底图层,空格仍保留不透明卡片底色。
+- 处理:`boardBackgroundAsset.imageSrc` 必须作为 `puzzle-clear-board` 内部的 `absolute inset-0` 静态底图渲染;空格、消除空位和拖拽源位必须透明或近透明,不能继续使用实体卡片白底。
+- 验证:`PuzzleClearRuntimeShell.test.tsx` 断言 `puzzle-clear-board-background` 在棋盘内,`/board-bg.png` 只出现一次,空格 class 包含 `bg-transparent` 且不包含 `bg-white/78`。
+- 关联:`src/components/puzzle-clear-runtime/PuzzleClearRuntimeShell.tsx`、`src/components/puzzle-clear-runtime/PuzzleClearRuntimeShell.test.tsx`、`docs/【玩法创作】平台入口与玩法链路-2026-05-15.md`。
+
+## 创作入口突然消失先查前后端是否串到不同 worktree
+
+- 现象:`http://127.0.0.1:3000/` 可访问,但创作 Tab 里新增玩法入口消失;例如 `puzzle-clear` 已在代码默认种子中存在,浏览器仍看不到“拼消消”。
+- 原因:Vite 可能来自当前 worktree,但代理目标的 `api-server` 仍是另一个 worktree 的旧进程,或者 `api-server` 连到旧 SpacetimeDB 模块;此时 `/api/creation-entry/config` 会返回旧入口配置。
+- 处理:先用 `Get-NetTCPConnection -State Listen -LocalPort 3000,8083,3103` 结合 `Get-CimInstance Win32_Process` 确认端口进程路径;停止串线的旧 `api-server`,再用当前 worktree 的 `npm run dev:spacetime -- --spacetime-port --database ` 和 `npm run dev:api-server -- --api-port --spacetime-port --database ` 拉起同一套服务。
+- 验证:`GET /api/creation-entry/config` 应包含目标入口,且监听端口的命令行都指向同一个 worktree;浏览器创作 Tab 对应分类应显示入口卡。
+- 关联:`scripts/dev.mjs`、`.hermes/skills/genarrative-dev-stack-port-routing/SKILL.md`、`docs/【玩法创作】平台入口与玩法链路-2026-05-15.md`。
+
+## Windows junction 工作区下 dev.mjs 直接执行入口要用 realpath 判断
+
+- 现象:在 `C:\Users\...\ .codex\worktrees\...` 这类 junction 路径里运行 `npm run dev:web`,进程会秒退,`3000` 不监听,但同一脚本从真实 worktree 路径能正常启动。
+- 原因:`scripts/dev.mjs` 的入口判断只比对 `process.argv[1]` 和 `import.meta.url` 的字面路径;junction 路径和 realpath 路径不一致时会误判成“不是直接执行”,于是主流程根本不进入。
+- 处理:入口判断改成基于 `realpathSync(...)` 的 `isDirectModuleExecution(...)`,让 junction 路径和真实 worktree 路径指向同一个模块;同时补回归测试覆盖该场景。
+- 验证:`npm run test -- scripts/dev.test.ts scripts/dev-stack-port-utils.test.ts` 通过后,`npm run dev:web -- --web-port 3000 --api-port 8083 --no-interactive` 应能稳定把 `0.0.0.0:3000` 监听起来。
+- 关联:`scripts/dev.mjs`、`scripts/dev.test.ts`。
+
+## Vitest 定向测试在 Windows junction 工作区要切真实路径
+
+- 现象:在 `C:\Users\...\ .codex\worktrees\...` 这类 junction 路径里跑 `npm run test -- src/...` 时,Vitest 会报 `Failed to load url ... (resolved id: F:/DevWorktrees/...)`,看起来像文件不存在。
+- 原因:Vite / Vitest 会把入口 realpath 到真实 worktree 路径;如果命令从 junction 路径传入相对文件参数,入口路径和 resolved id 可能跨盘符不一致。
+- 处理:前端定向测试优先从真实路径 `F:\DevWorktrees\codex\worktrees\f584\Genarrative` 运行,不要把这类文件加载失败误判成组件或路由断言失败。
+- 验证:同一命令从真实路径执行应正常收集并运行测试。
+- 关联:`src/components/puzzle-clear-creation/PuzzleClearWorkspace.test.tsx`、`src/components/puzzle-clear-runtime/PuzzleClearRuntimeShell.test.tsx`、`src/routing/appPageRoutes.test.ts`。
+- 现象:新增或扩展 `*-generating` 页面后,生成卡只渲染首帧,`已耗时` / `预计等待` 停在进入页那一刻不动。
+- 原因:平台壳层的共享 `miniGameGenerationProgressNowMs` 时钟没有把新生成阶段纳入 tick 条件,或者该阶段的 `buildMiniGameDraftGenerationProgress(..., nowMs)` 没有接入同一时钟。
+- 处理:任何共享生成页都要通过平台壳层统一的时钟判断和 `nowMs` 传递刷新,新增生成阶段时要同时补 `selectionStage` 判定、`useEffect` 依赖和进度调用点。
+- 验证:浏览器里进入对应生成页后,`已耗时` / `预计等待` 应持续变化,不应停在首帧。
+
+## 拼消消要用真实可消除判断,不要把“已相邻”当成可解
+
+- 现象:拼消消开局或补牌后会直接出现已完成的图案组,或者 `1x2` 被当成半锁定局部留在场上。
+- 原因:早期把可解性写成“场上已经有同组相邻卡”或“只要有一对相邻同组卡就算可解”,这会把已完成盘面误当成合法盘面;同时半锁定规则没有排除 `1x2`。
+- 处理:开局和补牌后的重排必须先排除现成消除,再用真实交换 / 落位模拟判断是否会产生新消除;`1x2` 永远不进入半锁定组,半锁定只允许 `1x3`、`2x2`、`2x3`。
+- 验证:`npm run test -- src/services/puzzle-clear/puzzleClearLocalRuntime.test.ts src/components/puzzle-clear-runtime/PuzzleClearRuntimeShell.test.tsx` 与 `cargo test -p module-puzzle-clear --manifest-path server-rs/Cargo.toml -- --nocapture` 通过后,开局盘面不应直接出现 completed group。
+- 关联:`src/services/puzzle-clear/puzzleClearLocalRuntime.ts`、`server-rs/crates/module-puzzle-clear/src/application.rs`。
+## 推荐页作品 key 漏玩法会导致运行内容和标题作者错位
+
+- 现象:移动端推荐页进入跳一跳或敲木鱼等作品时,游戏运行内容已经切到当前作品,但下方标题、作者和头像仍显示第一条拼图或其它推荐作品。
+- 原因:平台壳层用 `getPlatformPublicGalleryEntryKey(...)` 写入 `activeRecommendEntryKey`,而 `RpgEntryHomeView` 内部的 `buildPublicGalleryCardKey(...)` 漏掉新玩法 `sourceType` 分支,导致当前 key 查不到条目后回退到推荐列表第一条。
+- 处理:推荐页和平台壳层的公开作品 key 规则必须复用 `buildPlatformPublicGalleryCardKey(...)`,覆盖同一批 `sourceType`,至少包括 `big-fish`、`puzzle`、`jump-hop`、`wooden-fish`、`match3d`、`square-hole`、`visual-novel`、`bark-battle` 和 `edutainment:`;新增玩法公开推荐流时先补这个共享 helper。
+- 验证:`npm run test -- src/components/rpg-entry/RpgEntryHomeView.recharge.test.tsx -t "mobile recommend meta matches active"` 应覆盖跳一跳和敲木鱼的当前运行内容、标题和作者一致。
+- 关联:`src/components/rpg-entry/RpgEntryHomeView.tsx`、`src/components/platform-entry/PlatformEntryFlowShellImpl.tsx`、`src/components/rpg-entry/RpgEntryHomeView.recharge.test.tsx`、`docs/【玩法创作】平台入口与玩法链路-2026-05-15.md`。
+
## 跳一跳飞行动画不要直接用最新 run 重绘地块窗口
- 现象:跳一跳松手后如果后端很快返回下一帧 run,地块窗口会立刻前移,角色翻腾动画看起来像没播放;若同时刷新图片资产,还可能被误认为地块频闪。
diff --git a/.hermes/shared-memory/project-overview.md b/.hermes/shared-memory/project-overview.md
index f1503f39..fdb0b314 100644
--- a/.hermes/shared-memory/project-overview.md
+++ b/.hermes/shared-memory/project-overview.md
@@ -1,6 +1,6 @@
# Genarrative 项目共享概览
-更新时间:`2026-05-29`
+更新时间:`2026-06-03`
## 一句话定位
@@ -10,6 +10,7 @@ Genarrative / 陶泥儿是一个 AI 原生互动内容与小游戏平台,把 A
- RPG / 自定义世界创作与运行时。
- 拼图玩法创作、草稿、发布、运行态和排行榜。
+- 拼消消玩法创作、素材图集生成、结果页、发布、统一作品详情、正式运行态和基础统计。
- 敲木鱼玩法创作、草稿、发布、运行态、公开详情和分享码。
- 抓大鹅 Match3D 创作、2D 多视角素材生成、发布和运行态。
- 大鱼吃小鱼、方洞挑战、视觉小说、汪汪声浪和儿童向寓教于乐玩法。
diff --git a/CONTEXT.md b/CONTEXT.md
index 160d1edd..233cde60 100644
--- a/CONTEXT.md
+++ b/CONTEXT.md
@@ -18,6 +18,32 @@ _Avoid_: 为每个玩法单独发明素材流水线、把系列素材建模成
## Language
+### Puzzle Clear
+
+**拼消消**:
+基于拼图交换 / 拖拽手感的新玩法模板,玩家移动 1x1 卡牌碎片,把同一复合图案组拼成完整矩形后消除,并由顶部对应纵列补牌继续游玩。
+_Avoid_: 拼图整图过关、三消槽位玩法、前端本地裁决
+
+**复合图案组**:
+拼消消中可被消除的一幅小图,由 `1x2`、`1x3`、`2x2` 或 `2x3` 的 1x1 卡牌碎片组成;只有组内碎片按正确相对位置拼成完整矩形后才消除。
+_Avoid_: 单张卡牌、整关大图、任意相邻同色块
+
+**1x1 卡牌碎片**:
+复合图案组被服务端切成的最小可移动单位,带有所属组、形状、组内坐标和图片资产。
+_Avoid_: 前端临时裁图、无所属图案的普通方块
+
+**半锁定拼接组**:
+非 2 格复合图案组中已经局部完成的拼接状态,可作为整体拖动;玩家用外部单格撞入组内某格时只交换该格,其余部分保留并退回半完成状态。
+_Avoid_: 永久锁死、补牌打散、完整消除
+
+**顶部卡牌准备区**:
+拼消消棋盘上方按纵列排列的背面卡牌队列;某列产生空位时,准备区对应列的卡牌从顶部下落补齐。
+_Avoid_: 全局随机发牌槽、底部三消槽
+
+**防死局发牌**:
+拼消消开局和每次补牌后由后端保证至少存在一步可拼接;补牌时至少有一张新掉落卡能与场上剩余某张卡对应。
+_Avoid_: 前端提示代替可解性、完全随机补牌
+
### Wooden Fish
**敲木鱼**:
diff --git a/docs/prd/【玩法创作】拼消消玩法模板PRD-2026-05-30.md b/docs/prd/【玩法创作】拼消消玩法模板PRD-2026-05-30.md
new file mode 100644
index 00000000..6adfb9a7
--- /dev/null
+++ b/docs/prd/【玩法创作】拼消消玩法模板PRD-2026-05-30.md
@@ -0,0 +1,77 @@
+# 拼消消玩法模板 PRD
+
+日期:`2026-05-30`
+
+## 目标
+
+新增玩法模板 **拼消消**,工程域与 `playId` 均为 `puzzle-clear`,公开作品码前缀为 `PC-`。拼消消以拼图的交换 / 拖拽手感为原型,但运行态规则独立:玩家移动 1x1 卡牌碎片,把同一复合图案组拼成完整矩形后消除;消除产生空位后,由顶部对应纵列的卡牌准备区下落补位。
+
+首版必须完成公开闭环:
+
+```text
+创作入口 -> 轻表单工作台 -> 独立生成页 -> 结果页 -> 试玩 -> 发布 -> 统一作品详情 -> 正式 runtime -> 基础统计 / 作品架 / 广场
+```
+
+## 创作工具平台接入声明
+
+- 工作台模式:表单 / 图片输入创作工作台。
+- 创作链路:入口 -> 工作台 -> 生成页 -> 结果页 -> 试玩 -> 发布 -> 运行态。
+- 单图资产槽位:
+ - `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 卡牌切片。
+ - `slotSpecs`:每个复合图案组一个 `patternGroup`,服务端预排 `groupId`、`shape`、atlas 坐标和 1x1 切片坐标。
+ - 切图规则:生图 prompt 只要求复合图案组能按 4x6 素材工作表均等切成 1x1 方形小份,不允许模型在图上绘制切分线、边框、网格线或裁切参考线;服务端按 sheet 布局直接裁出 1x1 卡牌碎片,校验每个编号占格数与领域图案组面积一致,再合成最终 atlas,写入 `patternGroups[]` 与 `cardAssets[]`。
+ - 透明化规则:首版保留完整方形卡面,不强制透明化;若 provider 输出带边框、切分线、网格、裁切参考线或文字,生成任务失败并回写审计。
+ - 失败回写:生成页写回 `generationStatus=failed` 与失败阶段;结果页保留重试入口。
+ - 局部重生成:v1 允许整批 4 张素材工作表重试,不做单组局部重生。
+- API 命名空间:`/api/creation/puzzle-clear/...` 与 `/api/runtime/puzzle-clear/...`。
+- 业务真相:草稿、发布、runtime snapshot、胜负、补牌、防死局、统计均由后端裁决;前端只做动画和交互表现。
+- 创作工具模式例外:无。
+- 验证命令:`npm run check:encoding`、`npm run typecheck`、`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`;涉及 SpacetimeDB schema 后运行 `npm run spacetime:generate`、`npm run check:spacetime-runtime-access`、`npm run check:spacetime-schema`、`npm run check:server-rs-ddd`。
+
+## 工作台字段
+
+| 字段 | 契约字段 | 默认值 | 校验 | 落库 |
+| --- | --- | --- | --- | --- |
+| 作品标题 | `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 | 生成编排参数 |
+
+规则参数不开放创作者编辑:棋盘尺寸、倒计时、消除次数、形状解锁、防死局发牌和半锁定规则固定。
+
+## 运行规则
+
+| 关卡 | 棋盘 | 目标消除 | 倒计时 | 解锁形状 |
+| --- | --- | --- | --- | --- |
+| 1 | 6x6 | 35 | 10 分钟 | 1x2、1x3、2x2、2x3 |
+
+- 开局每个小格子从背面翻向正面。
+- 可消除图由横向或纵向复合图案组组成,最小消除单位为两张图拼接。
+- 完成一个复合图案组后,该组所有 1x1 卡牌碎片消除。
+- 消除后空位按列由顶部卡牌准备区下落补齐。
+- 每次补牌至少保证掉落卡中有一张可以与场上剩余某张卡拼接,防止死局。
+- 非 2 格消除时,若场上已有局部完成的半锁定拼接组,补牌不得破坏它。
+- 半锁定拼接组可整体拖动;玩家用外部单格撞入组内某格时,只交换该格,组其余部分保留,组状态退回半完成。
+- 超时只判当前关失败,可重试当前关;完成 35 次目标并清空当前棋盘后整局完成。
+
+## 结果页
+
+结果页展示:素材 atlas、中央场地底图、发布状态、试玩入口和失败重试。结果页不写功能说明类文案,不开放规则编辑器,不新增排行榜配置。
+
+## 统计
+
+首版只记录正式 `published` run:
+
+- 开局。
+- 全局完成。
+- 当前关失败。
+- 耗时。
+- 消除统计。
+
+草稿试玩不写正式统计,不进入排行榜;v1 不做排行榜。
diff --git a/docs/prd/【玩法创作】敲木鱼玩法模板PRD-2026-05-20.md b/docs/prd/【玩法创作】敲木鱼玩法模板PRD-2026-05-20.md
index 6bf23f8e..218b02f0 100644
--- a/docs/prd/【玩法创作】敲木鱼玩法模板PRD-2026-05-20.md
+++ b/docs/prd/【玩法创作】敲木鱼玩法模板PRD-2026-05-20.md
@@ -205,10 +205,11 @@ WF-*
1. 若 payload 已包含上传/录音音频资产,`compile-draft` 跳过音效生成,直接持久化该资产;
2. 若 payload 已上传或录制音频,则直接写回 `hitSoundAsset`;
-3. 若两者都没有,后端写回默认木鱼音 `/wooden-fish/default-hit-sound.mp3`;
-4. 音效资产必须包含可播放地址、对象键、asset object id、来源和可选时长;
-5. 通用创作音频接口当前对 `wooden_fish` 的 `hit_sound` 目标返回 `410 Gone`,不得在创作流程中按提示词生成音效;
-6. `spacetime-client` 不得自行合成 `/generated-wooden-fish-assets/...` 音效占位路径;缺少真实 `hitSoundAsset` 时应使用默认木鱼音兜底展示与播放。
+3. 麦克风录制音频在保存前由前端自动裁掉开头连续静音段;上传音频不做裁剪,裁剪失败时保留原始录音继续保存;
+4. 若两者都没有,后端写回默认木鱼音 `/wooden-fish/default-hit-sound.mp3`;
+5. 音效资产必须包含可播放地址、对象键、asset object id、来源和可选时长;
+6. 通用创作音频接口当前对 `wooden_fish` 的 `hit_sound` 目标返回 `410 Gone`,不得在创作流程中按提示词生成音效;
+7. `spacetime-client` 不得自行合成 `/generated-wooden-fish-assets/...` 音效占位路径;缺少真实 `hitSoundAsset` 时应使用默认木鱼音兜底展示与播放。
### 6.3 封面
@@ -371,7 +372,7 @@ finish
音频播放:
-1. 前端使用小复音池;
+1. 前端使用 10 路小复音池;
2. 设置最小播放间隔,避免极端连点导致浏览器抖动;
3. 点击计数不能因为音频节流而丢失;
4. 签名 URL 未就绪时先静音表现,不请求裸 generated 私有路径。
diff --git a/docs/technical/【玩法创作】拼消消玩法模板技术方案-2026-05-30.md b/docs/technical/【玩法创作】拼消消玩法模板技术方案-2026-05-30.md
new file mode 100644
index 00000000..b66efa99
--- /dev/null
+++ b/docs/technical/【玩法创作】拼消消玩法模板技术方案-2026-05-30.md
@@ -0,0 +1,123 @@
+# 拼消消玩法模板技术方案
+
+日期:`2026-05-30`
+
+## 总体边界
+
+拼消消使用独立工程域 `puzzle-clear`,不复用拼图运行态规则本体。实现按 DDD 分层:
+
+- `module-puzzle-clear`:纯领域规则,覆盖图案组规划、棋盘、交换、半锁定、消除、补牌、防死局、关卡状态。
+- `shared-contracts` / `packages/shared`:工作台输入、生成素材、结果页、作品摘要、runtime snapshot 与 action DTO。
+- `spacetime-module`:session、work profile、runtime run、事件 / 统计、公开 source view。
+- `spacetime-client`:typed facade 与 row mapper。
+- `api-server`:Axum 路由、鉴权、入口熔断、生成编排、资产持久化、BFF。
+- `platform-image` / OSS / asset object:图片生成、切图、上传、换签和失败审计。
+- 前端:轻表单、生成页、结果页与 runtime 动画,不承接正式业务真相。
+
+## 资产生成方案
+
+素材目标从“单张超大 atlas 生图”收敛为 4 张素材工作表,再由服务端合成最终 atlas:
+
+- image2 调用:4 次,每次生成 1 张 `1024x1536` 竖版素材工作表。
+- sheet 裁切:每张按 `4 列 x 6 行` 裁切,每个 1x1 单元为 `256x256`。
+- 最终 atlas:服务端把 95 个切片按领域坐标合成 `10x10 / 2560x2560` PNG,空单元保留浅色背景。
+- 运行态素材:最终写回 `35` 个复合图案组和 `95` 个 1x1 卡牌切片;`sheet-03` 的第 6 行第 4 列为 `FILL` 补位格,只为填满 4x6 工作表,生成后会被服务端丢弃,不进入最终 atlas 或运行态卡牌。
+
+服务端固定布局如下:
+
+| 形状 | 数量 | 单组单元数 | 解锁 |
+| --- | ---: | ---: | --- |
+| 1x2 | 23 | 2 | 第 1 关 |
+| 1x3 | 5 | 3 | 第 1 关 |
+| 2x2 | 4 | 4 | 第 1 关 |
+| 2x3 | 3 | 6 | 第 1 关 |
+
+流程:
+
+```text
+主题词 / 场地底图主题词 / 用户底图 -> 4 张 sheet 坐标规划 -> gpt-image-2 生成素材工作表 -> 按 4x6 裁切 1x1 -> 合成最终 atlas -> atlas 与卡牌切片持久化 -> OSS / asset_object / bind -> session draft 回写
+```
+
+中央场地底图的 prompt 来源固定为:若用户填写 `boardBackgroundPrompt`,AI 生成底图只读取该字段;若该字段为空,才回退读取 `themePrompt`。用户直接上传底图资产时不再用主题词重写该资产,只执行平台资产持久化与换签。中央场地底图在运行态不是普通棋盘衬底,而是玩家逐渐消除卡牌后露出的主题目标图;生成请求使用与中央棋盘一致的 1:1 正方形尺寸,prompt 必须强调探索、揭开全貌、追求完成目标、精致主题主视觉和强主题表现,不写“画面干净”或“适合作为卡牌棋盘底图”。
+
+### 素材工作表风险与切片验证
+
+风险:4x6 工作表 prompt 仍需要告诉 provider 编号布局;如果模型把布局理解成 UI 海报、说明图或卡牌模板,可能画出文字、编号、边框、切分线、贴纸外框或重复主体。若 provider 无法严格按布局输出,切片后可能出现跨格、主体贴边、重复图案、文字或图案错位。
+
+验证策略:
+
+- 生图 prompt 明确要求照片式构图 / 绘本式渲染的主题微场景拼图卡,每个 256x256 单元本身就是一张完整的单场景照片裁片,单元内部只能有一个连续画面,禁止出现两张照片、两个不同场景、拼接线、分割线、内部竖切、内部横切、左右 / 上下两块不同背景,场景变化只能发生在 256 单元边界上。
+- 同编号连续格表示同一视觉家族,不是随机独立小图;同组格子要共享同一场景锚点、主色和道具语言,像同一套连拍或同一场景的不同局部,彼此能看出是同一个故事或场景家族。
+- 同一张 sheet 内不同编号必须发散成不同视觉概念;以水果为例,应扩展为果园、集市摊位、野餐布、果汁杯、厨房案板、甜品盘、篮筐、玻璃罐、窗边餐桌、花园背景等微场景,禁止同品种主体换角度、换大小或换姿势后重复出现。
+- 每个 256x256 小卡切片独立查看时也要有可辨识的背景纹理、桌面、草地、天空、建筑、布料、器皿、叶片、阴影或装饰元素,避免“孤立主体 + 纯色背景”导致运行态难区分。
+- 生图 prompt 明确禁止文字、水印、UI、边框标签、切分线、网格线、裁切参考线、纯色背景、白底商品图、孤立主体、同品种重复和同一物体多角度。
+- 复合图案组本身不画任何可见分割辅助线,但 prompt 必须说明每个 `1x2`、`1x3`、`2x2`、`2x3` 图案都能被服务端按等大的 1x1 方形单元切分;纵向 `1x2` 按横向切线分成两个 1x1,横向 `1x2` 按纵向切线分成两个 1x1,其他形状同理。图案组可以在语义上成组,但不能把一张大图的照片边界或拼贴边界落在单个 1x1 单元内部。
+- 服务端保留 `PuzzleClearPatternGroup` 坐标清单,切片前校验每个 sheet 正式编号出现次数等于领域图案组 `width * height`,并要求同编号区域是完整连续矩形;`FILL` 补位格不参与校验、切片、atlas 合成和运行态。
+- 每张 sheet 生成后、正式切片前执行像素级质量门禁:非空格必须达到最低前景占比,空白格前景占比不得超阈值,单格内部明显人工拼贴式分割需要硬失败;内部强边缘检测必须同时满足“贯穿大部分高度或宽度”和“两侧近似低纹理平铺色块”,避免把照片式微场景里的窗框、桌沿、地平线等自然结构误杀。非同组边界前景贴边仅记录为质量提示,不作为硬失败,避免把模型正常铺满主体的图集误杀。
+- 每张 sheet 生成最多尝试 4 次;除质量门禁失败外,VectorEngine 返回 `retryable=true` 的 `502`、`504`、`429` 或请求超时也应消耗下一次 sheet attempt,避免上游 nginx 偶发 502 或单次拼贴式坏图直接把草稿置为 failed。
+- 前端拼消消创作 action 的请求等待窗口为 40 分钟,用于覆盖 VectorEngine 单张图偶发 10 分钟以上的慢返回;这只是本地验收稳定性兜底,后续若继续优化体验,应把素材生成迁到后台任务 / 轮询进度链路。
+- sheet 多次生成仍未通过硬质量门禁时,生成任务进入 `failed` 并写入错误原因;不得把明显空白格污染或主体缺失的工作表切成正式卡牌资产。
+- 首版若当前 provider 无法稳定产出可切 atlas,生成任务进入 `failed`,错误写入审计;不得退回前端假素材或绕过平台资产底座。
+- 草稿编译和作品发布都必须拒绝缺失 atlas、缺失卡牌切片、空 `assetObjectId` / `imageObjectKey` 或 `placeholder` 占位资产;`spacetime-client` 不再为编译请求合成默认 atlas / card assets。
+- 技术回退需要用户确认后才能改成更多 sheet、降低切片规格或改为逐图生成;当前需求固定为 4 张 `1024x1536` sheet 与最终 `2560x2560` atlas。
+
+## 领域规则
+
+`module-puzzle-clear` 已固定以下规则:
+
+- 关卡配置:单关 `6x6/35`,600 秒。
+- 图案组配比:`1x2=23`、`1x3=5`、`2x2=4`、`2x3=3`。
+- 开局随机铺满并保证至少一步可解。
+- 补牌按列重力下落;补牌后仍保证至少一步可解。
+- 完整图案组消除并清空对应格。
+- 半锁定拼接组只由玩家主动交换 / 撞入打散,补牌不破坏。
+- 超时失败只作用于当前单关,可重试;完成 35 次消除目标并清空棋盘后整局完成。
+
+## API 命名空间
+
+- `POST /api/creation/puzzle-clear/sessions`
+- `GET /api/creation/puzzle-clear/sessions/{sessionId}`
+- `POST /api/creation/puzzle-clear/sessions/{sessionId}/actions`
+- `GET /api/creation/puzzle-clear/works`
+- `GET /api/creation/puzzle-clear/works/{profileId}`
+- `POST /api/creation/puzzle-clear/works/{profileId}/publish`
+- `GET /api/runtime/puzzle-clear/works/{profileId}`
+- `POST /api/runtime/puzzle-clear/runs`
+- `POST /api/runtime/puzzle-clear/runs/{runId}/swap`
+- `POST /api/runtime/puzzle-clear/runs/{runId}/retry-level`
+- `POST /api/runtime/puzzle-clear/runs/{runId}/next-level`
+- `POST /api/runtime/puzzle-clear/runs/{runId}/time-up`
+
+api-server 路由熔断使用 SpacetimeDB 创作入口配置 `puzzle-clear`,不得新增前端硬编码事实源。
+
+## Runtime 事件与统计载荷
+
+正式 `published` run 记录开局、全局完成、当前关失败、耗时和消除统计。runtime action 返回的终态事件包括:
+
+- `run-finished`:第 1 关完成并结束整局,结果 JSON 至少包含 `status`、`level`、`clears`、`clearDelta`、`elapsedMs`。
+- `level-failed`:当前关超时失败,结果 JSON 至少包含 `status`、`level`、`clears`、`clearDelta`、`elapsedMs`。
+
+草稿试玩只消费同一份 snapshot/action 结果做表现,不写正式统计。
+
+## 前端阶段
+
+新增阶段:
+
+- `puzzle-clear-workspace` -> `/creation/puzzle-clear`
+- `puzzle-clear-generating` -> `/creation/puzzle-clear/generating`
+- `puzzle-clear-result` -> `/creation/puzzle-clear/result`
+- `puzzle-clear-runtime` -> `/runtime/puzzle-clear`
+
+runtime 移动端优先,首屏结构为顶部倒计时 / 单关铭牌、顶部列准备区、棋盘、失败 / 完成弹层。棋盘主网格、半锁定组覆盖层和消除 / 掉落覆盖层统一使用 1.5px 格间距。动画包括开场翻转、局部正确拼合高光、完整消除放大淡出和列补牌延迟下落,不再有下一关切换。消除和补牌动画只能作为当前后端 snapshot 的表现层覆盖;已有场上卡片因重力下沉后的最终格不得被旧消除坐标或掉落覆盖层隐藏,避免出现“下方空位但上方卡片未下落”的视觉假象;新补入卡牌应等完整消除淡出进入尾段后再播放下落反馈。
+
+## 验证计划
+
+- `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`
+- `cargo test -p spacetime-client --manifest-path server-rs/Cargo.toml puzzle_clear_compile_requires_real_atlas_assets_from_api_server`
+- `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`
+- `npm run test -- src/routing/appPageRoutes.test.ts src/services/publicWorkCode.test.ts`
+- `npm run check:encoding`
+- `npm run typecheck`
+- 接入 SpacetimeDB schema 后:`npm run spacetime:generate`、`npm run check:spacetime-runtime-access`、`npm run check:spacetime-schema`、`npm run check:server-rs-ddd`
diff --git a/docs/【后端架构】server-rs与SpacetimeDB数据契约-2026-05-15.md b/docs/【后端架构】server-rs与SpacetimeDB数据契约-2026-05-15.md
index a5fc8c2b..883e6663 100644
--- a/docs/【后端架构】server-rs与SpacetimeDB数据契约-2026-05-15.md
+++ b/docs/【后端架构】server-rs与SpacetimeDB数据契约-2026-05-15.md
@@ -334,7 +334,7 @@ npm run check:server-rs-ddd
- Rust 结构体:`CreationEntryConfig`
- 源码:`server-rs/crates/spacetime-module/src/runtime/creation_entry_config.rs`
- 字段:`config_id`、`start_title`、`start_description`、`start_idle_badge`、`start_busy_badge`、`modal_title`、`modal_description`、`updated_at`、`event_title`、`event_description`、`event_cover_image_src`、`event_prize_pool_mud_points`、`event_starts_at_text`、`event_ends_at_text`、`event_banners_json`。
-- 迁移兼容:旧迁移包缺少活动横幅字段时,由 `migration.rs` 写入 `None` / `58000` 默认值;旧库缺少 `event_banners_json` 时写入 `None`,运行态读取层再按 `module-runtime` 默认公告数组归一,不覆盖后台已保存配置。HTTP 响应同时返回 `eventBanners` 数组和旧 `eventBanner` 单条兼容字段,前端优先消费数组;后台新配置主格式为 HTML 公告字符串数组或 `{title, htmlCode}` 对象数组,旧结构化 banner 字段仅保留兼容。
+- 迁移兼容:旧迁移包缺少活动横幅字段时,由 `migration.rs` 写入 `None` / `58000` 默认值;旧库缺少 `event_banners_json` 时写入 `None`,运行态读取层再按 `module-runtime` 默认公告数组归一,不覆盖后台已保存配置,也不把旧结构化 `eventBanner` 升格为前端优先数组。HTTP 响应同时返回 `eventBanners` 数组和旧 `eventBanner` 单条兼容字段,前端优先消费数组;后台新配置主格式为 HTML 公告字符串数组或 `{title, htmlCode}` 对象数组,旧结构化 banner 字段仅保留兼容。默认公告背景和旧结构化默认 `coverImageSrc` 必须引用 `public/` 下真实存在的静态资源,当前为 `/creation-type-references/puzzle.webp`。
### `creation_entry_type_config`
@@ -632,6 +632,45 @@ npm run check:server-rs-ddd
- Rust 结构体:`PuzzleWorkProfileRow`
- 源码:`server-rs/crates/spacetime-module/src/puzzle.rs`
+### `puzzle_clear_agent_session`
+
+- Rust 结构体:`PuzzleClearAgentSessionRow`
+- 源码:`server-rs/crates/spacetime-module/src/puzzle_clear/tables.rs`
+- 说明:拼消消创作会话表,保存轻表单草稿、生成状态、已发布 profile 关联和更新时间;只由拼消消 procedure 读写。
+
+### `puzzle_clear_work_profile`
+
+- Rust 结构体:`PuzzleClearWorkProfileRow`
+- 源码:`server-rs/crates/spacetime-module/src/puzzle_clear/tables.rs`
+- 说明:拼消消作品 profile 表,保存中央底图资产、4 张素材工作表切片后合成的最终 atlas、35 个复合图案组、95 个 1x1 卡牌切片、卡背占位图、发布状态、可见性和基础 play count;公开列表 / 详情只通过 read model 消费,不让前端直接订阅源表。
+- 字段变更:`visible` 控制是否进入公开列表 / 详情,默认 `true`;旧迁移数据由 `migration.rs` 补默认值。
+
+### `puzzle_clear_runtime_run`
+
+- Rust 结构体:`PuzzleClearRuntimeRunRow`
+- 源码:`server-rs/crates/spacetime-module/src/puzzle_clear/tables.rs`
+- 说明:拼消消正式 runtime run 表,保存当前关卡、已消除次数、棋盘 snapshot、开始 / 完成时间和 run 状态;正式胜负、重试、完成、超时和交换结果以后端 procedure 裁决为准。
+
+### `puzzle_clear_event`
+
+- Rust 结构体:`PuzzleClearEventRow`
+- 源码:`server-rs/crates/spacetime-module/src/puzzle_clear/tables.rs`
+- 说明:拼消消基础 runtime 事件表,记录 published run 的开局、关卡完成、全局完成、失败、超时和消除统计来源;首版不做排行榜。
+
+### SpacetimeDB view:`puzzle_clear_gallery_view`
+
+- Rust view:`puzzle_clear_gallery_view`
+- 返回类型:`Vec`
+- 源码:`server-rs/crates/spacetime-module/src/puzzle_clear.rs`
+- 说明:拼消消公开详情 source 投影,只暴露 `publication_status = published` 且 `visible = true` 的作品,包含 atlas、底图、图案组和卡牌切片等详情级字段;统一公开详情主路径通过 `public_work_detail_entry` 消费该 view,只保留平台详情页展示摘要。
+
+### SpacetimeDB view:`puzzle_clear_gallery_card_view`
+
+- Rust view:`puzzle_clear_gallery_card_view`
+- 返回类型:`Vec`
+- 源码:`server-rs/crates/spacetime-module/src/puzzle_clear.rs`
+- 说明:拼消消公开列表 source 投影,只暴露平台卡片需要的公开字段;统一公开列表主路径通过 `public_work_gallery_entry` 消费该 view,`/api/runtime/puzzle-clear/gallery` 保留玩法专属 HTTP shape。
+
### SpacetimeDB view:`puzzle_gallery_view`
- Rust view:`puzzle_gallery_view`
@@ -661,6 +700,7 @@ npm run check:server-rs-ddd
- `SELECT * FROM public_work_detail_entry`
- `SELECT * FROM bark_battle_gallery_view`
- `SELECT * FROM puzzle_gallery_card_view`
+- `SELECT * FROM puzzle_clear_gallery_card_view`
- `SELECT * FROM jump_hop_gallery_card_view`
- `SELECT * FROM wooden_fish_gallery_card_view`
- `SELECT * FROM custom_world_gallery_entry`
@@ -677,6 +717,7 @@ npm run check:server-rs-ddd
- `SELECT * FROM public_work_play_daily_stat WHERE source_type = 'visual-novel'`
- `SELECT * FROM public_work_play_daily_stat WHERE source_type = 'big-fish'`
- `SELECT * FROM public_work_play_daily_stat WHERE source_type = 'bark-battle'`
+- `SELECT * FROM public_work_play_daily_stat WHERE source_type = 'puzzle-clear'`
- `SELECT * FROM creation_entry_config`
- `SELECT * FROM creation_entry_type_config`
- `SELECT * FROM asset_object`
diff --git a/docs/【开发运维】本地开发验证与生产运维-2026-05-15.md b/docs/【开发运维】本地开发验证与生产运维-2026-05-15.md
index e2e05bd8..16e7939c 100644
--- a/docs/【开发运维】本地开发验证与生产运维-2026-05-15.md
+++ b/docs/【开发运维】本地开发验证与生产运维-2026-05-15.md
@@ -1,4 +1,4 @@
-# 本地开发验证与生产运维
+# 本地开发验证与生产运维
更新时间:`2026-06-05`
@@ -51,6 +51,8 @@ Linux 本机多用户并发开发时,`npm run dev` 和 `npm run dev:*` 单模
开发态 `npm run dev` 与 `npm run dev:api-server` 会默认注入 `GENARRATIVE_DEV_PASSWORD_ENTRY_AUTO_REGISTER_ENABLED=true`,因此密码登录在本地开发环境可直接注册未知手机号账号;生产环境仍按 `api-server` 配置默认关闭该开关。
+本地只做账号/UI smoke 且需要短信登录时,`SMS_AUTH_PROVIDER` 应显式设为 `mock`,并把 `SMS_AUTH_MOCK_VERIFY_CODE` 设为固定值(当前常用 `123456`),再重启 `npm run dev` 或 `npm run dev:api-server`。如果 `.env.local` 还保留 `SMS_AUTH_PROVIDER=aliyun`,`POST /api/auth/phone/login` 用 mock 验证码会稳定报“验证码错误”,不是前端表单问题。真实短信联调再切回 `aliyun` 并重启。
+
微信小程序虚拟支付使用 `WECHAT_MINI_PROGRAM_VIRTUAL_PAYMENT_OFFER_ID`、`WECHAT_MINI_PROGRAM_VIRTUAL_PAYMENT_APP_KEY`、`WECHAT_MINI_PROGRAM_VIRTUAL_PAYMENT_SANDBOX_APP_KEY` 和 `WECHAT_MINI_PROGRAM_VIRTUAL_PAYMENT_ENV` 配置。小程序充值统一走 `wechat_mp_virtual` / `wx.requestVirtualPayment`:泥点属于代币(`coin`),`buyQuantity` 按当前充值商品快照里的 `points_amount` 传;会员和后台新增道具类商品走 `short_series_goods`,`productId` 对应微信后台道具 ID。旧登录快照若缺 `session_key`,需要用户在小程序内重新登录后再支付;客户端成功回调不是最终到账,仍以后端通知或查询确认订单为准。详细口径见 `docs/【技术方案】微信虚拟支付接入-2026-05-26.md`。
如果本地 `GET /api/creation-entry/config` 返回 `No such procedure`,或 `api-server` 日志出现 `no such table: puzzle_gallery_card_view` / `no such table: wooden_fish_gallery_card_view` 这类公开 view 缺失,通常是 `.env.local` 指向的 SpacetimeDB 库还没有发布当前 `spacetime-module`,或当前 CLI 身份无权发布该库。debug 构建的 `api-server` 会临时使用后端默认入口配置兜底,避免创作作品架整块消失;正式修复仍应切换到拥有目标库权限的 SpacetimeDB 身份后重新运行 `npm run dev` 完成发布,或用 gitignored 的 `spacetime.local.json` 指向可发布的本地库。
@@ -71,6 +73,8 @@ spacetime sql "SELECT * FROM puzzle_gallery_card_view LIMIT 1" --serv
VectorEngine 图片生成 / 编辑在 `request_send` 阶段出现 `timeout` 或 `connect` 错误时,`platform-image` 会对同一请求最多发送 3 次;multipart 图片编辑每次重试都会重新构造 form,避免复用已消费的 body。日志中 `VectorEngine 图片请求发送失败,准备重试` 表示本次失败已进入下一次尝试;最终仍失败时才会写入 `external_api_call_failure` 并返回 504。排查生产失败时应同时统计 retry 前的尝试日志和最终 audit,避免把一次用户请求内的多次发送误判成多个用户请求。
+拼图入口直创的 `compile_puzzle_draft` 是长耗时链路:后端会先快速编译草稿并返回 `image_refining` / `generating` 快照,然后在 api-server 后台任务中完成首图、UI 资产、OSS 持久化、作品投影、计费退款和失败态回写。生产排查小程序 `Failed to fetch` 时,若 Nginx access log 里 action POST 是 `499`、`upstream_status=-`,说明客户端或 WebView 先断开;此时不应再把长 POST 是否返回作为生成成败依据,而应继续按实际 `session_id` 查后台任务日志、VectorEngine provider 日志、`external_api_call_failure` 和后续 GET 轮询结果。同一用户可能先轮询旧的 `puzzle-session-*`,随后 POST 新建实际生成 session;必须用 action POST 的 `request_id` 和 `/api/runtime/puzzle/agent/sessions//actions` 路径对齐真实失败请求,避免被前端显示的“来源草稿”误导。
+
查看本地 Rust / SpacetimeDB 日志:
```bash
@@ -353,7 +357,7 @@ cargo test -p platform-auth --manifest-path server-rs/Cargo.toml aliyun_send_sms
- `profile_task_reward_claim`
- `profile_wallet_ledger`
-个人任务首版 scope 仅支持 `user`。后台、RPG、大鱼吃小鱼、Visual Novel、Story、Combat 等特定链路按 tracking 中间件排除规则处理;作品游玩统一使用 `work_play_start`。
+个人任务首版 scope 仅支持 `user`。每日登录任务按北京时间自然日 0 点重置;用户已登录并停留在“我的”页跨日时,前端需要先非阻断调用 refresh session 以写入新业务日 `daily_login`,再请求 `/api/profile/tasks` 刷新任务中心。后台、RPG、大鱼吃小鱼、Visual Novel、Story、Combat 等特定链路按 tracking 中间件排除规则处理;作品游玩统一使用 `work_play_start`。
外部 API 失败审计复用 `tracking_event`,不新增表。失败事件优先写入本机 tracking outbox,再由后台 worker 批量落库;如果 outbox 因权限、磁盘或保护阈值不可写,会回退同步直写 SpacetimeDB。`metadata_json` 包含 endpoint、operation、failureStage、statusCode、statusClass、timeout、retryable、errorMessage、errorSource、latencyMs、promptChars、referenceImageCount、imageModel、rawExcerpt、userId、profileId 和 requestId;其中 `userId` 是触发生成的用户,`profileId` 是调用方传入的草稿 / 作品 / 场景作用域,`requestId` 用于回查同一次 HTTP 请求日志,入口拿不到上下文时允许为空。常用查询:
diff --git a/docs/【玩法创作】平台入口与玩法链路-2026-05-15.md b/docs/【玩法创作】平台入口与玩法链路-2026-05-15.md
index 2b1ee83d..9f950b43 100644
--- a/docs/【玩法创作】平台入口与玩法链路-2026-05-15.md
+++ b/docs/【玩法创作】平台入口与玩法链路-2026-05-15.md
@@ -1,12 +1,14 @@
# 平台入口与玩法链路
-更新时间:`2026-06-03`
+更新时间:`2026-06-04`
## 平台创作入口
创作入口配置事实源在 SpacetimeDB,通过 `GET /api/creation-entry/config` 下发;后台通过 `/admin/api/creation-entry/config` 管理。前端只在展示层派生可见卡片和入口状态,`api-server` 路由熔断也使用同一份配置。不要恢复前端硬编码入口配置文件。
-当前点击底部加号进入的创作入口页承载后台公告位、创作入口页签和两列模板卡;页签中只有真实后端作品架摘要存在时才展示“最近创作”,其余为玩法模板分类。点击模板卡后直接进入对应玩法已有的入口创作表单 stage,不再经过空白占位页,也不把旧表单嵌进创作入口页。移动端创作入口页顶栏在 `陶泥儿` 品牌同一行显示真实账户泥点数,数据来自 `profileDashboard.walletBalance`,不得再把公告内容或活动奖池当作账号余额展示。创作入口页公告位数据优先读取 `GET /api/creation-entry/config` 的 `eventBanners` 数组,多条配置时前端自动轮播,旧 `eventBanner` 仅作为单条兼容兜底。后台公告配置面向表单:每条公告包含标题和 HTML 内容,后台保存时序列化为后端 `eventBannersJson` 传输字段,由前端空权限沙箱 iframe 展示;旧结构化 banner 字段仅保留回显兼容,不再作为后台公告配置主格式;不得执行 JSX 或把后台代码直接注入 DOM。玩法列表不再套外部边框卡片,移动端需要压缩横向边距和两列间距;玩法卡统一按“上图、左上状态标签(仅非开放态显示)、封面右下 `10-20泥点数`、下方白底标题/描述”结构展示,卡片高度保持紧凑但标题、描述和预估消耗点数都必须可见。创作入口页根容器不再使用 `platform-page-stage` 这类全局内容卡片壳,但继续保留 `platform-remap-surface` 作为主题和输入框样式命中钩子。创作入口页字号需要对齐平台普通 UI 档位:顶栏泥点组件、公告正文、分类 Tab 和玩法卡标题 / 副标题 / 消耗说明优先使用 `11px` 到 `14px`,不使用 `text-lg`、`text-xl` 或更大的展示级字号。草稿 Tab 继续承接作品架;底部加号入口页的“最近创作”只用 7 天内的真实后端作品架摘要判断是否展示,并从摘要里推导最近使用过的模板 ID,页面必须展示“仅显示最近7天内使用过的模板”提示,列表内容必须复用其它页签里的模板卡样式、文案和点击行为,不展示具体作品名称、摘要或生成状态,也不新增独立最近创作卡组件。RPG、RPG 之外的各玩法入口分别落到既有的 `agent-workspace`、`big-fish-agent-workspace`、`match3d-agent-workspace`、`square-hole-agent-workspace`、`jump-hop-workspace`、`wooden-fish-workspace`、`puzzle-agent-workspace`、`bark-battle-workspace`、`visual-novel-agent-workspace`、`baby-object-match-workspace`,这些入口继续承接各玩法自己的表单、草稿恢复和后续编排,不作为创作入口页内容。
+当前点击底部加号进入的创作入口页承载后台公告位、创作入口页签和两列模板卡;页签中只有真实后端作品架摘要存在时才展示“最近创作”,其余为玩法模板分类。点击模板卡后直接进入对应玩法已有的入口创作表单 stage,不再经过空白占位页,也不把旧表单嵌进创作入口页。移动端创作入口页顶栏在 `陶泥儿` 品牌同一行显示真实账户泥点数,数据来自 `profileDashboard.walletBalance`,不得再把公告内容或活动奖池当作账号余额展示。创作入口页公告位数据优先读取 `GET /api/creation-entry/config` 的 `eventBanners` 数组,多条配置时前端自动轮播;旧 `eventBanner` 只保留字段回显与旧客户端兼容,不再作为前端公告数组的兜底来源。后台公告配置面向表单:每条公告包含标题和 HTML 内容,后台保存时序列化为后端 `eventBannersJson` 传输字段,由前端空权限沙箱 iframe 展示;旧结构化 banner 字段仅保留回显兼容,不再作为后台公告配置主格式;不得执行 JSX 或把后台代码直接注入 DOM。玩法列表不再套外部边框卡片,移动端需要压缩横向边距和两列间距;玩法卡统一按“上图、左上状态标签(仅非开放态显示)、封面右下 `10-20泥点数`、下方白底标题/描述”结构展示,卡片高度保持紧凑但标题、描述和预估消耗点数都必须可见。创作入口页根容器不再使用 `platform-page-stage` 这类全局内容卡片壳,但继续保留 `platform-remap-surface` 作为主题和输入框样式命中钩子。创作入口页字号需要对齐平台普通 UI 档位:顶栏泥点组件、公告正文、分类 Tab 和玩法卡标题 / 副标题 / 消耗说明优先使用 `11px` 到 `14px`,不使用 `text-lg`、`text-xl` 或更大的展示级字号。草稿 Tab 继续承接作品架;底部加号入口页的“最近创作”只用 7 天内的真实后端作品架摘要判断是否展示,并从摘要里推导最近使用过的模板 ID,页面必须展示“仅显示最近7天内使用过的模板”提示,列表内容必须复用其它页签里的模板卡样式、文案和点击行为,不展示具体作品名称、摘要或生成状态,也不新增独立最近创作卡组件。RPG、RPG 之外的各玩法入口分别落到既有的 `agent-workspace`、`big-fish-agent-workspace`、`match3d-agent-workspace`、`square-hole-agent-workspace`、`jump-hop-workspace`、`wooden-fish-workspace`、`puzzle-agent-workspace`、`bark-battle-workspace`、`visual-novel-agent-workspace`、`baby-object-match-workspace`,这些入口继续承接各玩法自己的表单、草稿恢复和后续编排,不作为创作入口页内容。
+
+旧库或旧迁移包没有 `event_banners_json` 时,后端读取层必须把 `eventBanners` 归一到 `module-runtime` 默认公告数组,不能把旧结构化 `eventBanner` 当成前端优先数组下发。默认公告引用的背景图必须指向 `public/` 下真实存在的站内静态资源,当前默认使用 `/creation-type-references/puzzle.webp`,避免创作入口顶部 banner 出现失效图片。
创作页和草稿页顶栏右上角的泥点余额胶囊是补足泥点入口:如果当前运行环境开启充值入口,点击后直接打开账户充值弹窗;否则直接打开运营兑换码弹窗。该入口不再跳到账户面板或泥点账单,头像 / 设置等账号入口继续保留各自语义。
@@ -44,25 +46,25 @@
通用系列素材图集能力的实现真相源在 `platform-image::generated_asset_sheets`:`n` 是必选参数,模块负责组装 `n*n` sheet prompt、按 `n*n` 切片、默认绿幕 / 近白底透明化、导出 PNG 和 OSS 持久化请求;高风险撞色玩法可显式使用专用 key 色、关闭近白扣除并限制为边缘连通背景扣除。`api-server::generated_asset_sheets` 只保留 `AppError` / `AppState` 适配,不再承载图像处理和 OSS 请求构造细节。物品名称 prompt 和特殊设定 prompt 是可选输入;调用方可传入类似“每个物品生成五个不同视图”的视角约束,通用模块会把 sheet prompt、物品行 prompt、特殊设定 prompt 编码写入 OSS 元数据。玩法仍负责计费、物品规划、slot 映射、失败回写和把通用切片结果映射回自己的草稿 / profile / runtime 字段。
-当前所有玩法生成页 UI 统一收敛为圆环主视觉:`media/create_bg_video.mp4` 作为生成页固定全屏背景层循环静音播放,主进度圆环居中覆盖在背景之上,围绕陶泥儿视觉展示;页面只保留当前步骤名称和当前步骤进度,不再渲染步骤列表块。视频层需要显式触发播放,不能只依赖 `autoPlay/loop/muted` 属性。圆环内部保持 `400x400` SVG 坐标系,外层显示宽度以 `400px` 为上限,窄屏按视口宽度收缩,预计等待 / 已耗时信息卡在窄屏下落到圆环下方,避免右侧裁切。共用生成页 `CustomWorldGenerationView` 和汪汪声浪生成页都必须遵循这一口径。
+当前所有玩法生成页 UI 统一收敛为圆环主视觉:`media/create_bg_video.mp4` 作为生成页固定全屏背景层循环静音播放,主进度圆环居中覆盖在背景之上,围绕陶泥儿视觉展示;页面只保留当前步骤名称和当前步骤进度,不再渲染步骤列表块,也不再展示“当前拼图信息”“当前敲木鱼信息”“当前世界信息”等玩法设定信息模块。视频层需要显式触发播放,不能只依赖 `autoPlay/loop/muted` 属性。圆环内部保持 `400x400` SVG 坐标系,外层显示宽度以 `400px` 为上限,窄屏按视口宽度收缩,预计等待 / 已耗时信息卡在窄屏下落到圆环下方,和当前步骤卡保持更大的垂直间距;预计等待左边缘、已耗时右边缘必须分别与当前步骤卡左右边缘对齐,避免右侧裁切或横向漂移。生成页顶部返回栏和状态标识不参与内容滚动,滚动只发生在进度内容区。共用生成页 `CustomWorldGenerationView` 和汪汪声浪生成页都必须遵循这一口径。
## 草稿与作品架
1. 草稿页作品卡对齐发现页列表卡风格:左侧信息,右侧封面图,移动端单列,桌面两到三列。
2. 草稿页顶部 `全部 / 草稿 / 已发布` 筛选与发现页 `推荐 / 今日 / 分类 / 排行` 频道标签复用同一选中 / 未选中视觉,即 `platform-mobile-home-channel` 与 `platform-mobile-home-channel--active`,不再使用旧 `platform-tab` 胶囊样式。
-3. 草稿页与底部导航的未读提示点统一使用平台暖棕色点和暖棕光晕,不再使用红点或红色 glow;草稿 Tab 作品架卡片无论草稿 / 已发布都不外露作者信息;已发布作品卡右上角直接显示无边框分享 icon。删除等破坏性动作在作品卡上也要直接开放独立删除入口,左滑或长按仅作为辅助操作层。
+3. 草稿页与底部导航的未读提示点统一使用平台暖棕色点和暖棕光晕,不再使用红点或红色 glow;草稿 Tab 作品架卡片无论草稿 / 已发布都不外露作者信息;已发布作品卡右上角直接显示无边框分享 icon。删除等破坏性动作在作品卡上也要直接开放统一 `actions.delete` 入口,左滑、长按和键盘左箭头仅作为打开同一操作层的辅助交互;所有玩法草稿和已发布列表项都必须通过该统一接口接入删除确认、删除中状态和列表刷新,不允许只给拼图保留专属滑动删除分支。
4. 生成中作品在整卡上加等待遮罩,但不移除作品基础信息。
5. 生成中状态不能只存在前端内存 notice。后端作品摘要必须下发可恢复的 `generationStatus`;前端刷新或退出产品后,作品架优先用摘要状态恢复等待遮罩,本轮内存 notice 只作为即时反馈。
6. 点击 `generationStatus=generating` 的草稿卡必须恢复对应玩法的生成进度页,不能进入空白结果页或普通工作区;恢复生成页的 `startedAtMs` 优先使用后端 session 的 `updatedAt`,没有 session 时再使用作品摘要 `updatedAt`,不得因重新进入页面从 0 秒重新计时。
-7. 生成失败必须按 session 独立记录,不能用一个失败打断或覆盖同玩法的其它生成任务。失败 notice 需要保存错误消息并覆盖作品架本地状态:即使后端摘要暂时仍是 `generationStatus=generating` 或只写出半成品投影,草稿卡也不得继续显示“生成中”,点击后必须进入失败 / 重试生成页,不能重新创建一轮生成;拼图这类失败半成品若没有有效 `workTitle`,作品架标题回退为“拼图草稿”,不暴露“第1关”空壳。
+7. 生成失败必须按 session 独立记录,不能用一个失败打断或覆盖同玩法的其它生成任务。失败 notice 需要保存错误消息并覆盖作品架本地状态:即使后端摘要暂时仍是 `generationStatus=generating` 或只写出半成品投影,草稿卡也不得继续显示“生成中”,点击后必须进入失败 / 重试生成页,不能重新创建一轮生成。失败页点击重新生成时必须优先复用当前可恢复 `sessionId` 执行编译 action;只有没有可恢复 session 时才允许回退到新建草稿。拼图这类失败半成品若没有有效 `workTitle`,作品架标题回退为“拼图草稿”,不暴露“第1关”空壳。
8. 从草稿 Tab 作品架打开草稿工作区、生成页或结果页时,返回按钮必须回到草稿 Tab 的同一作品架语境;从创作 Tab 新建或直接进入创作链路时才回到创作 Tab。平台壳层需要显式记录本次创作流的返回来源,不能让结果页返回动作固定跳到创作入口。
9. 私有 generated 图片必须通过 `ResolvedAssetImage` / `/api/assets/read-url` 换签读取。
10. 敲木鱼作品架读取当前用户作品列表时走 `GET /api/creation/wooden-fish/works`;发布成功后平台壳必须同时刷新作品架与公开广场,避免作品刚发布时仍停留在旧列表。
11. 移动端草稿页整体禁止长按选择文字,避免误触系统选区;输入框、文本域和可编辑区域仍必须保留文本选择能力。
-发现页 / 推荐页公开作品卡的作者行只显示公开昵称或账号生成的脱敏手机号;不得把纯 `SY-*` 陶泥号或作品号当作卡片作者名。陶泥号搜索、作品号复制和完整作品身份只在搜索、详情页或明确的复制入口展示,避免卡片列表暴露额外账号标识。
+发现页 / 推荐页公开作品卡的作者行只显示可读公开昵称;不得把手机号掩码、账号生成的脱敏手机号、`SY-*` 陶泥号或作品号拼接进卡片作者名。陶泥号搜索、作品号复制和完整作品身份只在搜索、详情页或明确的复制入口展示,避免卡片列表暴露账号标识。推荐页运行态、标题和作者信息必须使用同一套公开作品 key 选中当前条目;新增或补齐公开玩法类型时复用 `buildPlatformPublicGalleryCardKey(...)`,避免运行内容已切换但标题 / 作者仍退回第一条作品。
-发现 Tab、创作 Tab 与草稿 Tab 的页面根内容区不再套 `platform-page-stage` 外层全局卡片壳,让列表、筛选和玩法卡获得更宽的横向空间;推荐页和我的页仍按各自页面设计保留原有全局卡片口径。移动端“我的”页仍按顶部头像 / 昵称 / 陶泥号、会员横幅、三张统计卡、每日任务、五项常用功能宫格、设置入口和法律信息组织,不保留旧的底部“填邀请码”次级入口;常用功能当前只展示四项常驻入口时必须按四列铺满整行,不保留五列网格导致左对齐空位;每日任务卡必须读取 `/api/profile/tasks` 的当前任务摘要并在领取后同步刷新卡片进度。字号必须维持平台普通 UI 档位,不能因为窄屏把卡片标题、功能 label 或法律信息撑成展示级字号;最后一屏内容必须能在底部 dock 上方完整滚动露出,不得被固定底部导航遮挡。
+发现 Tab、创作 Tab 与草稿 Tab 的页面根内容区不再套 `platform-page-stage` 外层全局卡片壳,让列表、筛选和玩法卡获得更宽的横向空间;推荐页和我的页仍按各自页面设计保留原有全局卡片口径。移动端“我的”页仍按顶部头像 / 昵称 / 陶泥号、会员横幅、三张统计卡、每日任务、五项常用功能宫格、通用设置入口和法律信息组织,不保留旧的底部“填邀请码”次级入口;主题设置、账号与安全只放在通用设置弹窗下一级,不在外层单独占行;常用功能当前只展示四项常驻入口时必须按四列铺满整行,不保留五列网格导致左对齐空位;每日任务卡必须读取 `/api/profile/tasks` 的当前任务摘要并在领取后同步刷新卡片进度,外层卡片不展示“去完成”等行动按钮。字号必须维持平台普通 UI 档位,不能因为窄屏把卡片标题、功能 label 或法律信息撑成展示级字号;最后一屏内容必须能在底部 dock 上方完整滚动露出,不得被固定底部导航遮挡。
## RPG / 自定义世界
@@ -166,7 +168,7 @@ RPG / 拼图等运行态存档仍以 `/api/profile/save-archives` 的后端列
跳一跳作品架走创作中心的统一作品列表:前端通过 `/api/creation/jump-hop/works` 拉取作品摘要,草稿态会与 pending notice 合并后显示在作品架里,已完成但未发布草稿点击后必须通过私有创作接口 `GET /api/creation/jump-hop/works/{profile_id}` 读取完整详情并进入创作结果页;已发布作品点击后才通过公开运行态接口 `GET /api/runtime/jump-hop/works/{profile_id}` 读取完整详情再进入公开详情或运行态,该公开接口保持 published-only 校验。生成中作品仍以后端摘要里的 `generationStatus` 为准,刷新后应能恢复等待遮罩,不能只依赖内存 notice。
-删除等破坏性动作当前未接入 jump-hop 删除 API;如果后续要在作品架提供删除入口,必须先补齐后端/SpacetimeDB/前端整条删除链路,再开放按钮。
+跳一跳作品架删除入口必须走 `/api/creation/jump-hop/works/{profile_id}`,并通过 SpacetimeDB 同步删除 work profile、源 session、运行态 run 与事件,再刷新作品架和公开广场;不得只做前端本地隐藏。
推荐页匿名游玩不再限定为跳一跳。移动端一级 `推荐` Tab 是内嵌运行态刷卡流,会自动选择推荐作品并启动对应玩法;桌面端首页不启动这套移动推荐运行态,而是渲染桌面发现壳,展示 `今日游戏`、`推荐`、`作品分类` 等桌面内容。断点事实统一走 `platformEntryResponsive.ts` 的 `usePlatformDesktopLayout()`,平台壳和首页视图必须共用同一个判断,避免桌面发现页与移动推荐页同时挂载、重复触发请求或启动运行态。移动端推荐页启动或切换作品时先展示当前作品封面,嵌入 runtime 在封面下层加载;只有对应运行态 run / profile 已准备且 lazy runtime 组件完成挂载后,封面才渐隐,不在中途展示“加载中”文案。拼图下一关在同一个 run 内推进到相似作品时不视为推荐作品切换,不能重新显示启动封面。推荐页嵌入运行态启动时按真实身份分流:已登录用户或本地已有 access token 时继续使用账号 Bearer,但请求选项必须是 local auth impact,避免单卡 401 清空整站登录态;只有确认为匿名访客时才申请短期 Runtime Guest Token,并只把它作为局部请求头传给运行态客户端,不写入全局登录态、不触发 refresh,也不把匿名流量伪装成普通用户。当前覆盖矩阵为:跳一跳、视觉小说、抓大鹅 Match3D、方洞挑战、拼图、敲木鱼、大鱼吃小鱼、汪汪声浪。每个模板的启动请求、推荐页内后续运行态动作以及需要上报的 play/finish/leaderboard/next-level 类请求,都必须继续按该身份分流;公开读取入口仍可匿名读取,创作、个人作品、删除、发布、Remix 等账号/所有权动作仍保持普通用户鉴权。
@@ -183,7 +185,7 @@ RPG / 拼图等运行态存档仍以 `/api/profile/save-archives` 的后端列
创作输入固定为:
1. `敲什么`:敲击物单图资产槽位。默认模板使用内置透明 PNG `/wooden-fish/default-hit-object.png` 作为 `bundled-default` 敲击物资产,避免默认关键词被重新语义化改形;用户输入自定义关键词或上传参考图时,后端必须以默认木鱼图作为基础结构和画风参考,使用 image2 生成最终敲击物图案,上传图只作为新主题参考,不直接进入运行态。自定义 `compile-draft` / `regenerate-hit-object` 必须完成 image2 -> OSS 私有对象 -> asset object 登记和绑定后,再由 `api-server` 注入真实 `hitObjectAsset.imageSrc`,不能只写 `/generated-wooden-fish-assets/...` 占位路径,也不能接受前端请求自带的 `hitObjectAsset` 短路生成。
-2. `敲击音效`:音频资产槽位,当前创作阶段只支持用户上传或麦克风录制;未提供音频时统一写回内置默认木鱼音 `/wooden-fish/default-hit-sound.mp3`。提示词生成音效入口临时关闭,通用 `/api/creation/audio/sound-effect` 对木鱼 `hit_sound` 目标也返回 `410 Gone`;`hitSoundPrompt` 只作为历史兼容字段保留,不参与当前创作流程,也不得由 `spacetime-client` 合成假音频路径。
+2. `敲击音效`:音频资产槽位,当前创作阶段只支持用户上传或麦克风录制;麦克风录制结束后,前端会自动裁掉音频开头连续静音段,再把裁剪后的录音作为 `recorded` 音频资产写入表单。上传音频不做裁剪;浏览器音频解码或裁剪失败时保留原始录音继续保存,不能让用户录音丢失。未提供音频时统一写回内置默认木鱼音 `/wooden-fish/default-hit-sound.mp3`。提示词生成音效入口临时关闭,通用 `/api/creation/audio/sound-effect` 对木鱼 `hit_sound` 目标也返回 `410 Gone`;`hitSoundPrompt` 只作为历史兼容字段保留,不参与当前创作流程,也不得由 `spacetime-client` 合成假音频路径。
3. `功德有什么`:最多 8 条飘字,创作态首屏只保留一个默认词条 `幸运`,其下提供加号格继续追加词条;创作态只保存词条名,运行态飘字展示时再追加 `+1`。运行态顶部总数卡采用品牌化徽标样式,子项计数器预置展示在可展开面板中,未出现词条初始值为 0。
4. `作品标题 / 作品简介 / 主题标签`:不再放在创作工作台首屏,改为生成草稿后的结果页补录区,提交试玩或发布前必须先写回当前作品信息。主题标签编辑样式对齐拼图结果页的胶囊标签编辑器。
@@ -195,6 +197,34 @@ RPG / 拼图等运行态存档仍以 `/api/profile/save-archives` 的后端列
平台首页推荐、精选、最新、公开详情、搜索、已玩作品和公开试玩统一按 `sourceType='wooden-fish'` 与 `WF-*` 公开作品号识别敲木鱼作品;公开列表应走 `wooden_fish_gallery_card_view` 订阅缓存,公开详情或运行态启动时卡片摘要不足则补读完整 work profile。
+## 拼消消
+
+对外名称:拼消消。工程域与 `playId`:`puzzle-clear`。公开作品码前缀:`PC-`。当前按新增玩法 SOP 接入完整公开闭环,不复用拼图运行态规则本体。
+
+链路为:
+
+```text
+创作入口 -> 轻表单工作台 -> 生成过程页 -> 结果页 -> 试玩 -> 发布 -> 统一作品详情 -> 正式运行态
+```
+
+工作台字段固定为作品标题、简介、主题词、场地底图主题词 `boardBackgroundPrompt`、中央场地底图槽位、是否 AI 生成底图。中央场地底图必须复用 `CreativeImageInputPanel`,支持上传、历史图和 AI 重绘;若用户填写 `boardBackgroundPrompt`,AI 生成底图只读取该字段,字段为空时才回退读取 `themePrompt`;用户上传底图时不再用主题词重写该资产。中央场地底图的字段名保留平台口径,但实际语义是玩家逐步消除清空棋盘后露出的主题目标图,生成尺寸必须与中央棋盘一致,按 1:1 正方形出图;prompt 必须强绑定主题、画面精致、强表现力并一眼体现主题,不再要求“画面干净”或“适合作为卡牌棋盘底图”。运行态必须把中央场地底图作为棋盘内部静态底图使用,不能降级成整页氛围背景;卡牌消除后产生的空位和拖拽源位应露出该棋盘底图。卡面背面背景 v1 使用默认占位图,不作为创作者配置项。规则参数不开放编辑:单关 `6x6`、每局 10 分钟、35 次目标消除、形状解锁、防死局发牌和半锁定规则均由后端规则集固定。
+
+素材生成使用拼消消专用编排,但必须复用 `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 侧合成临时素材绕过平台图片底座。
+
+运行态规则:
+
+1. 单关固定为 `6x6 / 35次消除`。
+2. 每局固定 10 分钟;超时只判当前关失败,可重试当前关。
+3. 当前关直接出现 `1x2`、`1x3`、`2x2` 和 `2x3`。
+4. 开局棋盘随机铺满并保证至少一步可解;补牌后也必须由后端保证至少一步可解。
+5. 顶部卡牌准备区按纵列补位,某列有空格时该列卡牌从顶部下落。
+6. 非 2 格消除时,补牌不得破坏已完成局部;只有玩家主动交换或撞入才允许打散半锁定拼接组。
+7. 正式 runtime 只消费后端 snapshot 与 action 结果;前端负责开局翻转、拖拽、掉落、消除和弹层动画。
+ 拖拽手感必须对齐拼图模板:开局小卡片只翻转一次,交换落位不得重新翻牌;按住后可见卡片立即跟随鼠标或手指,源位置即时留出空槽;放下时被替换卡片要快速飞向对应空位;已完成局部拼接组要以连续整体呈现并可作为整组拖起。拖拽浮层必须挂到页面级 `document.body` portal,避免平台壳层 transform 让 `position: fixed` 和 `clientX/clientY` 坐标系错位。
+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`。
+
## 抓大鹅 Match3D
对外名称:`抓大鹅`。工程域:`match3d`。
diff --git a/docs/【项目基线】当前产品与工程约束-2026-05-15.md b/docs/【项目基线】当前产品与工程约束-2026-05-15.md
index 49bdf7b3..8638b222 100644
--- a/docs/【项目基线】当前产品与工程约束-2026-05-15.md
+++ b/docs/【项目基线】当前产品与工程约束-2026-05-15.md
@@ -49,6 +49,7 @@ Genarrative / 陶泥儿是一个 AI 原生互动内容与小游戏平台。当
6. 小程序外壳注入到 H5 URL 的 `clientType`、`clientRuntime`、`miniProgramEnv` 是宿主上下文,H5 内部 `pushState` / 阶段导航必须跨页面保留,避免登录和充值误判为普通浏览器;首点时微信 JS bridge 可能尚未就绪,前端还需用 `MicroMessenger + miniProgram` User-Agent 作为小程序识别兜底。
7. 小程序 `web-view` 页必须启用好友分享与朋友圈分享,分享目标固定回到 `pages/web-view/index`,不把 H5 当前 URL 作为不受控启动参数传回小程序页。
8. 小程序 `web-view` 外壳运行时通过 `wx.getAccountInfoSync().miniProgram.envVersion` 自动识别版本:线上版 `release` 使用 `www.genarrative.world`,体验版 `trial` 与开发版 `develop` 使用 `dev.genarrative.world`;传给后端的 `x-mini-program-env` 分别为 `release`、`trial`、`dev`。
+9. 账号信息面板只展示 `账号信息` 标题;绑定手机号和绑定微信以紧凑模块展示当前绑定状态,已绑定手机号展示完整手机号,已绑定微信展示微信昵称而不是微信账号标识,换绑入口放在对应模块右上角,退出登录和退出全部设备固定放在面板内容最底部。
## 账户与充值
@@ -98,9 +99,9 @@ server-rs + Axum + SpacetimeDB
7. 主站入口已锁定移动端页面级缩放;单个游戏页面不要再重复实现整页缩放锁定。
8. 图像输入通用 UI 统一走 `src/components/common/CreativeImageInputPanel.tsx`。外层页面持有业务状态,组件只承担上传卡、预览、参考图缩略图、AI 重绘开关、错误展示和提交按钮。
9. 发现页 `分类` 子频道的筛选必须打开独立 dialog / drawer / modal,至少支持玩法类型过滤与排序切换;筛选结果为空时显示空状态,不把筛选内容展开在当前列表下方。
-10. 移动端“我的”页顶部品牌行承载扫码和设置入口,正文按参考图顺序组织为头像 / 昵称 / 陶泥号、会员横幅、三张统计卡、每日任务、五项常用功能宫格、设置入口和法律信息;`media/profile/` 中的陶泥素材作为该页图形资产。常用功能宫格固定承载泥点充值、邀请好友、兑换码、玩家社区、反馈与建议;当前只展示四项常驻入口时必须按四列铺满整行,不保留五列网格导致左对齐空位。页面不再提供独立存档按钮入口,也不在底部保留旧的填邀请码次级入口。填邀请码只由邀请链接 query 或其它明确引导打开独立弹窗,不作为“我的”页常驻按钮。
-11. “我的”页每日任务卡必须展示后端 `/api/profile/tasks` 返回的当前任务摘要,包括奖励泥点数、进度和领取 / 去完成 / 已完成状态;任务领取成功后,卡片摘要必须跟随返回的任务中心数据同步刷新,不能继续硬编码 `0 / 1` 或只更新弹窗内任务列表。
-12. “我的”页泥点、游戏时长、已玩游戏数量三张统计卡只展示各自标签和值,三个统计 icon 使用小尺寸普通 UI 档位,内容不换行,不在统计区底部展示“更新于”时间;移动端昵称、会员卡、每日任务、常用功能和法律信息也应保持 `10px` 到 `14px` 的普通 UI 字号区间,避免展示级字号挤压内容。
+10. 移动端“我的”页顶部品牌行承载扫码和设置入口,正文按参考图顺序组织为头像 / 昵称 / 陶泥号、会员横幅、三张统计卡、每日任务、五项常用功能宫格、通用设置入口和法律信息;`media/profile/` 中的陶泥素材作为该页图形资产。常用功能宫格固定承载泥点充值、邀请好友、兑换码、玩家社区、反馈与建议;当前只展示四项常驻入口时必须按四列铺满整行,不保留五列网格导致左对齐空位。页面不再提供独立存档按钮入口,也不在底部保留旧的填邀请码次级入口;主题设置、账号与安全只作为通用设置弹窗下一级入口,不在“我的”页外层单独占行。填邀请码只由邀请链接 query 或其它明确引导打开独立弹窗,不作为“我的”页常驻按钮。
+11. “我的”页每日任务卡必须展示后端 `/api/profile/tasks` 返回的当前任务摘要,包括奖励泥点数和进度;外层任务卡不展示“去完成”等左右侧行动按钮,领取 / 去完成 / 已完成状态只在任务中心弹窗内表达。任务领取成功后,卡片摘要必须跟随返回的任务中心数据同步刷新,不能继续硬编码 `0 / 1` 或只更新弹窗内任务列表。用户停留在“我的”页跨过北京时间 0 点时,前端必须非阻断刷新登录态以补齐 `daily_login` 埋点,再重拉任务中心,避免继续展示上一自然日已领取状态。
+12. “我的”页泥点余额、累计游玩、已玩游戏三张统计卡只展示各自标签和值,三个统计 icon 使用小尺寸普通 UI 档位,内容不换行,不在统计区底部展示“更新于”时间;移动端昵称、会员卡、每日任务、常用功能和法律信息也应保持 `10px` 到 `14px` 的普通 UI 字号区间,避免展示级字号挤压内容。
13. 移动端“我的”页需要兼容窄屏:头像 / 昵称 / 陶泥号、三张统计卡、每日任务、五项常用功能和法律信息都必须能在底部固定 TabBar 上方完整滚动露出,不得与底部 dock、刘海 safe-area 或相邻 UI 元素遮挡重叠。
14. RPG 等运行态的战斗飘字、血量变化和即时反馈必须在暗色、噪声高的场景背景上保持可读:使用高亮文字、深色描边、强阴影或小面积半透明底,不只依赖红/绿文字本身表达伤害或治疗。
15. 平台亮色 UI 配色以陶泥儿主视觉为准:暖白 / 米杏底、陶土橙主按钮、深棕正文与浅杏边框;新增界面优先复用 `src/index.css` 的 `--platform-*` 主题变量和 `apps/admin-web/src/styles/admin.css` 的同系色值,不再引入粉红、蓝绿等独立主色方案。
diff --git a/packages/shared/src/contracts/auth.ts b/packages/shared/src/contracts/auth.ts
index 4765cd9f..2a2aa6c6 100644
--- a/packages/shared/src/contracts/auth.ts
+++ b/packages/shared/src/contracts/auth.ts
@@ -6,10 +6,13 @@ export type AuthUser = {
publicUserCode: string;
displayName: string;
avatarUrl: string | null;
+ phoneNumber?: string | null;
phoneNumberMasked: string | null;
loginMethod: AuthLoginMethod;
bindingStatus: AuthBindingStatus;
wechatBound: boolean;
+ wechatDisplayName?: string | null;
+ wechatAccount?: string | null;
};
export type PublicUserSummary = {
diff --git a/packages/shared/src/contracts/index.ts b/packages/shared/src/contracts/index.ts
index c6484f20..5ba8e295 100644
--- a/packages/shared/src/contracts/index.ts
+++ b/packages/shared/src/contracts/index.ts
@@ -3,6 +3,7 @@ export type * from './creationAudio';
export type * from './hyper3d';
export type * from './jumpHop';
export type * from './puzzleCreativeTemplate';
+export type * from './puzzleClear';
export type * from './publicWork';
export type * from './visualNovel';
export type * from './barkBattle';
diff --git a/packages/shared/src/contracts/puzzleClear.ts b/packages/shared/src/contracts/puzzleClear.ts
new file mode 100644
index 00000000..728c93f9
--- /dev/null
+++ b/packages/shared/src/contracts/puzzleClear.ts
@@ -0,0 +1,226 @@
+export type PuzzleClearGenerationStatus = 'draft' | 'generating' | 'ready' | 'failed';
+
+export type PuzzleClearShapeKind = '1x2' | '1x3' | '2x2' | '2x3';
+
+export type PuzzleClearOrientation = 'horizontal' | 'vertical';
+
+export type PuzzleClearRunStatus =
+ | 'playing'
+ | 'level_failed'
+ | 'level_cleared'
+ | 'finished';
+
+export type PuzzleClearActionType =
+ | 'compile-draft'
+ | 'regenerate-atlas'
+ | 'update-work-meta'
+ | 'update-board-background';
+
+export interface PuzzleClearImageAsset {
+ assetId: string;
+ imageSrc: string;
+ imageObjectKey: string;
+ assetObjectId: string;
+ generationProvider: string;
+ prompt: string;
+ width: number;
+ height: number;
+}
+
+export interface PuzzleClearPatternGroup {
+ groupId: string;
+ shape: PuzzleClearShapeKind;
+ width: number;
+ height: number;
+ atlasX: number;
+ atlasY: number;
+ atlasWidth: number;
+ atlasHeight: number;
+}
+
+export interface PuzzleClearCardAsset {
+ cardId: string;
+ groupId: string;
+ shape: PuzzleClearShapeKind;
+ orientation: PuzzleClearOrientation;
+ partX: number;
+ partY: number;
+ imageSrc: string;
+ imageObjectKey: string;
+ assetObjectId: string;
+ sourceAtlasCell: string;
+}
+
+export interface PuzzleClearWorkspaceCreateRequest {
+ templateId: 'puzzle-clear' | string;
+ workTitle: string;
+ workDescription: string;
+ themePrompt: string;
+ boardBackgroundPrompt: string;
+ generateBoardBackground: boolean;
+ boardBackgroundAsset?: PuzzleClearImageAsset | null;
+}
+
+export interface PuzzleClearActionRequest {
+ actionType: PuzzleClearActionType;
+ profileId?: string | null;
+ workTitle?: string | null;
+ workDescription?: string | null;
+ themePrompt?: string | null;
+ boardBackgroundPrompt?: string | null;
+ generateBoardBackground?: boolean | null;
+ boardBackgroundAsset?: PuzzleClearImageAsset | null;
+ atlasAsset?: PuzzleClearImageAsset | null;
+ patternGroups?: PuzzleClearPatternGroup[] | null;
+ cardAssets?: PuzzleClearCardAsset[] | null;
+}
+
+export interface PuzzleClearDraftResponse {
+ templateId: string;
+ templateName: string;
+ profileId: string | null;
+ workTitle: string;
+ workDescription: string;
+ themePrompt: string;
+ boardBackgroundPrompt: string;
+ generateBoardBackground: boolean;
+ boardBackgroundAsset: PuzzleClearImageAsset | null;
+ cardBackImageSrc: string | null;
+ atlasAsset: PuzzleClearImageAsset | null;
+ patternGroups: PuzzleClearPatternGroup[];
+ cardAssets: PuzzleClearCardAsset[];
+ generationStatus: PuzzleClearGenerationStatus;
+}
+
+export interface PuzzleClearSessionSnapshotResponse {
+ sessionId: string;
+ ownerUserId: string;
+ status: PuzzleClearGenerationStatus;
+ draft: PuzzleClearDraftResponse | null;
+ createdAt: string;
+ updatedAt: string;
+}
+
+export interface PuzzleClearSessionResponse {
+ session: PuzzleClearSessionSnapshotResponse;
+}
+
+export interface PuzzleClearActionResponse {
+ actionType: PuzzleClearActionType;
+ session: PuzzleClearSessionSnapshotResponse;
+ work: PuzzleClearWorkProfileResponse | null;
+}
+
+export interface PuzzleClearWorkSummaryResponse {
+ runtimeKind: 'puzzle-clear';
+ workId: string;
+ profileId: string;
+ ownerUserId: string;
+ sourceSessionId: string | null;
+ workTitle: string;
+ workDescription: string;
+ themePrompt: string;
+ coverImageSrc: string | null;
+ publicationStatus: string;
+ playCount: number;
+ updatedAt: string;
+ publishedAt: string | null;
+ publishReady: boolean;
+ generationStatus: PuzzleClearGenerationStatus;
+}
+
+export interface PuzzleClearWorkProfileResponse {
+ summary: PuzzleClearWorkSummaryResponse;
+ draft: PuzzleClearDraftResponse;
+ boardBackgroundAsset: PuzzleClearImageAsset | null;
+ atlasAsset: PuzzleClearImageAsset;
+ patternGroups: PuzzleClearPatternGroup[];
+ cardAssets: PuzzleClearCardAsset[];
+}
+
+export interface PuzzleClearWorksResponse {
+ items: PuzzleClearWorkSummaryResponse[];
+}
+
+export interface PuzzleClearWorkDetailResponse {
+ item: PuzzleClearWorkProfileResponse;
+}
+
+export interface PuzzleClearWorkMutationResponse {
+ item: PuzzleClearWorkProfileResponse;
+}
+
+export interface PuzzleClearGalleryCardResponse
+ extends PuzzleClearWorkSummaryResponse {
+ publicWorkCode?: string;
+ authorDisplayName?: string;
+ recentPlayCount7d?: number;
+}
+
+export interface PuzzleClearGalleryResponse {
+ items: PuzzleClearGalleryCardResponse[];
+ hasMore: boolean;
+ nextCursor: string | null;
+}
+
+export interface PuzzleClearGalleryDetailResponse {
+ item: PuzzleClearWorkProfileResponse;
+}
+
+export interface PuzzleClearBoardCell {
+ row: number;
+ col: number;
+ card: PuzzleClearCardAsset | null;
+ lockedGroupId: string | null;
+}
+
+export interface PuzzleClearBoardSnapshot {
+ rows: number;
+ cols: number;
+ cells: PuzzleClearBoardCell[];
+}
+
+export interface PuzzleClearRuntimeSnapshotResponse {
+ runId: string;
+ profileId: string;
+ ownerUserId: string;
+ runtimeMode?: 'draft' | 'published';
+ status: PuzzleClearRunStatus;
+ levelIndex: number;
+ clearsDone: number;
+ targetClears: number;
+ levelDurationSeconds: number;
+ levelStartedAtMs: number;
+ board: PuzzleClearBoardSnapshot;
+ readyColumns: PuzzleClearCardAsset[][];
+ startedAtMs: number;
+ finishedAtMs: number | null;
+}
+
+export interface PuzzleClearRunResponse {
+ run: PuzzleClearRuntimeSnapshotResponse;
+}
+
+export interface PuzzleClearStartRunRequest {
+ profileId: string;
+}
+
+export interface PuzzleClearSwapRequest {
+ fromRow: number;
+ fromCol: number;
+ toRow: number;
+ toCol: number;
+ clientActionId: string;
+}
+
+export interface PuzzleClearRetryLevelRequest {
+ clientActionId: string;
+}
+
+export interface PuzzleClearNextLevelRequest {
+ clientActionId: string;
+}
+
+export interface PuzzleClearTimeUpRequest {
+ clientActionId: string;
+}
diff --git a/scripts/check-spacetime-schema-guard.mjs b/scripts/check-spacetime-schema-guard.mjs
index 6f72ac8d..a935012a 100644
--- a/scripts/check-spacetime-schema-guard.mjs
+++ b/scripts/check-spacetime-schema-guard.mjs
@@ -477,8 +477,14 @@ function getChangedFiles(baseRef) {
const diffOutput = tryGit(['diff', '--name-only', '-z', baseRef, '--']) ?? '';
const untrackedOutput =
tryGit(['ls-files', '--others', '--exclude-standard', '-z', moduleSrcRoot]) ?? '';
+ const untrackedBindingsOutput =
+ tryGit(['ls-files', '--others', '--exclude-standard', '-z', bindingsRoot]) ?? '';
return new Set(
- [...diffOutput.split(/\u0000/u), ...untrackedOutput.split(/\u0000/u)]
+ [
+ ...diffOutput.split(/\u0000/u),
+ ...untrackedOutput.split(/\u0000/u),
+ ...untrackedBindingsOutput.split(/\u0000/u),
+ ]
.map(normalizePath)
.filter(Boolean),
);
diff --git a/scripts/dev-utils.test.ts b/scripts/dev-utils.test.ts
index aeabfcee..b027b525 100644
--- a/scripts/dev-utils.test.ts
+++ b/scripts/dev-utils.test.ts
@@ -88,6 +88,29 @@ describe('dev utils env merge', () => {
);
});
+ test('本地短信 smoke 可以用 mock 验证码覆盖真实短信 provider 口径', () => {
+ withTempEnvFiles(
+ {
+ '.env.local': [
+ 'SMS_AUTH_ENABLED=true',
+ 'SMS_AUTH_PROVIDER=mock',
+ 'SMS_AUTH_MOCK_VERIFY_CODE=123456',
+ ].join('\n'),
+ },
+ (_env, tempDir) => {
+ const env = mergeApiServerEnv(tempDir, {
+ SMS_AUTH_ENABLED: 'true',
+ SMS_AUTH_PROVIDER: 'aliyun',
+ SMS_AUTH_MOCK_VERIFY_CODE: '654321',
+ });
+
+ expect(env.SMS_AUTH_ENABLED).toBe('true');
+ expect(env.SMS_AUTH_PROVIDER).toBe('mock');
+ expect(env.SMS_AUTH_MOCK_VERIFY_CODE).toBe('123456');
+ },
+ );
+ });
+
test('空外层 shell 变量不会遮蔽本地私密配置', () => {
withTempEnvFiles(
{
diff --git a/scripts/dev.mjs b/scripts/dev.mjs
index b45c06f6..6e8363a5 100644
--- a/scripts/dev.mjs
+++ b/scripts/dev.mjs
@@ -5,6 +5,7 @@ import {
existsSync,
readdirSync,
readFileSync,
+ realpathSync,
statSync,
watch,
writeFileSync,
@@ -2046,6 +2047,36 @@ function normalizePath(path) {
return path.replace(/\\/gu, '/');
}
+function normalizeDirectExecutionPath(path) {
+ return normalizePath(path).replace(/^\/([A-Za-z]:\/)/u, '$1');
+}
+
+function safeRealpath(pathValue) {
+ try {
+ return realpathSync(pathValue);
+ } catch {
+ return resolve(pathValue);
+ }
+}
+
+function isDirectModuleExecution(argv1, moduleUrl, resolvePath = safeRealpath) {
+ if (!argv1) {
+ return false;
+ }
+
+ try {
+ return (
+ normalizeDirectExecutionPath(resolvePath(argv1)) ===
+ normalizeDirectExecutionPath(resolvePath(fileURLToPath(moduleUrl)))
+ );
+ } catch {
+ return (
+ normalizeDirectExecutionPath(resolve(argv1)) ===
+ normalizeDirectExecutionPath(fileURLToPath(moduleUrl))
+ );
+ }
+}
+
function buildSpacetimePublishArgs({database, server, preserveDatabase}) {
const args = [
'publish',
@@ -2098,6 +2129,7 @@ export {
createDevServerSpawnOptions,
createWatchConfigs,
isSpacetimePublishPermissionError,
+ isDirectModuleExecution,
parseSpacetimeToolVersion,
parseArgs,
resolveDevStackStatePath,
@@ -2129,6 +2161,6 @@ async function main() {
}
}
-if (process.argv[1] && resolve(process.argv[1]) === fileURLToPath(import.meta.url)) {
+if (isDirectModuleExecution(process.argv[1], import.meta.url)) {
void main();
}
diff --git a/scripts/dev.test.ts b/scripts/dev.test.ts
index b22bad82..9e6a7ef5 100644
--- a/scripts/dev.test.ts
+++ b/scripts/dev.test.ts
@@ -13,6 +13,7 @@ import {
buildSpacetimePublishArgs,
createDevServerSpawnOptions,
createWatchConfigs,
+ isDirectModuleExecution,
isSpacetimePublishPermissionError,
parseSpacetimeToolVersion,
parseArgs,
@@ -39,6 +40,19 @@ function workspaceSpacetimeVersionForTest() {
describe('dev scheduler argument routing', () => {
const linuxTest = process.platform === 'linux' ? test : test.skip;
+ test('Windows junction 路径下的直接执行入口也能识别为当前模块', () => {
+ const moduleUrl =
+ 'file:///F:/DevWorktrees/codex/worktrees/f584/Genarrative/scripts/dev.mjs';
+ const argv1 =
+ 'C:\\Users\\wuxiangwanzi\\.codex\\worktrees\\f584\\Genarrative\\scripts\\dev.mjs';
+ const resolvePath = (value) =>
+ value.startsWith('C:\\Users\\')
+ ? 'F:\\DevWorktrees\\codex\\worktrees\\f584\\Genarrative\\scripts\\dev.mjs'
+ : value;
+
+ expect(isDirectModuleExecution(argv1, moduleUrl, resolvePath)).toBe(true);
+ });
+
test('完整 dev 栈覆盖前端代理到本次解析出的 api-server 地址', () => {
const {command, explicitOptions, options} = parseArgs([], {
GENARRATIVE_API_PORT: '8090',
diff --git a/server-rs/Cargo.lock b/server-rs/Cargo.lock
index 5b149323..14a00146 100644
--- a/server-rs/Cargo.lock
+++ b/server-rs/Cargo.lock
@@ -113,6 +113,7 @@ dependencies = [
"module-match3d",
"module-npc",
"module-puzzle",
+ "module-puzzle-clear",
"module-runtime",
"module-runtime-item",
"module-runtime-story",
@@ -1971,6 +1972,15 @@ dependencies = [
"spacetimedb",
]
+[[package]]
+name = "module-puzzle-clear"
+version = "0.1.0"
+dependencies = [
+ "serde",
+ "shared-kernel",
+ "spacetimedb",
+]
+
[[package]]
name = "module-quest"
version = "0.1.0"
@@ -3416,6 +3426,7 @@ dependencies = [
"module-match3d",
"module-npc",
"module-puzzle",
+ "module-puzzle-clear",
"module-runtime",
"module-runtime-item",
"module-runtime-story",
@@ -3451,6 +3462,7 @@ dependencies = [
"module-npc",
"module-progression",
"module-puzzle",
+ "module-puzzle-clear",
"module-quest",
"module-runtime",
"module-runtime-item",
diff --git a/server-rs/Cargo.toml b/server-rs/Cargo.toml
index fdb306a3..82abcab1 100644
--- a/server-rs/Cargo.toml
+++ b/server-rs/Cargo.toml
@@ -22,6 +22,7 @@ members = [
"crates/module-match3d",
"crates/module-npc",
"crates/module-puzzle",
+ "crates/module-puzzle-clear",
"crates/module-progression",
"crates/module-quest",
"crates/module-runtime",
@@ -68,6 +69,7 @@ module-match3d = { path = "crates/module-match3d", default-features = false }
module-npc = { path = "crates/module-npc", default-features = false }
module-progression = { path = "crates/module-progression", default-features = false }
module-puzzle = { path = "crates/module-puzzle", default-features = false }
+module-puzzle-clear = { path = "crates/module-puzzle-clear", default-features = false }
module-quest = { path = "crates/module-quest", default-features = false }
module-runtime = { path = "crates/module-runtime", default-features = false }
module-runtime-item = { path = "crates/module-runtime-item", default-features = false }
diff --git a/server-rs/crates/api-server/Cargo.toml b/server-rs/crates/api-server/Cargo.toml
index 28b54d93..0374defc 100644
--- a/server-rs/crates/api-server/Cargo.toml
+++ b/server-rs/crates/api-server/Cargo.toml
@@ -29,6 +29,7 @@ module-inventory = { workspace = true }
module-match3d = { workspace = true }
module-npc = { workspace = true }
module-puzzle = { workspace = true }
+module-puzzle-clear = { workspace = true }
module-runtime = { workspace = true }
module-runtime-story = { workspace = true }
module-runtime-item = { workspace = true }
diff --git a/server-rs/crates/api-server/src/app.rs b/server-rs/crates/api-server/src/app.rs
index 5fe098a3..248c09d1 100644
--- a/server-rs/crates/api-server/src/app.rs
+++ b/server-rs/crates/api-server/src/app.rs
@@ -67,6 +67,7 @@ pub fn build_router(state: AppState) -> Router {
.merge(modules::jump_hop::router(state.clone()))
.merge(modules::wooden_fish::router(state.clone()))
.merge(modules::public_work::router(state.clone()))
+ .merge(modules::puzzle_clear::router(state.clone()))
.merge(modules::puzzle::router(state.clone()))
.merge(visual_novel_router(state.clone()))
.route(
@@ -2697,6 +2698,18 @@ mod tests {
bind_payload["user"]["phoneNumberMasked"],
Value::String("138****8000".to_string())
);
+ assert_eq!(
+ bind_payload["user"]["phoneNumber"],
+ Value::String("+8613800138000".to_string())
+ );
+ assert_eq!(
+ bind_payload["user"]["wechatAccount"],
+ Value::String("wx-mini-code-bind-001".to_string())
+ );
+ assert_eq!(
+ bind_payload["user"]["wechatDisplayName"],
+ Value::String("微信旅人".to_string())
+ );
assert!(
bind_payload["token"]
.as_str()
@@ -3384,6 +3397,10 @@ mod tests {
serde_json::from_slice(&body).expect("response body should be valid json");
assert_eq!(payload["user"]["id"], Value::String(seed_user.id));
+ assert_eq!(
+ payload["user"]["phoneNumber"],
+ Value::String("+8613800138016".to_string())
+ );
assert_eq!(
payload["availableLoginMethods"],
serde_json::json!(["phone", "password", "wechat"])
@@ -4119,8 +4136,7 @@ mod tests {
.await
.expect("banners body should collect")
.to_bytes();
- let payload: Value =
- serde_json::from_slice(&body).expect("banners payload should be json");
+ let payload: Value = serde_json::from_slice(&body).expect("banners payload should be json");
assert_eq!(payload["eventBanners"][0]["title"], "后台表单公告");
assert_eq!(payload["eventBanners"][0]["renderMode"], "html");
diff --git a/server-rs/crates/api-server/src/auth_payload.rs b/server-rs/crates/api-server/src/auth_payload.rs
index c4cc8673..4c2a6242 100644
--- a/server-rs/crates/api-server/src/auth_payload.rs
+++ b/server-rs/crates/api-server/src/auth_payload.rs
@@ -7,10 +7,13 @@ pub fn map_auth_user_payload(user: AuthUser) -> AuthUserPayload {
public_user_code: user.public_user_code,
display_name: user.display_name,
avatar_url: user.avatar_url,
+ phone_number: user.phone_number,
phone_number_masked: user.phone_number_masked,
login_method: user.login_method.as_str().to_string(),
binding_status: user.binding_status.as_str().to_string(),
wechat_bound: user.wechat_bound,
+ wechat_display_name: user.wechat_display_name,
+ wechat_account: user.wechat_account,
}
}
diff --git a/server-rs/crates/api-server/src/bark_battle.rs b/server-rs/crates/api-server/src/bark_battle.rs
index f89affce..56cc47e7 100644
--- a/server-rs/crates/api-server/src/bark_battle.rs
+++ b/server-rs/crates/api-server/src/bark_battle.rs
@@ -30,7 +30,7 @@ use shared_kernel::{
use spacetime_client::{
BarkBattleDraftConfigUpsertRecordInput, BarkBattleDraftCreateRecordInput,
BarkBattleRunFinishRecordInput, BarkBattleRunRecord, BarkBattleRunStartRecordInput,
- BarkBattleWorkPublishRecordInput, SpacetimeClientError,
+ BarkBattleWorkDeleteRecordInput, BarkBattleWorkPublishRecordInput, SpacetimeClientError,
};
use time::{Duration as TimeDuration, OffsetDateTime};
@@ -406,6 +406,38 @@ pub async fn list_bark_battle_works(
))
}
+pub async fn delete_bark_battle_work(
+ State(state): State,
+ Path(work_id): Path,
+ Extension(request_context): Extension,
+ Extension(authenticated): Extension,
+) -> Result, Response> {
+ ensure_non_empty(&request_context, &work_id, "workId")?;
+ let items = state
+ .spacetime_client()
+ .delete_bark_battle_work(BarkBattleWorkDeleteRecordInput {
+ work_id,
+ owner_user_id: authenticated.claims().user_id().to_string(),
+ })
+ .await
+ .map_err(|error| {
+ bark_battle_error_response(&request_context, map_bark_battle_client_error(error))
+ })?;
+ let items = items
+ .into_iter()
+ .map(|item| {
+ let author_display_name =
+ resolve_bark_battle_author_display_name_for_record(&state, &item);
+ map_work_summary_record(item, &request_context, author_display_name)
+ })
+ .collect::, _>>()?;
+
+ Ok(json_success_body(
+ Some(&request_context),
+ BarkBattleWorksResponse { items },
+ ))
+}
+
pub async fn list_bark_battle_gallery(
State(state): State,
Extension(request_context): Extension,
diff --git a/server-rs/crates/api-server/src/creation_entry_config.rs b/server-rs/crates/api-server/src/creation_entry_config.rs
index 5089422a..81f95e93 100644
--- a/server-rs/crates/api-server/src/creation_entry_config.rs
+++ b/server-rs/crates/api-server/src/creation_entry_config.rs
@@ -77,17 +77,13 @@ pub fn resolve_creation_entry_route_id(path: &str) -> Option<&'static str> {
{
return Some("puzzle");
}
- if normalized.starts_with("/api/runtime/puzzle/gallery/")
- && normalized.ends_with("/remix")
- {
+ if normalized.starts_with("/api/runtime/puzzle/gallery/") && normalized.ends_with("/remix") {
return Some("puzzle");
}
if normalized == "/api/runtime/big-fish/agent/sessions" {
return Some("big-fish");
}
- if normalized.starts_with("/api/runtime/big-fish/gallery/")
- && normalized.ends_with("/remix")
- {
+ if normalized.starts_with("/api/runtime/big-fish/gallery/") && normalized.ends_with("/remix") {
return Some("big-fish");
}
if normalized == "/api/runtime/custom-world/agent/sessions"
@@ -115,6 +111,9 @@ pub fn resolve_creation_entry_route_id(path: &str) -> Option<&'static str> {
if normalized == "/api/creation/jump-hop/sessions" {
return Some("jump-hop");
}
+ if normalized == "/api/creation/puzzle-clear/sessions" {
+ return Some("puzzle-clear");
+ }
if normalized == "/api/creation/visual-novel/sessions" {
return Some("visual-novel");
}
@@ -178,6 +177,10 @@ mod tests {
resolve_creation_entry_route_id("/api/runtime/puzzle/agent/sessions"),
Some("puzzle"),
);
+ assert_eq!(
+ resolve_creation_entry_route_id("/api/creation/puzzle-clear/sessions"),
+ Some("puzzle-clear"),
+ );
assert_eq!(
resolve_creation_entry_route_id("/api/runtime/puzzle/gallery/profile-1/remix"),
Some("puzzle"),
@@ -236,6 +239,10 @@ mod tests {
resolve_creation_entry_route_id("/api/runtime/wooden-fish/runs/run-1"),
None,
);
+ assert_eq!(
+ resolve_creation_entry_route_id("/api/runtime/puzzle-clear/runs/run-1"),
+ None,
+ );
assert_eq!(
resolve_creation_entry_route_id("/api/creation/wooden-fish/sessions"),
Some("wooden-fish"),
diff --git a/server-rs/crates/api-server/src/http_error.rs b/server-rs/crates/api-server/src/http_error.rs
index 85699b70..ac061d6d 100644
--- a/server-rs/crates/api-server/src/http_error.rs
+++ b/server-rs/crates/api-server/src/http_error.rs
@@ -42,6 +42,10 @@ impl AppError {
&self.message
}
+ pub fn details(&self) -> Option<&Value> {
+ self.details.as_ref()
+ }
+
pub fn body_text(&self) -> String {
// 批处理任务不能只读 HTTP 状态文案,否则 DashScope 返回的真实失败原因会被压成“上游服务请求失败”。
self.details
diff --git a/server-rs/crates/api-server/src/jump_hop.rs b/server-rs/crates/api-server/src/jump_hop.rs
index 76649e17..c1372fc3 100644
--- a/server-rs/crates/api-server/src/jump_hop.rs
+++ b/server-rs/crates/api-server/src/jump_hop.rs
@@ -250,6 +250,36 @@ pub async fn get_jump_hop_work_detail(
))
}
+pub async fn delete_jump_hop_work(
+ State(state): State,
+ Path(profile_id): Path,
+ Extension(request_context): Extension,
+ Extension(authenticated): Extension,
+) -> Result, Response> {
+ ensure_non_empty(&request_context, &profile_id, "profileId")?;
+ let works = state
+ .spacetime_client()
+ .delete_jump_hop_work(
+ profile_id,
+ authenticated.claims().user_id().to_string(),
+ )
+ .await
+ .map_err(|error| {
+ jump_hop_error_response(
+ &request_context,
+ JUMP_HOP_CREATION_PROVIDER,
+ map_jump_hop_client_error(error),
+ )
+ })?;
+
+ Ok(json_success_body(
+ Some(&request_context),
+ JumpHopWorksResponse {
+ items: works.into_iter().map(|work| work.summary).collect(),
+ },
+ ))
+}
+
pub async fn get_jump_hop_runtime_work(
State(state): State,
Path(profile_id): Path,
@@ -311,7 +341,10 @@ pub async fn start_jump_hop_run(
) -> Result, Response> {
let Json(payload) = jump_hop_json(payload, &request_context, JUMP_HOP_RUNTIME_PROVIDER)?;
ensure_non_empty(&request_context, &payload.profile_id, "profileId")?;
- let is_draft_runtime = payload.runtime_mode.as_deref() == Some("draft");
+ let is_draft_runtime = payload
+ .runtime_mode
+ .as_deref()
+ .is_some_and(is_jump_hop_draft_runtime_mode);
let owner_user_id = principal.subject().to_string();
let principal_kind = principal.kind().as_str();
let run = state
@@ -1268,6 +1301,10 @@ fn build_jump_hop_work_play_tracking_draft(
WorkPlayTrackingDraft::runtime_principal("jump-hop", work_id, principal, source_route)
}
+fn is_jump_hop_draft_runtime_mode(runtime_mode: &str) -> bool {
+ runtime_mode.trim().eq_ignore_ascii_case("draft")
+}
+
fn build_jump_hop_draft(payload: &JumpHopWorkspaceCreateRequest) -> JumpHopDraftResponse {
let theme_text = normalize_theme_text(&payload.theme_text, &payload.work_title);
JumpHopDraftResponse {
@@ -1450,6 +1487,14 @@ fn current_utc_micros() -> i64 {
mod tests {
use super::*;
+ #[test]
+ fn jump_hop_draft_runtime_mode_detection_matches_client_normalization() {
+ assert!(is_jump_hop_draft_runtime_mode("draft"));
+ assert!(is_jump_hop_draft_runtime_mode(" DRAFT "));
+ assert!(!is_jump_hop_draft_runtime_mode("published"));
+ assert!(!is_jump_hop_draft_runtime_mode(""));
+ }
+
#[test]
fn jump_hop_tile_atlas_prompt_uses_dedicated_five_by_five_floor_layout() {
let prompt = build_jump_hop_tile_atlas_prompt("森林冒险", "森林主题清爽游戏化立体感平台");
diff --git a/server-rs/crates/api-server/src/main.rs b/server-rs/crates/api-server/src/main.rs
index fc9ee2e4..bb1098de 100644
--- a/server-rs/crates/api-server/src/main.rs
+++ b/server-rs/crates/api-server/src/main.rs
@@ -64,6 +64,7 @@ mod prompt;
mod public_work;
mod puzzle;
mod puzzle_agent_turn;
+mod puzzle_clear;
mod puzzle_gallery_cache;
mod refresh_session;
mod registration_reward;
diff --git a/server-rs/crates/api-server/src/modules.rs b/server-rs/crates/api-server/src/modules.rs
index 9d643493..1cac08d9 100644
--- a/server-rs/crates/api-server/src/modules.rs
+++ b/server-rs/crates/api-server/src/modules.rs
@@ -13,6 +13,7 @@ pub mod platform;
pub mod profile;
pub mod public_work;
pub mod puzzle;
+pub mod puzzle_clear;
pub mod square_hole;
pub mod story;
pub mod wooden_fish;
diff --git a/server-rs/crates/api-server/src/modules/bark_battle.rs b/server-rs/crates/api-server/src/modules/bark_battle.rs
index 14dac1ae..6184150e 100644
--- a/server-rs/crates/api-server/src/modules/bark_battle.rs
+++ b/server-rs/crates/api-server/src/modules/bark_battle.rs
@@ -1,15 +1,15 @@
use axum::{
Router, middleware,
- routing::{get, post},
+ routing::{delete, get, post},
};
use crate::{
auth::require_bearer_auth,
bark_battle::{
- create_bark_battle_draft, finish_bark_battle_run, generate_bark_battle_image_asset,
- get_bark_battle_run, get_bark_battle_runtime_config, list_bark_battle_gallery,
- list_bark_battle_works, publish_bark_battle_work, start_bark_battle_run,
- update_bark_battle_draft_config,
+ create_bark_battle_draft, delete_bark_battle_work, finish_bark_battle_run,
+ generate_bark_battle_image_asset, get_bark_battle_run, get_bark_battle_runtime_config,
+ list_bark_battle_gallery, list_bark_battle_works, publish_bark_battle_work,
+ start_bark_battle_run, update_bark_battle_draft_config,
},
state::AppState,
};
@@ -51,6 +51,13 @@ pub fn router(state: AppState) -> Router {
require_bearer_auth,
)),
)
+ .route(
+ "/api/runtime/bark-battle/works/{work_id}",
+ delete(delete_bark_battle_work).route_layer(middleware::from_fn_with_state(
+ state.clone(),
+ require_bearer_auth,
+ )),
+ )
.route(
"/api/runtime/bark-battle/gallery",
get(list_bark_battle_gallery),
diff --git a/server-rs/crates/api-server/src/modules/jump_hop.rs b/server-rs/crates/api-server/src/modules/jump_hop.rs
index d051d052..2ed65a3b 100644
--- a/server-rs/crates/api-server/src/modules/jump_hop.rs
+++ b/server-rs/crates/api-server/src/modules/jump_hop.rs
@@ -1,16 +1,17 @@
use axum::{
- Router, middleware,
+ middleware,
routing::{get, post},
+ Router,
};
use crate::{
auth::{require_bearer_auth, require_runtime_principal_auth},
jump_hop::{
- create_jump_hop_session, execute_jump_hop_action, get_jump_hop_gallery_detail,
- get_jump_hop_leaderboard, get_jump_hop_runtime_work, get_jump_hop_session,
- get_jump_hop_work_detail,
- jump_hop_run_jump, list_jump_hop_gallery, list_jump_hop_works, publish_jump_hop_work,
- restart_jump_hop_run, start_jump_hop_run,
+ create_jump_hop_session, delete_jump_hop_work, execute_jump_hop_action,
+ get_jump_hop_gallery_detail, get_jump_hop_leaderboard, get_jump_hop_runtime_work,
+ get_jump_hop_session, get_jump_hop_work_detail, jump_hop_run_jump,
+ list_jump_hop_gallery, list_jump_hop_works, publish_jump_hop_work, restart_jump_hop_run,
+ start_jump_hop_run,
},
state::AppState,
};
@@ -47,10 +48,12 @@ pub fn router(state: AppState) -> Router {
)
.route(
"/api/creation/jump-hop/works/{profile_id}",
- get(get_jump_hop_work_detail).route_layer(middleware::from_fn_with_state(
- state.clone(),
- require_bearer_auth,
- )),
+ get(get_jump_hop_work_detail)
+ .delete(delete_jump_hop_work)
+ .route_layer(middleware::from_fn_with_state(
+ state.clone(),
+ require_bearer_auth,
+ )),
)
.route(
"/api/creation/jump-hop/works/{profile_id}/publish",
diff --git a/server-rs/crates/api-server/src/modules/puzzle_clear.rs b/server-rs/crates/api-server/src/modules/puzzle_clear.rs
new file mode 100644
index 00000000..3eaabde6
--- /dev/null
+++ b/server-rs/crates/api-server/src/modules/puzzle_clear.rs
@@ -0,0 +1,116 @@
+use axum::{
+ Router, middleware,
+ routing::{get, post},
+};
+
+use crate::{
+ auth::{require_bearer_auth, require_runtime_principal_auth},
+ puzzle_clear::{
+ advance_puzzle_clear_next_level, create_puzzle_clear_session, execute_puzzle_clear_action,
+ get_puzzle_clear_gallery_detail, get_puzzle_clear_run, get_puzzle_clear_runtime_work,
+ get_puzzle_clear_session, get_puzzle_clear_work, list_puzzle_clear_gallery,
+ list_puzzle_clear_works, mark_puzzle_clear_level_time_up, publish_puzzle_clear_work,
+ retry_puzzle_clear_level, start_puzzle_clear_run, swap_puzzle_clear_cards,
+ },
+ state::AppState,
+};
+
+pub fn router(state: AppState) -> Router {
+ Router::new()
+ .route(
+ "/api/creation/puzzle-clear/sessions",
+ post(create_puzzle_clear_session).route_layer(middleware::from_fn_with_state(
+ state.clone(),
+ require_bearer_auth,
+ )),
+ )
+ .route(
+ "/api/creation/puzzle-clear/sessions/{session_id}",
+ get(get_puzzle_clear_session).route_layer(middleware::from_fn_with_state(
+ state.clone(),
+ require_bearer_auth,
+ )),
+ )
+ .route(
+ "/api/creation/puzzle-clear/sessions/{session_id}/actions",
+ post(execute_puzzle_clear_action).route_layer(middleware::from_fn_with_state(
+ state.clone(),
+ require_bearer_auth,
+ )),
+ )
+ .route(
+ "/api/creation/puzzle-clear/works",
+ get(list_puzzle_clear_works).route_layer(middleware::from_fn_with_state(
+ state.clone(),
+ require_bearer_auth,
+ )),
+ )
+ .route(
+ "/api/creation/puzzle-clear/works/{profile_id}",
+ get(get_puzzle_clear_work).route_layer(middleware::from_fn_with_state(
+ state.clone(),
+ require_bearer_auth,
+ )),
+ )
+ .route(
+ "/api/creation/puzzle-clear/works/{profile_id}/publish",
+ post(publish_puzzle_clear_work).route_layer(middleware::from_fn_with_state(
+ state.clone(),
+ require_bearer_auth,
+ )),
+ )
+ .route(
+ "/api/runtime/puzzle-clear/works/{profile_id}",
+ get(get_puzzle_clear_runtime_work),
+ )
+ .route(
+ "/api/runtime/puzzle-clear/runs",
+ post(start_puzzle_clear_run).route_layer(middleware::from_fn_with_state(
+ state.clone(),
+ require_runtime_principal_auth,
+ )),
+ )
+ .route(
+ "/api/runtime/puzzle-clear/runs/{run_id}",
+ get(get_puzzle_clear_run).route_layer(middleware::from_fn_with_state(
+ state.clone(),
+ require_runtime_principal_auth,
+ )),
+ )
+ .route(
+ "/api/runtime/puzzle-clear/runs/{run_id}/swap",
+ post(swap_puzzle_clear_cards).route_layer(middleware::from_fn_with_state(
+ state.clone(),
+ require_runtime_principal_auth,
+ )),
+ )
+ .route(
+ "/api/runtime/puzzle-clear/runs/{run_id}/retry-level",
+ post(retry_puzzle_clear_level).route_layer(middleware::from_fn_with_state(
+ state.clone(),
+ require_runtime_principal_auth,
+ )),
+ )
+ .route(
+ "/api/runtime/puzzle-clear/runs/{run_id}/next-level",
+ post(advance_puzzle_clear_next_level).route_layer(middleware::from_fn_with_state(
+ state.clone(),
+ require_runtime_principal_auth,
+ )),
+ )
+ .route(
+ "/api/runtime/puzzle-clear/runs/{run_id}/time-up",
+ post(mark_puzzle_clear_level_time_up).route_layer(middleware::from_fn_with_state(
+ state.clone(),
+ require_runtime_principal_auth,
+ )),
+ )
+ .route(
+ "/api/runtime/puzzle-clear/gallery",
+ get(list_puzzle_clear_gallery),
+ )
+ .route(
+ "/api/runtime/puzzle-clear/gallery/{public_work_code}",
+ get(get_puzzle_clear_gallery_detail),
+ )
+}
diff --git a/server-rs/crates/api-server/src/modules/wooden_fish.rs b/server-rs/crates/api-server/src/modules/wooden_fish.rs
index 556c31b0..b46c4750 100644
--- a/server-rs/crates/api-server/src/modules/wooden_fish.rs
+++ b/server-rs/crates/api-server/src/modules/wooden_fish.rs
@@ -1,16 +1,16 @@
use axum::{
Router, middleware,
- routing::{get, post},
+ routing::{delete, get, post},
};
use crate::{
auth::{require_bearer_auth, require_runtime_principal_auth},
state::AppState,
wooden_fish::{
- checkpoint_wooden_fish_run, create_wooden_fish_session, execute_wooden_fish_action,
- finish_wooden_fish_run, get_wooden_fish_gallery_detail, get_wooden_fish_runtime_work,
- get_wooden_fish_session, list_wooden_fish_gallery, list_wooden_fish_works,
- publish_wooden_fish_work, start_wooden_fish_run,
+ checkpoint_wooden_fish_run, create_wooden_fish_session, delete_wooden_fish_work,
+ execute_wooden_fish_action, finish_wooden_fish_run, get_wooden_fish_gallery_detail,
+ get_wooden_fish_runtime_work, get_wooden_fish_session, list_wooden_fish_gallery,
+ list_wooden_fish_works, publish_wooden_fish_work, start_wooden_fish_run,
},
};
@@ -44,6 +44,13 @@ pub fn router(state: AppState) -> Router {
require_bearer_auth,
)),
)
+ .route(
+ "/api/creation/wooden-fish/works/{profile_id}",
+ delete(delete_wooden_fish_work).route_layer(middleware::from_fn_with_state(
+ state.clone(),
+ require_bearer_auth,
+ )),
+ )
.route(
"/api/creation/wooden-fish/works/{profile_id}/publish",
post(publish_wooden_fish_work).route_layer(middleware::from_fn_with_state(
diff --git a/server-rs/crates/api-server/src/puzzle.rs b/server-rs/crates/api-server/src/puzzle.rs
index b4fe7b41..be4b8cb0 100644
--- a/server-rs/crates/api-server/src/puzzle.rs
+++ b/server-rs/crates/api-server/src/puzzle.rs
@@ -1,5 +1,6 @@
use std::{
- collections::BTreeMap,
+ collections::{BTreeMap, HashSet},
+ sync::{Mutex, OnceLock},
time::{Instant, SystemTime, UNIX_EPOCH},
};
@@ -130,6 +131,73 @@ const PUZZLE_UI_BACKGROUND_PROMPT_FALLBACK_MARKER: &str =
const PUZZLE_VECTOR_ENGINE_SQUARE_IMAGE_SIZE: &str = "1024x1024";
const PUZZLE_VECTOR_ENGINE_PORTRAIT_IMAGE_SIZE: &str = "1024x1536";
+static PUZZLE_BACKGROUND_COMPILE_TASKS: OnceLock>> = OnceLock::new();
+
+fn puzzle_background_compile_tasks() -> &'static Mutex> {
+ PUZZLE_BACKGROUND_COMPILE_TASKS.get_or_init(|| Mutex::new(HashSet::new()))
+}
+
+fn try_register_puzzle_background_compile_task(session_id: &str) -> bool {
+ match puzzle_background_compile_tasks().lock() {
+ Ok(mut tasks) => tasks.insert(session_id.to_string()),
+ Err(error) => {
+ tracing::warn!(
+ provider = PUZZLE_AGENT_API_BASE_PROVIDER,
+ session_id,
+ error = %error,
+ "拼图后台生成任务注册表锁已损坏,允许本次任务继续"
+ );
+ true
+ }
+ }
+}
+
+fn unregister_puzzle_background_compile_task(session_id: &str) {
+ match puzzle_background_compile_tasks().lock() {
+ Ok(mut tasks) => {
+ tasks.remove(session_id);
+ }
+ Err(error) => {
+ tracing::warn!(
+ provider = PUZZLE_AGENT_API_BASE_PROVIDER,
+ session_id,
+ error = %error,
+ "拼图后台生成任务注册表解锁失败,忽略清理"
+ );
+ }
+ }
+}
+
+fn has_puzzle_cover_image_src(value: &Option) -> bool {
+ value
+ .as_deref()
+ .map(str::trim)
+ .is_some_and(|value| !value.is_empty())
+}
+
+fn mark_puzzle_initial_generation_started_snapshot(
+ mut session: PuzzleAgentSessionRecord,
+) -> PuzzleAgentSessionRecord {
+ session.stage = "image_refining".to_string();
+ session.progress_percent = session.progress_percent.max(88);
+ if let Some(draft) = session.draft.as_mut() {
+ let draft_needs_cover = !has_puzzle_cover_image_src(&draft.cover_image_src);
+ if let Some(primary_level) = draft.levels.first_mut() {
+ if !has_puzzle_cover_image_src(&primary_level.cover_image_src) {
+ primary_level.generation_status = "generating".to_string();
+ }
+ draft.generation_status = primary_level.generation_status.clone();
+ draft.candidates = primary_level.candidates.clone();
+ draft.selected_candidate_id = primary_level.selected_candidate_id.clone();
+ draft.cover_image_src = primary_level.cover_image_src.clone();
+ draft.cover_asset_id = primary_level.cover_asset_id.clone();
+ } else if draft_needs_cover {
+ draft.generation_status = "generating".to_string();
+ }
+ }
+ session
+}
+
pub(crate) fn format_puzzle_reference_image_upload_bytes(bytes: usize) -> String {
format!("{:.1}MB", bytes as f64 / 1024.0 / 1024.0)
}
diff --git a/server-rs/crates/api-server/src/puzzle/draft.rs b/server-rs/crates/api-server/src/puzzle/draft.rs
index 276a29f5..43bc146d 100644
--- a/server-rs/crates/api-server/src/puzzle/draft.rs
+++ b/server-rs/crates/api-server/src/puzzle/draft.rs
@@ -1177,21 +1177,16 @@ pub(crate) fn find_puzzle_level_for_initial_asset_check<'a>(
.or_else(|| levels.first())
}
-pub(crate) async fn compile_puzzle_draft_with_initial_cover(
+pub(crate) async fn generate_puzzle_initial_cover_from_compiled_session(
state: &PuzzleApiState,
request_context: &RequestContext,
- session_id: String,
+ compiled_session: PuzzleAgentSessionRecord,
owner_user_id: String,
prompt_text: Option<&str>,
reference_image_src: Option<&str>,
image_model: Option<&str>,
now: i64,
) -> Result {
- let compiled_session = state
- .spacetime_client()
- .compile_puzzle_agent_draft(session_id.clone(), owner_user_id.clone(), now)
- .await
- .map_err(map_puzzle_compile_error)?;
let draft = compiled_session.draft.clone().ok_or_else(|| {
AppError::from_status(StatusCode::BAD_REQUEST).with_details(json!({
"provider": PUZZLE_AGENT_API_BASE_PROVIDER,
@@ -1419,7 +1414,7 @@ pub(crate) async fn compile_puzzle_draft_with_initial_cover(
match state
.spacetime_client()
.select_puzzle_cover_image(PuzzleSelectCoverImageRecordInput {
- session_id,
+ session_id: compiled_session.session_id.clone(),
owner_user_id,
level_id: Some(target_level.level_id),
candidate_id: selected_candidate_id,
diff --git a/server-rs/crates/api-server/src/puzzle/handlers.rs b/server-rs/crates/api-server/src/puzzle/handlers.rs
index afd6f3cf..873495f7 100644
--- a/server-rs/crates/api-server/src/puzzle/handlers.rs
+++ b/server-rs/crates/api-server/src/puzzle/handlers.rs
@@ -623,7 +623,7 @@ pub async fn execute_puzzle_agent_action(
session_id,
owner_user_id,
error_message,
- failed_at_micros: now,
+ failed_at_micros: current_utc_micros(),
})
.await;
if let Err(error) = result {
@@ -668,27 +668,128 @@ pub async fn execute_puzzle_agent_action(
Err(response) => return Err(response),
};
let session = if ai_redraw {
- execute_billable_asset_operation_with_cost(
- state.root_state(),
- &owner_user_id,
- "puzzle_initial_image",
- &billing_asset_id,
- PUZZLE_IMAGE_GENERATION_POINTS_COST,
- async {
- compile_puzzle_draft_with_initial_cover(
- &state,
- &request_context,
+ if !try_register_puzzle_background_compile_task(&compile_session_id) {
+ tracing::info!(
+ provider = PUZZLE_AGENT_API_BASE_PROVIDER,
+ session_id = %compile_session_id,
+ owner_user_id = %owner_user_id,
+ "拼图首图后台生成任务已存在,本次 action 直接返回生成中会话"
+ );
+ state
+ .spacetime_client()
+ .get_puzzle_agent_session(
+ compile_session_id.clone(),
+ owner_user_id.clone(),
+ )
+ .await
+ .map(mark_puzzle_initial_generation_started_snapshot)
+ .map_err(map_puzzle_client_error)
+ } else {
+ let compiled_session = state
+ .spacetime_client()
+ .compile_puzzle_agent_draft(
compile_session_id.clone(),
owner_user_id.clone(),
- prompt_text,
- primary_reference_image_src,
- payload.image_model.as_deref(),
now,
)
.await
- },
- )
- .await
+ .map_err(map_puzzle_compile_error);
+ match compiled_session {
+ Ok(compiled_session) => {
+ let response_session =
+ mark_puzzle_initial_generation_started_snapshot(
+ compiled_session.clone(),
+ );
+ let background_state = state.clone();
+ let background_request_context = request_context.clone();
+ let background_session_id = compile_session_id.clone();
+ let background_owner_user_id = owner_user_id.clone();
+ let background_prompt_text = prompt_text.map(str::to_string);
+ let background_reference_image_src =
+ primary_reference_image_src.map(str::to_string);
+ let background_image_model = payload.image_model.clone();
+ let background_billing_asset_id =
+ format!("{background_session_id}:compile_puzzle_draft");
+ tokio::spawn(async move {
+ let operation_owner_user_id =
+ background_owner_user_id.clone();
+ let background_root_state =
+ background_state.root_state().clone();
+ let operation_state = background_state.clone();
+ let result = execute_billable_asset_operation_with_cost(
+ &background_root_state,
+ &background_owner_user_id,
+ "puzzle_initial_image",
+ &background_billing_asset_id,
+ PUZZLE_IMAGE_GENERATION_POINTS_COST,
+ async move {
+ generate_puzzle_initial_cover_from_compiled_session(
+ &operation_state,
+ &background_request_context,
+ compiled_session,
+ operation_owner_user_id,
+ background_prompt_text.as_deref(),
+ background_reference_image_src.as_deref(),
+ background_image_model.as_deref(),
+ current_utc_micros(),
+ )
+ .await
+ },
+ )
+ .await;
+ match result {
+ Ok(session) => {
+ tracing::info!(
+ provider = PUZZLE_AGENT_API_BASE_PROVIDER,
+ session_id = %session.session_id,
+ owner_user_id = %background_owner_user_id,
+ "拼图首图后台生成任务完成"
+ );
+ }
+ Err(error) => {
+ let error_message = error.body_text();
+ let failure_result = background_state
+ .spacetime_client()
+ .mark_puzzle_draft_generation_failed(
+ PuzzleDraftCompileFailureRecordInput {
+ session_id: background_session_id.clone(),
+ owner_user_id: background_owner_user_id
+ .clone(),
+ error_message: error_message.clone(),
+ failed_at_micros: current_utc_micros(),
+ },
+ )
+ .await;
+ if let Err(mark_error) = failure_result {
+ tracing::warn!(
+ provider = PUZZLE_AGENT_API_BASE_PROVIDER,
+ session_id = %background_session_id,
+ owner_user_id = %background_owner_user_id,
+ message = %mark_error,
+ "拼图首图后台生成失败态回写失败"
+ );
+ }
+ tracing::warn!(
+ provider = PUZZLE_AGENT_API_BASE_PROVIDER,
+ session_id = %background_session_id,
+ owner_user_id = %background_owner_user_id,
+ message = %error_message,
+ "拼图首图后台生成任务失败"
+ );
+ }
+ }
+ unregister_puzzle_background_compile_task(
+ &background_session_id,
+ );
+ });
+ Ok(response_session)
+ }
+ Err(error) => {
+ unregister_puzzle_background_compile_task(&compile_session_id);
+ Err(error)
+ }
+ }
+ }
} else {
compile_puzzle_draft_with_uploaded_cover(
&state,
@@ -716,7 +817,7 @@ pub async fn execute_puzzle_agent_action(
"compile_puzzle_draft",
"首关拼图草稿",
if ai_redraw {
- "已编译首关草稿、并行生成首关画面和 UI 背景并写入正式草稿。"
+ "已编译首关草稿,并启动首关画面和 UI 资产后台生成。"
} else {
"已编译首关草稿,并直接应用上传图片、生成 UI 背景为第一关图片。"
},
diff --git a/server-rs/crates/api-server/src/puzzle/tests.rs b/server-rs/crates/api-server/src/puzzle/tests.rs
index 86512e7d..b5b902b9 100644
--- a/server-rs/crates/api-server/src/puzzle/tests.rs
+++ b/server-rs/crates/api-server/src/puzzle/tests.rs
@@ -980,6 +980,41 @@ fn puzzle_work_summary_response_keeps_levels_for_shelf_cover() {
);
}
+#[test]
+fn puzzle_compile_started_snapshot_marks_primary_level_generating() {
+ let mut session = PuzzleAgentSessionRecord {
+ session_id: "puzzle-session-1".to_string(),
+ seed_text: "画面描述:一只猫在雨夜灯牌下回头。".to_string(),
+ current_turn: 1,
+ progress_percent: 88,
+ stage: "draft_ready".to_string(),
+ anchor_pack: test_puzzle_anchor_pack_record(),
+ draft: Some(test_puzzle_draft_record()),
+ messages: Vec::new(),
+ last_assistant_reply: None,
+ published_profile_id: None,
+ suggested_actions: Vec::new(),
+ result_preview: None,
+ updated_at: "2024-01-01T00:00:00Z".to_string(),
+ };
+ {
+ let draft = session.draft.as_mut().expect("draft");
+ draft.generation_status = "idle".to_string();
+ draft.levels[0].generation_status = "idle".to_string();
+ draft.levels[0].cover_image_src = None;
+ draft.levels[0].cover_asset_id = None;
+ }
+
+ let session = mark_puzzle_initial_generation_started_snapshot(session);
+ let draft = session.draft.expect("draft");
+
+ assert_eq!(session.stage, "image_refining");
+ assert_eq!(draft.generation_status, "generating");
+ assert_eq!(draft.levels[0].generation_status, "generating");
+ assert!(draft.cover_image_src.is_none());
+ assert!(draft.levels[0].cover_image_src.is_none());
+}
+
#[test]
fn puzzle_ui_background_prompt_keeps_generated_slots_out_of_background() {
let prompt = build_puzzle_ui_background_request_prompt_for_test("雨夜猫街", "雨夜猫街主题背景");
diff --git a/server-rs/crates/api-server/src/puzzle_clear.rs b/server-rs/crates/api-server/src/puzzle_clear.rs
new file mode 100644
index 00000000..1221df47
--- /dev/null
+++ b/server-rs/crates/api-server/src/puzzle_clear.rs
@@ -0,0 +1,2626 @@
+use axum::{
+ Json,
+ body::{Body, to_bytes},
+ extract::{Extension, Path, State, rejection::JsonRejection},
+ http::{HeaderName, StatusCode, header},
+ response::Response,
+};
+use image::GenericImageView;
+use module_assets::{
+ AssetObjectAccessPolicy, build_asset_entity_binding_input, build_asset_object_upsert_input,
+ generate_asset_binding_id, generate_asset_object_id,
+};
+use platform_oss::{LegacyAssetPrefix, OssHeadObjectRequest, OssObjectAccess};
+use serde_json::{Value, json};
+use shared_contracts::puzzle_clear::{
+ PuzzleClearActionRequest, PuzzleClearActionType, PuzzleClearCardAsset,
+ PuzzleClearDraftResponse, PuzzleClearGenerationStatus, PuzzleClearImageAsset,
+ PuzzleClearNextLevelRequest, PuzzleClearPatternGroup, PuzzleClearRetryLevelRequest,
+ PuzzleClearRunResponse, PuzzleClearSessionResponse, PuzzleClearSessionSnapshotResponse,
+ PuzzleClearStartRunRequest, PuzzleClearSwapRequest, PuzzleClearTimeUpRequest,
+ PuzzleClearWorkDetailResponse, PuzzleClearWorkMutationResponse, PuzzleClearWorksResponse,
+ PuzzleClearWorkspaceCreateRequest,
+};
+use shared_kernel::{build_prefixed_uuid_id, format_timestamp_micros};
+use spacetime_client::SpacetimeClientError;
+use std::{
+ collections::BTreeMap,
+ time::{SystemTime, UNIX_EPOCH},
+};
+
+use crate::{
+ api_response::json_success_body,
+ auth::{AuthenticatedAccessToken, RuntimePrincipal},
+ generated_image_assets::{
+ GeneratedImageAssetAdapter, GeneratedImageAssetDataUrl,
+ adapter::{GeneratedImageAssetAdapterMetadata, GeneratedImageAssetPersistInput},
+ decode_generated_image_asset_data_url, normalize_generated_image_asset_mime,
+ },
+ http_error::AppError,
+ openai_image_generation::{
+ DownloadedOpenAiImage, build_openai_image_http_client, create_openai_image_generation,
+ require_openai_image_settings,
+ },
+ request_context::RequestContext,
+ state::AppState,
+ work_play_tracking::{WorkPlayTrackingDraft, record_work_play_start_after_success},
+};
+
+const PUZZLE_CLEAR_PROVIDER: &str = "puzzle-clear";
+const PUZZLE_CLEAR_CREATION_PROVIDER: &str = "puzzle-clear-creation";
+const PUZZLE_CLEAR_RUNTIME_PROVIDER: &str = "puzzle-clear-runtime";
+const PUZZLE_CLEAR_TEMPLATE_ID: &str = "puzzle-clear";
+const PUZZLE_CLEAR_TEMPLATE_NAME: &str = "拼消消";
+const PUZZLE_CLEAR_RUNTIME_RUNS_ROUTE: &str = "/api/runtime/puzzle-clear/runs";
+const PUZZLE_CLEAR_ATLAS_CELL_SIZE: u32 = 256;
+const PUZZLE_CLEAR_SHEET_COLUMNS: u32 = 4;
+const PUZZLE_CLEAR_SHEET_ROWS: u32 = 6;
+const PUZZLE_CLEAR_SHEET_COLUMNS_USIZE: usize = 4;
+const PUZZLE_CLEAR_SHEET_ROWS_USIZE: usize = 6;
+const PUZZLE_CLEAR_FINAL_ATLAS_COLUMNS: u32 = 10;
+const PUZZLE_CLEAR_FINAL_ATLAS_ROWS: u32 = 10;
+const PUZZLE_CLEAR_SHEET_UNUSED_CELL: &str = ".";
+const PUZZLE_CLEAR_SHEET_FILLER_CELL: &str = "FILL";
+const PUZZLE_CLEAR_ATLAS_GENERATION_SIZE: &str = "1024x1536";
+const PUZZLE_CLEAR_BOARD_BACKGROUND_GENERATION_SIZE: &str = "1024x1024";
+const PUZZLE_CLEAR_CARD_BACK_IMAGE_SRC: &str = "/creation-type-references/puzzle.webp";
+const PUZZLE_CLEAR_SHEET_GENERATION_MAX_ATTEMPTS: usize = 4;
+const PUZZLE_CLEAR_SHEET_FOREGROUND_DIFF_THRESHOLD: i32 = 58;
+const PUZZLE_CLEAR_SHEET_MIN_FOREGROUND_RATIO: f32 = 0.018;
+const PUZZLE_CLEAR_SHEET_BLANK_MAX_FOREGROUND_RATIO: f32 = 0.045;
+const PUZZLE_CLEAR_SHEET_EDGE_RATIO_THRESHOLD: f32 = 0.34;
+const PUZZLE_CLEAR_SHEET_STRONG_EDGE_RATIO_THRESHOLD: f32 = 0.66;
+const PUZZLE_CLEAR_SHEET_INTERNAL_SEAM_DIFF_THRESHOLD: i32 = 170;
+const PUZZLE_CLEAR_SHEET_INTERNAL_SEAM_RATIO_THRESHOLD: f32 = 0.92;
+const PUZZLE_CLEAR_SHEET_INTERNAL_SEAM_SIDE_CONTRAST_THRESHOLD: f32 = 145.0;
+const PUZZLE_CLEAR_SHEET_INTERNAL_SEAM_SIDE_TEXTURE_MAX: f32 = 36.0;
+const PUZZLE_CLEAR_ATLAS_NEGATIVE_PROMPT: &str = "文字、Logo、水印、按钮、UI 字、网格线、编号、标签、边框、外轮廓框、白色描边、白色贴纸边、圆角框、阴影框、分隔线、裁切参考线、单格内部拼接线、内部竖切、内部横切、照片拼贴、相册拼贴、多场景拼贴、双联图、三联图、画中画、单格双图、单格多图、低清晰度、纯色背景、空白背景、白底商品图、孤立主体、单体素材、素材表、图标、贴纸、同品种重复、同一物体多角度、重复同款小图、主体跨格、主体贴边、拼贴、重影、不同图案互相穿插";
+
+pub async fn create_puzzle_clear_session(
+ State(state): State,
+ Extension(request_context): Extension,
+ Extension(authenticated): Extension,
+ payload: Result, JsonRejection>,
+) -> Result, Response> {
+ let Json(payload) =
+ puzzle_clear_json(payload, &request_context, PUZZLE_CLEAR_CREATION_PROVIDER)?;
+ validate_workspace_request(&request_context, &payload)?;
+
+ let owner_user_id = authenticated.claims().user_id().to_string();
+ let session_id = build_prefixed_uuid_id(module_puzzle_clear::PUZZLE_CLEAR_SESSION_ID_PREFIX);
+ let now = current_utc_micros();
+ let draft = build_puzzle_clear_draft(&payload);
+ let session = PuzzleClearSessionSnapshotResponse {
+ session_id,
+ owner_user_id,
+ status: PuzzleClearGenerationStatus::Draft,
+ draft: Some(draft),
+ created_at: format_timestamp_micros(now),
+ updated_at: format_timestamp_micros(now),
+ };
+
+ Ok(json_success_body(
+ Some(&request_context),
+ PuzzleClearSessionResponse {
+ session: state
+ .spacetime_client()
+ .create_puzzle_clear_session(session)
+ .await
+ .map_err(|error| {
+ puzzle_clear_error_response(
+ &request_context,
+ PUZZLE_CLEAR_CREATION_PROVIDER,
+ map_puzzle_clear_client_error(error),
+ )
+ })?,
+ },
+ ))
+}
+
+pub async fn get_puzzle_clear_session(
+ State(state): State,
+ Path(session_id): Path,
+ Extension(request_context): Extension,
+ Extension(authenticated): Extension,
+) -> Result, Response> {
+ ensure_non_empty(&request_context, &session_id, "sessionId")?;
+ let session = state
+ .spacetime_client()
+ .get_puzzle_clear_session(session_id, authenticated.claims().user_id().to_string())
+ .await
+ .map_err(|error| {
+ puzzle_clear_error_response(
+ &request_context,
+ PUZZLE_CLEAR_CREATION_PROVIDER,
+ map_puzzle_clear_client_error(error),
+ )
+ })?;
+
+ Ok(json_success_body(
+ Some(&request_context),
+ PuzzleClearSessionResponse { session },
+ ))
+}
+
+pub async fn execute_puzzle_clear_action(
+ State(state): State,
+ Path(session_id): Path,
+ Extension(request_context): Extension,
+ Extension(authenticated): Extension,
+ payload: Result, JsonRejection>,
+) -> Result, Response> {
+ ensure_non_empty(&request_context, &session_id, "sessionId")?;
+ let Json(payload) =
+ puzzle_clear_json(payload, &request_context, PUZZLE_CLEAR_CREATION_PROVIDER)?;
+ let owner_user_id = authenticated.claims().user_id().to_string();
+ let author_display_name = authenticated
+ .claims()
+ .display_name
+ .as_deref()
+ .unwrap_or("拼消消玩家")
+ .to_string();
+ let mut payload = payload;
+ if let Err(response) = maybe_prepare_puzzle_clear_assets_inner(
+ &state,
+ &request_context,
+ session_id.as_str(),
+ owner_user_id.as_str(),
+ &mut payload,
+ )
+ .await
+ {
+ let (error_message, response) = extract_puzzle_clear_response_error_message(response).await;
+ tracing::warn!(
+ provider = PUZZLE_CLEAR_CREATION_PROVIDER,
+ session_id,
+ error = %error_message,
+ "拼消消素材生成失败,准备回写 failed 状态"
+ );
+ if let Err(writeback_error) = state
+ .spacetime_client()
+ .mark_puzzle_clear_generation_failed(
+ session_id.clone(),
+ owner_user_id.clone(),
+ author_display_name.clone(),
+ payload.clone(),
+ )
+ .await
+ {
+ tracing::warn!(
+ provider = PUZZLE_CLEAR_CREATION_PROVIDER,
+ session_id,
+ error = %writeback_error,
+ "拼消消素材生成失败状态回写失败"
+ );
+ }
+ return Err(response);
+ }
+ let response = state
+ .spacetime_client()
+ .execute_puzzle_clear_action(session_id, owner_user_id, author_display_name, payload)
+ .await
+ .map_err(|error| {
+ puzzle_clear_error_response(
+ &request_context,
+ PUZZLE_CLEAR_CREATION_PROVIDER,
+ map_puzzle_clear_client_error(error),
+ )
+ })?;
+
+ Ok(json_success_body(Some(&request_context), response))
+}
+
+pub async fn list_puzzle_clear_works(
+ State(state): State,
+ Extension(request_context): Extension,
+ Extension(authenticated): Extension,
+) -> Result, Response> {
+ let works = state
+ .spacetime_client()
+ .list_puzzle_clear_works(authenticated.claims().user_id().to_string())
+ .await
+ .map_err(|error| {
+ puzzle_clear_error_response(
+ &request_context,
+ PUZZLE_CLEAR_CREATION_PROVIDER,
+ map_puzzle_clear_client_error(error),
+ )
+ })?;
+
+ Ok(json_success_body(
+ Some(&request_context),
+ PuzzleClearWorksResponse {
+ items: works.into_iter().map(|work| work.summary).collect(),
+ },
+ ))
+}
+
+pub async fn get_puzzle_clear_work(
+ State(state): State,
+ Path(profile_id): Path,
+ Extension(request_context): Extension,
+ Extension(authenticated): Extension,
+) -> Result, Response> {
+ ensure_non_empty(&request_context, &profile_id, "profileId")?;
+ let item = state
+ .spacetime_client()
+ .get_puzzle_clear_work_profile(profile_id, authenticated.claims().user_id().to_string())
+ .await
+ .map_err(|error| {
+ puzzle_clear_error_response(
+ &request_context,
+ PUZZLE_CLEAR_CREATION_PROVIDER,
+ map_puzzle_clear_client_error(error),
+ )
+ })?;
+
+ Ok(json_success_body(
+ Some(&request_context),
+ PuzzleClearWorkDetailResponse { item },
+ ))
+}
+
+pub async fn publish_puzzle_clear_work(
+ State(state): State,
+ Path(profile_id): Path,
+ Extension(request_context): Extension,
+ Extension(authenticated): Extension,
+) -> Result, Response> {
+ ensure_non_empty(&request_context, &profile_id, "profileId")?;
+ let item = state
+ .spacetime_client()
+ .publish_puzzle_clear_work(profile_id, authenticated.claims().user_id().to_string())
+ .await
+ .map_err(|error| {
+ puzzle_clear_error_response(
+ &request_context,
+ PUZZLE_CLEAR_CREATION_PROVIDER,
+ map_puzzle_clear_client_error(error),
+ )
+ })?;
+
+ Ok(json_success_body(
+ Some(&request_context),
+ PuzzleClearWorkMutationResponse { item },
+ ))
+}
+
+pub async fn get_puzzle_clear_runtime_work(
+ State(state): State,
+ Path(profile_id): Path,
+ Extension(request_context): Extension,
+) -> Result, Response> {
+ ensure_non_empty(&request_context, &profile_id, "profileId")?;
+ let item = state
+ .spacetime_client()
+ .get_puzzle_clear_runtime_work(profile_id)
+ .await
+ .map_err(|error| {
+ puzzle_clear_error_response(
+ &request_context,
+ PUZZLE_CLEAR_RUNTIME_PROVIDER,
+ map_puzzle_clear_client_error(error),
+ )
+ })?;
+
+ Ok(json_success_body(
+ Some(&request_context),
+ PuzzleClearWorkDetailResponse { item },
+ ))
+}
+
+pub async fn start_puzzle_clear_run(
+ State(state): State,
+ Extension(request_context): Extension,
+ Extension(principal): Extension,
+ payload: Result, JsonRejection>,
+) -> Result, Response> {
+ let Json(payload) =
+ puzzle_clear_json(payload, &request_context, PUZZLE_CLEAR_RUNTIME_PROVIDER)?;
+ ensure_non_empty(&request_context, &payload.profile_id, "profileId")?;
+ let owner_user_id = principal.subject().to_string();
+ let principal_kind = principal.kind().as_str();
+ let run = state
+ .spacetime_client()
+ .start_puzzle_clear_run(payload, owner_user_id)
+ .await
+ .map_err(|error| {
+ puzzle_clear_error_response(
+ &request_context,
+ PUZZLE_CLEAR_RUNTIME_PROVIDER,
+ map_puzzle_clear_client_error(error),
+ )
+ })?;
+
+ record_work_play_start_after_success(
+ &state,
+ &request_context,
+ build_puzzle_clear_work_play_tracking_draft(
+ &principal,
+ run.profile_id.clone(),
+ PUZZLE_CLEAR_RUNTIME_RUNS_ROUTE,
+ )
+ .owner_user_id(run.owner_user_id.clone())
+ .run_id(run.run_id.clone())
+ .profile_id(run.profile_id.clone())
+ .extra(json!({
+ "runStatus": run.status,
+ "principalKind": principal_kind,
+ "levelIndex": run.level_index,
+ })),
+ )
+ .await;
+
+ Ok(json_success_body(
+ Some(&request_context),
+ PuzzleClearRunResponse { run },
+ ))
+}
+
+pub async fn get_puzzle_clear_run(
+ State(state): State,
+ Path(run_id): Path,
+ Extension(request_context): Extension,
+ Extension(principal): Extension,
+) -> Result, Response> {
+ ensure_non_empty(&request_context, &run_id, "runId")?;
+ let run = state
+ .spacetime_client()
+ .get_puzzle_clear_run(run_id, principal.subject().to_string())
+ .await
+ .map_err(|error| {
+ puzzle_clear_error_response(
+ &request_context,
+ PUZZLE_CLEAR_RUNTIME_PROVIDER,
+ map_puzzle_clear_client_error(error),
+ )
+ })?;
+
+ Ok(json_success_body(
+ Some(&request_context),
+ PuzzleClearRunResponse { run },
+ ))
+}
+
+pub async fn swap_puzzle_clear_cards(
+ State(state): State,
+ Path(run_id): Path,
+ Extension(request_context): Extension,
+ Extension(principal): Extension,
+ payload: Result, JsonRejection>,
+) -> Result, Response> {
+ ensure_non_empty(&request_context, &run_id, "runId")?;
+ let Json(payload) =
+ puzzle_clear_json(payload, &request_context, PUZZLE_CLEAR_RUNTIME_PROVIDER)?;
+ let run = state
+ .spacetime_client()
+ .swap_puzzle_clear_cards(run_id, principal.subject().to_string(), payload)
+ .await
+ .map_err(|error| {
+ puzzle_clear_error_response(
+ &request_context,
+ PUZZLE_CLEAR_RUNTIME_PROVIDER,
+ map_puzzle_clear_client_error(error),
+ )
+ })?;
+
+ Ok(json_success_body(
+ Some(&request_context),
+ PuzzleClearRunResponse { run },
+ ))
+}
+
+pub async fn retry_puzzle_clear_level(
+ State(state): State,
+ Path(run_id): Path,
+ Extension(request_context): Extension,
+ Extension(principal): Extension,
+ payload: Result, JsonRejection>,
+) -> Result, Response> {
+ ensure_non_empty(&request_context, &run_id, "runId")?;
+ let Json(payload) =
+ puzzle_clear_json(payload, &request_context, PUZZLE_CLEAR_RUNTIME_PROVIDER)?;
+ let run = state
+ .spacetime_client()
+ .retry_puzzle_clear_level(run_id, principal.subject().to_string(), payload)
+ .await
+ .map_err(|error| {
+ puzzle_clear_error_response(
+ &request_context,
+ PUZZLE_CLEAR_RUNTIME_PROVIDER,
+ map_puzzle_clear_client_error(error),
+ )
+ })?;
+
+ Ok(json_success_body(
+ Some(&request_context),
+ PuzzleClearRunResponse { run },
+ ))
+}
+
+pub async fn advance_puzzle_clear_next_level(
+ State(state): State,
+ Path(run_id): Path,
+ Extension(request_context): Extension,
+ Extension(principal): Extension,
+ payload: Result, JsonRejection>,
+) -> Result, Response> {
+ ensure_non_empty(&request_context, &run_id, "runId")?;
+ let Json(payload) =
+ puzzle_clear_json(payload, &request_context, PUZZLE_CLEAR_RUNTIME_PROVIDER)?;
+ let run = state
+ .spacetime_client()
+ .advance_puzzle_clear_next_level(run_id, principal.subject().to_string(), payload)
+ .await
+ .map_err(|error| {
+ puzzle_clear_error_response(
+ &request_context,
+ PUZZLE_CLEAR_RUNTIME_PROVIDER,
+ map_puzzle_clear_client_error(error),
+ )
+ })?;
+
+ Ok(json_success_body(
+ Some(&request_context),
+ PuzzleClearRunResponse { run },
+ ))
+}
+
+pub async fn mark_puzzle_clear_level_time_up(
+ State(state): State,
+ Path(run_id): Path,
+ Extension(request_context): Extension,
+ Extension(principal): Extension,
+ payload: Result, JsonRejection>,
+) -> Result, Response> {
+ ensure_non_empty(&request_context, &run_id, "runId")?;
+ let Json(payload) =
+ puzzle_clear_json(payload, &request_context, PUZZLE_CLEAR_RUNTIME_PROVIDER)?;
+ let run = state
+ .spacetime_client()
+ .mark_puzzle_clear_level_time_up(run_id, principal.subject().to_string(), payload)
+ .await
+ .map_err(|error| {
+ puzzle_clear_error_response(
+ &request_context,
+ PUZZLE_CLEAR_RUNTIME_PROVIDER,
+ map_puzzle_clear_client_error(error),
+ )
+ })?;
+
+ Ok(json_success_body(
+ Some(&request_context),
+ PuzzleClearRunResponse { run },
+ ))
+}
+
+pub async fn list_puzzle_clear_gallery(
+ State(state): State,
+ Extension(request_context): Extension,
+) -> Result, Response> {
+ let items = state
+ .spacetime_client()
+ .list_puzzle_clear_gallery()
+ .await
+ .map_err(|error| {
+ puzzle_clear_error_response(
+ &request_context,
+ PUZZLE_CLEAR_RUNTIME_PROVIDER,
+ map_puzzle_clear_client_error(error),
+ )
+ })?;
+
+ Ok(json_success_body(
+ Some(&request_context),
+ json!({
+ "items": items,
+ "hasMore": false,
+ "nextCursor": null,
+ }),
+ ))
+}
+
+pub async fn get_puzzle_clear_gallery_detail(
+ State(state): State,
+ Path(public_work_code): Path,
+ Extension(request_context): Extension,
+) -> Result, Response> {
+ ensure_non_empty(&request_context, &public_work_code, "publicWorkCode")?;
+ let normalized_code = normalize_public_work_code(public_work_code.as_str());
+ let items = state
+ .spacetime_client()
+ .list_puzzle_clear_gallery()
+ .await
+ .map_err(|error| {
+ puzzle_clear_error_response(
+ &request_context,
+ PUZZLE_CLEAR_RUNTIME_PROVIDER,
+ map_puzzle_clear_client_error(error),
+ )
+ })?;
+ let profile_id = items
+ .into_iter()
+ .find(|item| {
+ normalize_public_work_code(
+ build_puzzle_clear_public_work_code(&item.profile_id).as_str(),
+ ) == normalized_code
+ || normalize_public_work_code(item.profile_id.as_str()) == normalized_code
+ })
+ .map(|item| item.profile_id)
+ .ok_or_else(|| {
+ puzzle_clear_error_response(
+ &request_context,
+ PUZZLE_CLEAR_RUNTIME_PROVIDER,
+ AppError::from_status(StatusCode::NOT_FOUND).with_details(json!({
+ "provider": PUZZLE_CLEAR_PROVIDER,
+ "message": "拼消消公开作品不存在",
+ })),
+ )
+ })?;
+ let item = state
+ .spacetime_client()
+ .get_puzzle_clear_work_profile(profile_id, String::new())
+ .await
+ .map_err(|error| {
+ puzzle_clear_error_response(
+ &request_context,
+ PUZZLE_CLEAR_RUNTIME_PROVIDER,
+ map_puzzle_clear_client_error(error),
+ )
+ })?;
+
+ Ok(json_success_body(
+ Some(&request_context),
+ PuzzleClearWorkDetailResponse { item },
+ ))
+}
+
+#[derive(Clone, Debug, PartialEq, Eq)]
+struct PuzzleClearAtlasCardSlice {
+ group: PuzzleClearPatternGroup,
+ task_id: Option,
+ part_x: u32,
+ part_y: u32,
+ bytes: Vec,
+}
+
+#[derive(Clone, Copy, Debug)]
+struct PuzzleClearAtlasSheetSpec {
+ sheet_id: &'static str,
+ layout: [[&'static str; PUZZLE_CLEAR_SHEET_COLUMNS_USIZE]; PUZZLE_CLEAR_SHEET_ROWS_USIZE],
+ layout_prompt: &'static str,
+}
+
+#[derive(Clone, Debug)]
+struct PuzzleClearGeneratedSheet {
+ spec: PuzzleClearAtlasSheetSpec,
+ prompt: String,
+ task_id: String,
+ image: DownloadedOpenAiImage,
+}
+
+async fn maybe_prepare_puzzle_clear_assets_inner(
+ state: &AppState,
+ request_context: &RequestContext,
+ session_id: &str,
+ owner_user_id: &str,
+ payload: &mut PuzzleClearActionRequest,
+) -> Result<(), Response> {
+ if !matches!(
+ payload.action_type,
+ PuzzleClearActionType::CompileDraft | PuzzleClearActionType::RegenerateAtlas
+ ) {
+ return Ok(());
+ }
+ if payload.atlas_asset.is_some()
+ && payload
+ .pattern_groups
+ .as_ref()
+ .is_some_and(|groups| !groups.is_empty())
+ && payload
+ .card_assets
+ .as_ref()
+ .is_some_and(|cards| !cards.is_empty())
+ {
+ return Ok(());
+ }
+
+ let profile_id = payload
+ .profile_id
+ .as_ref()
+ .map(|value| value.trim())
+ .filter(|value| !value.is_empty())
+ .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());
+
+ if payload.generate_board_background.unwrap_or(false)
+ && payload
+ .board_background_asset
+ .as_ref()
+ .is_none_or(|asset| asset.image_src.trim().is_empty())
+ {
+ let board_background_prompt = payload
+ .board_background_prompt
+ .as_deref()
+ .map(str::trim)
+ .filter(|value| !value.is_empty());
+ let theme_prompt = payload.theme_prompt.as_deref().unwrap_or_default();
+ let background_asset = generate_and_persist_puzzle_clear_board_background(
+ state,
+ request_context,
+ owner_user_id,
+ profile_id.as_str(),
+ board_background_prompt.unwrap_or(theme_prompt),
+ )
+ .await?;
+ payload.board_background_asset = Some(background_asset);
+ } else if let Some(asset) = payload.board_background_asset.clone() {
+ if asset.image_src.trim_start().starts_with("data:image/") {
+ payload.board_background_asset = Some(
+ persist_puzzle_clear_data_url_asset(
+ state,
+ request_context,
+ owner_user_id,
+ profile_id.as_str(),
+ "board-background-upload",
+ asset.prompt.as_str(),
+ asset.image_src.as_str(),
+ 1024,
+ 1536,
+ )
+ .await?,
+ );
+ }
+ }
+
+ let groups = planned_puzzle_clear_pattern_groups();
+ let groups_by_id = groups
+ .iter()
+ .cloned()
+ .map(|group| (group.group_id.clone(), group))
+ .collect::>();
+ let sheet_specs = puzzle_clear_atlas_sheet_specs();
+ let settings = require_openai_image_settings(state)
+ .map(|settings| {
+ settings.with_external_api_audit_context(
+ request_context,
+ Some(owner_user_id.to_string()),
+ Some(profile_id.clone()),
+ )
+ })
+ .map_err(|error| {
+ puzzle_clear_error_response(request_context, PUZZLE_CLEAR_CREATION_PROVIDER, error)
+ })?;
+ let http_client = build_openai_image_http_client(&settings).map_err(|error| {
+ puzzle_clear_error_response(request_context, PUZZLE_CLEAR_CREATION_PROVIDER, error)
+ })?;
+ let mut generated_sheets = Vec::with_capacity(sheet_specs.len());
+ for sheet_spec in sheet_specs {
+ let sheet_prompt = build_puzzle_clear_atlas_prompt(
+ payload.theme_prompt.as_deref().unwrap_or_default(),
+ &sheet_spec,
+ );
+ let mut accepted_sheet = None;
+ for attempt_index in 0..PUZZLE_CLEAR_SHEET_GENERATION_MAX_ATTEMPTS {
+ let failure_context = format!(
+ "拼消消素材 {} 生成失败,第 {} 次",
+ sheet_spec.sheet_id,
+ attempt_index + 1
+ );
+ let generated = match create_openai_image_generation(
+ &http_client,
+ &settings,
+ sheet_prompt.as_str(),
+ Some(PUZZLE_CLEAR_ATLAS_NEGATIVE_PROMPT),
+ PUZZLE_CLEAR_ATLAS_GENERATION_SIZE,
+ 1,
+ &[],
+ failure_context.as_str(),
+ )
+ .await
+ {
+ Ok(generated) => generated,
+ Err(error)
+ if attempt_index + 1 < PUZZLE_CLEAR_SHEET_GENERATION_MAX_ATTEMPTS
+ && is_retryable_puzzle_clear_sheet_generation_error(&error) =>
+ {
+ tracing::warn!(
+ provider = PUZZLE_CLEAR_CREATION_PROVIDER,
+ sheet_id = sheet_spec.sheet_id,
+ attempt = attempt_index + 1,
+ generation_error = %error.body_text(),
+ "拼消消素材 sheet 生成遇到可重试上游错误,准备重试"
+ );
+ continue;
+ }
+ Err(error) => {
+ return Err(puzzle_clear_error_response(
+ request_context,
+ PUZZLE_CLEAR_CREATION_PROVIDER,
+ error,
+ ));
+ }
+ };
+ let task_id = generated.task_id;
+ let image = generated.images.into_iter().next().ok_or_else(|| {
+ puzzle_clear_error_response(
+ request_context,
+ PUZZLE_CLEAR_CREATION_PROVIDER,
+ AppError::from_status(StatusCode::BAD_GATEWAY).with_details(json!({
+ "provider": "vector-engine",
+ "message": format!("拼消消素材 {} 生成成功但未返回图片。", sheet_spec.sheet_id),
+ })),
+ )
+ })?;
+ match validate_puzzle_clear_sheet_quality(&image, &sheet_spec) {
+ Ok(()) => {
+ accepted_sheet = Some(PuzzleClearGeneratedSheet {
+ spec: sheet_spec,
+ prompt: sheet_prompt.clone(),
+ task_id,
+ image,
+ });
+ break;
+ }
+ Err(error) if attempt_index + 1 < PUZZLE_CLEAR_SHEET_GENERATION_MAX_ATTEMPTS => {
+ tracing::warn!(
+ provider = PUZZLE_CLEAR_CREATION_PROVIDER,
+ sheet_id = sheet_spec.sheet_id,
+ attempt = attempt_index + 1,
+ quality_error = %error.body_text(),
+ "拼消消素材 sheet 质量校验未通过,准备重试"
+ );
+ }
+ Err(error) => {
+ tracing::warn!(
+ provider = PUZZLE_CLEAR_CREATION_PROVIDER,
+ sheet_id = sheet_spec.sheet_id,
+ attempt = attempt_index + 1,
+ quality_error = %error.body_text(),
+ "拼消消素材 sheet 质量校验最终未通过"
+ );
+ return Err(puzzle_clear_error_response(
+ request_context,
+ PUZZLE_CLEAR_CREATION_PROVIDER,
+ error,
+ ));
+ }
+ }
+ }
+ let Some(accepted_sheet) = accepted_sheet else {
+ return Err(puzzle_clear_error_response(
+ request_context,
+ PUZZLE_CLEAR_CREATION_PROVIDER,
+ AppError::from_status(StatusCode::BAD_GATEWAY).with_details(json!({
+ "provider": PUZZLE_CLEAR_CREATION_PROVIDER,
+ "message": format!("拼消消素材 {} 多次生成后仍未得到可切图集。", sheet_spec.sheet_id),
+ })),
+ ));
+ };
+ generated_sheets.push(accepted_sheet);
+ }
+
+ let mut slices = Vec::new();
+ for generated_sheet in &generated_sheets {
+ slices.extend(
+ slice_puzzle_clear_sheet(
+ &generated_sheet.image,
+ &generated_sheet.spec,
+ &groups_by_id,
+ generated_sheet.task_id.as_str(),
+ )
+ .map_err(|error| {
+ puzzle_clear_error_response(request_context, PUZZLE_CLEAR_CREATION_PROVIDER, error)
+ })?,
+ );
+ }
+ let atlas_image =
+ compose_puzzle_clear_final_atlas(&slices, &groups_by_id).map_err(|error| {
+ puzzle_clear_error_response(request_context, PUZZLE_CLEAR_CREATION_PROVIDER, error)
+ })?;
+ let atlas_prompt = generated_sheets
+ .iter()
+ .map(|sheet| format!("{}:\n{}", sheet.spec.sheet_id, sheet.prompt))
+ .collect::>()
+ .join("\n\n---\n\n");
+ let atlas_task_ids = generated_sheets
+ .iter()
+ .map(|sheet| sheet.task_id.as_str())
+ .collect::>()
+ .join(",");
+ let atlas_asset = persist_puzzle_clear_generated_image_asset(
+ state,
+ owner_user_id,
+ profile_id.as_str(),
+ "atlas",
+ atlas_prompt.as_str(),
+ Some(atlas_task_ids.as_str()),
+ atlas_image,
+ PUZZLE_CLEAR_ATLAS_CELL_SIZE * PUZZLE_CLEAR_FINAL_ATLAS_COLUMNS,
+ PUZZLE_CLEAR_ATLAS_CELL_SIZE * PUZZLE_CLEAR_FINAL_ATLAS_ROWS,
+ request_context,
+ )
+ .await?;
+
+ let mut card_assets = Vec::with_capacity(slices.len());
+ for slice in slices {
+ let task_id = slice.task_id.clone();
+ card_assets.push(
+ persist_puzzle_clear_card_slice(
+ state,
+ owner_user_id,
+ profile_id.as_str(),
+ task_id.as_deref(),
+ slice,
+ request_context,
+ )
+ .await?,
+ );
+ }
+
+ payload.atlas_asset = Some(atlas_asset);
+ payload.pattern_groups = Some(groups);
+ payload.card_assets = Some(card_assets);
+ tracing::info!(
+ provider = PUZZLE_CLEAR_CREATION_PROVIDER,
+ session_id,
+ profile_id,
+ group_count = payload.pattern_groups.as_ref().map_or(0, Vec::len),
+ card_count = payload.card_assets.as_ref().map_or(0, Vec::len),
+ "拼消消素材 atlas 已生成、切片并写入资产索引"
+ );
+ Ok(())
+}
+
+async fn extract_puzzle_clear_response_error_message(response: Response) -> (String, Response) {
+ let status = response.status();
+ let (parts, body) = response.into_parts();
+ let body_bytes = match to_bytes(body, 64 * 1024).await {
+ Ok(bytes) => bytes,
+ Err(_) => {
+ let rebuilt = Response::from_parts(parts, Body::empty());
+ return (format!("拼消消素材生成失败:{status}"), rebuilt);
+ }
+ };
+ let body_text = String::from_utf8_lossy(&body_bytes).trim().to_string();
+ let message = if body_text.is_empty() {
+ format!("拼消消素材生成失败:{status}")
+ } else if let Ok(body_json) = serde_json::from_str::(&body_text) {
+ body_json
+ .get("error")
+ .and_then(|error| error.get("message"))
+ .and_then(Value::as_str)
+ .map(str::trim)
+ .filter(|message| !message.is_empty())
+ .unwrap_or(body_text.as_str())
+ .to_string()
+ } else {
+ body_text
+ };
+ let rebuilt = Response::from_parts(parts, Body::from(body_bytes));
+ (message, rebuilt)
+}
+
+fn planned_puzzle_clear_pattern_groups() -> Vec {
+ module_puzzle_clear::plan_puzzle_clear_pattern_groups(PUZZLE_CLEAR_ATLAS_CELL_SIZE)
+ .unwrap_or_default()
+ .into_iter()
+ .map(|group| PuzzleClearPatternGroup {
+ group_id: group.group_id,
+ shape: group.shape.as_str().to_string(),
+ width: group.width,
+ height: group.height,
+ atlas_x: group.atlas_x,
+ atlas_y: group.atlas_y,
+ atlas_width: group.atlas_width,
+ atlas_height: group.atlas_height,
+ })
+ .collect()
+}
+
+fn puzzle_clear_atlas_sheet_specs() -> Vec {
+ vec![
+ PuzzleClearAtlasSheetSpec {
+ sheet_id: "sheet-01",
+ layout: [
+ ["D01", "D01", "D01", "A02"],
+ ["D01", "D01", "D01", "A02"],
+ ["D02", "D02", "C01", "C01"],
+ ["D02", "D02", "C01", "C01"],
+ ["D02", "D02", "C02", "C02"],
+ ["A01", "A01", "C02", "C02"],
+ ],
+ layout_prompt: concat!(
+ "本张 sheet 的布局如下,编号只给后台理解,绝对不要画在图中:\n\n",
+ "第1行:D01 D01 D01 A02\n",
+ "第2行:D01 D01 D01 A02\n",
+ "第3行:D02 D02 C01 C01\n",
+ "第4行:D02 D02 C01 C01\n",
+ "第5行:D02 D02 C02 C02\n",
+ "第6行:A01 A01 C02 C02\n\n",
+ "A 表示 1x2 复合图案,C 表示 2x2 复合图案,D 表示 2x3 或 3x2 复合图案。相同编号表示同一视觉家族:请生成一组主题一致、共享同一场景锚点的小照片裁片;同组格子要像同一套连拍或同一场景的不同局部,彼此能看出是同一个故事或场景家族,但每个 256 单元仍需完整可读,不要做成彼此无关的随机独立小图。"
+ ),
+ },
+ PuzzleClearAtlasSheetSpec {
+ sheet_id: "sheet-02",
+ layout: [
+ ["D03", "D03", "D03", "A04"],
+ ["D03", "D03", "D03", "A04"],
+ ["C03", "C03", "C04", "C04"],
+ ["C03", "C03", "C04", "C04"],
+ ["B01", "B01", "B01", "A06"],
+ ["B03", "B03", "B03", "A06"],
+ ],
+ layout_prompt: concat!(
+ "本张 sheet 的布局如下,编号只给后台理解,绝对不要画在图中:\n\n",
+ "第1行:D03 D03 D03 A04\n",
+ "第2行:D03 D03 D03 A04\n",
+ "第3行:C03 C03 C04 C04\n",
+ "第4行:C03 C03 C04 C04\n",
+ "第5行:B01 B01 B01 A06\n",
+ "第6行:B03 B03 B03 A06\n\n",
+ "A 表示 1x2 复合图案,B 表示 1x3 或 3x1 复合图案,C 表示 2x2 复合图案,D 表示 2x3 或 3x2 复合图案。相同编号表示同一视觉家族:请生成一组主题一致、共享同一场景锚点的小照片裁片;同组格子要像同一套连拍或同一场景的不同局部,彼此能看出是同一个故事或场景家族,但每个 256 单元仍需完整可读,不要做成彼此无关的随机独立小图。"
+ ),
+ },
+ PuzzleClearAtlasSheetSpec {
+ sheet_id: "sheet-03",
+ layout: [
+ ["B02", "B04", "A03", "A03"],
+ ["B02", "B04", "A05", "A05"],
+ ["B02", "B04", "A07", "A07"],
+ ["B05", "B05", "B05", "A08"],
+ ["A09", "A09", "A10", "A08"],
+ ["A11", "A11", "A10", PUZZLE_CLEAR_SHEET_FILLER_CELL],
+ ],
+ layout_prompt: concat!(
+ "本张 sheet 的布局如下,编号只给后台理解,绝对不要画在图中:\n\n",
+ "第1行:B02 B04 A03 A03\n",
+ "第2行:B02 B04 A05 A05\n",
+ "第3行:B02 B04 A07 A07\n",
+ "第4行:B05 B05 B05 A08\n",
+ "第5行:A09 A09 A10 A08\n",
+ "第6行:A11 A11 A10 FILL\n\n",
+ "A 表示 1x2 复合图案,B 表示 1x3 或 3x1 复合图案。FILL 是后台会丢弃的补位格,请画成主题一致但不参与玩法的小照片裁片,不要写字或编号。相同编号表示同一视觉家族:请生成一组主题一致、共享同一场景锚点的小照片裁片;同组格子要像同一套连拍或同一场景的不同局部,彼此能看出是同一个故事或场景家族,但每个 256 单元仍需完整可读,不要做成彼此无关的随机独立小图。"
+ ),
+ },
+ PuzzleClearAtlasSheetSpec {
+ sheet_id: "sheet-04",
+ layout: [
+ ["A12", "A13", "A13", "A14"],
+ ["A12", "A15", "A15", "A14"],
+ ["A16", "A17", "A17", "A18"],
+ ["A16", "A19", "A19", "A18"],
+ ["A20", "A21", "A21", "A22"],
+ ["A20", "A23", "A23", "A22"],
+ ],
+ layout_prompt: concat!(
+ "本张 sheet 的布局如下,编号只给后台理解,绝对不要画在图中:\n\n",
+ "第1行:A12 A13 A13 A14\n",
+ "第2行:A12 A15 A15 A14\n",
+ "第3行:A16 A17 A17 A18\n",
+ "第4行:A16 A19 A19 A18\n",
+ "第5行:A20 A21 A21 A22\n",
+ "第6行:A20 A23 A23 A22\n\n",
+ "A 表示 1x2 复合图案。相同编号表示同一视觉家族:横向 1x2 和纵向 1x2 要共享同一场景锚点,用色调、道具和背景线索互相呼应;每个 256 单元仍需完整可读,但不要做成彼此无关的随机独立小图。"
+ ),
+ },
+ ]
+}
+
+fn build_puzzle_clear_atlas_prompt(
+ theme_prompt: &str,
+ sheet_spec: &PuzzleClearAtlasSheetSpec,
+) -> String {
+ let subject = normalize_non_empty_str(theme_prompt).unwrap_or_else(|| "梦幻物件".to_string());
+ format!(
+ concat!(
+ "生成一张拼消消素材工作表,主题是「{subject}」,竖版 1024x1536。\n\n",
+ "这张图供程序后台按 4 列 x 6 行裁切,每个裁切单元为 256x256 的正方形。4x6 网格只用于后台理解,画面中绝对不要画出网格线、切分线、边框、编号或坐标。\n\n",
+ "这不是单个物体素材表,而是一组照片式构图、绘本式渲染的主题微场景拼图卡。每个编号区域都要属于同一视觉家族,必须有明确背景、环境、道具、光影和构图线索,像从同一组丰富照片或插画中裁出的局部。\n\n",
+ "每个 256x256 单元本身就是一张完整的单场景照片裁片,单元内部只能有一个连续画面;同组之间要共享同一场景锚点、主色和道具语言。禁止在一个单元内部出现两张照片、两个不同场景、拼接线、分割线、内部竖切、内部横切、左右/上下两块不同背景。场景变化只能发生在 256 单元边界上。\n\n",
+ "相同编号连续占据的格子表示同一视觉家族,不是随机独立小图。请把同编号区域画成一组可辨认的兄弟卡片,至少共享一个明显场景锚点(同一张桌子、同一窗景、同一庭院、同一篮子或同一器物系统);每个格子可以展示这个家族的不同局部、视角或连贯片段,但仍需完整可读,不能在单格内部再切出第二张图或第二个场景。\n\n",
+ "同一张 sheet 内,不同编号必须使用不同视觉概念,并且拉开主色、场景和道具,不要把同一种主体换角度、换大小、换姿势后重复使用。比如主题是水果时,不要重复生成不同角度的葡萄、菠萝、西瓜、橙子;应扩展为果园、集市摊位、野餐布、果汁杯、厨房案板、甜品盘、篮筐、玻璃罐、窗边餐桌、花园背景等不同场景。\n\n",
+ "每个 256x256 单元独立查看时,都应该有可辨识的局部信息:可以包含主体局部、背景纹理、桌面、草地、天空、建筑、布料、器皿、叶片、阴影或装饰元素。不要让小卡只有一个孤立主体加纯色背景。\n\n",
+ "不同编号区域之间保持干净边界,主体不能越界或挤入相邻编号区域;FILL 补位格可以生成主题一致的小照片裁片,但后台会丢弃它,不要在 FILL 中写字、编号或画规则说明。\n\n",
+ "图案不要做成商品素材表、卡牌、贴纸、图标格子或带框小卡片。不能有外轮廓框、白色描边、圆角框、阴影框、分隔线、参考线或贴纸边。\n\n",
+ "画风为高清、清爽、适合休闲消除游戏的丰富主题插画;颜色鲜明,边缘干净,不能出现文字、Logo、水印、按钮、UI 或教程元素。\n\n",
+ "{layout_prompt}"
+ ),
+ subject = subject,
+ layout_prompt = sheet_spec.layout_prompt
+ )
+}
+
+fn build_puzzle_clear_board_background_prompt(theme_prompt: &str) -> String {
+ let subject = normalize_non_empty_str(theme_prompt).unwrap_or_else(|| "拼消消".to_string());
+ format!(
+ concat!(
+ "生成拼消消中央背景底图,1:1 正方形,尺寸与中央棋盘一致,主题是「{subject}」。",
+ "这张图不是棋盘装饰,而是玩家逐渐消除卡片后慢慢看见的目标画面;",
+ "画面需要让玩家有探索、揭开底图全貌和追求目标完成的感受。",
+ "请以主题为核心设计精致完整的主题场景或主题主视觉,主体清晰、细节丰富、色彩和氛围强绑定主题,强表现力,必须一眼体现主题。",
+ "不要文字、水印、按钮、教程浮层或明显网格。"
+ ),
+ subject = subject
+ )
+}
+
+async fn generate_and_persist_puzzle_clear_board_background(
+ state: &AppState,
+ request_context: &RequestContext,
+ owner_user_id: &str,
+ profile_id: &str,
+ theme_prompt: &str,
+) -> Result {
+ let prompt = build_puzzle_clear_board_background_prompt(theme_prompt);
+ let settings = require_openai_image_settings(state)
+ .map(|settings| {
+ settings.with_external_api_audit_context(
+ request_context,
+ Some(owner_user_id.to_string()),
+ Some(profile_id.to_string()),
+ )
+ })
+ .map_err(|error| {
+ puzzle_clear_error_response(request_context, PUZZLE_CLEAR_CREATION_PROVIDER, error)
+ })?;
+ let http_client = build_openai_image_http_client(&settings).map_err(|error| {
+ puzzle_clear_error_response(request_context, PUZZLE_CLEAR_CREATION_PROVIDER, error)
+ })?;
+ let generated = create_openai_image_generation(
+ &http_client,
+ &settings,
+ prompt.as_str(),
+ Some("文字、水印、按钮、教程浮层、明显网格"),
+ PUZZLE_CLEAR_BOARD_BACKGROUND_GENERATION_SIZE,
+ 1,
+ &[],
+ "拼消消场地底图生成失败",
+ )
+ .await
+ .map_err(|error| {
+ puzzle_clear_error_response(request_context, PUZZLE_CLEAR_CREATION_PROVIDER, error)
+ })?;
+ let task_id = generated.task_id;
+ let image = generated.images.into_iter().next().ok_or_else(|| {
+ puzzle_clear_error_response(
+ request_context,
+ PUZZLE_CLEAR_CREATION_PROVIDER,
+ AppError::from_status(StatusCode::BAD_GATEWAY).with_details(json!({
+ "provider": "vector-engine",
+ "message": "拼消消场地底图生成成功但未返回图片。",
+ })),
+ )
+ })?;
+ persist_puzzle_clear_generated_image_asset(
+ state,
+ owner_user_id,
+ profile_id,
+ "board-background",
+ prompt.as_str(),
+ Some(task_id.as_str()),
+ image,
+ 1024,
+ 1024,
+ request_context,
+ )
+ .await
+}
+
+async fn persist_puzzle_clear_data_url_asset(
+ state: &AppState,
+ request_context: &RequestContext,
+ owner_user_id: &str,
+ profile_id: &str,
+ slot: &str,
+ prompt: &str,
+ data_url: &str,
+ width: u32,
+ height: u32,
+) -> Result {
+ let parsed = decode_generated_image_asset_data_url(data_url).map_err(|error| {
+ puzzle_clear_error_response(
+ request_context,
+ PUZZLE_CLEAR_CREATION_PROVIDER,
+ AppError::from_status(StatusCode::BAD_REQUEST).with_details(json!({
+ "provider": "generated-image-assets",
+ "message": format!("拼消消图片 Data URL 解析失败:{error:?}"),
+ })),
+ )
+ })?;
+ let image = DownloadedOpenAiImage {
+ extension: parsed.format.extension,
+ mime_type: parsed.format.mime_type,
+ bytes: parsed.bytes,
+ };
+ persist_puzzle_clear_generated_image_asset(
+ state,
+ owner_user_id,
+ profile_id,
+ slot,
+ prompt,
+ None,
+ image,
+ width,
+ height,
+ request_context,
+ )
+ .await
+}
+
+#[derive(Clone, Copy, Debug)]
+struct PuzzleClearSheetCellBounds {
+ x0: u32,
+ y0: u32,
+ x1: u32,
+ y1: u32,
+}
+
+impl PuzzleClearSheetCellBounds {
+ fn width(self) -> u32 {
+ self.x1.saturating_sub(self.x0).max(1)
+ }
+
+ fn height(self) -> u32 {
+ self.y1.saturating_sub(self.y0).max(1)
+ }
+
+ fn area(self) -> u32 {
+ self.width().saturating_mul(self.height()).max(1)
+ }
+}
+
+#[derive(Clone, Copy, Debug)]
+struct PuzzleClearSheetCellQuality {
+ foreground_ratio: f32,
+ exposed_edge_count: usize,
+ strongest_edge_ratio: f32,
+ strongest_internal_seam_ratio: f32,
+}
+
+fn validate_puzzle_clear_sheet_quality(
+ image: &DownloadedOpenAiImage,
+ sheet_spec: &PuzzleClearAtlasSheetSpec,
+) -> Result<(), AppError> {
+ // 中文注释:生成图进入正式切片前先做像素级门禁,避免把明显错位的 sheet 持久化成卡牌资产。
+ 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();
+ if source_width < PUZZLE_CLEAR_SHEET_COLUMNS || source_height < PUZZLE_CLEAR_SHEET_ROWS {
+ return Err(
+ AppError::from_status(StatusCode::BAD_GATEWAY).with_details(json!({
+ "provider": PUZZLE_CLEAR_CREATION_PROVIDER,
+ "message": format!("拼消消素材 {} 尺寸过小,无法做切片质量校验。", sheet_spec.sheet_id),
+ })),
+ );
+ }
+
+ let mut findings = Vec::new();
+ let mut advisory_findings = 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);
+ if group_id == PUZZLE_CLEAR_SHEET_UNUSED_CELL {
+ if quality.foreground_ratio > PUZZLE_CLEAR_SHEET_BLANK_MAX_FOREGROUND_RATIO {
+ findings.push(format!("{cell_label} 空白格有主体"));
+ }
+ continue;
+ }
+ if group_id == PUZZLE_CLEAR_SHEET_FILLER_CELL {
+ continue;
+ }
+
+ if quality.foreground_ratio < PUZZLE_CLEAR_SHEET_MIN_FOREGROUND_RATIO {
+ findings.push(format!("{cell_label} 主体过少"));
+ }
+ if quality.strongest_internal_seam_ratio
+ > PUZZLE_CLEAR_SHEET_INTERNAL_SEAM_RATIO_THRESHOLD
+ {
+ findings.push(format!("{cell_label} 单格内部疑似拼接线"));
+ }
+ if quality.exposed_edge_count >= 2
+ && quality.strongest_edge_ratio > PUZZLE_CLEAR_SHEET_STRONG_EDGE_RATIO_THRESHOLD
+ {
+ advisory_findings.push(format!("{cell_label} 主体贴到不同图案边界"));
+ }
+ }
+ }
+
+ if !advisory_findings.is_empty() {
+ tracing::warn!(
+ provider = PUZZLE_CLEAR_CREATION_PROVIDER,
+ sheet_id = sheet_spec.sheet_id,
+ quality_warning = %advisory_findings.join(";"),
+ "拼消消素材 sheet 检测到边界接触,已作为提示继续切片"
+ );
+ }
+
+ if findings.is_empty() {
+ return Ok(());
+ }
+
+ Err(
+ AppError::from_status(StatusCode::BAD_GATEWAY).with_details(json!({
+ "provider": PUZZLE_CLEAR_CREATION_PROVIDER,
+ "reason": "invalid_puzzle_clear_sheet_quality",
+ "message": format!(
+ "拼消消素材 {} 不满足切片质量:{}。请重新生成图集。",
+ sheet_spec.sheet_id,
+ findings.join(";"),
+ ),
+ "findings": findings,
+ })),
+ )
+}
+
+fn is_retryable_puzzle_clear_sheet_generation_error(error: &AppError) -> bool {
+ if !matches!(
+ error.status_code(),
+ StatusCode::BAD_GATEWAY | StatusCode::GATEWAY_TIMEOUT | StatusCode::TOO_MANY_REQUESTS
+ ) {
+ return false;
+ }
+ error
+ .details()
+ .and_then(|details| details.get("retryable"))
+ .and_then(Value::as_bool)
+ .unwrap_or(false)
+}
+
+fn analyze_puzzle_clear_sheet_cell_quality(
+ source: &image::DynamicImage,
+ sheet_spec: &PuzzleClearAtlasSheetSpec,
+ row: u32,
+ col: u32,
+ bounds: PuzzleClearSheetCellBounds,
+) -> PuzzleClearSheetCellQuality {
+ let background = sample_puzzle_clear_sheet_cell_background(source, bounds);
+ let width = bounds.width() as usize;
+ let height = bounds.height() as usize;
+ let mut mask = vec![0u8; width.saturating_mul(height)];
+ let mut foreground_pixels = 0u32;
+ for local_y in 0..height {
+ let y = bounds.y0 + local_y as u32;
+ for local_x in 0..width {
+ let x = bounds.x0 + local_x as u32;
+ if is_puzzle_clear_sheet_foreground_pixel(source.get_pixel(x, y).0, background) {
+ mask[local_y * width + local_x] = 1;
+ foreground_pixels = foreground_pixels.saturating_add(1);
+ }
+ }
+ }
+
+ let (exposed_edge_count, strongest_edge_ratio) =
+ measure_puzzle_clear_sheet_exposed_edges(&mask, width, height, sheet_spec, row, col);
+ let strongest_internal_seam_ratio = measure_puzzle_clear_sheet_internal_seam(source, bounds);
+ PuzzleClearSheetCellQuality {
+ foreground_ratio: foreground_pixels as f32 / bounds.area() as f32,
+ exposed_edge_count,
+ strongest_edge_ratio,
+ strongest_internal_seam_ratio,
+ }
+}
+
+fn puzzle_clear_sheet_cell_bounds(
+ row: u32,
+ col: u32,
+ source_width: u32,
+ source_height: u32,
+) -> PuzzleClearSheetCellBounds {
+ let x0 = scale_sheet_coord(col, source_width, PUZZLE_CLEAR_SHEET_COLUMNS);
+ let y0 = scale_sheet_coord(row, source_height, PUZZLE_CLEAR_SHEET_ROWS);
+ let x1 = scale_sheet_coord(col + 1, source_width, PUZZLE_CLEAR_SHEET_COLUMNS)
+ .max(x0 + 1)
+ .min(source_width);
+ let y1 = scale_sheet_coord(row + 1, source_height, PUZZLE_CLEAR_SHEET_ROWS)
+ .max(y0 + 1)
+ .min(source_height);
+ PuzzleClearSheetCellBounds { x0, y0, x1, y1 }
+}
+
+fn is_puzzle_clear_sheet_foreground_pixel(pixel: [u8; 4], background: [u8; 4]) -> bool {
+ if pixel[3] <= 24 {
+ return false;
+ }
+ let alpha_diff = (pixel[3] as i32 - background[3] as i32).abs();
+ let color_diff = (pixel[0] as i32 - background[0] as i32).abs()
+ + (pixel[1] as i32 - background[1] as i32).abs()
+ + (pixel[2] as i32 - background[2] as i32).abs();
+ alpha_diff >= 48 || color_diff >= PUZZLE_CLEAR_SHEET_FOREGROUND_DIFF_THRESHOLD
+}
+
+fn sample_puzzle_clear_sheet_cell_background(
+ source: &image::DynamicImage,
+ bounds: PuzzleClearSheetCellBounds,
+) -> [u8; 4] {
+ let sample_size = (bounds.width().min(bounds.height()) / 12).clamp(2, 8);
+ let points = [
+ (bounds.x0, bounds.y0),
+ (bounds.x1.saturating_sub(sample_size), bounds.y0),
+ (bounds.x0, bounds.y1.saturating_sub(sample_size)),
+ (
+ bounds.x1.saturating_sub(sample_size),
+ bounds.y1.saturating_sub(sample_size),
+ ),
+ ];
+ let mut samples = Vec::new();
+ for (start_x, start_y) in points {
+ let mut totals = [0u32; 4];
+ let mut count = 0u32;
+ for y in start_y..start_y.saturating_add(sample_size).min(bounds.y1) {
+ for x in start_x..start_x.saturating_add(sample_size).min(bounds.x1) {
+ let pixel = source.get_pixel(x, y).0;
+ totals[0] = totals[0].saturating_add(pixel[0] as u32);
+ totals[1] = totals[1].saturating_add(pixel[1] as u32);
+ totals[2] = totals[2].saturating_add(pixel[2] as u32);
+ totals[3] = totals[3].saturating_add(pixel[3] as u32);
+ count = count.saturating_add(1);
+ }
+ }
+ if count > 0 {
+ samples.push([
+ (totals[0] / count) as u8,
+ (totals[1] / count) as u8,
+ (totals[2] / count) as u8,
+ (totals[3] / count) as u8,
+ ]);
+ }
+ }
+
+ samples
+ .into_iter()
+ .max_by_key(|sample| {
+ let max_channel = sample[0].max(sample[1]).max(sample[2]) as u16;
+ let min_channel = sample[0].min(sample[1]).min(sample[2]) as u16;
+ let luminance = sample[0] as u16 + sample[1] as u16 + sample[2] as u16;
+ let saturation = max_channel.saturating_sub(min_channel);
+ (luminance, u16::MAX.saturating_sub(saturation))
+ })
+ .unwrap_or([255, 255, 255, 255])
+}
+
+fn measure_puzzle_clear_sheet_exposed_edges(
+ mask: &[u8],
+ width: usize,
+ height: usize,
+ sheet_spec: &PuzzleClearAtlasSheetSpec,
+ row: u32,
+ col: u32,
+) -> (usize, f32) {
+ if width == 0 || height == 0 || mask.len() < width.saturating_mul(height) {
+ return (0, 0.0);
+ }
+ let band = (width.min(height) / 24).clamp(6, 12);
+ let mut exposed_edges = 0usize;
+ let mut strongest_ratio = 0.0f32;
+ let edge_specs = [
+ ((-1i32, 0i32), 0usize, 0usize, width, band),
+ ((1, 0), 0, height.saturating_sub(band), width, band),
+ ((0, -1), 0, 0, band, height),
+ ((0, 1), width.saturating_sub(band), 0, band, height),
+ ];
+
+ for ((row_delta, col_delta), start_x, start_y, edge_width, edge_height) in edge_specs {
+ if puzzle_clear_sheet_neighbor_is_same_group(sheet_spec, row, col, row_delta, col_delta) {
+ continue;
+ }
+ let mut foreground = 0usize;
+ let mut total = 0usize;
+ for local_y in start_y..start_y.saturating_add(edge_height).min(height) {
+ for local_x in start_x..start_x.saturating_add(edge_width).min(width) {
+ total = total.saturating_add(1);
+ if mask[local_y * width + local_x] != 0 {
+ foreground = foreground.saturating_add(1);
+ }
+ }
+ }
+ if total == 0 {
+ continue;
+ }
+ let ratio = foreground as f32 / total as f32;
+ strongest_ratio = strongest_ratio.max(ratio);
+ if ratio > PUZZLE_CLEAR_SHEET_EDGE_RATIO_THRESHOLD {
+ exposed_edges = exposed_edges.saturating_add(1);
+ }
+ }
+
+ (exposed_edges, strongest_ratio)
+}
+
+fn measure_puzzle_clear_sheet_internal_seam(
+ source: &image::DynamicImage,
+ bounds: PuzzleClearSheetCellBounds,
+) -> f32 {
+ let width = bounds.width();
+ let height = bounds.height();
+ if width < 48 || height < 48 {
+ return 0.0;
+ }
+ let margin_x = (width / 8).clamp(18, 36);
+ let margin_y = (height / 8).clamp(18, 36);
+ let x_start = bounds.x0.saturating_add(margin_x).max(bounds.x0 + 1);
+ let x_end = bounds.x1.saturating_sub(margin_x).max(x_start + 1);
+ let y_start = bounds.y0.saturating_add(margin_y).max(bounds.y0 + 1);
+ let y_end = bounds.y1.saturating_sub(margin_y).max(y_start + 1);
+ let mut strongest = 0.0f32;
+
+ for x in x_start..x_end {
+ let mut strong = 0u32;
+ let mut total = 0u32;
+ for y in y_start..y_end {
+ let left = source.get_pixel(x.saturating_sub(1), y).0;
+ let right = source.get_pixel(x, y).0;
+ if puzzle_clear_rgb_distance(left, right)
+ >= PUZZLE_CLEAR_SHEET_INTERNAL_SEAM_DIFF_THRESHOLD
+ {
+ strong = strong.saturating_add(1);
+ }
+ total = total.saturating_add(1);
+ }
+ if total > 0 {
+ let line_ratio = strong as f32 / total as f32;
+ if line_ratio > PUZZLE_CLEAR_SHEET_INTERNAL_SEAM_RATIO_THRESHOLD
+ && puzzle_clear_sheet_internal_seam_has_flat_split(
+ source, bounds, x, true, x_start, x_end, y_start, y_end,
+ )
+ {
+ strongest = strongest.max(line_ratio);
+ }
+ }
+ }
+
+ for y in y_start..y_end {
+ let mut strong = 0u32;
+ let mut total = 0u32;
+ for x in x_start..x_end {
+ let top = source.get_pixel(x, y.saturating_sub(1)).0;
+ let bottom = source.get_pixel(x, y).0;
+ if puzzle_clear_rgb_distance(top, bottom)
+ >= PUZZLE_CLEAR_SHEET_INTERNAL_SEAM_DIFF_THRESHOLD
+ {
+ strong = strong.saturating_add(1);
+ }
+ total = total.saturating_add(1);
+ }
+ if total > 0 {
+ let line_ratio = strong as f32 / total as f32;
+ if line_ratio > PUZZLE_CLEAR_SHEET_INTERNAL_SEAM_RATIO_THRESHOLD
+ && puzzle_clear_sheet_internal_seam_has_flat_split(
+ source, bounds, y, false, x_start, x_end, y_start, y_end,
+ )
+ {
+ strongest = strongest.max(line_ratio);
+ }
+ }
+ }
+
+ strongest
+}
+
+fn puzzle_clear_sheet_internal_seam_has_flat_split(
+ source: &image::DynamicImage,
+ bounds: PuzzleClearSheetCellBounds,
+ split: u32,
+ vertical: bool,
+ x_start: u32,
+ x_end: u32,
+ y_start: u32,
+ y_end: u32,
+) -> bool {
+ // 中文注释:富场景照片里常有窗框、桌沿、地平线等贯穿强边,只有两侧都近似人工平铺色块时才按拼贴硬失败。
+ let band = (bounds.width().min(bounds.height()) / 10).clamp(14, 28);
+ let (first, second) = if vertical {
+ let left_start = split.saturating_sub(band).max(x_start);
+ let left_end = split.saturating_sub(2).max(left_start);
+ let right_start = split.saturating_add(2).min(x_end);
+ let right_end = split.saturating_add(band).min(x_end).max(right_start);
+ (
+ puzzle_clear_rgb_stats_for_region(source, left_start, left_end, y_start, y_end),
+ puzzle_clear_rgb_stats_for_region(source, right_start, right_end, y_start, y_end),
+ )
+ } else {
+ let top_start = split.saturating_sub(band).max(y_start);
+ let top_end = split.saturating_sub(2).max(top_start);
+ let bottom_start = split.saturating_add(2).min(y_end);
+ let bottom_end = split.saturating_add(band).min(y_end).max(bottom_start);
+ (
+ puzzle_clear_rgb_stats_for_region(source, x_start, x_end, top_start, top_end),
+ puzzle_clear_rgb_stats_for_region(source, x_start, x_end, bottom_start, bottom_end),
+ )
+ };
+
+ if first.count == 0 || second.count == 0 {
+ return false;
+ }
+
+ let side_contrast = puzzle_clear_rgb_stats_distance(first, second);
+ let side_texture = first.texture().max(second.texture());
+ side_contrast >= PUZZLE_CLEAR_SHEET_INTERNAL_SEAM_SIDE_CONTRAST_THRESHOLD
+ && side_texture <= PUZZLE_CLEAR_SHEET_INTERNAL_SEAM_SIDE_TEXTURE_MAX
+}
+
+#[derive(Clone, Copy, Debug, Default)]
+struct PuzzleClearRgbStats {
+ count: u64,
+ sum: [u64; 3],
+ sum_square: [u64; 3],
+}
+
+impl PuzzleClearRgbStats {
+ fn push(&mut self, pixel: [u8; 4]) {
+ self.count = self.count.saturating_add(1);
+ for (index, channel) in pixel.iter().take(3).enumerate() {
+ let value = *channel as u64;
+ self.sum[index] = self.sum[index].saturating_add(value);
+ self.sum_square[index] = self.sum_square[index].saturating_add(value * value);
+ }
+ }
+
+ fn mean_channel(self, index: usize) -> f32 {
+ if self.count == 0 {
+ return 0.0;
+ }
+ self.sum[index] as f32 / self.count as f32
+ }
+
+ fn texture(self) -> f32 {
+ if self.count == 0 {
+ return f32::MAX;
+ }
+ let mut variance_sum = 0.0f32;
+ for index in 0..3 {
+ let mean = self.mean_channel(index);
+ let mean_square = self.sum_square[index] as f32 / self.count as f32;
+ variance_sum += (mean_square - mean * mean).max(0.0);
+ }
+ variance_sum.sqrt()
+ }
+}
+
+fn puzzle_clear_rgb_stats_for_region(
+ source: &image::DynamicImage,
+ x0: u32,
+ x1: u32,
+ y0: u32,
+ y1: u32,
+) -> PuzzleClearRgbStats {
+ let mut stats = PuzzleClearRgbStats::default();
+ if x0 >= x1 || y0 >= y1 {
+ return stats;
+ }
+ for y in (y0..y1).step_by(4) {
+ for x in (x0..x1).step_by(4) {
+ stats.push(source.get_pixel(x, y).0);
+ }
+ }
+ stats
+}
+
+fn puzzle_clear_rgb_stats_distance(left: PuzzleClearRgbStats, right: PuzzleClearRgbStats) -> f32 {
+ (0..3)
+ .map(|index| (left.mean_channel(index) - right.mean_channel(index)).abs())
+ .sum()
+}
+
+fn puzzle_clear_rgb_distance(left: [u8; 4], right: [u8; 4]) -> i32 {
+ (left[0] as i32 - right[0] as i32).abs()
+ + (left[1] as i32 - right[1] as i32).abs()
+ + (left[2] as i32 - right[2] as i32).abs()
+}
+
+fn is_puzzle_clear_sheet_discarded_cell(group_id: &str) -> bool {
+ group_id == PUZZLE_CLEAR_SHEET_UNUSED_CELL || group_id == PUZZLE_CLEAR_SHEET_FILLER_CELL
+}
+
+fn puzzle_clear_sheet_neighbor_is_same_group(
+ sheet_spec: &PuzzleClearAtlasSheetSpec,
+ row: u32,
+ col: u32,
+ row_delta: i32,
+ col_delta: i32,
+) -> bool {
+ let current = sheet_spec.layout[row as usize][col as usize];
+ if is_puzzle_clear_sheet_discarded_cell(current) {
+ return false;
+ }
+ let neighbor_row = row as i32 + row_delta;
+ let neighbor_col = col as i32 + col_delta;
+ if neighbor_row < 0
+ || neighbor_col < 0
+ || neighbor_row >= PUZZLE_CLEAR_SHEET_ROWS as i32
+ || neighbor_col >= PUZZLE_CLEAR_SHEET_COLUMNS as i32
+ {
+ return false;
+ }
+ sheet_spec.layout[neighbor_row as usize][neighbor_col as usize] == current
+}
+
+fn slice_puzzle_clear_sheet(
+ image: &DownloadedOpenAiImage,
+ sheet_spec: &PuzzleClearAtlasSheetSpec,
+ groups_by_id: &BTreeMap,
+ task_id: &str,
+) -> 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();
+ if source_width < 16 || source_height < 16 {
+ return Err(
+ AppError::from_status(StatusCode::BAD_GATEWAY).with_details(json!({
+ "provider": PUZZLE_CLEAR_CREATION_PROVIDER,
+ "message": format!("拼消消素材 {} 尺寸过小,无法切割。", sheet_spec.sheet_id),
+ })),
+ );
+ }
+ let mut slices = Vec::new();
+ let mut cells_by_group: BTreeMap<&str, Vec<(u32, u32)>> = BTreeMap::new();
+ for (row, cells) in sheet_spec.layout.iter().enumerate() {
+ for (col, group_id) in cells.iter().enumerate() {
+ if is_puzzle_clear_sheet_discarded_cell(group_id) {
+ continue;
+ }
+ cells_by_group
+ .entry(*group_id)
+ .or_default()
+ .push((row as u32, col as u32));
+ }
+ }
+
+ for (group_id, cells) in cells_by_group {
+ let group = groups_by_id.get(group_id).ok_or_else(|| {
+ AppError::from_status(StatusCode::INTERNAL_SERVER_ERROR).with_details(json!({
+ "provider": PUZZLE_CLEAR_CREATION_PROVIDER,
+ "message": format!("拼消消素材布局引用了未知图案组:{group_id}"),
+ }))
+ })?;
+ let min_row = cells.iter().map(|(row, _)| *row).min().unwrap_or(0);
+ let min_col = cells.iter().map(|(_, col)| *col).min().unwrap_or(0);
+ let expected_cell_count = (group.width * group.height) as usize;
+ if cells.len() != expected_cell_count {
+ return Err(
+ AppError::from_status(StatusCode::INTERNAL_SERVER_ERROR).with_details(json!({
+ "provider": PUZZLE_CLEAR_CREATION_PROVIDER,
+ "message": format!(
+ "拼消消素材 {} 的布局 {} 格数不匹配,期望 {} 格,实际 {} 格。",
+ sheet_spec.sheet_id,
+ group_id,
+ expected_cell_count,
+ cells.len(),
+ ),
+ })),
+ );
+ }
+ for part_y in 0..group.height {
+ for part_x in 0..group.width {
+ let expected_cell = (min_row + part_y, min_col + part_x);
+ if !cells.contains(&expected_cell) {
+ return Err(AppError::from_status(StatusCode::INTERNAL_SERVER_ERROR)
+ .with_details(json!({
+ "provider": PUZZLE_CLEAR_CREATION_PROVIDER,
+ "message": format!(
+ "拼消消素材 {} 的布局 {} 不是完整连续矩形,缺少第 {} 行第 {} 列。",
+ sheet_spec.sheet_id,
+ group_id,
+ expected_cell.0 + 1,
+ expected_cell.1 + 1,
+ ),
+ })));
+ }
+ }
+ }
+ for (row, col) in cells {
+ let part_x = col.saturating_sub(min_col);
+ let part_y = row.saturating_sub(min_row);
+ let x0 = scale_sheet_coord(col, source_width, PUZZLE_CLEAR_SHEET_COLUMNS);
+ let y0 = scale_sheet_coord(row, source_height, PUZZLE_CLEAR_SHEET_ROWS);
+ let x1 = scale_sheet_coord(col + 1, source_width, PUZZLE_CLEAR_SHEET_COLUMNS)
+ .max(x0 + 1)
+ .min(source_width);
+ let y1 = scale_sheet_coord(row + 1, source_height, PUZZLE_CLEAR_SHEET_ROWS)
+ .max(y0 + 1)
+ .min(source_height);
+ let cropped = source.crop_imm(x0, y0, x1 - x0, y1 - y0).resize_exact(
+ PUZZLE_CLEAR_ATLAS_CELL_SIZE,
+ PUZZLE_CLEAR_ATLAS_CELL_SIZE,
+ image::imageops::FilterType::Lanczos3,
+ );
+ let mut cursor = std::io::Cursor::new(Vec::new());
+ cropped
+ .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),
+ }))
+ })?;
+ slices.push(PuzzleClearAtlasCardSlice {
+ group: group.clone(),
+ task_id: Some(task_id.to_string()),
+ part_x,
+ part_y,
+ bytes: cursor.into_inner(),
+ });
+ }
+ }
+ Ok(slices)
+}
+
+fn compose_puzzle_clear_final_atlas(
+ slices: &[PuzzleClearAtlasCardSlice],
+ groups_by_id: &BTreeMap,
+) -> Result {
+ let width = PUZZLE_CLEAR_ATLAS_CELL_SIZE * PUZZLE_CLEAR_FINAL_ATLAS_COLUMNS;
+ let height = PUZZLE_CLEAR_ATLAS_CELL_SIZE * PUZZLE_CLEAR_FINAL_ATLAS_ROWS;
+ let mut atlas = image::RgbaImage::from_pixel(width, height, image::Rgba([255, 248, 234, 255]));
+ for slice in slices {
+ let group = groups_by_id.get(&slice.group.group_id).ok_or_else(|| {
+ AppError::from_status(StatusCode::INTERNAL_SERVER_ERROR).with_details(json!({
+ "provider": PUZZLE_CLEAR_CREATION_PROVIDER,
+ "message": format!("拼消消最终 atlas 缺少图案组:{}", slice.group.group_id),
+ }))
+ })?;
+ let piece = image::load_from_memory(slice.bytes.as_slice())
+ .map_err(|error| {
+ AppError::from_status(StatusCode::BAD_GATEWAY).with_details(json!({
+ "provider": PUZZLE_CLEAR_CREATION_PROVIDER,
+ "message": format!("拼消消最终 atlas 切片解码失败:{error}"),
+ }))
+ })?
+ .to_rgba8();
+ image::imageops::overlay(
+ &mut atlas,
+ &piece,
+ i64::from(group.atlas_x + slice.part_x * PUZZLE_CLEAR_ATLAS_CELL_SIZE),
+ i64::from(group.atlas_y + slice.part_y * PUZZLE_CLEAR_ATLAS_CELL_SIZE),
+ );
+ }
+ let mut cursor = std::io::Cursor::new(Vec::new());
+ image::DynamicImage::ImageRgba8(atlas)
+ .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!("拼消消最终 atlas 合成失败:{error}"),
+ }))
+ })?;
+ Ok(DownloadedOpenAiImage {
+ extension: "png".to_string(),
+ mime_type: "image/png".to_string(),
+ bytes: cursor.into_inner(),
+ })
+}
+
+fn scale_sheet_coord(value: u32, actual: u32, sheet_cells: u32) -> u32 {
+ ((u64::from(value) * u64::from(actual)) / u64::from(sheet_cells)) as u32
+}
+
+fn normalize_non_empty_str(value: &str) -> Option {
+ let value = value.trim();
+ if value.is_empty() {
+ None
+ } else {
+ Some(value.to_string())
+ }
+}
+
+async fn persist_puzzle_clear_card_slice(
+ state: &AppState,
+ owner_user_id: &str,
+ profile_id: &str,
+ task_id: Option<&str>,
+ slice: PuzzleClearAtlasCardSlice,
+ request_context: &RequestContext,
+) -> Result {
+ let card_id = format!(
+ "{}-part-{}-{}",
+ slice.group.group_id, slice.part_x, slice.part_y
+ );
+ let prompt = format!(
+ "拼消消素材切片 {} {}:{}",
+ slice.group.group_id, slice.part_x, slice.part_y
+ );
+ let image = DownloadedOpenAiImage {
+ extension: "png".to_string(),
+ mime_type: "image/png".to_string(),
+ bytes: slice.bytes,
+ };
+ let persisted = persist_puzzle_clear_generated_image_asset(
+ state,
+ owner_user_id,
+ profile_id,
+ format!("cards/{card_id}").as_str(),
+ prompt.as_str(),
+ task_id,
+ image,
+ PUZZLE_CLEAR_ATLAS_CELL_SIZE,
+ PUZZLE_CLEAR_ATLAS_CELL_SIZE,
+ request_context,
+ )
+ .await?;
+
+ Ok(PuzzleClearCardAsset {
+ card_id,
+ group_id: slice.group.group_id,
+ shape: slice.group.shape,
+ orientation: if slice.group.width >= slice.group.height {
+ "horizontal".to_string()
+ } else {
+ "vertical".to_string()
+ },
+ part_x: slice.part_x,
+ part_y: slice.part_y,
+ image_src: persisted.image_src,
+ image_object_key: persisted.image_object_key,
+ asset_object_id: persisted.asset_object_id,
+ source_atlas_cell: format!("{}:{}:{}", persisted.asset_id, slice.part_x, slice.part_y),
+ })
+}
+
+#[allow(clippy::too_many_arguments)]
+async fn persist_puzzle_clear_generated_image_asset(
+ state: &AppState,
+ owner_user_id: &str,
+ profile_id: &str,
+ slot: &str,
+ prompt: &str,
+ task_id: Option<&str>,
+ image: DownloadedOpenAiImage,
+ width: u32,
+ height: u32,
+ request_context: &RequestContext,
+) -> Result {
+ let image_format = normalize_generated_image_asset_mime(image.mime_type.as_str());
+ let prepared =
+ GeneratedImageAssetAdapter::prepare_put_object(GeneratedImageAssetPersistInput {
+ prefix: LegacyAssetPrefix::PuzzleClearAssets,
+ path_segments: vec![profile_id.to_string(), slot.to_string()],
+ file_stem: "image".to_string(),
+ image: GeneratedImageAssetDataUrl {
+ format: image_format,
+ bytes: image.bytes,
+ },
+ access: OssObjectAccess::Private,
+ metadata: GeneratedImageAssetAdapterMetadata {
+ asset_kind: Some(format!("puzzle-clear-{slot}")),
+ owner_user_id: Some(owner_user_id.to_string()),
+ entity_kind: Some("puzzle_clear_work".to_string()),
+ entity_id: Some(profile_id.to_string()),
+ slot: Some(slot.to_string()),
+ provider: Some("vector-engine".to_string()),
+ task_id: task_id.map(ToString::to_string),
+ },
+ extra_metadata: BTreeMap::new(),
+ })
+ .map_err(|error| {
+ puzzle_clear_error_response(
+ request_context,
+ PUZZLE_CLEAR_CREATION_PROVIDER,
+ AppError::from_status(StatusCode::INTERNAL_SERVER_ERROR).with_details(json!({
+ "provider": "generated-image-assets",
+ "message": format!("准备拼消消图片资产上传请求失败:{error:?}"),
+ })),
+ )
+ })?;
+ let persisted_mime_type = prepared.format.mime_type.clone();
+ let oss_client = state.oss_client().ok_or_else(|| {
+ puzzle_clear_error_response(
+ request_context,
+ PUZZLE_CLEAR_CREATION_PROVIDER,
+ AppError::from_status(StatusCode::SERVICE_UNAVAILABLE).with_details(json!({
+ "provider": "aliyun-oss",
+ "reason": "OSS 未完成环境变量配置",
+ })),
+ )
+ })?;
+ let http_client = reqwest::Client::new();
+ let put_result = oss_client
+ .put_object(&http_client, prepared.request)
+ .await
+ .map_err(|error| {
+ puzzle_clear_error_response(
+ request_context,
+ PUZZLE_CLEAR_CREATION_PROVIDER,
+ AppError::from_status(StatusCode::BAD_GATEWAY).with_details(json!({
+ "provider": "aliyun-oss",
+ "message": error.to_string(),
+ })),
+ )
+ })?;
+ let head = oss_client
+ .head_object(
+ &http_client,
+ OssHeadObjectRequest {
+ object_key: put_result.object_key.clone(),
+ },
+ )
+ .await
+ .map_err(|error| {
+ puzzle_clear_error_response(
+ request_context,
+ PUZZLE_CLEAR_CREATION_PROVIDER,
+ AppError::from_status(StatusCode::BAD_GATEWAY).with_details(json!({
+ "provider": "aliyun-oss",
+ "message": error.to_string(),
+ })),
+ )
+ })?;
+ let now_micros = current_utc_micros();
+ let asset_object_input = build_asset_object_upsert_input(
+ generate_asset_object_id(now_micros),
+ head.bucket,
+ head.object_key.clone(),
+ AssetObjectAccessPolicy::Private,
+ head.content_type.or(Some(persisted_mime_type)),
+ head.content_length,
+ head.etag,
+ format!("puzzle-clear-{slot}"),
+ task_id.map(ToString::to_string),
+ Some(owner_user_id.to_string()),
+ Some(profile_id.to_string()),
+ Some(profile_id.to_string()),
+ now_micros,
+ )
+ .map_err(|error| {
+ puzzle_clear_error_response(
+ request_context,
+ PUZZLE_CLEAR_CREATION_PROVIDER,
+ AppError::from_status(StatusCode::BAD_REQUEST).with_details(json!({
+ "provider": "asset-object",
+ "message": error.to_string(),
+ })),
+ )
+ })?;
+ let asset_object = state
+ .spacetime_client()
+ .confirm_asset_object(asset_object_input)
+ .await
+ .map_err(|error| {
+ puzzle_clear_error_response(
+ request_context,
+ PUZZLE_CLEAR_CREATION_PROVIDER,
+ AppError::from_status(StatusCode::BAD_GATEWAY).with_details(json!({
+ "provider": "spacetimedb",
+ "message": error.to_string(),
+ })),
+ )
+ })?;
+ let binding_input = build_asset_entity_binding_input(
+ generate_asset_binding_id(now_micros),
+ asset_object.asset_object_id.clone(),
+ "puzzle_clear_work".to_string(),
+ profile_id.to_string(),
+ slot.to_string(),
+ format!("puzzle-clear-{slot}"),
+ Some(owner_user_id.to_string()),
+ Some(profile_id.to_string()),
+ now_micros,
+ )
+ .map_err(|error| {
+ puzzle_clear_error_response(
+ request_context,
+ PUZZLE_CLEAR_CREATION_PROVIDER,
+ AppError::from_status(StatusCode::BAD_REQUEST).with_details(json!({
+ "provider": "asset-entity-binding",
+ "message": error.to_string(),
+ })),
+ )
+ })?;
+ state
+ .spacetime_client()
+ .bind_asset_object_to_entity(binding_input)
+ .await
+ .map_err(|error| {
+ puzzle_clear_error_response(
+ request_context,
+ PUZZLE_CLEAR_CREATION_PROVIDER,
+ AppError::from_status(StatusCode::BAD_GATEWAY).with_details(json!({
+ "provider": "spacetimedb",
+ "message": error.to_string(),
+ })),
+ )
+ })?;
+
+ Ok(PuzzleClearImageAsset {
+ asset_id: format!("{profile_id}-{}-{now_micros}", slot.replace('/', "-")),
+ image_src: put_result.legacy_public_path,
+ image_object_key: head.object_key,
+ asset_object_id: asset_object.asset_object_id,
+ generation_provider: "vector-engine".to_string(),
+ prompt: prompt.to_string(),
+ width,
+ height,
+ })
+}
+
+fn build_puzzle_clear_draft(
+ payload: &PuzzleClearWorkspaceCreateRequest,
+) -> PuzzleClearDraftResponse {
+ PuzzleClearDraftResponse {
+ template_id: PUZZLE_CLEAR_TEMPLATE_ID.to_string(),
+ template_name: PUZZLE_CLEAR_TEMPLATE_NAME.to_string(),
+ profile_id: None,
+ work_title: payload.work_title.trim().to_string(),
+ work_description: payload.work_description.trim().to_string(),
+ theme_prompt: payload.theme_prompt.trim().to_string(),
+ board_background_prompt: payload.board_background_prompt.trim().to_string(),
+ generate_board_background: payload.generate_board_background,
+ board_background_asset: payload.board_background_asset.clone(),
+ card_back_image_src: Some(PUZZLE_CLEAR_CARD_BACK_IMAGE_SRC.to_string()),
+ atlas_asset: None,
+ pattern_groups: Vec::new(),
+ card_assets: Vec::new(),
+ generation_status: PuzzleClearGenerationStatus::Draft,
+ }
+}
+
+fn validate_workspace_request(
+ request_context: &RequestContext,
+ payload: &PuzzleClearWorkspaceCreateRequest,
+) -> Result<(), Response> {
+ if payload.template_id.trim() != PUZZLE_CLEAR_TEMPLATE_ID {
+ return Err(puzzle_clear_error_response(
+ request_context,
+ PUZZLE_CLEAR_CREATION_PROVIDER,
+ AppError::from_status(StatusCode::BAD_REQUEST).with_details(json!({
+ "provider": PUZZLE_CLEAR_PROVIDER,
+ "message": "templateId 必须为 puzzle-clear",
+ })),
+ ));
+ }
+ ensure_non_empty(request_context, &payload.work_title, "workTitle")?;
+ ensure_non_empty(request_context, &payload.theme_prompt, "themePrompt")?;
+ Ok(())
+}
+
+fn ensure_non_empty(
+ request_context: &RequestContext,
+ value: &str,
+ field: &str,
+) -> Result<(), Response> {
+ if value.trim().is_empty() {
+ return Err(puzzle_clear_error_response(
+ request_context,
+ PUZZLE_CLEAR_PROVIDER,
+ AppError::from_status(StatusCode::BAD_REQUEST).with_details(json!({
+ "provider": PUZZLE_CLEAR_PROVIDER,
+ "field": field,
+ "message": format!("{field} 不能为空"),
+ })),
+ ));
+ }
+ Ok(())
+}
+
+fn puzzle_clear_json(
+ payload: Result, JsonRejection>,
+ request_context: &RequestContext,
+ provider: &str,
+) -> Result, Response> {
+ payload.map_err(|error| {
+ puzzle_clear_error_response(
+ request_context,
+ provider,
+ AppError::from_status(StatusCode::BAD_REQUEST).with_details(json!({
+ "provider": provider,
+ "message": error.to_string(),
+ })),
+ )
+ })
+}
+
+fn map_puzzle_clear_client_error(error: SpacetimeClientError) -> AppError {
+ let message = error.to_string();
+ let status = match &error {
+ SpacetimeClientError::Runtime(_) => StatusCode::BAD_REQUEST,
+ SpacetimeClientError::Procedure(value)
+ if value.contains("不存在")
+ || value.contains("not found")
+ || value.contains("does not exist") =>
+ {
+ StatusCode::NOT_FOUND
+ }
+ SpacetimeClientError::Procedure(value)
+ if value.contains("发布需要")
+ || value.contains("不能为空")
+ || value.contains("必须")
+ || value.contains("无权") =>
+ {
+ StatusCode::BAD_REQUEST
+ }
+ _ => StatusCode::BAD_GATEWAY,
+ };
+
+ AppError::from_status(status).with_details(json!({
+ "provider": "spacetimedb",
+ "message": message,
+ }))
+}
+
+fn puzzle_clear_error_response(
+ request_context: &RequestContext,
+ provider: &str,
+ error: AppError,
+) -> Response {
+ let mut response = error.into_response_with_context(Some(request_context));
+ response.headers_mut().insert(
+ HeaderName::from_static("x-genarrative-provider"),
+ header::HeaderValue::from_str(provider)
+ .unwrap_or_else(|_| header::HeaderValue::from_static(PUZZLE_CLEAR_PROVIDER)),
+ );
+ response
+}
+
+fn build_puzzle_clear_work_play_tracking_draft(
+ principal: &RuntimePrincipal,
+ work_id: impl Into,
+ source_route: &'static str,
+) -> WorkPlayTrackingDraft {
+ WorkPlayTrackingDraft::runtime_principal("puzzle-clear", work_id, principal, source_route)
+}
+
+fn normalize_public_work_code(value: &str) -> String {
+ value
+ .chars()
+ .filter(|character| character.is_ascii_alphanumeric())
+ .map(|character| character.to_ascii_uppercase())
+ .collect()
+}
+
+fn build_puzzle_clear_public_work_code(profile_id: &str) -> String {
+ let normalized = normalize_public_work_code(profile_id);
+ let fallback = if normalized.is_empty() {
+ "00000000".to_string()
+ } else {
+ normalized
+ };
+ let suffix = if fallback.len() >= 8 {
+ fallback[fallback.len() - 8..].to_string()
+ } else {
+ format!("{fallback:0>8}")
+ };
+ format!("PC-{suffix}")
+}
+
+#[cfg(test)]
+mod tests {
+ use super::{
+ PUZZLE_CLEAR_ATLAS_CELL_SIZE, PUZZLE_CLEAR_ATLAS_NEGATIVE_PROMPT,
+ PUZZLE_CLEAR_BOARD_BACKGROUND_GENERATION_SIZE, PUZZLE_CLEAR_SHEET_FILLER_CELL,
+ PUZZLE_CLEAR_SHEET_UNUSED_CELL, PuzzleClearAtlasSheetSpec, build_puzzle_clear_atlas_prompt,
+ build_puzzle_clear_board_background_prompt, build_puzzle_clear_draft,
+ is_puzzle_clear_sheet_discarded_cell, is_retryable_puzzle_clear_sheet_generation_error,
+ planned_puzzle_clear_pattern_groups, puzzle_clear_atlas_sheet_specs,
+ validate_puzzle_clear_sheet_quality,
+ };
+ use crate::http_error::AppError;
+ use crate::openai_image_generation::DownloadedOpenAiImage;
+ use axum::http::StatusCode;
+ use image::{ImageFormat, Rgba, RgbaImage};
+ use serde_json::json;
+ use shared_contracts::puzzle_clear::PuzzleClearWorkspaceCreateRequest;
+ use std::io::Cursor;
+
+ #[test]
+ fn puzzle_clear_atlas_prompt_uses_sheet_cells_and_subject() {
+ let sheet = puzzle_clear_atlas_sheet_specs()
+ .into_iter()
+ .next()
+ .expect("sheet exists");
+ let prompt = build_puzzle_clear_atlas_prompt("水果", &sheet);
+ assert!(prompt.contains("主题是「水果」"));
+ assert!(prompt.contains("竖版 1024x1536"));
+ assert!(prompt.contains("4 列 x 6 行裁切"));
+ assert!(prompt.contains("256x256 的正方形"));
+ assert!(prompt.contains("完整的单场景照片裁片"));
+ assert!(prompt.contains("照片式构图"));
+ assert!(prompt.contains("主题微场景拼图卡"));
+ assert!(prompt.contains("明确背景、环境、道具、光影和构图线索"));
+ assert!(prompt.contains("每个 256x256 单元本身就是一张完整的单场景照片裁片"));
+ assert!(prompt.contains("禁止在一个单元内部出现两张照片"));
+ assert!(prompt.contains("内部竖切"));
+ assert!(prompt.contains("内部横切"));
+ assert!(prompt.contains("场景变化只能发生在 256 单元边界上"));
+ assert!(prompt.contains("同一视觉家族"));
+ assert!(prompt.contains("同一场景锚点"));
+ assert!(prompt.contains("同一套连拍"));
+ assert!(prompt.contains("彼此无关的随机独立小图"));
+ assert!(prompt.contains("不能在单格内部再切出第二张图或第二个场景"));
+ assert!(prompt.contains("不同编号必须使用不同视觉概念"));
+ assert!(prompt.contains("不要把同一种主体换角度、换大小、换姿势后重复使用"));
+ assert!(prompt.contains("果园、集市摊位、野餐布、果汁杯、厨房案板"));
+ assert!(prompt.contains("可以包含主体局部、背景纹理、桌面、草地、天空"));
+ assert!(prompt.contains("不要让小卡只有一个孤立主体加纯色背景"));
+ assert!(prompt.contains("FILL 补位格可以生成主题一致的小照片裁片"));
+ assert!(prompt.contains("后台会丢弃它"));
+ assert!(prompt.contains("图案不要做成商品素材表、卡牌、贴纸、图标格子或带框小卡片"));
+ assert!(prompt.contains("外轮廓框"));
+ assert!(prompt.contains("贴纸边"));
+ assert!(prompt.contains("圆角框"));
+ assert!(prompt.contains("阴影框"));
+ assert!(PUZZLE_CLEAR_ATLAS_NEGATIVE_PROMPT.contains("纯色背景"));
+ assert!(PUZZLE_CLEAR_ATLAS_NEGATIVE_PROMPT.contains("白底商品图"));
+ assert!(PUZZLE_CLEAR_ATLAS_NEGATIVE_PROMPT.contains("孤立主体"));
+ assert!(PUZZLE_CLEAR_ATLAS_NEGATIVE_PROMPT.contains("同品种重复"));
+ assert!(PUZZLE_CLEAR_ATLAS_NEGATIVE_PROMPT.contains("同一物体多角度"));
+ assert!(PUZZLE_CLEAR_ATLAS_NEGATIVE_PROMPT.contains("单格内部拼接线"));
+ assert!(PUZZLE_CLEAR_ATLAS_NEGATIVE_PROMPT.contains("单格双图"));
+ assert!(PUZZLE_CLEAR_ATLAS_NEGATIVE_PROMPT.contains("照片拼贴"));
+ assert!(PUZZLE_CLEAR_ATLAS_NEGATIVE_PROMPT.contains("相册拼贴"));
+ assert!(!prompt.contains("135 幅"));
+ assert!(!prompt.contains("24 列 x 38 行"));
+ assert!(!prompt.contains("卡牌小格"));
+ assert!(!prompt.contains("卡牌排版图"));
+ assert!(!prompt.contains("贴纸表"));
+ }
+
+ #[test]
+ fn puzzle_clear_sheet_plan_matches_reduced_asset_strategy() {
+ let sheets = puzzle_clear_atlas_sheet_specs();
+ let groups = planned_puzzle_clear_pattern_groups();
+ let occupied_sheet_cells = sheets
+ .iter()
+ .flat_map(|sheet| sheet.layout.iter().flatten())
+ .filter(|group_id| **group_id != PUZZLE_CLEAR_SHEET_UNUSED_CELL)
+ .count();
+ let playable_sheet_cells = sheets
+ .iter()
+ .flat_map(|sheet| sheet.layout.iter().flatten())
+ .filter(|group_id| !is_puzzle_clear_sheet_discarded_cell(group_id))
+ .count();
+ let filler_sheet_cells = sheets
+ .iter()
+ .flat_map(|sheet| sheet.layout.iter().flatten())
+ .filter(|group_id| **group_id == PUZZLE_CLEAR_SHEET_FILLER_CELL)
+ .count();
+ let mut sheet_cells_by_group = std::collections::BTreeMap::<&str, u32>::new();
+ for group_id in sheets
+ .iter()
+ .flat_map(|sheet| sheet.layout.iter().flatten())
+ .filter(|group_id| !is_puzzle_clear_sheet_discarded_cell(group_id))
+ {
+ *sheet_cells_by_group.entry(*group_id).or_default() += 1;
+ }
+ let group_cells = groups
+ .iter()
+ .map(|group| group.width * group.height)
+ .sum::();
+
+ assert_eq!(sheets.len(), 4);
+ assert_eq!(groups.len(), 35);
+ assert_eq!(occupied_sheet_cells, 96);
+ assert_eq!(playable_sheet_cells, 95);
+ assert_eq!(filler_sheet_cells, 1);
+ assert_eq!(group_cells, 95);
+ assert_eq!(PUZZLE_CLEAR_ATLAS_CELL_SIZE, 256);
+ assert_eq!(sheet_cells_by_group.len(), groups.len());
+ for group in &groups {
+ assert_eq!(
+ sheet_cells_by_group.get(group.group_id.as_str()).copied(),
+ Some(group.width * group.height),
+ );
+ }
+ }
+
+ #[test]
+ fn puzzle_clear_sheet_quality_allows_edge_contact_as_advisory_warning() {
+ let sheet = puzzle_clear_atlas_sheet_specs()
+ .into_iter()
+ .find(|sheet| sheet.sheet_id == "sheet-04")
+ .expect("sheet exists");
+ let mut source = RgbaImage::from_pixel(1024, 1536, Rgba([250, 249, 242, 255]));
+ for row in 0..6u32 {
+ for col in 0..4u32 {
+ let base_x = col * PUZZLE_CLEAR_ATLAS_CELL_SIZE;
+ let base_y = row * PUZZLE_CLEAR_ATLAS_CELL_SIZE;
+ let color = Rgba([
+ 70u8.saturating_add((row * 23) as u8),
+ 80u8.saturating_add((col * 31) as u8),
+ 160,
+ 255,
+ ]);
+ for y in base_y + 80..base_y + 176 {
+ for x in base_x + 80..base_x + 176 {
+ source.put_pixel(x, y, color);
+ }
+ }
+ }
+ }
+
+ for y in 0..180u32 {
+ for x in 0..180u32 {
+ source.put_pixel(x, y, Rgba([215, 48, 62, 255]));
+ }
+ }
+
+ let mut encoded = Cursor::new(Vec::new());
+ image::DynamicImage::ImageRgba8(source)
+ .write_to(&mut encoded, ImageFormat::Png)
+ .expect("test image should encode");
+ let image = DownloadedOpenAiImage {
+ extension: "png".to_string(),
+ mime_type: "image/png".to_string(),
+ bytes: encoded.into_inner(),
+ };
+
+ validate_puzzle_clear_sheet_quality(&image, &sheet)
+ .expect("edge contact is advisory because generated sheets often touch borders");
+ }
+
+ fn build_test_puzzle_clear_sheet_image_with_cell_pollution(
+ row: u32,
+ col: u32,
+ ) -> DownloadedOpenAiImage {
+ let mut source = RgbaImage::from_pixel(1024, 1536, Rgba([250, 249, 242, 255]));
+ for row in 0..6u32 {
+ for col in 0..4u32 {
+ let base_x = col * PUZZLE_CLEAR_ATLAS_CELL_SIZE;
+ let base_y = row * PUZZLE_CLEAR_ATLAS_CELL_SIZE;
+ let color = Rgba([
+ 70u8.saturating_add((row * 23) as u8),
+ 80u8.saturating_add((col * 31) as u8),
+ 160,
+ 255,
+ ]);
+ for y in base_y + 80..base_y + 176 {
+ for x in base_x + 80..base_x + 176 {
+ source.put_pixel(x, y, color);
+ }
+ }
+ }
+ }
+
+ let cell_y0 = row * PUZZLE_CLEAR_ATLAS_CELL_SIZE;
+ let cell_x0 = col * PUZZLE_CLEAR_ATLAS_CELL_SIZE;
+ for y in cell_y0 + 40..cell_y0 + PUZZLE_CLEAR_ATLAS_CELL_SIZE - 40 {
+ for x in cell_x0 + 40..cell_x0 + PUZZLE_CLEAR_ATLAS_CELL_SIZE - 40 {
+ source.put_pixel(x, y, Rgba([215, 48, 62, 255]));
+ }
+ }
+
+ let mut encoded = Cursor::new(Vec::new());
+ image::DynamicImage::ImageRgba8(source)
+ .write_to(&mut encoded, ImageFormat::Png)
+ .expect("test image should encode");
+ DownloadedOpenAiImage {
+ extension: "png".to_string(),
+ mime_type: "image/png".to_string(),
+ bytes: encoded.into_inner(),
+ }
+ }
+
+ #[test]
+ fn puzzle_clear_sheet_quality_allows_filler_cell_pollution() {
+ let sheet = puzzle_clear_atlas_sheet_specs()
+ .into_iter()
+ .find(|sheet| sheet.sheet_id == "sheet-03")
+ .expect("sheet exists");
+
+ let image = build_test_puzzle_clear_sheet_image_with_cell_pollution(5, 3);
+
+ validate_puzzle_clear_sheet_quality(&image, &sheet)
+ .expect("filler cell is generated only to stabilize the sheet and is discarded later");
+ }
+
+ #[test]
+ fn puzzle_clear_sheet_quality_rejects_blank_cell_pollution() {
+ let sheet = PuzzleClearAtlasSheetSpec {
+ sheet_id: "blank-test",
+ layout: [
+ [PUZZLE_CLEAR_SHEET_UNUSED_CELL, "A01", "A01", "A02"],
+ ["A03", "A03", "A04", "A04"],
+ ["A05", "A05", "A06", "A06"],
+ ["A07", "A07", "A08", "A08"],
+ ["A09", "A09", "A10", "A10"],
+ ["A11", "A11", "A12", "A12"],
+ ],
+ layout_prompt: "test",
+ };
+ let image = build_test_puzzle_clear_sheet_image_with_cell_pollution(0, 0);
+
+ let error = validate_puzzle_clear_sheet_quality(&image, &sheet)
+ .expect_err("blank cell pollution should be rejected");
+ assert!(error.body_text().contains("空白格有主体"));
+ }
+
+ #[test]
+ fn puzzle_clear_sheet_quality_allows_textured_scene_divider() {
+ let sheet = puzzle_clear_atlas_sheet_specs()
+ .into_iter()
+ .find(|sheet| sheet.sheet_id == "sheet-04")
+ .expect("sheet exists");
+ let mut source = RgbaImage::from_pixel(1024, 1536, Rgba([250, 249, 242, 255]));
+ for row in 0..6u32 {
+ for col in 0..4u32 {
+ let base_x = col * PUZZLE_CLEAR_ATLAS_CELL_SIZE;
+ let base_y = row * PUZZLE_CLEAR_ATLAS_CELL_SIZE;
+ let color = Rgba([
+ 70u8.saturating_add((row * 23) as u8),
+ 80u8.saturating_add((col * 31) as u8),
+ 160,
+ 255,
+ ]);
+ for y in base_y + 80..base_y + 176 {
+ for x in base_x + 80..base_x + 176 {
+ source.put_pixel(x, y, color);
+ }
+ }
+ }
+ }
+
+ for y in 0..PUZZLE_CLEAR_ATLAS_CELL_SIZE {
+ for x in 0..PUZZLE_CLEAR_ATLAS_CELL_SIZE {
+ let noise = ((x * 17 + y * 31) % 120) as u8;
+ let color = if x < PUZZLE_CLEAR_ATLAS_CELL_SIZE / 2 {
+ Rgba([
+ 72u8.saturating_add(noise),
+ 88u8.saturating_add(((x * 7 + y * 11) % 96) as u8),
+ 112u8.saturating_add(((x * 5 + y * 13) % 72) as u8),
+ 255,
+ ])
+ } else {
+ Rgba([
+ 104u8.saturating_add(((x * 19 + y * 3) % 92) as u8),
+ 76u8.saturating_add(noise),
+ 56u8.saturating_add(((x * 11 + y * 23) % 88) as u8),
+ 255,
+ ])
+ };
+ source.put_pixel(x, y, color);
+ }
+ source.put_pixel(PUZZLE_CLEAR_ATLAS_CELL_SIZE / 2, y, Rgba([24, 24, 24, 255]));
+ }
+
+ let mut encoded = Cursor::new(Vec::new());
+ image::DynamicImage::ImageRgba8(source)
+ .write_to(&mut encoded, ImageFormat::Png)
+ .expect("test image should encode");
+ let image = DownloadedOpenAiImage {
+ extension: "png".to_string(),
+ mime_type: "image/png".to_string(),
+ bytes: encoded.into_inner(),
+ };
+
+ validate_puzzle_clear_sheet_quality(&image, &sheet)
+ .expect("textured photo-like scene divider should not be rejected as collage");
+ }
+
+ #[test]
+ fn puzzle_clear_sheet_quality_rejects_internal_photo_seam() {
+ let sheet = puzzle_clear_atlas_sheet_specs()
+ .into_iter()
+ .find(|sheet| sheet.sheet_id == "sheet-04")
+ .expect("sheet exists");
+ let mut source = RgbaImage::from_pixel(1024, 1536, Rgba([250, 249, 242, 255]));
+ for row in 0..6u32 {
+ for col in 0..4u32 {
+ let base_x = col * PUZZLE_CLEAR_ATLAS_CELL_SIZE;
+ let base_y = row * PUZZLE_CLEAR_ATLAS_CELL_SIZE;
+ let color = Rgba([
+ 70u8.saturating_add((row * 23) as u8),
+ 80u8.saturating_add((col * 31) as u8),
+ 160,
+ 255,
+ ]);
+ for y in base_y + 80..base_y + 176 {
+ for x in base_x + 80..base_x + 176 {
+ source.put_pixel(x, y, color);
+ }
+ }
+ }
+ }
+
+ for y in 0..PUZZLE_CLEAR_ATLAS_CELL_SIZE {
+ for x in 0..PUZZLE_CLEAR_ATLAS_CELL_SIZE / 2 {
+ source.put_pixel(x, y, Rgba([206, 46, 62, 255]));
+ }
+ for x in PUZZLE_CLEAR_ATLAS_CELL_SIZE / 2..PUZZLE_CLEAR_ATLAS_CELL_SIZE {
+ source.put_pixel(x, y, Rgba([38, 112, 218, 255]));
+ }
+ }
+
+ let mut encoded = Cursor::new(Vec::new());
+ image::DynamicImage::ImageRgba8(source)
+ .write_to(&mut encoded, ImageFormat::Png)
+ .expect("test image should encode");
+ let image = DownloadedOpenAiImage {
+ extension: "png".to_string(),
+ mime_type: "image/png".to_string(),
+ bytes: encoded.into_inner(),
+ };
+
+ let error = validate_puzzle_clear_sheet_quality(&image, &sheet)
+ .expect_err("internal photo seam should be rejected");
+ assert!(error.body_text().contains("单格内部疑似拼接线"));
+ }
+
+ #[test]
+ fn puzzle_clear_sheet_generation_retries_only_retryable_upstream_errors() {
+ let retryable_error = AppError::from_status(StatusCode::BAD_GATEWAY).with_details(json!({
+ "provider": "vector-engine",
+ "message": "上游服务请求失败",
+ "retryable": true,
+ }));
+ assert!(is_retryable_puzzle_clear_sheet_generation_error(
+ &retryable_error
+ ));
+
+ let non_retryable_gateway =
+ AppError::from_status(StatusCode::BAD_GATEWAY).with_details(json!({
+ "provider": "vector-engine",
+ "message": "上游服务请求失败",
+ "retryable": false,
+ }));
+ assert!(!is_retryable_puzzle_clear_sheet_generation_error(
+ &non_retryable_gateway
+ ));
+
+ let bad_request = AppError::from_status(StatusCode::BAD_REQUEST).with_details(json!({
+ "provider": "vector-engine",
+ "message": "请求参数不合法",
+ "retryable": true,
+ }));
+ assert!(!is_retryable_puzzle_clear_sheet_generation_error(
+ &bad_request
+ ));
+ }
+
+ #[test]
+ fn puzzle_clear_board_background_prompt_reveals_theme_goal() {
+ let prompt = build_puzzle_clear_board_background_prompt("星港花园");
+
+ assert!(prompt.contains("星港花园"));
+ assert!(prompt.contains("逐渐消除"));
+ assert!(prompt.contains("底图全貌"));
+ assert!(prompt.contains("探索"));
+ assert!(prompt.contains("追求目标"));
+ assert!(prompt.contains("尺寸与中央棋盘一致"));
+ assert!(prompt.contains("精致"));
+ assert!(prompt.contains("强表现力"));
+ assert_eq!(PUZZLE_CLEAR_BOARD_BACKGROUND_GENERATION_SIZE, "1024x1024");
+ assert!(!prompt.contains("画面干净"));
+ assert!(!prompt.contains("适合作为卡牌棋盘底图"));
+ assert!(!prompt.contains("9:16"));
+ }
+
+ #[test]
+ fn puzzle_clear_draft_uses_existing_card_back_placeholder() {
+ let draft = build_puzzle_clear_draft(&PuzzleClearWorkspaceCreateRequest {
+ template_id: "puzzle-clear".to_string(),
+ work_title: "星港拼消消".to_string(),
+ work_description: String::new(),
+ theme_prompt: "星港".to_string(),
+ board_background_prompt: String::new(),
+ generate_board_background: true,
+ board_background_asset: None,
+ });
+
+ assert_eq!(
+ draft.card_back_image_src.as_deref(),
+ Some("/creation-type-references/puzzle.webp"),
+ );
+ }
+}
+
+fn current_utc_micros() -> i64 {
+ SystemTime::now()
+ .duration_since(UNIX_EPOCH)
+ .map(|duration| duration.as_micros().min(i64::MAX as u128) as i64)
+ .unwrap_or(0)
+}
diff --git a/server-rs/crates/api-server/src/wooden_fish.rs b/server-rs/crates/api-server/src/wooden_fish.rs
index 4a5fe0cd..1a30d2cb 100644
--- a/server-rs/crates/api-server/src/wooden_fish.rs
+++ b/server-rs/crates/api-server/src/wooden_fish.rs
@@ -229,6 +229,33 @@ pub async fn list_wooden_fish_works(
))
}
+pub async fn delete_wooden_fish_work(
+ State(state): State,
+ Path(profile_id): Path,
+ Extension(request_context): Extension,
+ Extension(authenticated): Extension,
+) -> Result, Response> {
+ ensure_non_empty(&request_context, &profile_id, "profileId")?;
+ let works = state
+ .spacetime_client()
+ .delete_wooden_fish_work(profile_id, authenticated.claims().user_id().to_string())
+ .await
+ .map_err(|error| {
+ wooden_fish_error_response(
+ &request_context,
+ WOODEN_FISH_CREATION_PROVIDER,
+ map_wooden_fish_client_error(error),
+ )
+ })?;
+
+ Ok(json_success_body(
+ Some(&request_context),
+ WoodenFishWorksResponse {
+ items: works.into_iter().map(|work| work.summary).collect(),
+ },
+ ))
+}
+
pub async fn get_wooden_fish_runtime_work(
State(state): State,
Path(profile_id): Path,
@@ -1364,6 +1391,7 @@ fn current_utc_micros() -> i64 {
#[cfg(test)]
mod tests {
use super::*;
+ use crate::AppConfig;
use base64::{Engine as _, engine::general_purpose::STANDARD as BASE64_STANDARD};
#[test]
@@ -1535,8 +1563,7 @@ mod tests {
#[tokio::test]
async fn wooden_fish_draft_uses_default_hit_sound_asset_and_ignores_prompt() {
- let state = crate::state::AppState::new(crate::config::AppConfig::default())
- .expect("state should build");
+ let state = AppState::new(AppConfig::default()).expect("state should build");
let payload = WoodenFishWorkspaceCreateRequest {
template_id: WOODEN_FISH_TEMPLATE_ID.to_string(),
work_title: "今日敲木鱼".to_string(),
diff --git a/server-rs/crates/module-auth/src/domain.rs b/server-rs/crates/module-auth/src/domain.rs
index ac63f925..19c8dae8 100644
--- a/server-rs/crates/module-auth/src/domain.rs
+++ b/server-rs/crates/module-auth/src/domain.rs
@@ -57,10 +57,16 @@ pub struct AuthUser {
pub display_name: String,
#[serde(default)]
pub avatar_url: Option,
+ #[serde(default)]
+ pub phone_number: Option,
pub phone_number_masked: Option,
pub login_method: AuthLoginMethod,
pub binding_status: AuthBindingStatus,
pub wechat_bound: bool,
+ #[serde(default)]
+ pub wechat_display_name: Option,
+ #[serde(default)]
+ pub wechat_account: Option,
pub token_version: u64,
#[serde(default)]
pub created_at: String,
diff --git a/server-rs/crates/module-auth/src/lib.rs b/server-rs/crates/module-auth/src/lib.rs
index 3b0e5677..fd809932 100644
--- a/server-rs/crates/module-auth/src/lib.rs
+++ b/server-rs/crates/module-auth/src/lib.rs
@@ -97,6 +97,33 @@ struct StoredWechatIdentity {
session_key: Option,
}
+fn hydrate_private_auth_fields(
+ state: &InMemoryAuthStoreState,
+ stored_user: &StoredPasswordUser,
+) -> StoredPasswordUser {
+ let mut hydrated = stored_user.clone();
+ if hydrated.user.phone_number.is_none() {
+ hydrated.user.phone_number = hydrated.phone_number.clone();
+ }
+ let hydrated_wechat_identity = state
+ .wechat_identity_by_provider_uid
+ .values()
+ .find(|identity| identity.user_id == hydrated.user.id);
+ if hydrated.user.wechat_display_name.is_none() {
+ hydrated.user.wechat_display_name = hydrated_wechat_identity
+ .and_then(|identity| identity.display_name.clone())
+ .or_else(|| {
+ (hydrated.user.login_method == AuthLoginMethod::Wechat)
+ .then(|| hydrated.user.display_name.clone())
+ });
+ }
+ if hydrated.user.wechat_account.is_none() {
+ hydrated.user.wechat_account =
+ hydrated_wechat_identity.map(|identity| identity.provider_uid.clone());
+ }
+ hydrated
+}
+
#[derive(Clone, Debug)]
pub struct PasswordEntryService {
store: InMemoryAuthStore,
@@ -1024,6 +1051,36 @@ impl InMemoryAuthStore {
.map_err(RefreshSessionError::Store)
}
+ fn resolve_phone_user_locked(
+ state: &mut InMemoryAuthStoreState,
+ phone_number: &str,
+ ) -> Option {
+ if let Some(user_id) = state.phone_to_user_id.get(phone_number).cloned() {
+ if let Some(stored_user) = state
+ .users_by_username
+ .values()
+ .find(|stored_user| stored_user.user.id == user_id)
+ .cloned()
+ {
+ return Some(stored_user);
+ }
+ state.phone_to_user_id.remove(phone_number);
+ }
+
+ let Some(stored_user) = state
+ .users_by_username
+ .values()
+ .find(|stored_user| stored_user.phone_number.as_deref() == Some(phone_number))
+ .cloned()
+ else {
+ return None;
+ };
+ state
+ .phone_to_user_id
+ .insert(phone_number.to_string(), stored_user.user.id.clone());
+ Some(stored_user)
+ }
+
fn find_by_user_id(
&self,
user_id: &str,
@@ -1037,7 +1094,7 @@ impl InMemoryAuthStore {
.users_by_username
.values()
.find(|stored_user| stored_user.user.id == user_id)
- .cloned())
+ .map(|stored_user| hydrate_private_auth_fields(&state, stored_user)))
}
fn ensure_orphan_work_owner_user(
@@ -1077,10 +1134,13 @@ impl InMemoryAuthStore {
username: username.clone(),
display_name,
avatar_url: None,
+ phone_number: None,
phone_number_masked: None,
login_method: AuthLoginMethod::Password,
binding_status: AuthBindingStatus::Active,
wechat_bound: false,
+ wechat_display_name: None,
+ wechat_account: None,
token_version: 1,
created_at,
};
@@ -1111,43 +1171,31 @@ impl InMemoryAuthStore {
.users_by_username
.values()
.find(|stored_user| stored_user.user.public_user_code == public_user_code)
- .cloned())
+ .map(|stored_user| hydrate_private_auth_fields(&state, stored_user)))
}
fn find_by_phone_number(
&self,
phone_number: &str,
) -> Result