Add generationStatus and match3d/runtime fixes
Introduce persistent generationStatus to work summaries (puzzle & match3d) and propagate generation recovery rules across docs and frontend/backends so "generating" is restored from server-side work summary rather than ephemeral front-end notices. Update API server image/asset handling (improve match3d material sheet green/alpha decontamination and promote generatedItemAssets background fields) and add runtime improvements: alpha-based hotspot hit-testing, tray insertion/three-match animation behavior, and session re-read on client-side VectorEngine timeouts/lock-screen interruptions. Many docs, tests and related frontend modules updated/added to reflect these contract and behavior changes.
This commit is contained in:
@@ -16,6 +16,14 @@
|
||||
|
||||
---
|
||||
|
||||
## 2026-05-16 草稿生成中态由后端作品摘要恢复
|
||||
|
||||
- 背景:草稿作品在生成中时,用户退出或刷新页面后,如果前端只依赖内存 notice,生成遮罩会丢失,作品架会误显示为普通草稿。
|
||||
- 决策:作品架与入口壳层恢复生成中态时,统一以后端 work summary 下发的 `generationStatus` 为准;前端内存 notice 只承担本轮会话的即时反馈。拼图草稿在结果页编译时会把 `generating` 写回可持久化的 work profile 关卡状态,抓大鹅草稿则通过 work summary 的素材完整性回推生成态。
|
||||
- 影响范围:草稿页作品架、平台入口壳层、拼图 / 抓大鹅 work summary 契约与后端编排。
|
||||
- 验证方式:刷新或重新进入产品后,仍可从 `generationStatus=generating` 恢复等待遮罩;相关测试见 `creationWorkShelf.test.ts`、拼图 / 抓大鹅 works 合约测试与后端编排测试。
|
||||
- 关联文档:`docs/【玩法创作】平台入口与玩法链路-2026-05-15.md`。
|
||||
|
||||
## 2026-05-15 抓大鹅结果页 UI 预览复用运行态布局
|
||||
|
||||
- 背景:抓大鹅结果页 `素材配置 > UI` 的预览弹层曾手写简化 HUD 和容器布局,和真实运行态顶部关卡卡片、右上设置入口、容器图定位及槽位样式出现漂移。
|
||||
|
||||
@@ -22,6 +22,14 @@
|
||||
- 验证:拼图入口测试仍可通过,且新组件可通过不同页面复用而不需要复制上传卡实现。
|
||||
- 关联:`src/components/common/CreativeImageInputPanel.tsx`、`src/components/puzzle-agent/PuzzleAgentWorkspace.tsx`。
|
||||
|
||||
## 拼图参考图生成草稿报 VectorEngine 编辑接口超时先查降级链路
|
||||
|
||||
- 现象:上传参考图并开启 AI 重绘生成拼图草稿时,页面报 `VectorEngine` 编辑接口超时,生成过程显示未到前端长超时上限但草稿失败。
|
||||
- 原因:参考图 AI 重绘优先走 `/v1/images/edits` multipart,旧逻辑在编辑接口超时后直接失败,没有改走支持弱参考图的 `/v1/images/generations`。
|
||||
- 处理:`api-server` 只在编辑接口超时类错误时降级到 `/v1/images/generations`,把同一参考图压成 Data URL 后放进 `image` 数组;鉴权、参数、参考图格式等非超时错误仍原样返回。
|
||||
- 验证:`cargo test -p api-server puzzle_vector_engine --manifest-path server-rs/Cargo.toml` 覆盖超时映射、降级条件和生成请求体携带参考图。
|
||||
- 关联:`server-rs/crates/api-server/src/puzzle.rs`、`docs/【玩法创作】平台入口与玩法链路-2026-05-15.md`。
|
||||
|
||||
## 汪汪声浪重新开放时不要再回到独立配置阶段
|
||||
|
||||
- 现象:汪汪声浪入口如果继续切换到独立配置阶段,会和拼图、抓大鹅的创作页内嵌结构不一致,用户会感觉入口跳页。
|
||||
@@ -262,6 +270,14 @@
|
||||
- 验证:`npm run test -- src/components/puzzle-result/PuzzleResultView.test.tsx`,以及自动试玩入口测试 `npm run test -- src/components/rpg-entry/RpgEntryFlowShell.agent.interaction.test.tsx -t "puzzle draft generation auto starts trial"`。
|
||||
- 关联:`docs/【玩法创作】平台入口与玩法链路-2026-05-15.md`、`docs/【项目基线】当前产品与工程约束-2026-05-15.md`、`docs/【开发运维】本地开发验证与生产运维-2026-05-15.md`。
|
||||
|
||||
## 拼图某关生成完成后不要抢占其它关卡编辑面板
|
||||
|
||||
- 现象:用户在第 2 关图片生成中继续编辑第 3 关时,第 2 关生成完成回包到达后,结果页关卡详情面板会突然关闭、切回或弹出第 2 关,打断当前输入。
|
||||
- 原因:生成完成回包只包含后端已知关卡快照,可能不包含本地正在编辑或刚新增的关卡;如果 `activeLevelId` 按回包原始 `levels` 校验,就会把当前本地关卡误判为不存在并清空面板状态。
|
||||
- 处理:`PuzzleResultView` 收到新 draft 时先通过 `mergeDraftEditStateWithIncomingState(...)` 得到合并后的本地编辑态,再用合并态维护 `activeLevelId` 和 `generationRuntimeByLevelId`。生成完成只更新对应关卡素材,不主动打开或切换详情面板。
|
||||
- 验证:`npm run test -- src/components/puzzle-result/PuzzleResultView.test.tsx -t "keeps the current level dialog open"`。
|
||||
- 关联:`src/components/puzzle-result/PuzzleResultView.tsx`、`docs/【玩法创作】平台入口与玩法链路-2026-05-15.md`。
|
||||
|
||||
## 自动草稿成功但缺音乐或 UI 先查后端吞错
|
||||
|
||||
- 现象:拼图或抓大鹅生成页提示完成,但草稿页仍显示“暂无音乐”,拼图 UI 仍是默认预览,试玩局内也没有生成音乐或 UI 背景。
|
||||
@@ -270,6 +286,14 @@
|
||||
- 验证:`cargo test -p api-server puzzle_initial_draft_assets_must_include_music_and_ui_background match3d_background_music_ready_requires_audio_src match3d_background_music_title_is_required_for_auto_draft --manifest-path server-rs\Cargo.toml`,并重启 `npm run api-server` 后检查 `/healthz`。
|
||||
- 关联:`docs/【后端架构】server-rs与SpacetimeDB数据契约-2026-05-15.md`、`docs/【玩法创作】平台入口与玩法链路-2026-05-15.md`。
|
||||
|
||||
## 草稿生成中态不能只靠前端 notice
|
||||
|
||||
- 现象:用户在拼图或抓大鹅生成中退出产品或刷新页面后,作品架上的等待遮罩消失,生成中的草稿看起来像普通草稿。
|
||||
- 原因:生成中态只停留在前端内存 notice,后端 work summary 没有稳定下发 `generationStatus`,刷新后无法重建。
|
||||
- 处理:作品架和平台入口壳层统一以后端 work summary 的 `generationStatus` 恢复生成中态;前端 notice 只做当前会话的即时反馈。拼图编译草稿时要把 `generating` 写回可持久化的 work profile,抓大鹅则用素材和背景完整性回推生成态。
|
||||
- 验证:刷新或重新进入后仍能看到等待遮罩;`src/components/custom-world-home/creationWorkShelf.test.ts`、拼图 / 抓大鹅 works 合约测试和后端编排测试覆盖恢复。
|
||||
- 关联:`docs/【玩法创作】平台入口与玩法链路-2026-05-15.md`、`docs/【开发运维】本地开发验证与生产运维-2026-05-15.md`。
|
||||
|
||||
## 拼图草稿生成 180 秒后 502/504 先查 VectorEngine 超时与前端重试
|
||||
|
||||
- 现象:点击“生成拼图游戏草稿”后,`POST /api/runtime/puzzle/agent/sessions/{sessionId}/actions` 等待约 180 秒返回 `502 Bad Gateway` 或 `504 Gateway Timeout`;钱包流水里同一 session 可能出现连续两组 `puzzle_initial_image` 扣费后退款。
|
||||
@@ -278,6 +302,14 @@
|
||||
- 验证:运行 `npm run test -- src/services/creation-agent/creationAgentClientFactory.test.ts src/services/apiClient.test.ts`、`cargo test -p api-server puzzle_vector_engine --manifest-path server-rs/Cargo.toml`,真实联调重启 `npm run api-server` 后检查 `/healthz`。
|
||||
- 关联:`docs/【后端架构】server-rs与SpacetimeDB数据契约-2026-05-15.md`、`docs/【玩法创作】平台入口与玩法链路-2026-05-15.md`。
|
||||
|
||||
## 拼图草稿锁屏后报 VectorEngine 超时先复读 session
|
||||
|
||||
- 现象:电脑锁屏、息屏或浏览器后台挂起后,拼图生成页显示 VectorEngine `images/edits` 超时,但页面计时只显示不到 90 秒;稍后草稿或后端 session 里可能已经有生成好的关卡图。
|
||||
- 原因:前端页面生命周期或网络连接先中断,HTTP action promise 进入失败分支;后端实际长耗时生图和回写可能仍在继续。若前端直接按失败收尾,会把“前端断连”误报成“服务端生成失败”,且重新打开生成页时若用 `Date.now()` 重建状态,会让已耗时看起来被重置。
|
||||
- 处理:拼图 action 失败后先 `getPuzzleAgentSession(sessionId)` 复读最新 session;只要读到 `draft.coverImageSrc`、首关 `coverImageSrc` 或首关候选图,就把 session 规范成 ready、冻结 `finishedAtMs`、刷新作品架并继续自动试玩/结果页链路。生成页从草稿架恢复时用作品 `updatedAt` 还原 `startedAtMs`,完成/失败态不要继续累加耗时。
|
||||
- 验证:`npm run test -- src/components/rpg-entry/RpgEntryFlowShell.agent.interaction.test.tsx -t "embedded puzzle form recovers"`、`npm run test -- src/services/miniGameDraftGenerationProgress.test.ts`、`npm run typecheck`。
|
||||
- 关联:`src/components/platform-entry/PlatformEntryFlowShellImpl.tsx`、`src/services/miniGameDraftGenerationProgress.ts`、`docs/【玩法创作】平台入口与玩法链路-2026-05-15.md`。
|
||||
|
||||
## 本地脚本调 VectorEngine 生图卡住先区分 fetch 首部超时
|
||||
|
||||
- 现象:用 Node `fetch` 直接请求 `POST /v1/images/generations`,已经设置较长的 AbortController 超时,但仍在约 180 到 300 秒后抛 `AbortError`、`TypeError: fetch failed` 或 `UND_ERR_HEADERS_TIMEOUT`;同一 prompt 改用原生 `https.request` 可以在较短时间内成功返回图片。
|
||||
@@ -724,8 +756,8 @@
|
||||
|
||||
- 现象:草稿恢复、结果页素材配置、UI 预览或试玩时仍显示默认背景 / 默认容器,但 work profile 的首个 `generatedItemAssets[].backgroundAsset` 里已经有生成的背景和容器图。
|
||||
- 原因:UI 背景和容器资产没有独立表字段,后端持久化常落在 `generatedItemAssets[].backgroundAsset`;如果 session 映射、结果页 profile、推荐运行态详情补读后不提升到顶层 `generatedBackgroundAsset` 和 `backgroundImageSrc`,后续组件会误判“没有生成 UI 资产”。
|
||||
- 处理:Agent session 返回前要用持久化 work profile 资产回填 draft;前端进入结果页、构建草稿 profile、推荐 / 公开作品启动运行态前,都要把 `generatedItemAssets[].backgroundAsset` 提升为顶层背景字段。容器图在运行态和 UI 预览复用同一套居中 `object-contain` 样式,移动端宽度接近屏宽,只有缺失或加载失败时才使用透明参考图兜底。
|
||||
- 验证:`cargo test -p api-server match3d_agent_session_response_hydrates_persisted_ui_assets --manifest-path server-rs/Cargo.toml`、`npm run test -- src/components/match3d-result/Match3DResultView.test.tsx src/components/match3d-runtime/Match3DRuntimeShell.test.tsx`。
|
||||
- 处理:Agent session 返回前要用持久化 work profile 资产回填 draft;草稿编译后的 `draft_json` 也必须携带 `generated_item_assets_json` 快照,且 HTTP facade 在 work detail 回读为空时不得清空 draft 内已有 UI 资产。前端进入结果页、构建草稿 profile、作品架 / 广场列表刷新、生成完成自动试玩、结果页手动试玩、推荐 / 公开作品启动运行态前,都要把 `generatedItemAssets[].backgroundAsset` 提升为顶层背景字段。容器图在运行态和 UI 预览复用同一套居中 `object-contain` 样式,移动端宽度略大于屏宽并保持原图比例,只有缺失或加载失败时才使用透明参考图兜底。
|
||||
- 验证:`cargo test -p api-server match3d_agent_session_response --manifest-path server-rs/Cargo.toml`、`cargo test -p spacetime-client match3d --manifest-path server-rs/Cargo.toml`、`cargo check -p spacetime-module --manifest-path server-rs/Cargo.toml`、`npm run test -- src/components/match3d-result/Match3DResultView.test.tsx src/components/match3d-runtime/Match3DRuntimeShell.test.tsx`。
|
||||
|
||||
## 抓大鹅重启时不要清空 generated 图片签名缓存
|
||||
|
||||
@@ -819,7 +851,7 @@
|
||||
|
||||
- 现象:抓大鹅生成的物品视角图裁剪后仍带白边,或者整块纯绿色绿幕背景没有被透明化,运行态看到绿色方块。
|
||||
- 原因:素材 sheet 可能是“每格内部绿幕、整张图外圈近白底”,内部绿幕不一定连通到 sheet 外边缘;旧 flood fill 只从外边缘找背景会漏掉这种绿幕块。白底抗锯齿如果不纳入抠像和边缘去污染,也会随裁剪输出成一圈白边。即使顺序已是先整张 sheet 去绿再裁剪,较厚的半透明或混色软绿边仍可能低于高置信绿幕阈值,被当作前景带进独立 PNG。
|
||||
- 处理:`api-server` 的 `slice_match3d_material_sheet` 必须先在整张 sheet 上做透明背景后处理:外边缘连通绿幕/近白底清 alpha,非连通但高置信纯绿块也清 alpha,沿整张 sheet 透明背景继续吃掉软绿边;每个视角单图还要以扩大的 PNG 边界带为种子,把连通的浅绿 / 近白抗锯齿边直接改为透明,再按剩余可见主体收紧裁边,同时保护不够纯的绿色主体像素。不要改成先裁剪单格再去绿。
|
||||
- 处理:`api-server` 的 `slice_match3d_material_sheet` 必须先在整张 sheet 上做透明背景后处理:外边缘连通绿幕/近白底清 alpha,非连通但高置信纯绿块也清 alpha,沿整张 sheet 透明背景继续吃掉软绿边;每个视角单图还要以扩大的 PNG 边界带为种子,把连通的浅绿 / 近白抗锯齿边直接改为透明,并对贴透明背景的弱绿 / 暗绿轮廓像素做去绿污染处理,再按剩余可见主体收紧裁边,同时保护不够纯的绿色主体像素。不要改成先裁剪单格再去绿。
|
||||
- 验证:`cargo test -p api-server match3d_material_sheet_slicing --manifest-path server-rs\Cargo.toml` 覆盖非连通绿幕、白边、贴边主体保留和固定 5x5 切图。
|
||||
- 关联:`docs/【后端架构】server-rs与SpacetimeDB数据契约-2026-05-15.md`、`docs/【玩法创作】平台入口与玩法链路-2026-05-15.md`。
|
||||
|
||||
@@ -865,11 +897,11 @@
|
||||
|
||||
## 拼图 UI 背景只有 objectKey 时不要回退默认 UI
|
||||
|
||||
- 现象:拼图草稿页、试玩和正式运行态都显示默认 UI,或者只在结果页看到生成图,进入试玩后又回到默认背景。
|
||||
- 原因:`uiBackgroundImageSrc` 可能为空而真实生成结果只写了 `uiBackgroundImageObjectKey`;如果前端和运行态只读 `src`,或者本地试玩 / 正式 run 没把 `objectKey` 一起传递,就会丢掉已有背景。
|
||||
- 处理:统一通过一个解析入口把 `uiBackgroundImageSrc || uiBackgroundImageObjectKey` 归一到可展示路径;本地试玩和正式运行态都要保留 `uiBackgroundImageObjectKey`,并在 `uiBackgroundImageSrc` 为空时换签读取。
|
||||
- 验证:结果页 UI Tab、`startLocalPuzzleRun` 和 `PuzzleRuntimeShell` 都应在仅有 `objectKey` 时显示生成背景,不再回落默认 UI。
|
||||
- 关联:`src/services/puzzle-runtime/puzzleUiBackgroundSource.ts`、`src/components/puzzle-result/PuzzleResultView.tsx`、`src/services/puzzle-runtime/puzzleLocalRuntime.ts`、`src/components/puzzle-runtime/PuzzleRuntimeShell.tsx`、`server-rs/crates/module-puzzle/src/application.rs`。
|
||||
- 现象:拼图草稿页、试玩和正式运行态都显示默认 UI,或者只在结果页看到生成图,进入试玩后又回到默认背景;也可能第一关应用了生成 UI 背景,第二关开始回到默认 UI 背景。
|
||||
- 原因:`uiBackgroundImageSrc` 可能为空而真实生成结果只写了 `uiBackgroundImageObjectKey`;如果前端和运行态只读 `src`,或者本地试玩 / 正式 run 没把 `objectKey` 一起传递,就会丢掉已有背景。多关卡作品里 UI 背景是作品运行态背景,不是只属于第一关的关卡图;如果后续关卡字段为空但运行态只读当前关卡字段,也会回退默认 UI。
|
||||
- 处理:统一通过一个解析入口把 `uiBackgroundImageSrc || uiBackgroundImageObjectKey` 归一到可展示路径;本地试玩和正式运行态都要保留 `uiBackgroundImageObjectKey`,并在 `uiBackgroundImageSrc` 为空时换签读取。直达指定关卡或推进同作品后续关卡时,按“目标关卡 UI 背景 > 同作品首个可用 UI 背景 > 当前运行态快照背景 > 默认 UI”解析。
|
||||
- 验证:结果页 UI Tab、`startLocalPuzzleRun`、`PuzzleRuntimeShell` 和 `module-puzzle` 同作品下一关推进都应在仅有 `objectKey` 或后续关卡缺字段时显示生成背景,不再回落默认 UI。
|
||||
- 关联:`src/services/puzzle-runtime/puzzleUiBackgroundSource.ts`、`src/components/puzzle-result/PuzzleResultView.tsx`、`src/services/puzzle-runtime/puzzleLocalRuntime.ts`、`src/components/puzzle-runtime/PuzzleRuntimeShell.tsx`、`server-rs/crates/module-puzzle/src/application.rs`、`server-rs/crates/spacetime-module/src/puzzle.rs`。
|
||||
|
||||
## 拼图 UI 背景提示词或作品元信息异常先查首关命名契约
|
||||
|
||||
@@ -918,3 +950,19 @@
|
||||
- 处理:前端标题和选中标签从 `imageSrc` 路径末尾推导,例如 `image.png`;时间解析兼容 ISO 与 `1713686400.000000Z`;创作页主图、历史列表图和结果页参考图继续用 `ResolvedAssetImage`,提交给后端时仍保留原始 `imageSrc`。
|
||||
- 验证:`npm run test -- src/components/puzzle-agent/PuzzleAgentWorkspace.interaction.test.tsx src/components/puzzle-result/PuzzleResultView.test.tsx`,并执行 `npm run check:encoding`。
|
||||
- 关联:`docs/【后端架构】server-rs与SpacetimeDB数据契约-2026-05-15.md`、`docs/【玩法创作】平台入口与玩法链路-2026-05-15.md`、`docs/【项目基线】当前产品与工程约束-2026-05-15.md`、`docs/【开发运维】本地开发验证与生产运维-2026-05-15.md`。
|
||||
|
||||
## 抓大鹅透明 PNG 不能只按圆形粗筛命中
|
||||
|
||||
- 现象:抓大鹅物体看起来点中了透明边角或 `object-contain` 留白,但局内仍被判定为可点击。
|
||||
- 原因:仅用中心圆半径做粗筛,会把图片透明角和留白一起算进热区;`itemSize` 缩小后,空白更明显。
|
||||
- 处理:先用圆形半径做粗筛,再按当前展示图的 alpha 像素做精筛;透明像素、`object-contain` 留白和缩放后的空白区都不能命中。若 alpha 读取失败,再回退粗筛保留可点能力。
|
||||
- 验证:`npm run test -- src/components/match3d-runtime/match3dHotspot.test.ts`。
|
||||
- 关联:`src/components/match3d-runtime/match3dHotspot.ts`、`src/components/match3d-runtime/Match3DRuntimeShell.tsx`、`docs/【玩法创作】平台入口与玩法链路-2026-05-15.md`。
|
||||
|
||||
## 抓大鹅物品栏插入要按同类后插并在三消后前压补位
|
||||
|
||||
- 现象:抓大鹅运行态点击物品后,底部物品栏只是简单追加到第一个空位,导致同类物品顺序被打乱;三件同类凑齐后只是瞬时清空,没有“左右向中间合成再一起消失”的过渡感,后面的物品也不会在视觉上前压补位。
|
||||
- 原因:前端和后端都曾按“第一个空槽”入槽,没保留同类末尾插入的托盘布局规则;清除后也只做了静态快照更新,没有单独的合成覆盖层和补位动画层。
|
||||
- 处理:托盘插入统一改成“先找同类最后一个物品,插到它后面;没有同类就追加到末尾”,并在前端为同类三消增加覆盖层动画:三个物品在飞入结束后向托盘中点收拢并淡出,随后后面的托盘物品做前压补位。后端确认逻辑只清当前点击类型的三连,清除后再压缩槽位。
|
||||
- 验证:`npm run test -- src/services/match3d-runtime/match3dTrayLayout.test.ts src/components/match3d-runtime/match3dHotspot.test.ts src/components/match3d-runtime/Match3DRuntimeShell.test.tsx`,`cargo test -p module-match3d --manifest-path server-rs/Cargo.toml`,`npm run typecheck`。
|
||||
- 关联:`src/services/match3d-runtime/match3dTrayLayout.ts`、`src/services/match3d-runtime/match3dLocalRuntime.ts`、`src/components/match3d-runtime/Match3DRuntimeShell.tsx`、`server-rs/crates/module-match3d/src/application.rs`。
|
||||
|
||||
@@ -14,7 +14,8 @@
|
||||
2. 草稿 / 已发布状态尽量图标化,不使用大段状态文案。
|
||||
3. 草稿卡常态不外露低频动作;已发布作品卡右上角可直接显示无边框分享 icon,删除等破坏性动作继续收口到左滑或长按操作层。
|
||||
4. 生成中作品在整卡上加等待遮罩,但不移除作品基础信息。
|
||||
5. 私有 generated 图片必须通过 `ResolvedAssetImage` / `/api/assets/read-url` 换签读取。
|
||||
5. 生成中状态不能只存在前端内存 notice。后端作品摘要必须下发可恢复的 `generationStatus`;前端刷新或退出产品后,作品架优先用摘要状态恢复等待遮罩,本轮内存 notice 只作为即时反馈。
|
||||
6. 私有 generated 图片必须通过 `ResolvedAssetImage` / `/api/assets/read-url` 换签读取。
|
||||
|
||||
## 拼图
|
||||
|
||||
@@ -28,8 +29,13 @@
|
||||
|
||||
- 图像输入复用 `CreativeImageInputPanel`。
|
||||
- 支持画面描述生图、多参考图生图、上传主图后 AI 重绘、上传主图后不重绘。
|
||||
- 草稿生成会保留关卡图和 UI 背景;当前不自动生成背景音乐。
|
||||
- 草稿生成会先持久化 `generationStatus=generating` 的作品摘要,生成完成并回写关卡图、UI 背景后再变为 `ready`;当前不自动生成背景音乐。
|
||||
- 拼图草稿编译是长耗时 action,前端 action 请求默认等待 `1_000_000ms` 且不自动重试;生成页恢复时必须沿用作品摘要 `updatedAt` 作为原始 `startedAtMs`,失败/完成态用 `finishedAtMs` 冻结耗时,不能在锁屏或返回草稿页后重新从 0 计时。
|
||||
- 若浏览器锁屏、息屏或网络切换导致 compile 请求失败,前端在标记失败前必须先复读 `getPuzzleAgentSession(sessionId)`;只有最新 session 仍缺 `draft.coverImageSrc`、首关 `coverImageSrc` 或候选图时才展示失败,复读到已生成草稿时按成功收尾、刷新作品架并继续自动试玩/结果页链路。
|
||||
- 拼图参考图 AI 重绘优先走 VectorEngine `/v1/images/edits`;若编辑接口超时,`api-server` 会降级为 `/v1/images/generations`,并把同一参考图塞进 `image` 数组继续生成,避免参考图草稿整单失败。
|
||||
- 结果页素材配置当前只保留 UI 相关能力;旧背景音乐入口隐藏。
|
||||
- 结果页允许多关卡并行编辑和生成;某一关卡图片生成完成回包只静默更新该关卡素材与生成态,不得自动打开或切换关卡详情面板,避免打断用户正在编辑的其它关卡。
|
||||
- 拼图 UI 背景是作品运行态背景,不只属于第一关;本地试玩、直达指定关卡和正式 `next-level` 推进时,目标关卡缺 `uiBackgroundImageSrc/uiBackgroundImageObjectKey` 必须继承同作品首个可用 UI 背景,仍缺失时才沿用当前运行态快照背景或默认 UI。
|
||||
- 拼图运行态棋盘不叠加分块蒙版、描边、阴影、选中底色或合并块 SVG 轮廓;拼图片本体需要裁切为圆角形状,单块使用独立圆角裁切,合并块使用 SVG 原生 `clipPath` 裁切整体外轮廓,外凸角和内凹角分别计算半径,内凹角半径要比外凸角更明显以避免手机 WebView 中看起来仍是直角。原图道具只在用户主动确认后打开独立原图查看层,不在当前拼图棋盘上叠加原图。
|
||||
- 拼图运行态拖拽必须完全跟随手指或鼠标位置,`pointermove` 期间即时写入可见拼块的 transform,不依赖等待后端回包、React 重渲染或下一帧动画队列;进入拖动后不展示拼块选中态或“已选择”提示,松手后再提交目标格同步规则真相。
|
||||
- 拼图运行态的提示、设置等点击弹层跟随当前运行态主色主题,使用普通圆角主题面板,不复用像素九宫格素材框。
|
||||
@@ -51,24 +57,24 @@
|
||||
难度映射:
|
||||
|
||||
| 难度 | clearCount | difficulty | 总物品数 | 物品种类 |
|
||||
| --- | ---: | ---: | ---: | ---: |
|
||||
| 轻松 | 8 | 2 | 24 | 3 |
|
||||
| 标准 | 12 | 4 | 36 | 9 |
|
||||
| 进阶 | 16 | 6 | 48 | 15 |
|
||||
| 硬核 | 21 | 8 | 63 | 21 |
|
||||
| ---- | ---------: | ---------: | -------: | -------: |
|
||||
| 轻松 | 8 | 2 | 24 | 3 |
|
||||
| 标准 | 12 | 4 | 36 | 9 |
|
||||
| 进阶 | 16 | 6 | 48 | 15 |
|
||||
| 硬核 | 21 | 8 | 63 | 21 |
|
||||
|
||||
当前素材生成流水线:
|
||||
|
||||
1. 点击生成前弹出泥点确认,草稿生成固定消耗 `10` 泥点。
|
||||
2. 先写入可恢复草稿 profile,再执行文本计划、图片生成、切图、OSS 上传、背景和容器生成;草稿完成条件不包含 `backgroundMusic`。
|
||||
2. 先写入可恢复草稿 profile,再执行文本计划、图片生成、切图、OSS 上传、背景和容器生成;作品摘要在素材或背景未完整时下发 `generationStatus=generating`,素材和背景完整后下发 `ready`,草稿完成条件不包含 `backgroundMusic`。
|
||||
3. 物品素材不再调用 Hyper3D Rodin,不再生成 GLB。新草稿和批量新增固定生成 2D 五视角素材。
|
||||
4. 物品 sheet 走 VectorEngine Gemini `gemini-3-pro-image-preview` 原生 `generateContent`,单张 `1:1` 图固定 `5*5`,每张承载 `5` 个物品、每个物品 `5` 个视角。
|
||||
5. 切图前先在整张 sheet 上做绿幕 / 近白底透明化和边缘去污染,再按格子导出独立 PNG;每个视角图再以扩大的 PNG 边界带为种子,把连通的浅绿 / 近白抗锯齿边直接改为透明,并按剩余可见主体二次收紧;不要先裁剪单格再各自去绿。
|
||||
5. 切图前先在整张 sheet 上做绿幕 / 近白底透明化和边缘去污染,再按格子导出独立 PNG;每个视角图再以扩大的 PNG 边界带为种子,把连通的浅绿 / 近白抗锯齿边直接改为透明,并对贴透明背景的弱绿 / 暗绿轮廓像素做去绿污染处理,最后按剩余可见主体二次收紧;不要先裁剪单格再各自去绿。
|
||||
6. `generatedItemAssets[].imageViews[]` 是新素材主字段,`imageSrc/imageObjectKey` 只兼容首张视角。
|
||||
7. 文本生成物品名称时必须同时生成 `itemSize`,只允许 `大`、`中`、`小`。该字段随 `generatedItemAssets[].itemSize` 持久化并下发;历史缺失字段的素材按 `大` 兼容,模型缺失或非法值按物品名本地推断。
|
||||
8. 局内 `9:16` 纯背景图和 `1:1` 中心容器 UI 图分开生成。纯背景不得包含锅、盘、托盘、HUD、按钮、文字或物品,且入库前必须合成为全画幅不透明图片,不允许出现透明区域;容器图走 `/v1/images/edits` 参考透明容器图。
|
||||
9. 当前抓大鹅音频生成关闭:入口无 `生成音效`,草稿不生成背景音乐或点击音效,结果页不展示背景音乐 Tab 或点击音效生成入口。历史 `backgroundMusic` / `clickSound` 字段继续兼容传递。
|
||||
10. UI 背景和容器资产的持久化真相仍在 `generatedItemAssets[].backgroundAsset`;Agent session、work summary/detail、结果页和运行态入口都必须把该字段提升为 `backgroundImageSrc/backgroundImageObjectKey/generatedBackgroundAsset` 读取,避免草稿重进、结果页预览或试玩退回默认素材。
|
||||
10. UI 背景和容器资产的持久化真相仍在 `generatedItemAssets[].backgroundAsset`;Agent session、work summary/detail、结果页和运行态入口都必须把该字段提升为 `backgroundImageSrc/backgroundImageObjectKey/generatedBackgroundAsset` 读取。草稿编译后的 `draftJson` 自身也必须携带 `generatedItemAssets` 快照;HTTP facade 不能只依赖 work detail 回读补齐 UI 资产,外部回读为空时也不得清空草稿内已有的背景 / 容器图。平台壳层从作品架、广场、生成完成回调、结果页保存 / 发布 / 试玩回调进入 Match3D profile 时也要先归一化并提升,避免首次试玩、手动试玩、推荐流或公开详情运行态退回默认背景 / 默认容器。
|
||||
|
||||
结果页当前结构:
|
||||
|
||||
@@ -81,15 +87,16 @@
|
||||
运行态当前口径:
|
||||
|
||||
- 规则真相在后端;前端只做即时表现、点击候选、飞入、入槽、三消和胜负过渡。
|
||||
- 物品选择只在 `pointerup` 时提交;`pointerdown` / `pointermove` 只更新候选样式。松手时按当前位置和最新快照命中一个最上层可点击物品。
|
||||
- 物品选择只在 `pointerup` 时提交;`pointerdown` / `pointermove` 只更新候选样式。松手时按当前位置和最新快照命中一个最上层可点击物品;生成 2D PNG 物品必须按当前展示图的 alpha 像素做热区精筛,透明像素、`object-contain` 留白和 `itemSize` 缩小后的空白区不能响应点击。
|
||||
- 物品 DOM 只负责展示,不通过自身 `click` 事件直接提交,避免浏览器后续 click 绕过松手判定造成重复提交。
|
||||
- 初始物品坐标围绕容器口中心生成,并保留内缩安全距离,避免贴边和局部角落聚集。
|
||||
- 本地试玩与 Rust `module-match3d` 后端领域生成使用同一套中心铺开口径;生成点覆盖四象限且均值接近中心。
|
||||
- 运行态优先消费 2D 生成图;默认积木 / 程序化 3D 表现只作为视觉分支和兜底,不改变规则真相。
|
||||
- 运行态启动前要预加载 `generatedItemAssets[].imageViews[]`、顶层 `generatedBackgroundAsset`、物品挂载 `backgroundAsset` 中的背景和容器图;卡片摘要缺 UI 背景或容器字段时,进入运行态前必须补读 work detail。补读后的 profile 也要再次提升 `generatedItemAssets[].backgroundAsset`,确保背景和容器字段传给 `Match3DRuntimeShell`。
|
||||
- 局内容器图在移动端宽度接近屏幕宽度并居中显示,保持原图比例不拉伸;生成容器图加载成功后棋盘外壳透明且 `overflow-visible`,只有生成图缺失或加载失败时才显示透明参考容器兜底。
|
||||
- 运行态启动前要预加载 `generatedItemAssets[].imageViews[]`、顶层 `generatedBackgroundAsset`、物品挂载 `backgroundAsset` 中的背景和容器图;首次生成自动试玩、结果页手动试玩、推荐流和公开详情启动都必须传入提升后的 profile。卡片摘要缺 UI 背景或容器字段时,进入运行态前必须补读 work detail。补读后的 profile 也要再次提升 `generatedItemAssets[].backgroundAsset`,确保背景和容器字段传给 `Match3DRuntimeShell`。
|
||||
- 局内容器图在移动端宽度大于屏幕宽度并略向下压,当前运行态使用 `w-[min(116vw,42rem)]` 与 `top-[54%]` 放大和下移容器图本体,保持原图比例不拉伸且不改变后端物品布局、点击半径或消除规则;生成容器图加载成功后棋盘外壳透明且 `overflow-visible`,只有生成图缺失或加载失败时才显示透明参考容器兜底。
|
||||
- generated 私有图换签未完成时,局内物品先隐藏等待,不得短暂显示默认积木;同一批资源在重启 run 时保留已解析签名 URL,只有资源源列表变化或换签失败后才允许进入兜底视觉。
|
||||
- `itemSize` 只缩放生成 2D 图片本体:`大` 使用当前默认显示尺寸,`中` 和 `小` 缩小显示;不改变后端下发的布局半径、点击半径或三消规则。
|
||||
- `itemSize` 只缩放生成 2D 图片本体:`大`、`中`、`小` 均按相对尺寸缩放,其中 `大` 也比原始图片略小,`中` 和 `小` 进一步缩小;不改变后端下发的布局半径、点击半径或三消规则。
|
||||
- 物品进入底部物品栏时按同类型插入:如果物品栏已有同类物品,新物品插到该类型最后一个物品后面,后续物品整体后移;没有同类时追加到当前末尾。达到三件同类时,在飞入物品栏动画结束后,左侧和右侧同类物品向中间合成,三件一起消失,播放合成音效,不展示星星图标,后面的物品再向前补位。该动效只是前端表现层,后端和本地试玩仍负责权威插入、指定点击类型清除与补位后的槽位快照。
|
||||
- 抓大鹅运行态右上角常驻设置入口,不直接暴露重新开始按钮;重新开始收口到设置面板内,结算弹层仍保留结果态的再来一局动作。
|
||||
- 高 DPR 移动端 WebGL canvas 必须锁定 CSS 尺寸,避免右下溢出。
|
||||
|
||||
|
||||
@@ -5,6 +5,7 @@
|
||||
import type { CreationAudioAsset } from './creationAudio';
|
||||
|
||||
export type Match3DWorkPublicationStatus = 'draft' | 'published' | string;
|
||||
export type Match3DWorkGenerationStatus = 'idle' | 'generating' | 'ready' | string;
|
||||
|
||||
export type Match3DGeneratedItemAssetStatus =
|
||||
| 'pending'
|
||||
@@ -163,6 +164,7 @@ export interface Match3DWorkSummary {
|
||||
updatedAt: string;
|
||||
publishedAt?: string | null;
|
||||
publishReady: boolean;
|
||||
generationStatus?: Match3DWorkGenerationStatus | null;
|
||||
backgroundPrompt?: string | null;
|
||||
backgroundImageSrc?: string | null;
|
||||
backgroundImageObjectKey?: string | null;
|
||||
|
||||
@@ -2,6 +2,7 @@ import type { JsonObject } from './common';
|
||||
import type { PuzzleAnchorPack, PuzzleDraftLevel } from './puzzleAgentDraft';
|
||||
|
||||
export type PuzzleWorkPublicationStatus = 'draft' | 'published';
|
||||
export type PuzzleWorkGenerationStatus = PuzzleDraftLevel['generationStatus'];
|
||||
|
||||
export interface PuzzleWorkSummary {
|
||||
workId: string;
|
||||
@@ -28,6 +29,7 @@ export interface PuzzleWorkSummary {
|
||||
pointIncentiveTotalPoints?: number;
|
||||
pointIncentiveClaimablePoints?: number;
|
||||
publishReady: boolean;
|
||||
generationStatus?: PuzzleWorkGenerationStatus | null;
|
||||
levels?: PuzzleDraftLevel[];
|
||||
}
|
||||
|
||||
|
||||
@@ -6016,13 +6016,16 @@ fn remove_match3d_material_view_edge_matte(pixels: &mut [u8], width: usize, heig
|
||||
let mut background_mask = vec![0u8; pixel_count];
|
||||
let mut queue = Vec::<usize>::new();
|
||||
let mut queue_index = 0usize;
|
||||
let mut transparent_pixel_count = 0usize;
|
||||
for pixel_index in 0..pixel_count {
|
||||
let offset = pixel_index * 4;
|
||||
if pixels[offset + 3] == 0 {
|
||||
background_mask[pixel_index] = 1;
|
||||
queue.push(pixel_index);
|
||||
transparent_pixel_count = transparent_pixel_count.saturating_add(1);
|
||||
}
|
||||
}
|
||||
let has_transparent_background = transparent_pixel_count > pixel_count / 200;
|
||||
|
||||
// 中文注释:单图被前景边界收紧后,浅绿框可能正好贴在 PNG 外缘;
|
||||
// 把外缘一段宽度作为去背种子,但只清理绿幕 / 近白 matte,避免误伤贴边主体。
|
||||
@@ -6136,6 +6139,98 @@ fn remove_match3d_material_view_edge_matte(pixels: &mut [u8], width: usize, heig
|
||||
}
|
||||
}
|
||||
|
||||
if has_transparent_background {
|
||||
let mut visible_mask = vec![0u8; pixel_count];
|
||||
for pixel_index in 0..pixel_count {
|
||||
let offset = pixel_index * 4;
|
||||
if is_match3d_material_visible_pixel([
|
||||
pixels[offset],
|
||||
pixels[offset + 1],
|
||||
pixels[offset + 2],
|
||||
pixels[offset + 3],
|
||||
]) {
|
||||
visible_mask[pixel_index] = 1;
|
||||
}
|
||||
}
|
||||
|
||||
for _ in 0..2 {
|
||||
let mut changed_this_round = false;
|
||||
for y in 0..height {
|
||||
for x in 0..width {
|
||||
let pixel_index = y * width + x;
|
||||
if visible_mask[pixel_index] == 0 {
|
||||
continue;
|
||||
}
|
||||
let offset = pixel_index * 4;
|
||||
let pixel = [
|
||||
pixels[offset],
|
||||
pixels[offset + 1],
|
||||
pixels[offset + 2],
|
||||
pixels[offset + 3],
|
||||
];
|
||||
if !is_match3d_material_green_contaminated_edge_pixel(pixel) {
|
||||
continue;
|
||||
}
|
||||
if !touches_match3d_material_background_mask(
|
||||
x,
|
||||
y,
|
||||
width,
|
||||
height,
|
||||
&background_mask,
|
||||
) {
|
||||
continue;
|
||||
}
|
||||
|
||||
if is_match3d_material_strong_green_contamination(pixel) {
|
||||
pixels[offset] = 0;
|
||||
pixels[offset + 1] = 0;
|
||||
pixels[offset + 2] = 0;
|
||||
pixels[offset + 3] = 0;
|
||||
visible_mask[pixel_index] = 0;
|
||||
background_mask[pixel_index] = 1;
|
||||
changed = true;
|
||||
changed_this_round = true;
|
||||
continue;
|
||||
}
|
||||
|
||||
let replacement = collect_match3d_material_visible_neighbor_color(
|
||||
pixels,
|
||||
width,
|
||||
height,
|
||||
x,
|
||||
y,
|
||||
&background_mask,
|
||||
&visible_mask,
|
||||
)
|
||||
.unwrap_or((
|
||||
pixels[offset],
|
||||
pixels[offset + 1],
|
||||
pixels[offset + 2],
|
||||
));
|
||||
let next_red = replacement.0.max(pixels[offset]);
|
||||
let next_blue = replacement.2.max(pixels[offset + 2]);
|
||||
let next_green = replacement
|
||||
.1
|
||||
.min(next_red.max(next_blue).saturating_add(12));
|
||||
if next_red != pixels[offset]
|
||||
|| next_green != pixels[offset + 1]
|
||||
|| next_blue != pixels[offset + 2]
|
||||
{
|
||||
pixels[offset] = next_red;
|
||||
pixels[offset + 1] = next_green;
|
||||
pixels[offset + 2] = next_blue;
|
||||
changed = true;
|
||||
changed_this_round = true;
|
||||
}
|
||||
background_mask[pixel_index] = 1;
|
||||
}
|
||||
}
|
||||
if !changed_this_round {
|
||||
break;
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
changed
|
||||
}
|
||||
|
||||
@@ -6167,6 +6262,98 @@ fn is_match3d_material_soft_edge_pixel(pixel: [u8; 4]) -> bool {
|
||||
&& (red >= 48 || blue >= 96 || pixel[3] < 236)
|
||||
}
|
||||
|
||||
fn is_match3d_material_green_contaminated_edge_pixel(pixel: [u8; 4]) -> bool {
|
||||
if pixel[3] == 0 {
|
||||
return false;
|
||||
}
|
||||
|
||||
let red = pixel[0];
|
||||
let green = pixel[1];
|
||||
let blue = pixel[2];
|
||||
green >= 72 && green.saturating_sub(red.max(blue)) >= 18
|
||||
}
|
||||
|
||||
fn is_match3d_material_strong_green_contamination(pixel: [u8; 4]) -> bool {
|
||||
let red = pixel[0];
|
||||
let green = pixel[1];
|
||||
let blue = pixel[2];
|
||||
green >= 148 && green.saturating_sub(red.max(blue)) >= 34
|
||||
}
|
||||
|
||||
fn collect_match3d_material_visible_neighbor_color(
|
||||
pixels: &[u8],
|
||||
width: usize,
|
||||
height: usize,
|
||||
x: usize,
|
||||
y: usize,
|
||||
background_mask: &[u8],
|
||||
visible_mask: &[u8],
|
||||
) -> Option<(u8, u8, u8)> {
|
||||
let mut total_weight = 0.0f32;
|
||||
let mut total_red = 0.0f32;
|
||||
let mut total_green = 0.0f32;
|
||||
let mut total_blue = 0.0f32;
|
||||
|
||||
for offset_y in -3i32..=3 {
|
||||
for offset_x in -3i32..=3 {
|
||||
if offset_x == 0 && offset_y == 0 {
|
||||
continue;
|
||||
}
|
||||
let next_x = x as i32 + offset_x;
|
||||
let next_y = y as i32 + offset_y;
|
||||
if next_x < 0 || next_x >= width as i32 || next_y < 0 || next_y >= height as i32 {
|
||||
continue;
|
||||
}
|
||||
|
||||
let next_pixel_index = next_y as usize * width + next_x as usize;
|
||||
if background_mask[next_pixel_index] != 0 || visible_mask[next_pixel_index] == 0 {
|
||||
continue;
|
||||
}
|
||||
|
||||
let next_offset = next_pixel_index * 4;
|
||||
let next_alpha = pixels[next_offset + 3];
|
||||
if next_alpha < 96 {
|
||||
continue;
|
||||
}
|
||||
let pixel = [
|
||||
pixels[next_offset],
|
||||
pixels[next_offset + 1],
|
||||
pixels[next_offset + 2],
|
||||
next_alpha,
|
||||
];
|
||||
if is_match3d_material_green_contaminated_edge_pixel(pixel)
|
||||
|| is_match3d_material_soft_edge_pixel(pixel)
|
||||
{
|
||||
continue;
|
||||
}
|
||||
|
||||
let distance = offset_x.unsigned_abs() + offset_y.unsigned_abs();
|
||||
let weight = (next_alpha as f32 / 255.0)
|
||||
* if distance <= 1 {
|
||||
2.0
|
||||
} else if distance <= 3 {
|
||||
1.2
|
||||
} else {
|
||||
0.7
|
||||
};
|
||||
total_weight += weight;
|
||||
total_red += pixels[next_offset] as f32 * weight;
|
||||
total_green += pixels[next_offset + 1] as f32 * weight;
|
||||
total_blue += pixels[next_offset + 2] as f32 * weight;
|
||||
}
|
||||
}
|
||||
|
||||
if total_weight <= 0.0 {
|
||||
return None;
|
||||
}
|
||||
|
||||
Some((
|
||||
(total_red / total_weight).round() as u8,
|
||||
(total_green / total_weight).round() as u8,
|
||||
(total_blue / total_weight).round() as u8,
|
||||
))
|
||||
}
|
||||
|
||||
fn apply_match3d_material_green_screen_alpha(source: image::DynamicImage) -> image::DynamicImage {
|
||||
let mut image = source.to_rgba8();
|
||||
let (width, height) = image.dimensions();
|
||||
@@ -7571,6 +7758,37 @@ mod tests {
|
||||
);
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn match3d_material_view_edge_matte_neutralizes_dark_green_contour_pixels() {
|
||||
let width = 64;
|
||||
let height = 64;
|
||||
let mut view = image::RgbaImage::from_pixel(width, height, image::Rgba([0, 0, 0, 0]));
|
||||
for y in 16..48 {
|
||||
for x in 16..48 {
|
||||
if x <= 18 || x >= 45 || y <= 18 || y >= 45 {
|
||||
view.put_pixel(x, y, image::Rgba([42, 118, 36, 255]));
|
||||
} else {
|
||||
view.put_pixel(x, y, image::Rgba([174, 92, 72, 255]));
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
let cleaned =
|
||||
crop_match3d_material_view_edge_matte(image::DynamicImage::ImageRgba8(view)).to_rgba8();
|
||||
|
||||
assert!(
|
||||
cleaned.pixels().all(|pixel| {
|
||||
let [red, green, blue, alpha] = pixel.0;
|
||||
alpha == 0 || green <= red.max(blue).saturating_add(18)
|
||||
}),
|
||||
"暗绿轮廓污染也必须被透明化或去绿,不能残留可见绿边"
|
||||
);
|
||||
assert!(
|
||||
cleaned.pixels().any(|pixel| pixel.0 == [174, 92, 72, 255]),
|
||||
"暗绿轮廓清理不能误删物品主体"
|
||||
);
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn match3d_material_sheet_slicing_cleans_white_matte_edge() {
|
||||
let width = 500;
|
||||
@@ -8388,6 +8606,7 @@ mod tests {
|
||||
total_item_count: 36,
|
||||
publish_ready: false,
|
||||
blockers: Vec::new(),
|
||||
generated_item_assets_json: None,
|
||||
}),
|
||||
messages: Vec::new(),
|
||||
last_assistant_reply: None,
|
||||
@@ -8472,6 +8691,131 @@ mod tests {
|
||||
);
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn match3d_agent_session_response_keeps_draft_ui_assets_without_work_detail_hydration() {
|
||||
let assets = vec![Match3DGeneratedItemAsset {
|
||||
item_id: "match3d-item-1".to_string(),
|
||||
item_name: "草莓".to_string(),
|
||||
item_size: Some(MATCH3D_ITEM_SIZE_SMALL.to_string()),
|
||||
image_src: Some(
|
||||
"/generated-match3d-assets/session/profile/items/strawberry/view-01.png"
|
||||
.to_string(),
|
||||
),
|
||||
image_object_key: Some(
|
||||
"generated-match3d-assets/session/profile/items/strawberry/view-01.png".to_string(),
|
||||
),
|
||||
image_views: Vec::new(),
|
||||
model_src: None,
|
||||
model_object_key: None,
|
||||
model_file_name: None,
|
||||
task_uuid: None,
|
||||
subscription_key: None,
|
||||
sound_prompt: None,
|
||||
background_music_title: None,
|
||||
background_music_style: None,
|
||||
background_music_prompt: None,
|
||||
background_music: None,
|
||||
click_sound: None,
|
||||
background_asset: Some(Match3DGeneratedBackgroundAsset {
|
||||
prompt: "果园背景".to_string(),
|
||||
image_src: Some(
|
||||
"/generated-match3d-assets/session/profile/background/background.png"
|
||||
.to_string(),
|
||||
),
|
||||
image_object_key: Some(
|
||||
"generated-match3d-assets/session/profile/background/background.png"
|
||||
.to_string(),
|
||||
),
|
||||
container_prompt: Some("果园容器".to_string()),
|
||||
container_image_src: Some(
|
||||
"/generated-match3d-assets/session/profile/ui-container/container.png"
|
||||
.to_string(),
|
||||
),
|
||||
container_image_object_key: Some(
|
||||
"generated-match3d-assets/session/profile/ui-container/container.png"
|
||||
.to_string(),
|
||||
),
|
||||
status: "image_ready".to_string(),
|
||||
error: None,
|
||||
}),
|
||||
status: "image_ready".to_string(),
|
||||
error: None,
|
||||
}];
|
||||
let session = Match3DAgentSessionRecord {
|
||||
session_id: "match3d-session-1".to_string(),
|
||||
current_turn: 3,
|
||||
progress_percent: 100,
|
||||
stage: "DraftCompiled".to_string(),
|
||||
anchor_pack: Match3DAnchorPackRecord {
|
||||
theme: Match3DAnchorItemRecord {
|
||||
key: "theme".to_string(),
|
||||
label: "题材主题".to_string(),
|
||||
value: "水果".to_string(),
|
||||
status: "confirmed".to_string(),
|
||||
},
|
||||
clear_count: Match3DAnchorItemRecord {
|
||||
key: "clearCount".to_string(),
|
||||
label: "消除次数".to_string(),
|
||||
value: "12".to_string(),
|
||||
status: "confirmed".to_string(),
|
||||
},
|
||||
difficulty: Match3DAnchorItemRecord {
|
||||
key: "difficulty".to_string(),
|
||||
label: "难度".to_string(),
|
||||
value: "4".to_string(),
|
||||
status: "confirmed".to_string(),
|
||||
},
|
||||
},
|
||||
config: None,
|
||||
draft: Some(Match3DResultDraftRecord {
|
||||
profile_id: "match3d-profile-1".to_string(),
|
||||
game_name: "水果抓大鹅".to_string(),
|
||||
theme_text: "水果".to_string(),
|
||||
summary_text: "水果主题".to_string(),
|
||||
tags: vec!["水果".to_string(), "抓大鹅".to_string()],
|
||||
cover_image_src: None,
|
||||
reference_image_src: None,
|
||||
clear_count: 12,
|
||||
difficulty: 4,
|
||||
total_item_count: 36,
|
||||
publish_ready: false,
|
||||
blockers: Vec::new(),
|
||||
generated_item_assets_json: serialize_match3d_generated_item_assets(&assets),
|
||||
}),
|
||||
messages: Vec::new(),
|
||||
last_assistant_reply: None,
|
||||
published_profile_id: None,
|
||||
updated_at: "2026-05-15T00:00:00.000Z".to_string(),
|
||||
};
|
||||
|
||||
let response = map_match3d_agent_session_response_with_assets(session, &[]);
|
||||
let draft = response.draft.expect("session draft should exist");
|
||||
|
||||
assert_eq!(draft.generated_item_assets.len(), 1);
|
||||
assert_eq!(
|
||||
draft.background_image_src.as_deref(),
|
||||
Some("/generated-match3d-assets/session/profile/background/background.png")
|
||||
);
|
||||
assert_eq!(
|
||||
draft.background_image_object_key.as_deref(),
|
||||
Some("generated-match3d-assets/session/profile/background/background.png")
|
||||
);
|
||||
assert_eq!(
|
||||
draft
|
||||
.generated_background_asset
|
||||
.as_ref()
|
||||
.and_then(|asset| asset.container_image_src.as_deref()),
|
||||
Some("/generated-match3d-assets/session/profile/ui-container/container.png")
|
||||
);
|
||||
assert_eq!(
|
||||
draft.generated_item_assets[0]
|
||||
.background_asset
|
||||
.as_ref()
|
||||
.and_then(|asset| asset.image_src.as_deref()),
|
||||
Some("/generated-match3d-assets/session/profile/background/background.png")
|
||||
);
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn match3d_tag_normalization_only_strips_numbered_list_prefix() {
|
||||
assert_eq!(normalize_match3d_tag("3D素材"), "3D素材");
|
||||
@@ -8761,9 +9105,59 @@ mod tests {
|
||||
assert_eq!(response.generated_item_assets.len(), 1);
|
||||
assert_eq!(response.generated_item_assets[0].item_name, "草莓");
|
||||
assert_eq!(response.generated_item_assets[0].status, "image_ready");
|
||||
assert_eq!(response.generation_status.as_deref(), Some("generating"));
|
||||
assert_eq!(
|
||||
response.generated_item_assets[0].image_src.as_deref(),
|
||||
Some("/generated-match3d-assets/session/profile/items/match3d-item-1-item/image.png")
|
||||
);
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn match3d_work_summary_marks_complete_generated_assets_ready() {
|
||||
let assets = vec![Match3DGeneratedItemAsset {
|
||||
background_asset: Some(Match3DGeneratedBackgroundAsset {
|
||||
prompt: "水果厨房背景".to_string(),
|
||||
image_src: Some(
|
||||
"/generated-match3d-assets/session/profile/background.png".to_string(),
|
||||
),
|
||||
image_object_key: Some(
|
||||
"generated-match3d-assets/session/profile/background.png".to_string(),
|
||||
),
|
||||
container_prompt: None,
|
||||
container_image_src: Some(
|
||||
"/generated-match3d-assets/session/profile/container.png".to_string(),
|
||||
),
|
||||
container_image_object_key: Some(
|
||||
"generated-match3d-assets/session/profile/container.png".to_string(),
|
||||
),
|
||||
status: "image_ready".to_string(),
|
||||
error: None,
|
||||
}),
|
||||
..test_match3d_generated_item_asset(1, "草莓")
|
||||
}];
|
||||
let response = map_match3d_work_summary_response(Match3DWorkProfileRecord {
|
||||
work_id: "match3d-profile-1".to_string(),
|
||||
profile_id: "match3d-profile-1".to_string(),
|
||||
owner_user_id: "user-1".to_string(),
|
||||
source_session_id: Some("match3d-session-1".to_string()),
|
||||
author_display_name: "玩家".to_string(),
|
||||
game_name: "水果抓大鹅".to_string(),
|
||||
theme_text: "水果".to_string(),
|
||||
summary: "水果主题".to_string(),
|
||||
tags: vec!["水果".to_string()],
|
||||
cover_image_src: None,
|
||||
cover_asset_id: None,
|
||||
reference_image_src: None,
|
||||
clear_count: 3,
|
||||
difficulty: 3,
|
||||
publication_status: "draft".to_string(),
|
||||
play_count: 0,
|
||||
updated_at: "2026-05-10T00:00:00.000Z".to_string(),
|
||||
published_at: None,
|
||||
publish_ready: false,
|
||||
generated_item_assets_json: serialize_match3d_generated_item_assets(&assets),
|
||||
});
|
||||
|
||||
assert_eq!(response.generation_status.as_deref(), Some("ready"));
|
||||
}
|
||||
}
|
||||
|
||||
@@ -32,6 +32,10 @@ pub(super) fn map_match3d_agent_session_response_with_assets(
|
||||
) -> Match3DAgentSessionSnapshotResponse {
|
||||
let mut response = map_match3d_agent_session_response(session);
|
||||
if let Some(draft) = response.draft.as_mut() {
|
||||
if generated_item_assets.is_empty() {
|
||||
return response;
|
||||
}
|
||||
|
||||
draft.generated_item_assets = generated_item_assets
|
||||
.iter()
|
||||
.cloned()
|
||||
@@ -129,7 +133,15 @@ pub(super) fn map_match3d_config_response(
|
||||
pub(super) fn map_match3d_draft_response(
|
||||
draft: Match3DResultDraftRecord,
|
||||
) -> Match3DResultDraftResponse {
|
||||
Match3DResultDraftResponse {
|
||||
// 中文注释:session draft 自身也可能携带生成素材快照,不能只依赖 work detail 回读补齐 UI 背景和容器图。
|
||||
let generated_item_assets = parse_match3d_generated_item_assets(
|
||||
draft.generated_item_assets_json.as_deref(),
|
||||
)
|
||||
.into_iter()
|
||||
.map(Match3DGeneratedItemAsset::from)
|
||||
.collect::<Vec<_>>();
|
||||
let background_asset = find_match3d_generated_background_asset(&generated_item_assets);
|
||||
let mut response = Match3DResultDraftResponse {
|
||||
profile_id: draft.profile_id,
|
||||
game_name: draft.game_name,
|
||||
theme_text: draft.theme_text,
|
||||
@@ -147,8 +159,24 @@ pub(super) fn map_match3d_draft_response(
|
||||
background_image_src: None,
|
||||
background_image_object_key: None,
|
||||
generated_background_asset: None,
|
||||
generated_item_assets: Vec::new(),
|
||||
generated_item_assets: generated_item_assets
|
||||
.iter()
|
||||
.cloned()
|
||||
.map(map_match3d_generated_item_asset_for_agent)
|
||||
.collect(),
|
||||
};
|
||||
|
||||
if response
|
||||
.cover_image_src
|
||||
.as_deref()
|
||||
.map(str::trim)
|
||||
.unwrap_or_default()
|
||||
.is_empty()
|
||||
{
|
||||
response.cover_image_src = resolve_match3d_default_cover_image_src(&generated_item_assets);
|
||||
}
|
||||
apply_match3d_background_asset_to_agent_draft(&mut response, background_asset);
|
||||
response
|
||||
}
|
||||
|
||||
pub(super) fn map_match3d_generated_item_asset_for_agent(
|
||||
@@ -365,6 +393,45 @@ pub(super) fn build_match3d_work_profile_record_with_assets(
|
||||
item
|
||||
}
|
||||
|
||||
fn match3d_text_present(value: Option<&String>) -> bool {
|
||||
value.is_some_and(|value| !value.trim().is_empty())
|
||||
}
|
||||
|
||||
fn match3d_item_asset_has_image(asset: &Match3DGeneratedItemAssetJson) -> bool {
|
||||
match3d_text_present(asset.image_src.as_ref())
|
||||
|| match3d_text_present(asset.image_object_key.as_ref())
|
||||
|| asset.image_views.iter().any(|view| {
|
||||
match3d_text_present(view.image_src.as_ref())
|
||||
|| match3d_text_present(view.image_object_key.as_ref())
|
||||
})
|
||||
}
|
||||
|
||||
fn match3d_background_asset_has_image(asset: &Match3DGeneratedBackgroundAsset) -> bool {
|
||||
match3d_text_present(asset.image_src.as_ref())
|
||||
|| match3d_text_present(asset.image_object_key.as_ref())
|
||||
|| match3d_text_present(asset.container_image_src.as_ref())
|
||||
|| match3d_text_present(asset.container_image_object_key.as_ref())
|
||||
}
|
||||
|
||||
fn resolve_match3d_work_generation_status(
|
||||
item: &Match3DWorkProfileRecord,
|
||||
assets: &[Match3DGeneratedItemAssetJson],
|
||||
background_asset: Option<&Match3DGeneratedBackgroundAsset>,
|
||||
) -> Option<String> {
|
||||
if item.publication_status.eq_ignore_ascii_case("published") {
|
||||
return Some("ready".to_string());
|
||||
}
|
||||
|
||||
if assets.is_empty()
|
||||
|| !assets.iter().any(match3d_item_asset_has_image)
|
||||
|| !background_asset.is_some_and(match3d_background_asset_has_image)
|
||||
{
|
||||
return Some("generating".to_string());
|
||||
}
|
||||
|
||||
Some("ready".to_string())
|
||||
}
|
||||
|
||||
pub(super) fn map_match3d_message_response(
|
||||
message: Match3DAgentMessageRecord,
|
||||
) -> Match3DAgentMessageResponse {
|
||||
@@ -383,6 +450,11 @@ pub(super) fn map_match3d_work_summary_response(
|
||||
let generated_item_asset_json =
|
||||
parse_match3d_generated_item_assets(item.generated_item_assets_json.as_deref());
|
||||
let background_asset = find_match3d_generated_background_asset_json(&generated_item_asset_json);
|
||||
let generation_status = resolve_match3d_work_generation_status(
|
||||
&item,
|
||||
&generated_item_asset_json,
|
||||
background_asset.as_ref(),
|
||||
);
|
||||
let generated_background_asset = background_asset
|
||||
.clone()
|
||||
.map(map_match3d_background_asset_for_work);
|
||||
@@ -408,6 +480,7 @@ pub(super) fn map_match3d_work_summary_response(
|
||||
updated_at: item.updated_at,
|
||||
published_at: item.published_at,
|
||||
publish_ready: item.publish_ready,
|
||||
generation_status,
|
||||
background_prompt: background_asset.as_ref().map(|asset| asset.prompt.clone()),
|
||||
background_image_src: background_asset
|
||||
.as_ref()
|
||||
|
||||
@@ -4030,6 +4030,11 @@ fn map_puzzle_generation_endpoint_error(error: AppError) -> AppError {
|
||||
error
|
||||
}
|
||||
|
||||
fn should_fallback_puzzle_reference_edit_to_generation(error: &AppError) -> bool {
|
||||
error.status_code() == StatusCode::GATEWAY_TIMEOUT
|
||||
|| is_puzzle_request_timeout_message(error.body_text().as_str())
|
||||
}
|
||||
|
||||
async fn generate_puzzle_image_candidates(
|
||||
state: &AppState,
|
||||
owner_user_id: &str,
|
||||
@@ -4111,7 +4116,7 @@ async fn generate_puzzle_image_candidates(
|
||||
"message": "AI 重绘需要提供参考图。",
|
||||
}))
|
||||
})?;
|
||||
create_puzzle_vector_engine_image_edit(
|
||||
let edit_result = create_puzzle_vector_engine_image_edit(
|
||||
&http_client,
|
||||
&settings,
|
||||
actual_prompt.as_str(),
|
||||
@@ -4120,7 +4125,34 @@ async fn generate_puzzle_image_candidates(
|
||||
count,
|
||||
reference_image,
|
||||
)
|
||||
.await
|
||||
.await;
|
||||
match edit_result {
|
||||
Ok(generated) => Ok(generated),
|
||||
Err(error) if should_fallback_puzzle_reference_edit_to_generation(&error) => {
|
||||
tracing::warn!(
|
||||
provider = resolved_model.provider_name(),
|
||||
image_model = resolved_model.request_model_name(),
|
||||
session_id,
|
||||
level_name,
|
||||
reference_mime = %reference_image.mime_type,
|
||||
reference_bytes = reference_image.bytes_len,
|
||||
error = %error,
|
||||
"拼图参考图编辑接口超时,降级为带参考图的生成接口"
|
||||
);
|
||||
create_puzzle_vector_engine_image_generation(
|
||||
&http_client,
|
||||
&settings,
|
||||
resolved_model,
|
||||
actual_prompt.as_str(),
|
||||
PUZZLE_DEFAULT_NEGATIVE_PROMPT,
|
||||
PUZZLE_VECTOR_ENGINE_GENERATED_IMAGE_SIZE,
|
||||
count,
|
||||
Some(reference_image),
|
||||
)
|
||||
.await
|
||||
}
|
||||
Err(error) => Err(error),
|
||||
}
|
||||
} else {
|
||||
create_puzzle_vector_engine_image_generation(
|
||||
&http_client,
|
||||
@@ -4130,6 +4162,7 @@ async fn generate_puzzle_image_candidates(
|
||||
PUZZLE_DEFAULT_NEGATIVE_PROMPT,
|
||||
PUZZLE_VECTOR_ENGINE_GENERATED_IMAGE_SIZE,
|
||||
count,
|
||||
None,
|
||||
)
|
||||
.await
|
||||
}
|
||||
@@ -4263,6 +4296,7 @@ mod tests {
|
||||
PUZZLE_DEFAULT_NEGATIVE_PROMPT,
|
||||
PUZZLE_VECTOR_ENGINE_GENERATED_IMAGE_SIZE,
|
||||
4,
|
||||
None,
|
||||
);
|
||||
|
||||
assert_eq!(body["model"], VECTOR_ENGINE_GPT_IMAGE_2_MODEL);
|
||||
@@ -4278,6 +4312,40 @@ mod tests {
|
||||
);
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn puzzle_vector_engine_generation_fallback_includes_reference_image() {
|
||||
let image = image::DynamicImage::ImageRgb8(image::RgbImage::new(4, 4));
|
||||
let mut cursor = std::io::Cursor::new(Vec::new());
|
||||
image
|
||||
.write_to(&mut cursor, ImageFormat::Png)
|
||||
.expect("test image should encode");
|
||||
let reference_image = PuzzleResolvedReferenceImage {
|
||||
mime_type: "image/png".to_string(),
|
||||
bytes_len: cursor.get_ref().len(),
|
||||
bytes: cursor.into_inner(),
|
||||
};
|
||||
|
||||
let body = build_puzzle_vector_engine_image_request_body(
|
||||
PuzzleImageModel::GptImage2,
|
||||
"参考图里的小猫做成拼图主图。",
|
||||
PUZZLE_DEFAULT_NEGATIVE_PROMPT,
|
||||
PUZZLE_VECTOR_ENGINE_GENERATED_IMAGE_SIZE,
|
||||
1,
|
||||
Some(&reference_image),
|
||||
);
|
||||
|
||||
let images = body["image"]
|
||||
.as_array()
|
||||
.expect("fallback generation should include reference image array");
|
||||
assert_eq!(images.len(), 1);
|
||||
assert!(
|
||||
images[0]
|
||||
.as_str()
|
||||
.unwrap_or_default()
|
||||
.starts_with("data:image/png;base64,")
|
||||
);
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn puzzle_vector_engine_edit_url_uses_images_edits_endpoint() {
|
||||
let settings = PuzzleVectorEngineSettings {
|
||||
@@ -4363,6 +4431,39 @@ mod tests {
|
||||
assert_eq!(response.status(), StatusCode::GATEWAY_TIMEOUT);
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn puzzle_vector_engine_upstream_timeout_maps_to_gateway_timeout() {
|
||||
let error = map_puzzle_vector_engine_upstream_error(
|
||||
reqwest::StatusCode::GATEWAY_TIMEOUT,
|
||||
r#"{"error":{"message":"VectorEngine edit endpoint timeout"}}"#,
|
||||
"创建拼图 VectorEngine 图片编辑任务失败",
|
||||
);
|
||||
|
||||
let response = error.into_response();
|
||||
assert_eq!(response.status(), StatusCode::GATEWAY_TIMEOUT);
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn puzzle_reference_edit_fallback_only_accepts_timeout_errors() {
|
||||
let timeout_error = map_puzzle_vector_engine_upstream_error(
|
||||
reqwest::StatusCode::GATEWAY_TIMEOUT,
|
||||
r#"{"error":{"message":"VectorEngine edit endpoint timeout"}}"#,
|
||||
"创建拼图 VectorEngine 图片编辑任务失败",
|
||||
);
|
||||
assert!(should_fallback_puzzle_reference_edit_to_generation(
|
||||
&timeout_error
|
||||
));
|
||||
|
||||
let auth_error = map_puzzle_vector_engine_upstream_error(
|
||||
reqwest::StatusCode::UNAUTHORIZED,
|
||||
r#"{"error":{"message":"invalid api key"}}"#,
|
||||
"创建拼图 VectorEngine 图片编辑任务失败",
|
||||
);
|
||||
assert!(!should_fallback_puzzle_reference_edit_to_generation(
|
||||
&auth_error
|
||||
));
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn puzzle_vector_engine_reqwest_error_maps_to_bad_gateway() {
|
||||
let error = match reqwest::Client::new().get("http://[::1").build() {
|
||||
@@ -4834,6 +4935,7 @@ mod tests {
|
||||
);
|
||||
|
||||
assert_eq!(response.levels.len(), 1);
|
||||
assert_eq!(response.generation_status.as_deref(), Some("ready"));
|
||||
assert_eq!(
|
||||
response.levels[0].cover_image_src.as_deref(),
|
||||
Some("/generated-puzzle-assets/session/cover.png")
|
||||
@@ -5242,6 +5344,7 @@ async fn create_puzzle_vector_engine_image_generation(
|
||||
negative_prompt: &str,
|
||||
size: &str,
|
||||
candidate_count: u32,
|
||||
reference_image: Option<&PuzzleResolvedReferenceImage>,
|
||||
) -> Result<PuzzleGeneratedImages, AppError> {
|
||||
let request_body = build_puzzle_vector_engine_image_request_body(
|
||||
image_model,
|
||||
@@ -5249,6 +5352,7 @@ async fn create_puzzle_vector_engine_image_generation(
|
||||
negative_prompt,
|
||||
size,
|
||||
candidate_count,
|
||||
reference_image,
|
||||
);
|
||||
let request_url = puzzle_vector_engine_images_generation_url(settings);
|
||||
let request_started_at = Instant::now();
|
||||
@@ -5277,7 +5381,7 @@ async fn create_puzzle_vector_engine_image_generation(
|
||||
status = status.as_u16(),
|
||||
prompt_chars = prompt.chars().count(),
|
||||
size,
|
||||
has_reference_image = false,
|
||||
has_reference_image = reference_image.is_some(),
|
||||
elapsed_ms = upstream_elapsed_ms,
|
||||
"拼图 VectorEngine 图片生成 HTTP 返回"
|
||||
);
|
||||
@@ -5434,8 +5538,9 @@ fn build_puzzle_vector_engine_image_request_body(
|
||||
negative_prompt: &str,
|
||||
size: &str,
|
||||
candidate_count: u32,
|
||||
reference_image: Option<&PuzzleResolvedReferenceImage>,
|
||||
) -> Value {
|
||||
Value::Object(Map::from_iter([
|
||||
let mut body = Map::from_iter([
|
||||
(
|
||||
"model".to_string(),
|
||||
Value::String(image_model.request_model_name().to_string()),
|
||||
@@ -5446,7 +5551,15 @@ fn build_puzzle_vector_engine_image_request_body(
|
||||
),
|
||||
("n".to_string(), json!(candidate_count.clamp(1, 1))),
|
||||
("size".to_string(), Value::String(size.to_string())),
|
||||
]))
|
||||
]);
|
||||
if let Some(reference_image) = reference_image
|
||||
&& let Some(reference_data_url) =
|
||||
build_puzzle_generation_reference_image_data_url(reference_image)
|
||||
{
|
||||
body.insert("image".to_string(), json!([reference_data_url]));
|
||||
}
|
||||
|
||||
Value::Object(body)
|
||||
}
|
||||
|
||||
fn build_puzzle_vector_engine_generation_prompt(prompt: &str, has_reference_image: bool) -> String {
|
||||
@@ -5465,6 +5578,32 @@ fn build_puzzle_vector_engine_generation_prompt(prompt: &str, has_reference_imag
|
||||
)
|
||||
}
|
||||
|
||||
fn build_puzzle_generation_reference_image_data_url(
|
||||
image: &PuzzleResolvedReferenceImage,
|
||||
) -> Option<String> {
|
||||
let bytes = resize_puzzle_generation_reference_image_bytes(image.bytes.as_slice())
|
||||
.unwrap_or_else(|| image.bytes.clone());
|
||||
let mime_type = if bytes.starts_with(b"\x89PNG\r\n\x1A\n") {
|
||||
"image/png"
|
||||
} else {
|
||||
image.mime_type.as_str()
|
||||
};
|
||||
|
||||
Some(format!(
|
||||
"data:{};base64,{}",
|
||||
normalize_puzzle_downloaded_image_mime_type(mime_type),
|
||||
BASE64_STANDARD.encode(bytes)
|
||||
))
|
||||
}
|
||||
|
||||
fn resize_puzzle_generation_reference_image_bytes(bytes: &[u8]) -> Option<Vec<u8>> {
|
||||
let image = image::load_from_memory(bytes).ok()?;
|
||||
let resized = image.resize(1024, 1024, image::imageops::FilterType::Triangle);
|
||||
let mut cursor = std::io::Cursor::new(Vec::new());
|
||||
resized.write_to(&mut cursor, ImageFormat::Png).ok()?;
|
||||
Some(cursor.into_inner())
|
||||
}
|
||||
|
||||
fn has_puzzle_reference_image(reference_image_src: Option<&str>) -> bool {
|
||||
reference_image_src
|
||||
.map(str::trim)
|
||||
@@ -6185,19 +6324,28 @@ fn map_puzzle_vector_engine_upstream_error(
|
||||
) -> AppError {
|
||||
let message = parse_puzzle_api_error_message(raw_text, fallback_message);
|
||||
let raw_excerpt = trim_puzzle_upstream_excerpt(raw_text, 800);
|
||||
let is_timeout = is_puzzle_request_timeout_message(message.as_str())
|
||||
|| is_puzzle_request_timeout_message(raw_excerpt.as_str());
|
||||
let status = if is_timeout {
|
||||
StatusCode::GATEWAY_TIMEOUT
|
||||
} else {
|
||||
StatusCode::BAD_GATEWAY
|
||||
};
|
||||
tracing::warn!(
|
||||
provider = VECTOR_ENGINE_PROVIDER,
|
||||
upstream_status = upstream_status.as_u16(),
|
||||
timeout = is_timeout,
|
||||
message = %message,
|
||||
raw_excerpt = %raw_excerpt,
|
||||
"拼图 VectorEngine 上游请求失败"
|
||||
);
|
||||
|
||||
AppError::from_status(StatusCode::BAD_GATEWAY).with_details(json!({
|
||||
AppError::from_status(status).with_details(json!({
|
||||
"provider": VECTOR_ENGINE_PROVIDER,
|
||||
"upstreamStatus": upstream_status.as_u16(),
|
||||
"message": message,
|
||||
"rawExcerpt": raw_excerpt,
|
||||
"timeout": is_timeout,
|
||||
}))
|
||||
}
|
||||
|
||||
|
||||
@@ -278,10 +278,31 @@ pub(super) fn map_puzzle_result_preview_finding_response(
|
||||
}
|
||||
}
|
||||
|
||||
fn resolve_puzzle_work_generation_status(item: &PuzzleWorkProfileRecord) -> Option<String> {
|
||||
item.levels
|
||||
.iter()
|
||||
.map(|level| level.generation_status.trim())
|
||||
.find(|status| *status == "generating")
|
||||
.or_else(|| {
|
||||
item.levels
|
||||
.iter()
|
||||
.map(|level| level.generation_status.trim())
|
||||
.find(|status| *status == "ready")
|
||||
})
|
||||
.or_else(|| {
|
||||
item.levels
|
||||
.iter()
|
||||
.map(|level| level.generation_status.trim())
|
||||
.find(|status| !status.is_empty())
|
||||
})
|
||||
.map(str::to_string)
|
||||
}
|
||||
|
||||
pub(super) fn map_puzzle_work_summary_response(
|
||||
state: &AppState,
|
||||
item: PuzzleWorkProfileRecord,
|
||||
) -> PuzzleWorkSummaryResponse {
|
||||
let generation_status = resolve_puzzle_work_generation_status(&item);
|
||||
let author = resolve_work_author_by_user_id(
|
||||
state,
|
||||
&item.owner_user_id,
|
||||
@@ -316,6 +337,7 @@ pub(super) fn map_puzzle_work_summary_response(
|
||||
.saturating_div(2)
|
||||
.saturating_sub(item.point_incentive_claimed_points),
|
||||
publish_ready: item.publish_ready,
|
||||
generation_status,
|
||||
levels: Vec::new(),
|
||||
}
|
||||
}
|
||||
|
||||
@@ -237,7 +237,9 @@ pub fn confirm_click_at(
|
||||
return Ok(rejected(next, Match3DClickRejectReason::ItemNotClickable));
|
||||
}
|
||||
|
||||
let Some(slot_index) = first_empty_slot_index(&next.tray_slots) else {
|
||||
let Some(slot_index) =
|
||||
insert_item_into_tray_after_same_type(&mut next.tray_slots, &mut next.items, item_index)
|
||||
else {
|
||||
next = fail_run(next, Match3DFailureReason::TrayFull, client_action_id);
|
||||
return Ok(rejected(next, Match3DClickRejectReason::TrayFull));
|
||||
};
|
||||
@@ -246,7 +248,6 @@ pub fn confirm_click_at(
|
||||
next.items[item_index].state = Match3DItemState::InTray;
|
||||
next.items[item_index].clickable = false;
|
||||
next.items[item_index].tray_slot_index = Some(slot_index);
|
||||
fill_tray_slot(&mut next.tray_slots, slot_index, &next.items[item_index]);
|
||||
|
||||
let cleared_item_instance_ids = clear_first_triple(&mut next, &item_type_id);
|
||||
compact_tray(&mut next);
|
||||
@@ -540,12 +541,64 @@ fn first_empty_slot_index(slots: &[Match3DTraySlot]) -> Option<u32> {
|
||||
.map(|slot| slot.slot_index)
|
||||
}
|
||||
|
||||
fn fill_tray_slot(slots: &mut [Match3DTraySlot], slot_index: u32, item: &Match3DItemSnapshot) {
|
||||
if let Some(slot) = slots.iter_mut().find(|slot| slot.slot_index == slot_index) {
|
||||
slot.item_instance_id = Some(item.item_instance_id.clone());
|
||||
slot.item_type_id = Some(item.item_type_id.clone());
|
||||
slot.visual_key = Some(item.visual_key.clone());
|
||||
fn insert_item_into_tray_after_same_type(
|
||||
slots: &mut [Match3DTraySlot],
|
||||
items: &mut [Match3DItemSnapshot],
|
||||
item_index: usize,
|
||||
) -> Option<u32> {
|
||||
let occupied = slots
|
||||
.iter()
|
||||
.filter_map(|slot| {
|
||||
Some((
|
||||
slot.item_instance_id.clone()?,
|
||||
slot.item_type_id.clone()?,
|
||||
slot.visual_key.clone()?,
|
||||
))
|
||||
})
|
||||
.collect::<Vec<_>>();
|
||||
if occupied.len() >= slots.len() {
|
||||
return None;
|
||||
}
|
||||
|
||||
let item = items.get(item_index)?.clone();
|
||||
let insertion_index = occupied
|
||||
.iter()
|
||||
.rposition(|(_, item_type_id, _)| item_type_id == &item.item_type_id)
|
||||
.map(|index| index + 1)
|
||||
.unwrap_or(occupied.len());
|
||||
let mut next_occupied = occupied;
|
||||
next_occupied.insert(
|
||||
insertion_index,
|
||||
(
|
||||
item.item_instance_id.clone(),
|
||||
item.item_type_id.clone(),
|
||||
item.visual_key.clone(),
|
||||
),
|
||||
);
|
||||
|
||||
for slot in slots.iter_mut() {
|
||||
slot.item_instance_id = None;
|
||||
slot.item_type_id = None;
|
||||
slot.visual_key = None;
|
||||
}
|
||||
for (index, (item_instance_id, item_type_id, visual_key)) in
|
||||
next_occupied.into_iter().enumerate()
|
||||
{
|
||||
let slot_index = index as u32;
|
||||
if let Some(slot) = slots.iter_mut().find(|slot| slot.slot_index == slot_index) {
|
||||
slot.item_instance_id = Some(item_instance_id.clone());
|
||||
slot.item_type_id = Some(item_type_id);
|
||||
slot.visual_key = Some(visual_key);
|
||||
}
|
||||
if let Some(entry) = items
|
||||
.iter_mut()
|
||||
.find(|entry| entry.item_instance_id == item_instance_id)
|
||||
{
|
||||
entry.tray_slot_index = Some(slot_index);
|
||||
}
|
||||
}
|
||||
|
||||
Some(insertion_index as u32)
|
||||
}
|
||||
|
||||
fn clear_first_triple(run: &mut Match3DRunSnapshot, item_type_id: &str) -> Vec<String> {
|
||||
@@ -579,6 +632,7 @@ fn clear_first_triple(run: &mut Match3DRunSnapshot, item_type_id: &str) -> Vec<S
|
||||
slot.visual_key = None;
|
||||
}
|
||||
}
|
||||
compact_tray(run);
|
||||
|
||||
matched_slot_item_ids
|
||||
}
|
||||
@@ -1005,8 +1059,16 @@ mod tests {
|
||||
for item in board_items {
|
||||
let quadrant = format!(
|
||||
"{}-{}",
|
||||
if item.x >= MATCH3D_BOARD_CENTER { "r" } else { "l" },
|
||||
if item.y >= MATCH3D_BOARD_CENTER { "b" } else { "t" },
|
||||
if item.x >= MATCH3D_BOARD_CENTER {
|
||||
"r"
|
||||
} else {
|
||||
"l"
|
||||
},
|
||||
if item.y >= MATCH3D_BOARD_CENTER {
|
||||
"b"
|
||||
} else {
|
||||
"t"
|
||||
},
|
||||
);
|
||||
*quadrants.entry(quadrant).or_default() += 1;
|
||||
}
|
||||
@@ -1108,6 +1170,82 @@ mod tests {
|
||||
);
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn clicking_item_inserts_after_same_type_and_shifts_following_slots() {
|
||||
let mut run = Match3DRunSnapshot {
|
||||
run_id: "run-insert".to_string(),
|
||||
profile_id: "profile-1".to_string(),
|
||||
owner_user_id: "user-1".to_string(),
|
||||
status: Match3DRunStatus::Running,
|
||||
started_at_ms: 0,
|
||||
duration_limit_ms: MATCH3D_DEFAULT_DURATION_LIMIT_MS,
|
||||
remaining_ms: MATCH3D_DEFAULT_DURATION_LIMIT_MS,
|
||||
clear_count: 3,
|
||||
total_item_count: 4,
|
||||
cleared_item_count: 0,
|
||||
board_version: 1,
|
||||
items: vec![
|
||||
manual_item("apple-3", "apple", None),
|
||||
manual_item("apple-1", "apple", Some(0)),
|
||||
manual_item("apple-2", "apple", Some(1)),
|
||||
manual_item("pear-1", "pear", Some(2)),
|
||||
],
|
||||
tray_slots: empty_tray_slots(),
|
||||
failure_reason: None,
|
||||
last_confirmed_action_id: None,
|
||||
};
|
||||
run.tray_slots[0].item_instance_id = Some("apple-1".to_string());
|
||||
run.tray_slots[0].item_type_id = Some("apple".to_string());
|
||||
run.tray_slots[0].visual_key = Some("apple".to_string());
|
||||
run.tray_slots[1].item_instance_id = Some("apple-2".to_string());
|
||||
run.tray_slots[1].item_type_id = Some("apple".to_string());
|
||||
run.tray_slots[1].visual_key = Some("apple".to_string());
|
||||
run.tray_slots[2].item_instance_id = Some("pear-1".to_string());
|
||||
run.tray_slots[2].item_type_id = Some("pear".to_string());
|
||||
run.tray_slots[2].visual_key = Some("pear".to_string());
|
||||
|
||||
let confirmed = confirm_click_at(
|
||||
&run,
|
||||
&Match3DClickInput {
|
||||
run_id: run.run_id.clone(),
|
||||
owner_user_id: run.owner_user_id.clone(),
|
||||
item_instance_id: "apple-3".to_string(),
|
||||
client_action_id: "action-insert".to_string(),
|
||||
snapshot_version: run.board_version,
|
||||
clicked_at_ms: 1_000,
|
||||
},
|
||||
)
|
||||
.expect("click should confirm");
|
||||
|
||||
assert_eq!(confirmed.entered_slot_index, Some(2));
|
||||
assert_eq!(
|
||||
confirmed
|
||||
.run
|
||||
.tray_slots
|
||||
.iter()
|
||||
.map(|slot| slot.item_instance_id.as_deref())
|
||||
.collect::<Vec<_>>(),
|
||||
vec![Some("pear-1"), None, None, None, None, None, None]
|
||||
);
|
||||
assert_eq!(
|
||||
confirmed
|
||||
.run
|
||||
.items
|
||||
.iter()
|
||||
.find(|item| item.item_instance_id == "pear-1")
|
||||
.and_then(|item| item.tray_slot_index),
|
||||
Some(0)
|
||||
);
|
||||
assert_eq!(
|
||||
confirmed.cleared_item_instance_ids,
|
||||
vec![
|
||||
"apple-1".to_string(),
|
||||
"apple-2".to_string(),
|
||||
"apple-3".to_string()
|
||||
]
|
||||
);
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn tray_full_fails_when_no_triple_can_clear() {
|
||||
let mut run = Match3DRunSnapshot {
|
||||
|
||||
@@ -786,6 +786,45 @@ fn first_profile_level(profile: &PuzzleWorkProfile) -> Option<PuzzleDraftLevel>
|
||||
.next()
|
||||
}
|
||||
|
||||
fn first_profile_ui_background_level(profile: &PuzzleWorkProfile) -> Option<PuzzleDraftLevel> {
|
||||
normalize_puzzle_levels(profile.levels.clone(), &profile.theme_tags)
|
||||
.unwrap_or_else(|_| profile.levels.clone())
|
||||
.into_iter()
|
||||
.find(|level| {
|
||||
level
|
||||
.ui_background_image_src
|
||||
.as_deref()
|
||||
.and_then(normalize_required_string)
|
||||
.is_some()
|
||||
|| level
|
||||
.ui_background_image_object_key
|
||||
.as_deref()
|
||||
.and_then(normalize_required_string)
|
||||
.is_some()
|
||||
})
|
||||
}
|
||||
|
||||
fn resolve_puzzle_runtime_ui_background_fields(
|
||||
level: Option<&PuzzleDraftLevel>,
|
||||
fallback_level: Option<&PuzzleDraftLevel>,
|
||||
) -> (Option<String>, Option<String>) {
|
||||
for candidate in [level, fallback_level].into_iter().flatten() {
|
||||
let image_src = candidate
|
||||
.ui_background_image_src
|
||||
.as_deref()
|
||||
.and_then(normalize_required_string);
|
||||
let object_key = candidate
|
||||
.ui_background_image_object_key
|
||||
.as_deref()
|
||||
.and_then(|value| normalize_required_string(value.trim_start_matches('/')));
|
||||
if image_src.is_some() || object_key.is_some() {
|
||||
return (image_src, object_key);
|
||||
}
|
||||
}
|
||||
|
||||
(None, None)
|
||||
}
|
||||
|
||||
pub fn resolve_puzzle_runtime_remaining_ms(level: &PuzzleRuntimeLevelSnapshot, now_ms: u64) -> u64 {
|
||||
let time_limit_ms = if level.time_limit_ms == 0 {
|
||||
resolve_puzzle_level_time_limit_ms_by_index(level.level_index)
|
||||
@@ -1047,6 +1086,12 @@ pub fn start_run_with_shuffle_seed_at(
|
||||
let grid_size = level_config.grid_size;
|
||||
let board = build_initial_board_with_seed(grid_size, shuffle_seed)?;
|
||||
let current_profile_level = first_profile_level(entry_profile);
|
||||
let ui_background_level = first_profile_ui_background_level(entry_profile);
|
||||
let (ui_background_image_src, ui_background_image_object_key) =
|
||||
resolve_puzzle_runtime_ui_background_fields(
|
||||
current_profile_level.as_ref(),
|
||||
ui_background_level.as_ref(),
|
||||
);
|
||||
Ok(PuzzleRunSnapshot {
|
||||
run_id: run_id.clone(),
|
||||
entry_profile_id: entry_profile.profile_id.clone(),
|
||||
@@ -1067,12 +1112,8 @@ pub fn start_run_with_shuffle_seed_at(
|
||||
author_display_name: entry_profile.author_display_name.clone(),
|
||||
theme_tags: entry_profile.theme_tags.clone(),
|
||||
cover_image_src: entry_profile.cover_image_src.clone(),
|
||||
ui_background_image_src: current_profile_level
|
||||
.as_ref()
|
||||
.and_then(|level| level.ui_background_image_src.clone()),
|
||||
ui_background_image_object_key: current_profile_level
|
||||
.as_ref()
|
||||
.and_then(|level| level.ui_background_image_object_key.clone()),
|
||||
ui_background_image_src,
|
||||
ui_background_image_object_key,
|
||||
background_music: current_profile_level
|
||||
.as_ref()
|
||||
.and_then(|level| level.background_music.clone()),
|
||||
@@ -1326,6 +1367,16 @@ pub fn advance_next_level_at(
|
||||
let mut played_profile_ids = run.played_profile_ids.clone();
|
||||
played_profile_ids.push(next_profile.profile_id.clone());
|
||||
let current_profile_level = first_profile_level(next_profile);
|
||||
let ui_background_level = first_profile_ui_background_level(next_profile);
|
||||
let (mut ui_background_image_src, mut ui_background_image_object_key) =
|
||||
resolve_puzzle_runtime_ui_background_fields(
|
||||
current_profile_level.as_ref(),
|
||||
ui_background_level.as_ref(),
|
||||
);
|
||||
if ui_background_image_src.is_none() && ui_background_image_object_key.is_none() {
|
||||
ui_background_image_src = current_level.ui_background_image_src.clone();
|
||||
ui_background_image_object_key = current_level.ui_background_image_object_key.clone();
|
||||
}
|
||||
|
||||
Ok(PuzzleRunSnapshot {
|
||||
run_id: run.run_id.clone(),
|
||||
@@ -1347,12 +1398,8 @@ pub fn advance_next_level_at(
|
||||
author_display_name: next_profile.author_display_name.clone(),
|
||||
theme_tags: next_profile.theme_tags.clone(),
|
||||
cover_image_src: next_profile.cover_image_src.clone(),
|
||||
ui_background_image_src: current_profile_level
|
||||
.as_ref()
|
||||
.and_then(|level| level.ui_background_image_src.clone()),
|
||||
ui_background_image_object_key: current_profile_level
|
||||
.as_ref()
|
||||
.and_then(|level| level.ui_background_image_object_key.clone()),
|
||||
ui_background_image_src,
|
||||
ui_background_image_object_key,
|
||||
background_music: current_profile_level
|
||||
.as_ref()
|
||||
.and_then(|level| level.background_music.clone()),
|
||||
@@ -1408,6 +1455,12 @@ pub fn advance_to_new_work_first_level_at(
|
||||
played_profile_ids.push(next_profile.profile_id.clone());
|
||||
}
|
||||
let current_profile_level = first_profile_level(next_profile);
|
||||
let ui_background_level = first_profile_ui_background_level(next_profile);
|
||||
let (ui_background_image_src, ui_background_image_object_key) =
|
||||
resolve_puzzle_runtime_ui_background_fields(
|
||||
current_profile_level.as_ref(),
|
||||
ui_background_level.as_ref(),
|
||||
);
|
||||
|
||||
Ok(PuzzleRunSnapshot {
|
||||
run_id: run.run_id.clone(),
|
||||
@@ -1429,12 +1482,8 @@ pub fn advance_to_new_work_first_level_at(
|
||||
author_display_name: next_profile.author_display_name.clone(),
|
||||
theme_tags: next_profile.theme_tags.clone(),
|
||||
cover_image_src: next_profile.cover_image_src.clone(),
|
||||
ui_background_image_src: current_profile_level
|
||||
.as_ref()
|
||||
.and_then(|level| level.ui_background_image_src.clone()),
|
||||
ui_background_image_object_key: current_profile_level
|
||||
.as_ref()
|
||||
.and_then(|level| level.ui_background_image_object_key.clone()),
|
||||
ui_background_image_src,
|
||||
ui_background_image_object_key,
|
||||
background_music: current_profile_level
|
||||
.as_ref()
|
||||
.and_then(|level| level.background_music.clone()),
|
||||
@@ -3151,8 +3200,7 @@ mod tests {
|
||||
.background_music
|
||||
.as_ref()
|
||||
.map(|music| music.audio_src.as_str()),
|
||||
Some("/generated-puzzle-assets/background.mp3".to_string())
|
||||
.as_deref()
|
||||
Some("/generated-puzzle-assets/background.mp3".to_string()).as_deref()
|
||||
);
|
||||
assert_eq!(
|
||||
current_level.ui_background_image_object_key.as_deref(),
|
||||
@@ -3175,8 +3223,8 @@ mod tests {
|
||||
current_level.cleared_at_ms = Some(2_000);
|
||||
current_level.elapsed_ms = Some(1_000);
|
||||
|
||||
let next_run =
|
||||
advance_to_new_work_first_level_at(&cleared_run, &next_profile, 3_000).expect("next run");
|
||||
let next_run = advance_to_new_work_first_level_at(&cleared_run, &next_profile, 3_000)
|
||||
.expect("next run");
|
||||
|
||||
assert_eq!(
|
||||
next_run
|
||||
@@ -3187,6 +3235,52 @@ mod tests {
|
||||
);
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn same_work_next_level_inherits_first_available_ui_background() {
|
||||
let mut profile = build_published_profile("entry", "owner-a", vec!["奇幻"]);
|
||||
profile.levels[0].ui_background_image_src =
|
||||
Some("/generated-puzzle-assets/entry-ui.png".to_string());
|
||||
profile.levels.push(PuzzleDraftLevel {
|
||||
level_id: "puzzle-level-2".to_string(),
|
||||
level_name: "第二关".to_string(),
|
||||
picture_description: "第二关画面".to_string(),
|
||||
picture_reference: None,
|
||||
ui_background_prompt: None,
|
||||
ui_background_image_src: None,
|
||||
ui_background_image_object_key: None,
|
||||
background_music: None,
|
||||
candidates: Vec::new(),
|
||||
selected_candidate_id: None,
|
||||
cover_image_src: Some("/level-2.png".to_string()),
|
||||
cover_asset_id: None,
|
||||
generation_status: "ready".to_string(),
|
||||
});
|
||||
|
||||
let mut run = start_run("run-same-work-ui".to_string(), &profile, 0).expect("run");
|
||||
run.cleared_level_count = run.current_level_index;
|
||||
let current_level = run.current_level.as_mut().expect("level");
|
||||
current_level.status = PuzzleRuntimeLevelStatus::Cleared;
|
||||
current_level.cleared_at_ms = Some(2_000);
|
||||
current_level.elapsed_ms = Some(1_000);
|
||||
let next_level = selected_profile_level_after_runtime_level(&profile, current_level)
|
||||
.expect("same work next level");
|
||||
let mut next_profile = profile.clone();
|
||||
next_profile.level_name = next_level.level_name.clone();
|
||||
next_profile.cover_image_src = next_level.cover_image_src.clone();
|
||||
next_profile.cover_asset_id = next_level.cover_asset_id.clone();
|
||||
next_profile.levels = vec![next_level];
|
||||
|
||||
let next_run = advance_next_level_at(&run, &next_profile, 3_000).expect("next run");
|
||||
|
||||
assert_eq!(
|
||||
next_run
|
||||
.current_level
|
||||
.as_ref()
|
||||
.and_then(|level| level.ui_background_image_src.as_deref()),
|
||||
Some("/generated-puzzle-assets/entry-ui.png")
|
||||
);
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn swap_pieces_marks_cleared_when_back_to_origin() {
|
||||
let profile = build_published_profile("entry", "owner-a", vec!["蒸汽城市", "雨夜", "猫咪"]);
|
||||
|
||||
@@ -151,6 +151,8 @@ pub struct Match3DWorkSummaryResponse {
|
||||
#[serde(default)]
|
||||
pub published_at: Option<String>,
|
||||
pub publish_ready: bool,
|
||||
#[serde(default)]
|
||||
pub generation_status: Option<String>,
|
||||
#[serde(default, skip_serializing_if = "Option::is_none")]
|
||||
pub background_prompt: Option<String>,
|
||||
#[serde(default, skip_serializing_if = "Option::is_none")]
|
||||
@@ -282,4 +284,36 @@ mod tests {
|
||||
assert_eq!(payload["gameName"], json!("水果抓大鹅"));
|
||||
assert_eq!(payload["clearCount"], json!(4));
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn match3d_work_summary_uses_camel_case_generation_status() {
|
||||
let payload = serde_json::to_value(Match3DWorkSummaryResponse {
|
||||
work_id: "work-1".to_string(),
|
||||
profile_id: "profile-1".to_string(),
|
||||
owner_user_id: "user-1".to_string(),
|
||||
source_session_id: Some("session-1".to_string()),
|
||||
game_name: "水果抓大鹅".to_string(),
|
||||
theme_text: "水果".to_string(),
|
||||
summary: "水果主题".to_string(),
|
||||
tags: vec!["水果".to_string()],
|
||||
cover_image_src: None,
|
||||
reference_image_src: None,
|
||||
clear_count: 4,
|
||||
difficulty: 5,
|
||||
publication_status: "draft".to_string(),
|
||||
play_count: 0,
|
||||
updated_at: "2026-05-01T00:00:00Z".to_string(),
|
||||
published_at: None,
|
||||
publish_ready: false,
|
||||
generation_status: Some("generating".to_string()),
|
||||
background_prompt: None,
|
||||
background_image_src: None,
|
||||
background_image_object_key: None,
|
||||
generated_background_asset: None,
|
||||
generated_item_assets: Vec::new(),
|
||||
})
|
||||
.expect("payload should serialize");
|
||||
|
||||
assert_eq!(payload["generationStatus"], json!("generating"));
|
||||
}
|
||||
}
|
||||
|
||||
@@ -57,6 +57,8 @@ pub struct PuzzleWorkSummaryResponse {
|
||||
pub point_incentive_claimable_points: u64,
|
||||
pub publish_ready: bool,
|
||||
#[serde(default)]
|
||||
pub generation_status: Option<String>,
|
||||
#[serde(default)]
|
||||
pub levels: Vec<PuzzleDraftLevelResponse>,
|
||||
}
|
||||
|
||||
@@ -91,6 +93,7 @@ mod tests {
|
||||
point_incentive_total_points: 1.5,
|
||||
point_incentive_claimable_points: 0,
|
||||
publish_ready: true,
|
||||
generation_status: Some("ready".to_string()),
|
||||
levels: Vec::new(),
|
||||
})
|
||||
.expect("payload should serialize");
|
||||
@@ -99,6 +102,7 @@ mod tests {
|
||||
assert_eq!(payload["pointIncentiveClaimedPoints"], 1);
|
||||
assert_eq!(payload["pointIncentiveTotalPoints"], 1.5);
|
||||
assert_eq!(payload["pointIncentiveClaimablePoints"], 0);
|
||||
assert_eq!(payload["generationStatus"], "ready");
|
||||
}
|
||||
}
|
||||
|
||||
|
||||
@@ -3195,6 +3195,7 @@ fn map_match3d_result_draft(
|
||||
total_item_count: snapshot.clear_count.saturating_mul(3),
|
||||
publish_ready: false,
|
||||
blockers: Vec::new(),
|
||||
generated_item_assets_json: snapshot.generated_item_assets_json,
|
||||
}
|
||||
}
|
||||
|
||||
@@ -6398,6 +6399,7 @@ pub struct Match3DResultDraftRecord {
|
||||
pub total_item_count: u32,
|
||||
pub publish_ready: bool,
|
||||
pub blockers: Vec<String>,
|
||||
pub generated_item_assets_json: Option<String>,
|
||||
}
|
||||
|
||||
#[derive(Clone, Debug, PartialEq, Eq)]
|
||||
@@ -6541,6 +6543,8 @@ struct Match3DDraftJsonRecord {
|
||||
tags: Vec<String>,
|
||||
clear_count: u32,
|
||||
difficulty: u32,
|
||||
#[serde(default)]
|
||||
generated_item_assets_json: Option<String>,
|
||||
}
|
||||
|
||||
#[derive(Clone, Debug, PartialEq, Eq, serde::Deserialize)]
|
||||
|
||||
@@ -459,6 +459,11 @@ fn compile_match3d_draft_tx(
|
||||
config.theme_text.as_str(),
|
||||
);
|
||||
let summary_text = resolve_compile_summary_text(&input.summary_text, existing_work.as_ref());
|
||||
let compiled_at = Timestamp::from_micros_since_unix_epoch(input.compiled_at_micros);
|
||||
let generated_item_assets_json = resolve_generated_item_assets_json_for_compile(
|
||||
input.generated_item_assets_json.as_deref(),
|
||||
existing_work.as_ref(),
|
||||
)?;
|
||||
let draft = Match3DDraftSnapshot {
|
||||
profile_id: input.profile_id.clone(),
|
||||
game_name: game_name.clone(),
|
||||
@@ -467,12 +472,9 @@ fn compile_match3d_draft_tx(
|
||||
tags: tags.clone(),
|
||||
clear_count: config.clear_count,
|
||||
difficulty: config.difficulty,
|
||||
// 中文注释:草稿响应本身也携带生成素材快照,避免 HTTP facade 回读 work 详情失败时丢失背景/容器图。
|
||||
generated_item_assets_json: generated_item_assets_json.clone(),
|
||||
};
|
||||
let compiled_at = Timestamp::from_micros_since_unix_epoch(input.compiled_at_micros);
|
||||
let generated_item_assets_json = resolve_generated_item_assets_json_for_compile(
|
||||
input.generated_item_assets_json.as_deref(),
|
||||
existing_work.as_ref(),
|
||||
)?;
|
||||
let previous_publication_status = existing_work
|
||||
.as_ref()
|
||||
.map(|work| work.publication_status.clone())
|
||||
@@ -1889,6 +1891,32 @@ mod tests {
|
||||
);
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn match3d_draft_snapshot_keeps_generated_item_assets_json() {
|
||||
let draft = Match3DDraftSnapshot {
|
||||
profile_id: "profile-1".to_string(),
|
||||
game_name: "水果抓大鹅".to_string(),
|
||||
theme_text: "水果".to_string(),
|
||||
summary_text: "水果主题".to_string(),
|
||||
tags: vec!["水果".to_string()],
|
||||
clear_count: 3,
|
||||
difficulty: 3,
|
||||
generated_item_assets_json: Some(
|
||||
r#"[{"itemId":"match3d-item-1","itemName":"草莓","backgroundAsset":{"prompt":"果园背景","imageSrc":"/generated-match3d-assets/session/profile/background/background.png","containerImageSrc":"/generated-match3d-assets/session/profile/ui-container/container.png","status":"image_ready"},"status":"image_ready"}]"#
|
||||
.to_string(),
|
||||
),
|
||||
};
|
||||
|
||||
let row_json = to_json_string(&draft);
|
||||
let restored =
|
||||
parse_json::<Match3DDraftSnapshot>(&row_json, "match3d draft_json").unwrap();
|
||||
|
||||
assert_eq!(
|
||||
restored.generated_item_assets_json.as_deref(),
|
||||
draft.generated_item_assets_json.as_deref()
|
||||
);
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn match3d_work_update_preserves_assets_and_allows_empty_summary() {
|
||||
let existing = Match3DWorkProfileRow {
|
||||
|
||||
@@ -256,6 +256,8 @@ pub struct Match3DDraftSnapshot {
|
||||
pub tags: Vec<String>,
|
||||
pub clear_count: u32,
|
||||
pub difficulty: u32,
|
||||
#[serde(default)]
|
||||
pub generated_item_assets_json: Option<String>,
|
||||
}
|
||||
|
||||
#[derive(Clone, Debug, PartialEq, Eq, Serialize, Deserialize)]
|
||||
|
||||
@@ -870,7 +870,10 @@ fn compile_puzzle_agent_draft_tx(
|
||||
}
|
||||
let anchor_pack = infer_anchor_pack(&row.seed_text, Some(&row.seed_text));
|
||||
let messages = list_session_messages(ctx, &row.session_id);
|
||||
let draft = compile_result_draft_from_seed(&anchor_pack, &messages, Some(&row.seed_text));
|
||||
let draft = mark_puzzle_draft_generation_status(
|
||||
compile_result_draft_from_seed(&anchor_pack, &messages, Some(&row.seed_text)),
|
||||
"generating",
|
||||
);
|
||||
// 创作中心的拼图草稿卡只是 Agent session 的列表投影,
|
||||
// 每次编译结果页时同步 upsert,保证后续能按 source_session_id 恢复聊天。
|
||||
upsert_puzzle_draft_work_profile(
|
||||
@@ -2472,10 +2475,52 @@ fn profile_for_single_level(
|
||||
level: &module_puzzle::PuzzleDraftLevel,
|
||||
) -> PuzzleWorkProfile {
|
||||
let mut next_profile = profile.clone();
|
||||
let ui_background_carrier = profile.levels.iter().find(|candidate| {
|
||||
candidate
|
||||
.ui_background_image_src
|
||||
.as_deref()
|
||||
.map(str::trim)
|
||||
.map(|value| !value.is_empty())
|
||||
.unwrap_or(false)
|
||||
|| candidate
|
||||
.ui_background_image_object_key
|
||||
.as_deref()
|
||||
.map(str::trim)
|
||||
.map(|value| !value.is_empty())
|
||||
.unwrap_or(false)
|
||||
});
|
||||
let mut single_level = level.clone();
|
||||
if single_level
|
||||
.ui_background_image_src
|
||||
.as_deref()
|
||||
.map(str::trim)
|
||||
.unwrap_or("")
|
||||
.is_empty()
|
||||
&& single_level
|
||||
.ui_background_image_object_key
|
||||
.as_deref()
|
||||
.map(str::trim)
|
||||
.unwrap_or("")
|
||||
.is_empty()
|
||||
&& let Some(carrier) = ui_background_carrier
|
||||
{
|
||||
single_level.ui_background_image_src = carrier
|
||||
.ui_background_image_src
|
||||
.as_deref()
|
||||
.map(str::trim)
|
||||
.filter(|value| !value.is_empty())
|
||||
.map(str::to_string);
|
||||
single_level.ui_background_image_object_key = carrier
|
||||
.ui_background_image_object_key
|
||||
.as_deref()
|
||||
.map(str::trim)
|
||||
.filter(|value| !value.is_empty())
|
||||
.map(|value| value.trim_start_matches('/').to_string());
|
||||
}
|
||||
next_profile.level_name = level.level_name.clone();
|
||||
next_profile.cover_image_src = level.cover_image_src.clone();
|
||||
next_profile.cover_asset_id = level.cover_asset_id.clone();
|
||||
next_profile.levels = vec![level.clone()];
|
||||
next_profile.levels = vec![single_level];
|
||||
next_profile
|
||||
}
|
||||
|
||||
@@ -2496,6 +2541,17 @@ fn micros_to_millis(value: i64) -> u64 {
|
||||
(value as u64).saturating_div(1_000)
|
||||
}
|
||||
|
||||
fn mark_puzzle_draft_generation_status(
|
||||
mut draft: PuzzleResultDraft,
|
||||
generation_status: &str,
|
||||
) -> PuzzleResultDraft {
|
||||
draft.generation_status = generation_status.to_string();
|
||||
for level in &mut draft.levels {
|
||||
level.generation_status = generation_status.to_string();
|
||||
}
|
||||
draft
|
||||
}
|
||||
|
||||
fn upsert_puzzle_draft_work_profile(
|
||||
ctx: &TxContext,
|
||||
session_id: &str,
|
||||
@@ -3466,6 +3522,37 @@ mod tests {
|
||||
assert!(preview.publish_ready);
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn puzzle_draft_generation_status_updates_all_levels() {
|
||||
let anchor_pack = infer_anchor_pack("蒸汽城市雨夜猫咪", Some("蒸汽城市雨夜猫咪"));
|
||||
let mut draft = compile_result_draft(&anchor_pack, &[]);
|
||||
draft.levels.push(module_puzzle::PuzzleDraftLevel {
|
||||
level_id: "puzzle-level-2".to_string(),
|
||||
level_name: "第二关".to_string(),
|
||||
picture_description: "第二关画面".to_string(),
|
||||
picture_reference: None,
|
||||
ui_background_prompt: None,
|
||||
ui_background_image_src: None,
|
||||
ui_background_image_object_key: None,
|
||||
background_music: None,
|
||||
candidates: Vec::new(),
|
||||
selected_candidate_id: None,
|
||||
cover_image_src: None,
|
||||
cover_asset_id: None,
|
||||
generation_status: "idle".to_string(),
|
||||
});
|
||||
|
||||
let draft = mark_puzzle_draft_generation_status(draft, "generating");
|
||||
|
||||
assert_eq!(draft.generation_status, "generating");
|
||||
assert!(
|
||||
draft
|
||||
.levels
|
||||
.iter()
|
||||
.all(|level| level.generation_status == "generating")
|
||||
);
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn puzzle_generated_images_replace_existing_candidate() {
|
||||
let anchor_pack = infer_anchor_pack("蒸汽城市雨夜猫咪", Some("蒸汽城市雨夜猫咪"));
|
||||
|
||||
@@ -215,8 +215,7 @@ fn migrate_visual_novel_entry_from_old_visible_default(ctx: &ReducerContext, now
|
||||
&& row.subtitle == "分支叙事体验"
|
||||
&& row.image_src == "/creation-type-references/visual-novel.webp"
|
||||
&& row.visible
|
||||
&& ((row.badge == "可创建" && row.open)
|
||||
|| (row.badge == "敬请期待" && !row.open))
|
||||
&& ((row.badge == "可创建" && row.open) || (row.badge == "敬请期待" && !row.open))
|
||||
&& row.sort_order == 60;
|
||||
if !still_old_visible_default {
|
||||
return;
|
||||
|
||||
@@ -86,6 +86,58 @@ test('buildCreationWorkShelfItems attaches open and delete actions through shelf
|
||||
expect(onDeletePuzzle).toHaveBeenCalledWith(puzzleWork);
|
||||
});
|
||||
|
||||
test('buildCreationWorkShelfItems restores persisted generation state for puzzle and match3d drafts', () => {
|
||||
const items = buildCreationWorkShelfItems({
|
||||
rpgItems: [],
|
||||
bigFishItems: [],
|
||||
puzzleItems: [
|
||||
{
|
||||
workId: 'puzzle:generating',
|
||||
profileId: 'puzzle-profile-generating',
|
||||
ownerUserId: 'user-1',
|
||||
sourceSessionId: 'puzzle-session-generating',
|
||||
authorDisplayName: '测试作者',
|
||||
levelName: '生成中拼图',
|
||||
summary: '退出产品后仍应显示生成中。',
|
||||
themeTags: [],
|
||||
coverImageSrc: null,
|
||||
publicationStatus: 'draft',
|
||||
updatedAt: '2026-05-08T00:00:00.000Z',
|
||||
publishedAt: null,
|
||||
publishReady: false,
|
||||
generationStatus: 'generating',
|
||||
},
|
||||
],
|
||||
match3dItems: [
|
||||
{
|
||||
workId: 'match3d:generating',
|
||||
profileId: 'match3d-profile-generating',
|
||||
ownerUserId: 'user-1',
|
||||
sourceSessionId: 'match3d-session-generating',
|
||||
gameName: '生成中抓鹅',
|
||||
themeText: '糖果厨房',
|
||||
summary: '退出产品后仍应显示生成中。',
|
||||
tags: [],
|
||||
coverImageSrc: null,
|
||||
clearCount: 18,
|
||||
difficulty: 1,
|
||||
publicationStatus: 'draft',
|
||||
playCount: 0,
|
||||
updatedAt: '2026-05-07T00:00:00.000Z',
|
||||
publishReady: false,
|
||||
generationStatus: 'generating',
|
||||
},
|
||||
],
|
||||
});
|
||||
|
||||
expect(items.find((item) => item.kind === 'puzzle')?.isGenerating).toBe(
|
||||
true,
|
||||
);
|
||||
expect(items.find((item) => item.kind === 'match3d')?.isGenerating).toBe(
|
||||
true,
|
||||
);
|
||||
});
|
||||
|
||||
test('buildCreationWorkShelfItems maps baby object match local drafts', () => {
|
||||
const onOpenBabyObjectMatchDetail = vi.fn();
|
||||
const onDeleteBabyObjectMatch = vi.fn();
|
||||
|
||||
@@ -238,13 +238,19 @@ export function buildCreationWorkShelfItems(params: {
|
||||
]
|
||||
.map((item) => {
|
||||
const state = getItemState?.(item);
|
||||
const persistedIsGenerating = isPersistedCreationWorkGenerating(item);
|
||||
return state
|
||||
? {
|
||||
...item,
|
||||
isGenerating: state.isGenerating,
|
||||
isGenerating: Boolean(state.isGenerating || persistedIsGenerating),
|
||||
hasUnreadUpdate: state.hasUnreadUpdate,
|
||||
}
|
||||
: item;
|
||||
: persistedIsGenerating
|
||||
? {
|
||||
...item,
|
||||
isGenerating: true,
|
||||
}
|
||||
: item;
|
||||
})
|
||||
.sort(
|
||||
(left, right) =>
|
||||
@@ -793,6 +799,17 @@ function buildPuzzleWorkShelfActions(
|
||||
};
|
||||
}
|
||||
|
||||
function isPersistedCreationWorkGenerating(item: CreationWorkShelfItem) {
|
||||
switch (item.source.kind) {
|
||||
case 'match3d':
|
||||
return item.source.item.generationStatus === 'generating';
|
||||
case 'puzzle':
|
||||
return item.source.item.generationStatus === 'generating';
|
||||
default:
|
||||
return false;
|
||||
}
|
||||
}
|
||||
|
||||
function buildRpgWorkShelfActions(
|
||||
item: CustomWorldWorkSummary,
|
||||
adapter: RpgWorkShelfAdapter,
|
||||
|
||||
@@ -1359,7 +1359,7 @@ describe('Match3DResultView', () => {
|
||||
'img[src="/match3d-background-references/pot-fused-reference.png"]',
|
||||
);
|
||||
expect(containerImage).toBeTruthy();
|
||||
expect(containerImage?.className).toContain('w-[min(99vw,34rem)]');
|
||||
expect(containerImage?.className).toContain('w-[min(108vw,38rem)]');
|
||||
expect(containerImage?.className).toContain('-translate-x-1/2');
|
||||
expect(
|
||||
document.querySelector('.animate-spin, [class*="border-l-transparent"]'),
|
||||
|
||||
@@ -52,6 +52,19 @@ import {
|
||||
import { Match3DRuntimeShell } from './Match3DRuntimeShell';
|
||||
import { resolveGeometryAsset } from './match3dVisualAssets';
|
||||
|
||||
const runtimeAudioFeedback = vi.hoisted(() => ({
|
||||
playRuntimeMergeSound: vi.fn(),
|
||||
}));
|
||||
|
||||
vi.mock('../../services/runtimeAudioFeedback', async (importOriginal) => {
|
||||
const actual =
|
||||
await importOriginal<typeof import('../../services/runtimeAudioFeedback')>();
|
||||
return {
|
||||
...actual,
|
||||
playRuntimeMergeSound: runtimeAudioFeedback.playRuntimeMergeSound,
|
||||
};
|
||||
});
|
||||
|
||||
vi.mock('./Match3DPhysicsBoard', async (importOriginal) => {
|
||||
const actual = await importOriginal<typeof import('./Match3DPhysicsBoard')>();
|
||||
return {
|
||||
@@ -82,6 +95,7 @@ afterEach(() => {
|
||||
__MATCH3D_KEEP_3D_TEST_RENDER__?: boolean;
|
||||
}
|
||||
).__MATCH3D_KEEP_3D_TEST_RENDER__;
|
||||
runtimeAudioFeedback.playRuntimeMergeSound.mockReset();
|
||||
vi.restoreAllMocks();
|
||||
});
|
||||
|
||||
@@ -519,6 +533,318 @@ test('运行态按生成素材的相对尺寸缩放场内和托盘图片', () =>
|
||||
).toBe('scale(0.58)');
|
||||
});
|
||||
|
||||
test('点击物品乐观插入到物品栏同类后面并后移后续物品', async () => {
|
||||
const baseRun = startLocalMatch3DRun(3);
|
||||
const [appleBoard, pearTray, appleTray] = baseRun.items.slice(0, 3);
|
||||
expect(appleBoard && pearTray && appleTray).toBeTruthy();
|
||||
const run: Match3DRunSnapshot = {
|
||||
...baseRun,
|
||||
items: [
|
||||
{
|
||||
...appleBoard!,
|
||||
itemInstanceId: 'apple-3',
|
||||
itemTypeId: 'apple',
|
||||
visualKey: 'block-red-2x4',
|
||||
clickable: true,
|
||||
state: 'InBoard',
|
||||
x: 0.5,
|
||||
y: 0.5,
|
||||
layer: 10,
|
||||
traySlotIndex: null,
|
||||
},
|
||||
{
|
||||
...pearTray!,
|
||||
itemInstanceId: 'apple-1',
|
||||
itemTypeId: 'apple',
|
||||
visualKey: 'block-red-2x4',
|
||||
clickable: false,
|
||||
state: 'InTray',
|
||||
traySlotIndex: 0,
|
||||
},
|
||||
{
|
||||
...appleTray!,
|
||||
itemInstanceId: 'apple-2',
|
||||
itemTypeId: 'apple',
|
||||
visualKey: 'block-red-2x4',
|
||||
clickable: false,
|
||||
state: 'InTray',
|
||||
traySlotIndex: 1,
|
||||
},
|
||||
{
|
||||
...baseRun.items[3]!,
|
||||
itemInstanceId: 'pear-1',
|
||||
itemTypeId: 'pear',
|
||||
visualKey: 'block-blue-1x2',
|
||||
clickable: false,
|
||||
state: 'InTray',
|
||||
traySlotIndex: 2,
|
||||
},
|
||||
...baseRun.items.slice(4).map((item) => ({
|
||||
...item,
|
||||
clickable: false,
|
||||
state: 'InBoard' as const,
|
||||
})),
|
||||
],
|
||||
traySlots: baseRun.traySlots.map((slot) => {
|
||||
if (slot.slotIndex === 0) {
|
||||
return {
|
||||
slotIndex: 0,
|
||||
itemInstanceId: 'apple-1',
|
||||
itemTypeId: 'apple',
|
||||
visualKey: 'block-red-2x4',
|
||||
};
|
||||
}
|
||||
if (slot.slotIndex === 1) {
|
||||
return {
|
||||
slotIndex: 1,
|
||||
itemInstanceId: 'apple-2',
|
||||
itemTypeId: 'apple',
|
||||
visualKey: 'block-red-2x4',
|
||||
};
|
||||
}
|
||||
if (slot.slotIndex === 2) {
|
||||
return {
|
||||
slotIndex: 2,
|
||||
itemInstanceId: 'pear-1',
|
||||
itemTypeId: 'pear',
|
||||
visualKey: 'block-blue-1x2',
|
||||
};
|
||||
}
|
||||
return { slotIndex: slot.slotIndex };
|
||||
}),
|
||||
};
|
||||
const onClickItem = vi.fn(async (payload: Match3DClickItemRequest) => ({
|
||||
status: 'Accepted' as const,
|
||||
acceptedItemInstanceId: payload.itemInstanceId,
|
||||
clearedItemInstanceIds: [],
|
||||
run: {
|
||||
...run,
|
||||
snapshotVersion: run.snapshotVersion + 1,
|
||||
},
|
||||
}));
|
||||
const onOptimisticRunChange = vi.fn();
|
||||
render(
|
||||
<Match3DRuntimeShell
|
||||
run={run}
|
||||
onBack={vi.fn()}
|
||||
onRestart={vi.fn()}
|
||||
onOptimisticRunChange={onOptimisticRunChange}
|
||||
onClickItem={onClickItem}
|
||||
/>,
|
||||
);
|
||||
const board = screen.getByTestId('match3d-board');
|
||||
mockMatch3DBoardRect();
|
||||
mockMatch3DPointerCapture(board);
|
||||
Object.defineProperty(screen.getAllByTestId('match3d-tray-slot')[3]!, 'getBoundingClientRect', {
|
||||
configurable: true,
|
||||
value: () => ({
|
||||
bottom: 530,
|
||||
height: 56,
|
||||
left: 220,
|
||||
right: 276,
|
||||
top: 474,
|
||||
width: 56,
|
||||
x: 220,
|
||||
y: 474,
|
||||
toJSON: () => ({}),
|
||||
}),
|
||||
});
|
||||
|
||||
const point = toMatch3DBoardClientPoint(run.items[0]!);
|
||||
fireMatch3DBoardPointer(board, 'pointerdown', point, 14);
|
||||
fireMatch3DBoardPointer(board, 'pointerup', point, 14);
|
||||
|
||||
await waitFor(() => expect(onOptimisticRunChange).toHaveBeenCalled());
|
||||
const optimisticRun = onOptimisticRunChange.mock.calls[0]?.[0] as
|
||||
| Match3DRunSnapshot
|
||||
| undefined;
|
||||
expect(optimisticRun?.traySlots.map((slot) => slot.itemInstanceId ?? null)).toEqual([
|
||||
'apple-1',
|
||||
'apple-2',
|
||||
'apple-3',
|
||||
'pear-1',
|
||||
null,
|
||||
null,
|
||||
null,
|
||||
]);
|
||||
expect(
|
||||
optimisticRun?.items.find((item) => item.itemInstanceId === 'apple-3')
|
||||
?.traySlotIndex,
|
||||
).toBe(2);
|
||||
});
|
||||
|
||||
test('三消确认后物品栏播放合成动画并隐藏权威快照中已清除的槽位', async () => {
|
||||
const baseRun = startLocalMatch3DRun(1);
|
||||
const [first, second, third, fourth] = baseRun.items.slice(0, 4);
|
||||
expect(first && second && third).toBeTruthy();
|
||||
const run: Match3DRunSnapshot = {
|
||||
...baseRun,
|
||||
totalItemCount: 4,
|
||||
items: [
|
||||
{
|
||||
...first!,
|
||||
itemInstanceId: 'apple-1',
|
||||
itemTypeId: 'apple',
|
||||
visualKey: 'block-red-2x4',
|
||||
state: 'InTray',
|
||||
clickable: false,
|
||||
traySlotIndex: 0,
|
||||
},
|
||||
{
|
||||
...second!,
|
||||
itemInstanceId: 'apple-2',
|
||||
itemTypeId: 'apple',
|
||||
visualKey: 'block-red-2x4',
|
||||
state: 'InTray',
|
||||
clickable: false,
|
||||
traySlotIndex: 1,
|
||||
},
|
||||
{
|
||||
...third!,
|
||||
itemInstanceId: 'apple-3',
|
||||
itemTypeId: 'apple',
|
||||
visualKey: 'block-red-2x4',
|
||||
state: 'InBoard',
|
||||
clickable: true,
|
||||
x: 0.5,
|
||||
y: 0.5,
|
||||
layer: 10,
|
||||
traySlotIndex: null,
|
||||
},
|
||||
{
|
||||
...(fourth ?? third!),
|
||||
itemInstanceId: 'pear-1',
|
||||
itemTypeId: 'pear',
|
||||
visualKey: 'block-blue-1x2',
|
||||
state: 'InTray',
|
||||
clickable: false,
|
||||
traySlotIndex: 2,
|
||||
},
|
||||
],
|
||||
traySlots: baseRun.traySlots.map((slot) => {
|
||||
if (slot.slotIndex === 0) {
|
||||
return {
|
||||
slotIndex: 0,
|
||||
itemInstanceId: 'apple-1',
|
||||
itemTypeId: 'apple',
|
||||
visualKey: 'block-red-2x4',
|
||||
};
|
||||
}
|
||||
if (slot.slotIndex === 1) {
|
||||
return {
|
||||
slotIndex: 1,
|
||||
itemInstanceId: 'apple-2',
|
||||
itemTypeId: 'apple',
|
||||
visualKey: 'block-red-2x4',
|
||||
};
|
||||
}
|
||||
if (slot.slotIndex === 2) {
|
||||
return {
|
||||
slotIndex: 2,
|
||||
itemInstanceId: 'pear-1',
|
||||
itemTypeId: 'pear',
|
||||
visualKey: 'block-blue-1x2',
|
||||
};
|
||||
}
|
||||
return { slotIndex: slot.slotIndex };
|
||||
}),
|
||||
};
|
||||
const acceptedRun: Match3DRunSnapshot = {
|
||||
...run,
|
||||
snapshotVersion: run.snapshotVersion + 1,
|
||||
clearedItemCount: 3,
|
||||
items: run.items.map((item) =>
|
||||
item.itemTypeId === 'apple'
|
||||
? {
|
||||
...item,
|
||||
state: 'Cleared' as const,
|
||||
clickable: false,
|
||||
traySlotIndex: null,
|
||||
}
|
||||
: { ...item, traySlotIndex: 0 },
|
||||
),
|
||||
traySlots: run.traySlots.map((slot) =>
|
||||
slot.slotIndex === 0
|
||||
? {
|
||||
slotIndex: 0,
|
||||
itemInstanceId: 'pear-1',
|
||||
itemTypeId: 'pear',
|
||||
visualKey: 'block-blue-1x2',
|
||||
}
|
||||
: { slotIndex: slot.slotIndex },
|
||||
),
|
||||
};
|
||||
const onClickItem = vi.fn(async (payload: Match3DClickItemRequest) => ({
|
||||
status: 'Accepted' as const,
|
||||
acceptedItemInstanceId: payload.itemInstanceId,
|
||||
clearedItemInstanceIds: ['apple-1', 'apple-2', 'apple-3'],
|
||||
run: acceptedRun,
|
||||
}));
|
||||
const onOptimisticRunChange = vi.fn((nextRun: Match3DRunSnapshot) => {
|
||||
rerender(
|
||||
<Match3DRuntimeShell
|
||||
run={nextRun}
|
||||
onBack={vi.fn()}
|
||||
onRestart={vi.fn()}
|
||||
onOptimisticRunChange={onOptimisticRunChange}
|
||||
onClickItem={onClickItem}
|
||||
/>,
|
||||
);
|
||||
});
|
||||
const { rerender } = render(
|
||||
<Match3DRuntimeShell
|
||||
run={run}
|
||||
onBack={vi.fn()}
|
||||
onRestart={vi.fn()}
|
||||
onOptimisticRunChange={onOptimisticRunChange}
|
||||
onClickItem={onClickItem}
|
||||
/>,
|
||||
);
|
||||
const board = screen.getByTestId('match3d-board');
|
||||
mockMatch3DBoardRect();
|
||||
mockMatch3DPointerCapture(board);
|
||||
screen.getAllByTestId('match3d-tray-slot').forEach((slot, index) => {
|
||||
Object.defineProperty(slot, 'getBoundingClientRect', {
|
||||
configurable: true,
|
||||
value: () => ({
|
||||
bottom: 530,
|
||||
height: 56,
|
||||
left: 52 + index * 58,
|
||||
right: 108 + index * 58,
|
||||
top: 474,
|
||||
width: 56,
|
||||
x: 52 + index * 58,
|
||||
y: 474,
|
||||
toJSON: () => ({}),
|
||||
}),
|
||||
});
|
||||
});
|
||||
|
||||
const point = toMatch3DBoardClientPoint(run.items[2]!);
|
||||
fireMatch3DBoardPointer(board, 'pointerdown', point, 15);
|
||||
fireMatch3DBoardPointer(board, 'pointerup', point, 15);
|
||||
|
||||
await waitFor(() =>
|
||||
expect(screen.getByTestId('match3d-tray-clear-animation')).toBeTruthy(),
|
||||
);
|
||||
expect(screen.getAllByTestId('match3d-tray-clear-token')).toHaveLength(3);
|
||||
expect(screen.getByTestId('match3d-merge-feedback')).toBeTruthy();
|
||||
expect(screen.queryByTestId('match3d-merge-feedback')?.querySelector('svg')).toBeNull();
|
||||
expect(runtimeAudioFeedback.playRuntimeMergeSound).toHaveBeenCalledTimes(1);
|
||||
const latestRun = onOptimisticRunChange.mock.calls.at(-1)?.[0] as
|
||||
| Match3DRunSnapshot
|
||||
| undefined;
|
||||
expect(latestRun?.traySlots.map((slot) => slot.itemInstanceId ?? null)).toEqual([
|
||||
'pear-1',
|
||||
null,
|
||||
null,
|
||||
null,
|
||||
null,
|
||||
null,
|
||||
null,
|
||||
]);
|
||||
});
|
||||
|
||||
test('点击物品时播放飞入底部栏位动画并使用第一张物品视图', async () => {
|
||||
const run = startLocalMatch3DRun(1);
|
||||
const clickableItem = run.items.find((item) => item.clickable)!;
|
||||
@@ -1025,9 +1351,10 @@ test('运行态会换签并渲染抓大鹅中心容器 UI 图', async () => {
|
||||
const containerImage = screen.getByTestId(
|
||||
'match3d-container-image',
|
||||
) as HTMLImageElement;
|
||||
expect(containerImage.className).toContain('w-[min(99vw,34rem)]');
|
||||
expect(containerImage.className).toContain('w-[min(116vw,42rem)]');
|
||||
expect(containerImage.className).toContain('h-auto');
|
||||
expect(containerImage.className).toContain('left-1/2');
|
||||
expect(containerImage.className).toContain('top-[54%]');
|
||||
expect(containerImage.className).toContain('-translate-x-1/2');
|
||||
expect(screen.getByTestId('match3d-board').className).toContain(
|
||||
'bg-transparent',
|
||||
|
||||
File diff suppressed because it is too large
Load Diff
@@ -0,0 +1,124 @@
|
||||
import { expect, test } from 'vitest';
|
||||
|
||||
import type { Match3DRunSnapshot } from '../../../packages/shared/src/contracts/match3dRuntime';
|
||||
import {
|
||||
findMatch3DHitItem,
|
||||
isPointInsideMatch3DItemAlphaHotspot,
|
||||
resolveMatch3DItemSizeScale,
|
||||
type Match3DResolvedImageSourceEntry,
|
||||
} from './match3dHotspot';
|
||||
import { resolveRenderableItemFrame } from './match3dRuntimePresentation';
|
||||
|
||||
function buildMatch3DHotspotRun(): Match3DRunSnapshot {
|
||||
return {
|
||||
runId: 'hotspot-run',
|
||||
profileId: 'hotspot-profile',
|
||||
status: 'Running',
|
||||
snapshotVersion: 1,
|
||||
startedAtMs: 1,
|
||||
durationLimitMs: 600_000,
|
||||
remainingMs: 600_000,
|
||||
clearCount: 1,
|
||||
totalItemCount: 1,
|
||||
clearedItemCount: 0,
|
||||
traySlots: Array.from({ length: 7 }, (_, slotIndex) => ({ slotIndex })),
|
||||
items: [
|
||||
{
|
||||
clickable: true,
|
||||
itemInstanceId: 'alpha-hotspot-item',
|
||||
itemTypeId: 'match3d-type-01',
|
||||
visualKey: 'block-red-2x2',
|
||||
x: 0.5,
|
||||
y: 0.5,
|
||||
radius: 0.1,
|
||||
layer: 1,
|
||||
state: 'InBoard',
|
||||
},
|
||||
],
|
||||
};
|
||||
}
|
||||
|
||||
test('透明像素不作为抓大鹅物品点击热区', () => {
|
||||
const run = buildMatch3DHotspotRun();
|
||||
const item = run.items[0]!;
|
||||
const mask = {
|
||||
width: 4,
|
||||
height: 4,
|
||||
alpha: new Uint8ClampedArray([
|
||||
0, 0, 0, 0,
|
||||
0, 255, 255, 0,
|
||||
0, 255, 255, 0,
|
||||
0, 0, 0, 0,
|
||||
]),
|
||||
};
|
||||
const imageSourceEntriesByType = new Map<string, Match3DResolvedImageSourceEntry[]>([
|
||||
[
|
||||
'match3d-type-01',
|
||||
[
|
||||
{
|
||||
source: '/generated-match3d-assets/item-01.png',
|
||||
resolvedSource: 'https://oss.example.com/item-01.png',
|
||||
},
|
||||
],
|
||||
],
|
||||
]);
|
||||
const alphaHitMasks = new Map([
|
||||
['/generated-match3d-assets/item-01.png', mask],
|
||||
]);
|
||||
const itemSizeByType = new Map([['match3d-type-01', '大' as const]]);
|
||||
const frame = resolveRenderableItemFrame(item);
|
||||
|
||||
expect(
|
||||
findMatch3DHitItem(run, frame.x - frame.radius * 0.6, 0.5, {
|
||||
alphaHitMasks,
|
||||
imageSourceEntriesByType,
|
||||
itemSizeByType,
|
||||
}),
|
||||
).toBeUndefined();
|
||||
expect(
|
||||
findMatch3DHitItem(run, 0.5, 0.5, {
|
||||
alphaHitMasks,
|
||||
imageSourceEntriesByType,
|
||||
itemSizeByType,
|
||||
})?.itemInstanceId,
|
||||
).toBe('alpha-hotspot-item');
|
||||
});
|
||||
|
||||
test('小尺寸物品只在缩放后的非透明主体内命中', () => {
|
||||
const item = buildMatch3DHotspotRun().items[0]!;
|
||||
const mask = {
|
||||
width: 2,
|
||||
height: 2,
|
||||
alpha: new Uint8ClampedArray([255, 255, 255, 255]),
|
||||
};
|
||||
|
||||
expect(
|
||||
isPointInsideMatch3DItemAlphaHotspot({
|
||||
item,
|
||||
pointX: 0.5,
|
||||
pointY: 0.5,
|
||||
mask,
|
||||
itemSize: '小',
|
||||
}),
|
||||
).toBe(true);
|
||||
expect(
|
||||
isPointInsideMatch3DItemAlphaHotspot({
|
||||
item,
|
||||
pointX: 0.38,
|
||||
pointY: 0.5,
|
||||
mask,
|
||||
itemSize: '小',
|
||||
}),
|
||||
).toBe(false);
|
||||
});
|
||||
|
||||
test('抓大鹅生成物品大和中也会做一定程度缩小', () => {
|
||||
expect(resolveMatch3DItemSizeScale('大')).toBeLessThan(1);
|
||||
expect(resolveMatch3DItemSizeScale('中')).toBeLessThan(0.78);
|
||||
expect(resolveMatch3DItemSizeScale('大')).toBeGreaterThan(
|
||||
resolveMatch3DItemSizeScale('中'),
|
||||
);
|
||||
expect(resolveMatch3DItemSizeScale('中')).toBeGreaterThan(
|
||||
resolveMatch3DItemSizeScale('小'),
|
||||
);
|
||||
});
|
||||
@@ -0,0 +1,194 @@
|
||||
import type {
|
||||
Match3DItemSnapshot,
|
||||
Match3DRunSnapshot,
|
||||
} from '../../../packages/shared/src/contracts/match3dRuntime';
|
||||
import { isGeneratedLegacyPath } from '../../services/assetReadUrlService';
|
||||
import {
|
||||
isItemState,
|
||||
resolveRenderableItemFrame,
|
||||
} from './match3dRuntimePresentation';
|
||||
|
||||
export type Match3DGeneratedItemRelativeSize = '大' | '中' | '小';
|
||||
|
||||
export type Match3DAlphaHitMask = {
|
||||
width: number;
|
||||
height: number;
|
||||
alpha: Uint8ClampedArray;
|
||||
};
|
||||
|
||||
export type Match3DResolvedImageSourceEntry = {
|
||||
source: string;
|
||||
resolvedSource: string;
|
||||
};
|
||||
|
||||
const MATCH3D_HIT_ALPHA_THRESHOLD = 8;
|
||||
|
||||
function isPointInsideCircle(
|
||||
pointX: number,
|
||||
pointY: number,
|
||||
item: Match3DItemSnapshot,
|
||||
) {
|
||||
const frame = resolveRenderableItemFrame(item);
|
||||
return Math.hypot(pointX - frame.x, pointY - frame.y) <= frame.radius;
|
||||
}
|
||||
|
||||
function clampMatch3DHitPixelIndex(value: number, size: number) {
|
||||
return Math.min(size - 1, Math.max(0, Math.floor(value * size)));
|
||||
}
|
||||
|
||||
export function resolveMatch3DItemSizeScale(
|
||||
itemSize: Match3DGeneratedItemRelativeSize | undefined,
|
||||
) {
|
||||
if (itemSize === '小') {
|
||||
return 0.58;
|
||||
}
|
||||
if (itemSize === '中') {
|
||||
return 0.68;
|
||||
}
|
||||
return 0.88;
|
||||
}
|
||||
|
||||
function isPointInsideAlphaHitMask(
|
||||
localX: number,
|
||||
localY: number,
|
||||
mask: Match3DAlphaHitMask,
|
||||
itemSize: Match3DGeneratedItemRelativeSize,
|
||||
) {
|
||||
if (
|
||||
mask.width <= 0 ||
|
||||
mask.height <= 0 ||
|
||||
mask.alpha.length < mask.width * mask.height ||
|
||||
localX < 0 ||
|
||||
localX > 1 ||
|
||||
localY < 0 ||
|
||||
localY > 1
|
||||
) {
|
||||
return false;
|
||||
}
|
||||
|
||||
const aspectRatio = mask.width / mask.height;
|
||||
const containWidth = aspectRatio >= 1 ? 1 : aspectRatio;
|
||||
const containHeight = aspectRatio >= 1 ? 1 / aspectRatio : 1;
|
||||
const imageScale = resolveMatch3DItemSizeScale(itemSize);
|
||||
const renderedWidth = containWidth * imageScale;
|
||||
const renderedHeight = containHeight * imageScale;
|
||||
const imageLeft = (1 - renderedWidth) / 2;
|
||||
const imageTop = (1 - renderedHeight) / 2;
|
||||
|
||||
if (
|
||||
localX < imageLeft ||
|
||||
localX > imageLeft + renderedWidth ||
|
||||
localY < imageTop ||
|
||||
localY > imageTop + renderedHeight
|
||||
) {
|
||||
return false;
|
||||
}
|
||||
|
||||
const imageX = (localX - imageLeft) / renderedWidth;
|
||||
const imageY = (localY - imageTop) / renderedHeight;
|
||||
const pixelX = clampMatch3DHitPixelIndex(imageX, mask.width);
|
||||
const pixelY = clampMatch3DHitPixelIndex(imageY, mask.height);
|
||||
return (
|
||||
(mask.alpha[pixelY * mask.width + pixelX] ?? 0) >
|
||||
MATCH3D_HIT_ALPHA_THRESHOLD
|
||||
);
|
||||
}
|
||||
|
||||
export function isPointInsideMatch3DItemAlphaHotspot({
|
||||
item,
|
||||
pointX,
|
||||
pointY,
|
||||
mask,
|
||||
itemSize,
|
||||
}: {
|
||||
item: Match3DItemSnapshot;
|
||||
pointX: number;
|
||||
pointY: number;
|
||||
mask: Match3DAlphaHitMask;
|
||||
itemSize: Match3DGeneratedItemRelativeSize;
|
||||
}) {
|
||||
const frame = resolveRenderableItemFrame(item);
|
||||
const diameter = frame.radius * 2;
|
||||
if (diameter <= 0) {
|
||||
return false;
|
||||
}
|
||||
return isPointInsideAlphaHitMask(
|
||||
(pointX - (frame.x - frame.radius)) / diameter,
|
||||
(pointY - (frame.y - frame.radius)) / diameter,
|
||||
mask,
|
||||
itemSize,
|
||||
);
|
||||
}
|
||||
|
||||
export function hashMatch3DString(value: string) {
|
||||
let hash = 0;
|
||||
for (let index = 0; index < value.length; index += 1) {
|
||||
hash = (hash * 31 + value.charCodeAt(index)) >>> 0;
|
||||
}
|
||||
return hash;
|
||||
}
|
||||
|
||||
export function resolveMatch3DImageSourceEntryForItem(
|
||||
item: Match3DItemSnapshot,
|
||||
imageSourceEntriesByType: ReadonlyMap<
|
||||
string,
|
||||
readonly Match3DResolvedImageSourceEntry[]
|
||||
>,
|
||||
) {
|
||||
const sources = imageSourceEntriesByType.get(item.itemTypeId);
|
||||
if (!sources || sources.length <= 0) {
|
||||
return null;
|
||||
}
|
||||
return sources[hashMatch3DString(item.itemInstanceId) % sources.length] ?? null;
|
||||
}
|
||||
|
||||
export function findMatch3DHitItem(
|
||||
run: Match3DRunSnapshot,
|
||||
pointX: number,
|
||||
pointY: number,
|
||||
options: {
|
||||
imageSourceEntriesByType?: ReadonlyMap<
|
||||
string,
|
||||
readonly Match3DResolvedImageSourceEntry[]
|
||||
>;
|
||||
alphaHitMasks?: ReadonlyMap<string, Match3DAlphaHitMask>;
|
||||
failedAlphaHitMaskSources?: ReadonlySet<string>;
|
||||
itemSizeByType?: ReadonlyMap<string, Match3DGeneratedItemRelativeSize>;
|
||||
} = {},
|
||||
) {
|
||||
return run.items
|
||||
.filter((item) => {
|
||||
if (
|
||||
!isItemState(item.state, 'in_board') ||
|
||||
!item.clickable ||
|
||||
!isPointInsideCircle(pointX, pointY, item)
|
||||
) {
|
||||
return false;
|
||||
}
|
||||
|
||||
const imageSourceEntry = resolveMatch3DImageSourceEntryForItem(
|
||||
item,
|
||||
options.imageSourceEntriesByType ?? new Map(),
|
||||
);
|
||||
const mask = imageSourceEntry
|
||||
? options.alphaHitMasks?.get(imageSourceEntry.source)
|
||||
: null;
|
||||
if (!mask) {
|
||||
return (
|
||||
!imageSourceEntry ||
|
||||
!isGeneratedLegacyPath(imageSourceEntry.source) ||
|
||||
options.failedAlphaHitMaskSources?.has(imageSourceEntry.source) ===
|
||||
true
|
||||
);
|
||||
}
|
||||
|
||||
return isPointInsideMatch3DItemAlphaHotspot({
|
||||
item,
|
||||
pointX,
|
||||
pointY,
|
||||
mask,
|
||||
itemSize: options.itemSizeByType?.get(item.itemTypeId) ?? '大',
|
||||
});
|
||||
})
|
||||
.sort((left, right) => right.layer - left.layer)[0];
|
||||
}
|
||||
Some files were not shown because too many files have changed in this diff Show More
Reference in New Issue
Block a user