From bf82f04b64b8719d36897ea1ae16f0657672655c Mon Sep 17 00:00:00 2001 From: kdletters <61648117+kdletters@users.noreply.github.com> Date: Fri, 22 May 2026 05:00:07 +0800 Subject: [PATCH] fix: polish bark battle creation flow --- .hermes/shared-memory/decision-log.md | 32 +- .hermes/shared-memory/pitfalls.md | 54 + ...玩法创作】平台入口与玩法链路-2026-05-15.md | 34 +- .../shared/src/contracts/barkBattle.test.ts | 107 +- packages/shared/src/contracts/barkBattle.ts | 106 +- .../creation-type-references/bark-battle.webp | Bin 0 -> 70252 bytes server-rs/crates/api-server/src/app.rs | 1 + .../crates/api-server/src/bark_battle.rs | 1090 +++++- .../api-server/src/creation_entry_config.rs | 19 + .../crates/api-server/src/match3d/draft.rs | 9 +- .../api-server/src/match3d/item_assets.rs | 9 +- .../crates/api-server/src/match3d/mappers.rs | 11 +- .../crates/api-server/src/match3d/tests.rs | 3440 ++++++++--------- .../crates/api-server/src/match3d/works.rs | 14 +- .../api-server/src/modules/bark_battle.rs | 29 +- .../crates/api-server/src/process_metrics.rs | 29 +- .../crates/module-bark-battle/src/domain.rs | 2 +- .../crates/module-bark-battle/src/scoring.rs | 1 + .../crates/module-runtime/src/application.rs | 6 +- server-rs/crates/module-runtime/src/lib.rs | 13 +- server-rs/crates/platform-oss/src/lib.rs | 7 +- .../shared-contracts/src/bark_battle.rs | 429 +- .../spacetime-client/src/bark_battle.rs | 106 + server-rs/crates/spacetime-client/src/lib.rs | 2 + .../crates/spacetime-client/src/mapper.rs | 5 +- .../src/mapper/bark_battle.rs | 102 +- .../spacetime-client/src/module_bindings.rs | 26 + .../bark_battle_draft_config_snapshot_type.rs | 1 - ...k_battle_draft_config_upsert_input_type.rs | 1 - .../bark_battle_draft_create_input_type.rs | 7 +- .../bark_battle_gallery_view_row_type.rs | 106 + .../bark_battle_gallery_view_table.rs | 114 + .../bark_battle_run_snapshot_type.rs | 1 - ...ark_battle_runtime_config_snapshot_type.rs | 1 - .../crates/spacetime-client/src/telemetry.rs | 4 +- .../spacetime-module/src/bark_battle.rs | 256 +- .../spacetime-module/src/bark_battle/types.rs | 50 +- .../src/runtime/creation_entry_config.rs | 42 +- .../BarkBattleConfigEditor.test.tsx | 138 +- .../BarkBattleConfigEditor.tsx | 269 +- .../BarkBattleGeneratingView.test.tsx | 306 ++ .../BarkBattleGeneratingView.tsx | 357 ++ .../BarkBattlePreviewCard.tsx | 57 +- .../BarkBattleResultView.test.tsx | 120 +- .../BarkBattleResultView.tsx | 86 +- ...ustomWorldCreationHub.interaction.test.tsx | 78 + .../CustomWorldCreationHub.tsx | 17 + .../custom-world-home/CustomWorldWorkCard.tsx | 3 + .../creationWorkShelf.test.ts | 406 ++ .../custom-world-home/creationWorkShelf.ts | 163 + .../PlatformEntryFlowShellImpl.tsx | 815 +++- .../platform-entry/PlatformWorkDetailView.tsx | 4 + .../barkBattleWorkCache.test.ts | 108 + .../platform-entry/barkBattleWorkCache.ts | 112 + .../platform-entry/platformEntryTypes.ts | 1 + ...gEntryFlowShell.agent.interaction.test.tsx | 301 +- src/components/rpg-entry/RpgEntryHomeView.tsx | 118 +- .../rpgEntryWorldPresentation.test.ts | 97 + .../rpg-entry/rpgEntryWorldPresentation.ts | 138 + .../application/BarkBattleConfig.ts | 60 +- .../application/BarkBattleController.ts | 5 + .../__tests__/BarkBattleController.test.ts | 18 + .../bark-battle/domain/BarkBattleSession.ts | 49 +- .../__tests__/BarkBattleSession.test.ts | 46 +- src/games/bark-battle/ui/BarkBattleHud.css | 86 + src/games/bark-battle/ui/BarkBattleHud.tsx | 45 +- .../bark-battle/ui/BarkBattleRuntimeShell.tsx | 739 +++- .../__tests__/BarkBattleRuntimeShell.test.tsx | 548 ++- src/routing/appPageRoutes.ts | 3 + .../barkBattleCreationClient.test.ts | 183 +- .../barkBattleCreationClient.ts | 249 +- src/services/bark-battle-creation/index.ts | 7 + src/services/publicWorkCode.ts | 27 +- 73 files changed, 9362 insertions(+), 2663 deletions(-) create mode 100644 public/creation-type-references/bark-battle.webp create mode 100644 server-rs/crates/spacetime-client/src/module_bindings/bark_battle_gallery_view_row_type.rs create mode 100644 server-rs/crates/spacetime-client/src/module_bindings/bark_battle_gallery_view_table.rs create mode 100644 src/components/bark-battle-creation/BarkBattleGeneratingView.test.tsx create mode 100644 src/components/bark-battle-creation/BarkBattleGeneratingView.tsx create mode 100644 src/components/platform-entry/barkBattleWorkCache.test.ts create mode 100644 src/components/platform-entry/barkBattleWorkCache.ts diff --git a/.hermes/shared-memory/decision-log.md b/.hermes/shared-memory/decision-log.md index 62d53d03..769b1021 100644 --- a/.hermes/shared-memory/decision-log.md +++ b/.hermes/shared-memory/decision-log.md @@ -16,12 +16,20 @@ --- -## 2026-05-19 汪汪声浪创作先进入草稿结果页 +## 2026-05-20 汪汪声浪 v1 公开闭环计划 -- 背景:汪汪声浪轻配置表单直接发布会缺少草稿编译、资源预览、手动上传、重新生成和发布前试玩环节,创作者无法确认角色形象、UI 背景和狗叫音效替换效果。 -- 决策:`bark-battle` 入口继续保持创作 Tab 内嵌轻配置表单;提交后先调用 `/api/creation/bark-battle/drafts` 生成草稿并进入 `bark-battle-result`,草稿响应必须带回 SpacetimeDB 草稿行上的稳定 `workId`、`configVersion` 和 `rulesetVersion`。结果页负责资源预览、图片槽位重新生成、四类资源手动上传、发布前试玩和最终发布;发布必须复用草稿返回的同一个 `workId`,不得在 publish 阶段重新生成作品 ID。排行榜字段暂保留兼容,但创作 UI 不展示排行榜开关。 -- 影响范围:`BarkBattleConfigEditor`、`BarkBattleResultView`、`BarkBattlePreviewCard`、`PlatformEntryFlowShellImpl`、Bark Battle creation client、玩法链路文档和相关交互测试。 -- 验证方式:创作 Tab 选择汪汪声浪后应看到轻配置表单;点击生成草稿进入结果页;结果页能看到玩家形象、对手形象、UI 背景和狗叫音效槽位,试玩在发布前可进入 runtime,发布成功后再进入正式 runtime。 +- 背景:Bark Battle v1 需要把创作、生成、结果、发布、详情和正式运行态收成一条闭环,避免把草稿试玩、公开广场和正式成绩混在一起。 +- 决策:`bark-battle` 入口改为 6 字段表单(作品标题、简介、主题 / 竞技背景描述 `themeDescription`、玩家形象描述、对手形象描述、难度);提交后进入 `bark-battle-generating` 独立生成页,自动生成玩家形象、对手形象和竞技背景三图,部分失败也继续进入结果页。旧“角色设定 / 狗狗皮肤预设 / themePreset”统一退场,配置和文档只使用“形象描述 / themeDescription”。结果页只保留单槽重试、重新生成和上传,不再保留一次生成按钮、音频配置入口、皮肤预设入口或排名配置。发布后先跳统一作品详情页 `/works/detail?work=BB-xxxxxxxx`,再由详情页进入正式 `published` runtime;正式 runtime 必须真实麦克风,`draft` 可试玩、可 mock 且不写正式统计。公开广场统一读取 `bark_battle_gallery_view` read model。 +- 影响范围:`BarkBattleConfigEditor`、`BarkBattleGeneratingView`、`BarkBattleResultView`、`BarkBattleRuntimeShell`、`PlatformEntryFlowShellImpl`、`appPageRoutes`、Bark Battle creation/runtime client、公开广场聚合与相关交互测试。 +- 验证方式:提交表单后先进入生成页;生成页部分失败仍能落到结果页;结果页只出现单槽重试 / 重新生成 / 上传;发布后先到 `/works/detail?work=BB-xxxxxxxx` 再进正式 runtime;正式 runtime 会要求麦克风并写基础统计,草稿试玩可 mock 且不写正式 run;公开广场读取 `bark_battle_gallery_view`。 +- 关联文档:`docs/【玩法创作】平台入口与玩法链路-2026-05-15.md`。 + +## 2026-05-22 汪汪声浪运行态与作品外显信息收口 + +- 背景:Bark Battle v1 在正式运行态、图片生成提示词和作品外部卡片上仍存在体验漂移:能量条推满后还要等计时结束、进入正式 runtime 后还要二次点击声控、角色形象 prompt 会默认注入狗主体、草稿 / 已发布卡片外部看不到创作者。 +- 决策:能量条到玩家或对手边界即结算;正式 `published` runtime 从作品详情启动后立即申请真实麦克风权限,授权成功后立刻进入倒计时,并使用 start run 返回的 `runtimeConfig` 作为本局前端规则参数;结束后弹出独立结算弹窗,运行态固定提供返回按钮。玩家 / 对手形象图提示词保持用户填写的形象描述,只要求单个完整形象、正面和透明背景,不把非狗描述改写成狗;草稿架、已发布作品架、统一作品详情和公开广场列表都展示后端返回的 `authorDisplayName`。Bark Battle 卡片封面按竞技背景、玩家形象、对手形象、入口参考图兜底;works summary 优先读取 `publishedSnapshotJson` 的最终发布素材。拟声词进入配置 JSON,未手动编辑时随主题 / 形象描述重算,手动编辑后保持创作者自定义;触发阈值降到 `0.35`、冷却降到 `150ms`,后端 `BarkBattleRuleset.min_bark_gap_ms` 同步为 `150`,局内有效触发后快速随机展示高能词池。 +- 影响范围:`BarkBattleSession`、`BarkBattleRuntimeShell`、`BarkBattleConfigEditor`、`BarkBattleConfig`、Bark Battle 生图 prompt、Bark Battle works/gallery summary、创作中心作品架卡片、公开作品码、`module-bark-battle` ruleset 和玩法链路文档。 +- 验证方式:能量条推到 `100/-100` 的领域测试应提前 finished;发布态 runtime mount 后应自动调用麦克风 sampler、登记正式 run 并使用服务端 runtimeConfig;prompt 单测应覆盖透明背景、正面和非狗描述不强注入狗;作品架测试应覆盖草稿与已发布卡片作者展示和封面兜底;拟声词测试应覆盖主题自动重算、自定义保持和随机展示。 - 关联文档:`docs/【玩法创作】平台入口与玩法链路-2026-05-15.md`。 ## 2026-05-18 Rust 手写模块入口统一不用 mod.rs @@ -59,6 +67,7 @@ - 影响范围:`jenkins/Jenkinsfile.production-stdb-module-build` 及后续所有同类 Windows 构建流水线。 - 验证方式:Jenkins 日志中应能看到 `[jenkins-powershell] user:` 和 `[jenkins-powershell] exe:`,Checkout 阶段会打印当前 `HEAD` 与请求 commit,并在 `COMMIT_HASH` 为空或一致时直接继续;不再停在 `PipelineNodeTreeScanner... Cannot run program "powershell"` 或重复 `git clean` 的退出码 5。 - 关联文档:`docs/【开发运维】本地开发验证与生产运维-2026-05-15.md`、`.hermes/shared-memory/pitfalls.md`。 + ## 2026-05-19 tracking outbox 改为 rotate 后异步 flush - 背景:普通 route tracking 写入压力上来后,不能让 HTTP 请求线程等待 SpacetimeDB 批量入库。 @@ -273,6 +282,7 @@ - 影响范围:平台个人页、登录弹窗、法律 Markdown 渲染和前端认证交互测试。 - 验证方式:执行 `npm run test -- src/components/auth/AuthGate.test.tsx src/components/rpg-entry/RpgEntryHomeView.recharge.test.tsx`、触碰文件 ESLint、`npm run check:encoding`。 - 关联文档:`docs/prd/PROFILE_LEGAL_INFO_AND_AUTH_AGREEMENT_PRD_2026-05-12.md`。 + ## 2026-05-12 微信小程序待绑定手机号优先走原生手机号授权 - 背景:微信小程序 `web-view` 壳登录后若返回 `pending_bind_phone`,H5 仍会展示手输手机号和短信验证码绑定页,体验上多了一步。 @@ -651,3 +661,15 @@ - 默认阈值:每批 500 条或 1 秒 flush 一次;outbox 磁盘上限 256 MiB,超过后丢弃低价值 route 事件并记录指标 / 日志。 - 影响范围:`api-server` tracking 中间件、SpacetimeDB tracking procedure、部署数据目录、OTLP 指标和运维排障。 - 验证方式:数据库不可用时公开 route 请求不失败且 outbox 文件保留;恢复后批量写入成功并删除本地 sealed 文件;关键事件仍立即影响任务 / 统计。 + +## 2026-05-19 汪汪声浪默认开放并区分草稿试玩与正式运行态 + +- 背景:`bark-battle` 已具备草稿结果页、发布链路与运行态 API,继续在入口层标记“敬请期待”会阻断创作闭环;同时草稿试玩不应污染正式成绩统计。 +- 决策:默认入口改为 `visible=true`、`open=true`、`badge=可创建`,参考图固定为 `/creation-type-references/bark-battle.webp`。系统默认迁移只纠偏未被后台人工改过的汪汪声浪入口。发布后先进入统一作品详情页 `/works/detail?work=BB-xxxxxxxx`;正式 runtime 使用 `runtimeMode=published` 并必须真实麦克风,调用 `startBarkBattleRun` / `finishBarkBattleRun` 写正式 run;草稿结果页试玩仍使用 `runtimeMode=draft`,允许 mock 且不写正式 run。 +- 验证方式:入口配置响应应返回汪汪声浪可创建和专属参考图;发布后地址应为 `/works/detail?work=BB-xxxxxxxx`;草稿试玩不调用 runtime run API;正式 runtime 无麦克风时不登记正式 run,结算后提交派生指标。 + +## 2026-05-20 汪汪声浪生成页负责三图自动生成 + +- 背景:结果页承载预览、修补和发布,若继续放“一次生成”按钮会把初始生成和结果修补职责混在一起。 +- 决策:初始三图生成改由 `bark-battle-generating` 独立生成页自动执行,目标槽位只有玩家形象、对手形象和竞技背景;表单术语统一为 `themeDescription`、玩家形象描述和对手形象描述,不再回退 `themePreset`、狗狗皮肤预设或“角色设定”。部分失败也进入结果页。结果页不再提供一次生成按钮,音频配置和排名配置不进入 v1 公开闭环;结果页只保留单槽重试、重新生成和上传。发布时 SpacetimeDB `bark_battle_published_config.config_json` 使用规范化后的最终 `publishedSnapshot`,`published_snapshot_json` 同步保存同一份快照。 +- 验证方式:表单提交后进入 `bark-battle-generating`;结果页不会出现一次生成按钮、音频槽、皮肤预设入口或排名配置;Bark Battle 发布后正式 runtime 应读取结果页最终图片素材而不是初始草稿素材。 diff --git a/.hermes/shared-memory/pitfalls.md b/.hermes/shared-memory/pitfalls.md index 656466b7..6f3cf2ef 100644 --- a/.hermes/shared-memory/pitfalls.md +++ b/.hermes/shared-memory/pitfalls.md @@ -46,6 +46,36 @@ - 验证:点击汪汪声浪后直接看到创作页内嵌表单,不再出现独立配置页;测试应覆盖内嵌表单与 runtime 返回路径。 - 关联:`src/components/platform-entry/PlatformEntryFlowShellImpl.tsx`、`src/components/bark-battle-creation/BarkBattleConfigEditor.tsx`、`src/components/rpg-entry/RpgEntryFlowShell.agent.interaction.test.tsx`。 +## 汪汪声浪发布态不要丢失结果页最终素材 + +- 现象:结果页上传或批量生成玩家形象、对手形象、UI 背景后,发布进入正式 runtime 仍可能显示初始草稿素材或兜底视觉。 +- 原因:`publish_bark_battle_work` 如果只把结果页最终状态保存到 `published_snapshot_json`,但正式 runtime 读取的 `config_json` 仍来自草稿行旧值,就会丢失结果页局部替换。 +- 处理:发布时把最终 `publishedSnapshot` 解析为 `BarkBattleEditorConfigSnapshot`、规范化后同时写入 `bark_battle_published_config.config_json` 和 `published_snapshot_json`;首轮自动生成只由 `bark-battle-generating` 负责,结果页仅覆盖已接入的玩家形象、对手形象和竞技背景图片槽位,不再提供音频配置入口。 +- 验证:发布后 runtime config 应包含结果页最终 `playerCharacterImageSrc`、`opponentCharacterImageSrc` 和 `uiBackgroundImageSrc`。 + +## 汪汪声浪 v1 生成页和正式运行态要分开 + +- 现象:如果把初始三图自动生成、结果页修补、公开发布和正式运行态混在一页,创作者容易误以为一次生成和正式运行是同一职责。 +- 原因:`bark-battle-generating` 才应该承担玩家形象、对手形象和竞技背景的自动生成;结果页只做单槽修补,正式 runtime 又必须切到真实麦克风和正式统计。 +- 处理:表单提交后先进入独立生成页,部分失败仍进结果页;结果页只保留单槽重试、重新生成和上传,不再保留一次生成按钮、音频配置入口、皮肤预设入口或排名配置。发布后先到统一作品详情页,再进正式 runtime;草稿试玩允许 mock,不写正式 run。 +- 验证:生成页负责首轮自动产出三图;结果页不出现一次生成按钮、音频配置入口、皮肤预设入口或排名配置;正式 runtime 必须麦克风可用且会写正式 run,草稿试玩不写正式统计。 + +## 汪汪声浪生成页不要只停留在前端内存草稿 + +- 现象:点击“生成草稿”后生成页一直转圈,或刷新 / 回到草稿架后看不到三图素材。 +- 原因:生成页只在前端内存里合并玩家形象、对手形象和竞技背景,没有把生成结果写回 `bark_battle_draft_config.config_json`;另外 BFF 若在刚创建草稿后先读 `spacetime-client` 订阅 cache 再保存,cache 可能短暂落后,导致保存失败或返回旧快照。 +- 处理:生成页三图完成后调用 `POST /api/creation/bark-battle/drafts/{draftId}/config` 持久化;保存接口直接把请求快照交给 SpacetimeDB procedure,由模块事务校验 owner / work,并在 HTTP 回包用本次请求里的三图字段覆盖,避免订阅 cache 滞后;保存请求必须设置前端超时,保存失败也进入结果页并标记部分失败。 +- 验证:`npm run test -- src/components/bark-battle-creation/BarkBattleGeneratingView.test.tsx src/services/bark-battle-creation/barkBattleCreationClient.test.ts src/components/bark-battle-creation/BarkBattleResultView.test.tsx packages/shared/src/contracts/barkBattle.test.ts`;`npm run test -- src/components/rpg-entry/RpgEntryFlowShell.agent.interaction.test.tsx -t "bark battle"`;`cargo check --manifest-path server-rs\Cargo.toml -p api-server`。 +- 关联:`src/components/bark-battle-creation/BarkBattleGeneratingView.tsx`、`src/services/bark-battle-creation/barkBattleCreationClient.ts`、`server-rs/crates/api-server/src/bark_battle.rs`、`server-rs/crates/spacetime-module/src/bark_battle.rs`。 + +## 汪汪声浪三图不要复用 RPG 场景图链路 + +- 现象:玩家形象和对手形象看起来走了场景图片 prompt;生成页三个槽位同时转圈,但只有第一个真实生成,首图返回后三个槽位一起停止或只显示首图。 +- 原因:前端曾复用 `/api/runtime/custom-world/scene-image`,三类素材都被当成 RPG landmark scene image;生成页又只用父级 draft 判断 ready,批量 Promise 结束后才一次性合并结果,缺少逐槽状态。 +- 处理:Bark Battle 生图统一走 `POST /api/creation/bark-battle/images/generate`,请求体包含 `slot` 和 v1 配置;后端在 `api-server/src/bark_battle.rs` 按 `player-character`、`opponent-character`、`ui-background` 分别拼装正式 prompt,写入 `generated-bark-battle-assets`,并返回 `prompt/actualPrompt`。前端 `generateAllBarkBattleImageAssets` 保持三槽 `Promise.allSettled` 并通过 `onSlotComplete` 逐槽刷新生成页状态。 +- 验证:`npm run test -- src/services/bark-battle-creation/barkBattleCreationClient.test.ts src/components/bark-battle-creation/BarkBattleGeneratingView.test.tsx packages/shared/src/contracts/barkBattle.test.ts`;`cargo test -p shared-contracts bark_battle --manifest-path server-rs\Cargo.toml`;`cargo check --manifest-path server-rs\Cargo.toml -p platform-oss -p api-server`。 +- 关联:`src/services/bark-battle-creation/barkBattleCreationClient.ts`、`src/components/bark-battle-creation/BarkBattleGeneratingView.tsx`、`server-rs/crates/api-server/src/bark_battle.rs`、`server-rs/crates/platform-oss/src/lib.rs`。 + ## 抓大鹅批量重新生成物品不要新增 itemId - 现象:结果页批量重新生成物品后,试玩或正式运行态的物品类型和图片对应关系漂移,或者用户输入一个不存在名称后被当作新物品追加。 @@ -1039,3 +1069,27 @@ - 处理:打开草稿时把持久化 `generationStatus=generating` 等同于生成中 notice,恢复对应玩法生成进度页;恢复计时使用作品摘要 `updatedAt` 推导 `startedAtMs`。 - 验证:`npm test -- src/components/rpg-entry/RpgEntryFlowShell.agent.interaction.test.tsx -t "persisted generating"`。 - 关联:`src/components/platform-entry/PlatformEntryFlowShellImpl.tsx`、`src/components/rpg-entry/RpgEntryFlowShell.agent.interaction.test.tsx`、`docs/【玩法创作】平台入口与玩法链路-2026-05-15.md`。 + +## 汪汪声浪草稿试玩不要写正式 run + +- 现象:如果草稿结果页试玩和发布后 runtime 共用同一写成绩路径,未发布或未确认资源的草稿试玩会污染正式单局、排行榜和作品统计。 +- 原因:`BarkBattleRuntimeShell` 同时承担草稿预览和发布后运行态,需要由调用方显式传入 `runtimeMode` 区分是否写正式 run。 +- 处理:草稿结果页试玩保持 `runtimeMode=draft`,只做本地预览;发布成功后先进入 `/works/detail?work=BB-xxxxxxxx`,再从详情页以 `runtimeMode=published` 进入正式 runtime,并在开始/结算时分别调用 `startBarkBattleRun` 与 `finishBarkBattleRun`。 +- 验证:草稿试玩不触发 start / finish run;正式 runtime 必须先通过麦克风授权,再写 start run 和结算派生指标。 +- 关联:`src/components/platform-entry/PlatformEntryFlowShellImpl.tsx`、`src/games/bark-battle/ui/BarkBattleRuntimeShell.tsx`、`src/services/bark-battle-runtime/barkBattleRuntimeClient.ts`。 + +## 汪汪声浪移动端创作表单不要再套一层纵向滚动 + +- 现象:移动端创作 Tab 里进入汪汪声浪表单后,页面右侧出现不自然的内层滚动条,最后的形象描述输入框容易被“生成草稿”按钮、键盘或底部 TabBar 挤压 / 遮挡;顶部玩法卡首尾也可能贴边显得被裁。 +- 原因:外层 `.platform-tab-panel` 已经是纵向滚动容器,创作页中间又有多层 `overflow-hidden`,旧的 `BarkBattleConfigEditor` 根节点再加 `overflow-y-auto`,形成外层 Tab 面板 + 内层表单的套滚动;底部按钮只预留 safe-area,不预留真实操作区距离;顶部玩法卡横向滚动条隐藏且首尾没有 scroll padding。 +- 处理:移动端让 Bark Battle 表单跟随父级滚动,`lg` 以上才恢复表单内滚动;创作页容器移动端使用 `overflow-visible` 和 safe-area 底部 padding;顶部模板 tablist 加 `scroll-px-3` / 横向 padding,移动端卡片宽度收窄,避免首尾 ring 和圆角贴边裁切。 +- 验证:`npm run test -- src/components/bark-battle-creation/BarkBattleConfigEditor.test.tsx`、`npm run test -- src/components/rpg-entry/RpgEntryFlowShell.agent.interaction.test.tsx -t "create tab shows template tabs"`、移动端视口检查最后一个输入框与“生成草稿”按钮不重叠。 +- 关联:`src/components/bark-battle-creation/BarkBattleConfigEditor.tsx`、`src/components/platform-entry/PlatformEntryFlowShellImpl.tsx`、`docs/【玩法创作】平台入口与玩法链路-2026-05-15.md`。 + +## 汪汪声浪拟声词不要被默认狗主题锁死 + +- 现象:创作者把主题或形象改成机甲、猫、骑士等非狗主题后,局内仍播放 `轰汪!`、`汪爆!` 这类狗叫词,表现像系统强行把主题带回狗。 +- 原因:拟声词 textarea 如果一开始就填入默认小狗词池,并且始终作为自定义 `onomatopoeia` 提交,runtime 会优先使用该字段,无法再根据新的 `themeDescription` / `playerImageDescription` / `opponentImageDescription` 走主题 fallback。 +- 处理:`BarkBattleConfigEditor` 需要区分“系统默认词池”和“创作者已手动编辑”。未手动编辑时随主题 / 形象描述自动重算;手动编辑后才冻结为自定义词池。默认词池只在命中狗相关关键词时加入狗叫词,非狗主题使用科技、幻想或通用高能词。 +- 验证:`npm run test -- src/components/bark-battle-creation/BarkBattleConfigEditor.test.tsx src/games/bark-battle/ui/__tests__/BarkBattleRuntimeShell.test.tsx`,并确认非狗主题的拟声词不含 `汪`。 +- 关联:`src/components/bark-battle-creation/BarkBattleConfigEditor.tsx`、`src/games/bark-battle/application/BarkBattleConfig.ts`、`src/games/bark-battle/ui/BarkBattleRuntimeShell.tsx`。 diff --git a/docs/【玩法创作】平台入口与玩法链路-2026-05-15.md b/docs/【玩法创作】平台入口与玩法链路-2026-05-15.md index c14aa004..d7b97986 100644 --- a/docs/【玩法创作】平台入口与玩法链路-2026-05-15.md +++ b/docs/【玩法创作】平台入口与玩法链路-2026-05-15.md @@ -133,30 +133,38 @@ 当前领域语言: - 有效声浪触发:麦克风归一化响度在冷却结束后达到阈值的一次计分输入。 -- 能量条:玩家与对手当前声浪优势的连续对抗刻度。 +- 能量条:玩家与对手当前声浪优势的连续对抗刻度,推到玩家或对手一侧边界时本局立即结算。 +- 主题 / 竞技背景描述:配置字段为 `themeDescription`,用于生成竞技背景并表达整体场景,不再使用 `themePreset` 或狗狗皮肤预设。 +- 玩家 / 对手形象描述:配置字段为 `playerImageDescription` / `opponentImageDescription`,对外统一称“形象描述”,不再称“角色设定”。 - 后端裁决结果:后端根据 start run 与 finish 派生指标校验后的正式单局结果。 -- 排行榜分榜:按 `workId + difficultyPreset + rulesetVersion` 拆分,只收录后端裁决玩家胜利的成绩。 +- 基础统计:只记录正式 `published` run 的开始、结算和派生指标,草稿试玩不写正式统计。 +- 公开广场:统一读取 `bark_battle_gallery_view` 这类 read model,不再由前端自己拼公开列表。 +- 创作者信息:草稿架、已发布作品架、统一作品详情和公开广场都必须展示后端返回的 `authorDisplayName`,不得只在详情页内层可见。 +- 拟声词:配置字段为 `onomatopoeia`。创作者未手动编辑时,前端根据主题 / 竞技背景描述、玩家形象描述和对手形象描述生成高能词池;创作者手动编辑后按自定义词池发布。默认词池只在命中狗相关主题时加入狗叫词,不能把非狗主题强行带回狗语义。 -当前入口沿用创作 Tab 内嵌轻配置表单,不再切到独立 `bark-battle-config` 阶段;配置提交后先进入草稿结果页,再由结果页执行资源预览、手动上传替换、重新生成、试玩和发布。runtime 从草稿试玩返回草稿结果页,从入口回退时恢复汪汪声浪模板选中态。 +当前入口默认开放:`visible=true`、`open=true`、`badge=可创建`,入口参考图使用 `/creation-type-references/bark-battle.webp`。创作入口使用 7 字段表单(作品标题、简介、主题 / 竞技背景描述 `themeDescription`、玩家形象描述、对手形象描述、拟声词、难度);提交后先进入 `bark-battle-generating` 独立生成页,自动生成玩家形象、对手形象和竞技背景三图。生成页即使部分槽位失败也要继续落到结果页,失败槽位保留错误态和单槽重试入口,不在生成页停留。结果页只保留单槽重试、重新生成和上传,不再展示一次生成按钮、音频配置入口、皮肤预设入口或排名配置。发布成功后先跳统一作品详情页 `/works/detail?work=BB-xxxxxxxx`,正式 `published` runtime 从作品详情页进入并必须使用真实麦克风;`draft` 可试玩,可使用 mock 输入,且不写正式统计。草稿与已发布作品在外部卡片、作品架和广场列表都展示创作者名称。 + +移动端创作 Tab 内嵌 Bark Battle 表单时,只保留外层 Tab 面板承担纵向滚动;表单自身移动端不再创建独立纵向滚动容器,底部“生成草稿”按钮作为普通表单尾部并保留 safe-area 底部间距,避免与最后一组输入框、移动端键盘或底部 TabBar 形成套滚动 / 遮挡。 创作流程为: -- 创作 Tab 表单:填写作品标题、简介、主题、玩家角色设定、对手角色设定、难度和资源源。 +- 创作 Tab 表单:填写作品标题、简介、主题 / 竞技背景描述、玩家形象描述、对手形象描述、拟声词和难度。拟声词支持换行、逗号、顿号、斜杠或竖线分隔;未手动编辑时随主题 / 形象描述自动重算,手动编辑后保持创作者自定义。 - 草稿编译:`POST /api/creation/bark-battle/drafts` 写入配置 JSON,返回包含 `draftId`、稳定 `workId`、`configVersion` 和 `rulesetVersion` 的草稿结果。 -- 资源预览:草稿结果页展示玩家形象、对手形象、UI 背景和狗叫音效槽位。 +- 生成页:`bark-battle-generating` 自动并行产出玩家形象、对手形象和竞技背景三图;前端按槽位实时显示生成中 / 已生成 / 失败状态,三图都走 Bark Battle 专用后端生图接口 `POST /api/creation/bark-battle/images/generate`,由后端按 `player-character`、`opponent-character`、`ui-background` 分别拼装正式提示词、写入 `generated-bark-battle-assets` 私有资产前缀并返回实际 prompt。玩家 / 对手形象提示词必须保持用户形象描述,不强行注入狗相关主体,并要求正面、单个完整形象和透明背景。部分失败也继续进入结果页。 +- 结果页:围绕三图槽位展示错误态与已生成结果,只保留单槽重试、重新生成和上传,不再提供一次生成按钮、音频配置入口或排名配置。 - 手动上传:结果页通过平台资产直传 `/api/assets/direct-upload-tickets` 与 `/api/assets/objects/confirm` 写入私有资产,再把返回的历史 generated 路径写回草稿配置。 -- 重新生成:玩家形象、对手形象和 UI 背景先复用现有图片生成链路;狗叫音效暂不假装自动生成,未接专用音频生成时走手动上传。 -- 试玩:在发布前使用草稿配置启动本地 runtime 预览,不写正式发布记录。 -- 发布:结果页确认后必须携带草稿返回的同一个 `workId` 调用 `POST /api/creation/bark-battle/works/publish`,发布成功后进入 runtime;缺少 `workId` 的旧草稿状态需要重新生成草稿。 +- 发布:结果页确认后必须携带草稿返回的同一个 `workId` 和结果页最终 `publishedSnapshot` 调用 `POST /api/creation/bark-battle/works/publish`;SpacetimeDB 发布态的 `config_json` 必须使用该最终快照,works summary 若拿到 `publishedSnapshotJson` 也优先使用最终快照映射封面三图。发布成功后先进入统一作品详情页,再由详情页进入正式 runtime;缺少 `workId` 的旧草稿状态需要重新生成草稿。 +- 作品架:Bark Battle 草稿 / 已发布列表优先读取后端 `/works`,但创建、生成完成、保存或发布后的本地摘要必须在后端 read model 尚未回读到同 `workId` 前继续保留;创作中心作品架同时接入 pending shelf 兜底,避免 ready 且三图齐全的草稿在刷新窗口期从“我的草稿 / 已发布”中消失。 +- 试玩与正式 runtime:草稿试玩使用 `runtimeMode=draft` 和 mock 输入,不写正式 run;正式 runtime 使用 `runtimeMode=published`,进入运行态后直接申请真实麦克风权限,授权成功后立刻进入倒计时,启动对局时调用 `POST /api/runtime/bark-battle/works/{workId}/runs` 登记 start run,并以返回的 `runtimeConfig` 作为本局前端规则参数;结算时调用 `POST /api/runtime/bark-battle/runs/{runId}/finish` 写入基础统计派生指标;对局会在能量条推到任一侧边界时提前结算并弹出独立结算弹窗,运行态内固定提供返回按钮。 支持的创作者可替换内容: -- 基础信息:作品标题、简介、主题背景、玩家角色设定、对手角色设定和难度。 -- 角色形象:可分别替换玩家与对手角色图片;未配置图片时继续使用狗狗预设兜底。 -- UI 视觉:可替换运行态主背景图;未配置图片时继续使用主题背景兜底。 -- 狗叫音效:可替换局内触发叫声的音频资源;未配置音频时不强制播放自定义音效。 +- 基础信息:作品标题、简介、主题 / 竞技背景描述(`themeDescription`)、玩家形象描述、对手形象描述和难度。 +- 生成素材:玩家形象、对手形象和竞技背景三个槽位可单槽重试、重新生成或上传;形象图保持正面和透明背景,不把非狗形象描述改写成狗。 +- 拟声词:最多保留前 `24` 个有效词;默认池按狗、机甲 / 科技、幻想 / 骑士等主题补充高能短词,并叠加通用“炸场 / 破阵 / 声浪拉满”等基础词。局内只要有效声浪触发就随机快速展示,避免连续重复。 +- 运行态输入:正式 runtime 必须真实麦克风;草稿试玩允许 mock,不写正式统计。 -这些替换槽位写入 Bark Battle 配置 JSON,发布后由 runtime 读取;计分阈值、对局时长、反作弊校验和后端裁决仍由规则集与后端控制,不能通过前端替换项改变。排行榜相关后端字段暂保留兼容,但创作 UI 不再展示排行榜开关。 +这些创作字段写入 Bark Battle 配置 JSON,发布后由 runtime 和基础统计链路读取;对局时长、反作弊校验和后端裁决仍由规则集与后端控制,不能通过前端替换项改变。当前声浪触发口径为前端默认阈值 `0.35`、有效触发冷却 `150ms`,后端 `BarkBattleRuleset` 的 `min_bark_gap_ms` 也保持 `150ms`,用于正式成绩校验的物理触发上限。历史排名相关后端字段暂保留兼容,但 v1 公开闭环不展示音频、皮肤预设或排名配置入口。 ## 方洞挑战 diff --git a/packages/shared/src/contracts/barkBattle.test.ts b/packages/shared/src/contracts/barkBattle.test.ts index 8a65acbe..d9f6aebc 100644 --- a/packages/shared/src/contracts/barkBattle.test.ts +++ b/packages/shared/src/contracts/barkBattle.test.ts @@ -1,15 +1,19 @@ import { describe, expect, test } from 'vitest'; import { + BARK_BATTLE_ASSET_SLOTS, BARK_BATTLE_DIFFICULTY_PRESETS, type BarkBattleDraftConfig, + type BarkBattleDraftConfigUpdateRequest, type BarkBattleFinishResponse, + type BarkBattleGeneratedImageAsset, + type BarkBattleImageAssetGenerateRequest, type BarkBattlePersonalBestSummary, type BarkBattleWorkStats, } from './barkBattle'; describe('Bark Battle shared contracts', () => { - test('default draft config fixture uses normal difficulty and camelCase fields', () => { + test('default draft config fixture uses normal difficulty and v1 description fields', () => { const draft: BarkBattleDraftConfig = { draftId: 'draft-bark-1', workId: 'work-bark-1', @@ -17,15 +21,14 @@ describe('Bark Battle shared contracts', () => { rulesetVersion: 'bark-battle-ruleset-v1', title: '汪汪声浪挑战', description: '轻配置草稿', - themePreset: 'city-park', - playerDogSkinPreset: 'corgi', - opponentDogSkinPreset: 'husky', + themeDescription: '傍晚城市公园里的声浪擂台', + playerImageDescription: '戴红围巾的柯基主角', + opponentImageDescription: '蓝色运动头带的哈士奇对手', + onomatopoeia: ['轰汪!', '嗷呜!', '咚咚!'], playerCharacterImageSrc: '/generated-bark-battle/player/image.png', opponentCharacterImageSrc: 'https://example.test/opponent.png', uiBackgroundImageSrc: '/generated-bark-battle/ui/background.png', - barkSoundSrc: '/generated-bark-battle/audio/bark.mp3', difficultyPreset: 'normal', - leaderboardEnabled: true, updatedAt: '2026-05-13T03:00:00.000Z', }; @@ -38,18 +41,100 @@ describe('Bark Battle shared contracts', () => { 'rulesetVersion', 'title', 'description', - 'themePreset', - 'playerDogSkinPreset', - 'opponentDogSkinPreset', + 'themeDescription', + 'playerImageDescription', + 'opponentImageDescription', + 'onomatopoeia', 'playerCharacterImageSrc', 'opponentCharacterImageSrc', 'uiBackgroundImageSrc', - 'barkSoundSrc', 'difficultyPreset', - 'leaderboardEnabled', 'updatedAt', ]); expect(draft.playerCharacterImageSrc).toContain('/generated-bark-battle/'); + expect('barkSoundSrc' in draft).toBe(false); + expect('leaderboardEnabled' in draft).toBe(false); + }); + + test('draft config update contract persists generated image slots only', () => { + const update: BarkBattleDraftConfigUpdateRequest = { + draftId: 'draft-bark-1', + workId: 'BB-12345678', + configVersion: 2, + rulesetVersion: 'bark-battle-ruleset-v1', + title: '汪汪声浪挑战', + description: '轻配置草稿', + themeDescription: '傍晚城市公园里的声浪擂台', + playerImageDescription: '戴红围巾的柯基主角', + opponentImageDescription: '蓝色运动头带的哈士奇对手', + onomatopoeia: ['轰!', '燃起来!', '破阵!'], + playerCharacterImageSrc: '/generated-bark-battle/player/image.png', + opponentCharacterImageSrc: '/generated-bark-battle/opponent/image.png', + uiBackgroundImageSrc: '/generated-bark-battle/ui/background.png', + difficultyPreset: 'normal', + }; + + expect(Object.keys(update)).toEqual([ + 'draftId', + 'workId', + 'configVersion', + 'rulesetVersion', + 'title', + 'description', + 'themeDescription', + 'playerImageDescription', + 'opponentImageDescription', + 'onomatopoeia', + 'playerCharacterImageSrc', + 'opponentCharacterImageSrc', + 'uiBackgroundImageSrc', + 'difficultyPreset', + ]); + expect('barkSoundSrc' in update).toBe(false); + expect('leaderboardEnabled' in update).toBe(false); + }); + + test('image generation contract uses dedicated Bark Battle slots and backend prompt result', () => { + expect(BARK_BATTLE_ASSET_SLOTS).toEqual([ + 'player-character', + 'opponent-character', + 'ui-background', + ]); + + const request: BarkBattleImageAssetGenerateRequest = { + slot: 'opponent-character', + draftId: 'bark-battle-draft-1', + config: { + title: '汪汪冠军杯', + description: '', + themeDescription: '霓虹公园擂台', + playerImageDescription: '红围巾柴犬', + opponentImageDescription: '蓝头带哈士奇', + onomatopoeia: ['轰汪!', '炸场!', '冲啊!'], + difficultyPreset: 'normal', + }, + }; + const response: BarkBattleGeneratedImageAsset = { + imageSrc: '/generated-bark-battle-assets/draft/opponent/image.webp', + assetId: 'asset-1', + sourceType: 'generated', + model: 'gpt-image-2', + size: '1024*1024', + taskId: 'task-1', + prompt: '后端拼装后的对手形象 prompt', + }; + + expect(JSON.parse(JSON.stringify(request))).toMatchObject({ + slot: 'opponent-character', + config: { + opponentImageDescription: '蓝头带哈士奇', + onomatopoeia: ['轰汪!', '炸场!', '冲啊!'], + }, + }); + expect(JSON.parse(JSON.stringify(response))).toMatchObject({ + imageSrc: '/generated-bark-battle-assets/draft/opponent/image.webp', + prompt: '后端拼装后的对手形象 prompt', + }); }); test('finish accepted player_win fixture exposes backend adjudication result', () => { diff --git a/packages/shared/src/contracts/barkBattle.ts b/packages/shared/src/contracts/barkBattle.ts index 99e0763e..18b23ef2 100644 --- a/packages/shared/src/contracts/barkBattle.ts +++ b/packages/shared/src/contracts/barkBattle.ts @@ -16,31 +16,65 @@ export type BarkBattleFinishStatus = export type BarkBattlePlayTypeId = 'bark-battle'; +export const BARK_BATTLE_ASSET_SLOTS = [ + 'player-character', + 'opponent-character', + 'ui-background', +] as const; + +export type BarkBattleAssetSlot = (typeof BARK_BATTLE_ASSET_SLOTS)[number]; + export interface BarkBattleReplacementConfig { playerCharacterImageSrc?: string; opponentCharacterImageSrc?: string; uiBackgroundImageSrc?: string; - barkSoundSrc?: string; } +export type BarkBattleOnomatopoeia = string[]; + export interface BarkBattleConfigEditorPayload extends BarkBattleReplacementConfig { title: string; description?: string; - themePreset: string; - playerDogSkinPreset: string; - opponentDogSkinPreset: string; + themeDescription: string; + playerImageDescription: string; + opponentImageDescription: string; + onomatopoeia?: BarkBattleOnomatopoeia; difficultyPreset: BarkBattleDifficultyPreset; - leaderboardEnabled: boolean; } export interface BarkBattleDraftCreateRequest extends BarkBattleConfigEditorPayload {} +export interface BarkBattleDraftConfigUpdateRequest + extends BarkBattleConfigEditorPayload { + draftId: string; + workId?: string | null; + configVersion?: number; + rulesetVersion?: string; +} + export interface BarkBattleWorkPublishRequest { draftId: string; workId: string; publishedSnapshot?: BarkBattleConfigEditorPayload; } +export interface BarkBattleImageAssetGenerateRequest { + slot: BarkBattleAssetSlot; + draftId?: string | null; + config: BarkBattleConfigEditorPayload; +} + +export interface BarkBattleGeneratedImageAsset { + imageSrc: string; + assetId: string; + sourceType?: 'generated' | string; + model: string; + size: string; + taskId: string; + prompt: string; + actualPrompt?: string; +} + export interface BarkBattleDraftConfig extends BarkBattleConfigEditorPayload { draftId: string; workId?: string; @@ -57,19 +91,62 @@ export interface BarkBattlePublishedConfig { playTypeId: BarkBattlePlayTypeId; title: string; description?: string; - themePreset: string; - playerDogSkinPreset: string; - opponentDogSkinPreset: string; + themeDescription: string; + playerImageDescription: string; + opponentImageDescription: string; + onomatopoeia?: BarkBattleOnomatopoeia; playerCharacterImageSrc?: string; opponentCharacterImageSrc?: string; uiBackgroundImageSrc?: string; - barkSoundSrc?: string; difficultyPreset: BarkBattleDifficultyPreset; - leaderboardEnabled: boolean; updatedAt: string; publishedAt: string; } +export type BarkBattleWorkStatus = 'draft' | 'published'; + +export type BarkBattleGenerationStatus = + | 'pending_assets' + | 'ready' + | 'partial_failed' + | string; + +export interface BarkBattleWorkSummary { + workId: string; + draftId?: string | null; + ownerUserId: string; + authorDisplayName: string; + title: string; + summary: string; + themeDescription: string; + playerImageDescription: string; + opponentImageDescription: string; + onomatopoeia?: BarkBattleOnomatopoeia; + playerCharacterImageSrc?: string | null; + opponentCharacterImageSrc?: string | null; + uiBackgroundImageSrc?: string | null; + difficultyPreset: BarkBattleDifficultyPreset; + status: BarkBattleWorkStatus; + generationStatus?: BarkBattleGenerationStatus | null; + publishReady: boolean; + playCount: number; + finishCount?: number; + winCount?: number; + drawCount?: number; + lossCount?: number; + recentPlayCount7d?: number; + updatedAt: string; + publishedAt?: string | null; +} + +export interface BarkBattleWorksResponse { + items: BarkBattleWorkSummary[]; +} + +export interface BarkBattleWorkDetailResponse { + item: BarkBattleWorkSummary; +} + export interface BarkBattleRuntimeConfig { workId: string; configVersion: number; @@ -81,14 +158,13 @@ export interface BarkBattleRuntimeConfig { drawThreshold: number; minBarkGapMs: number; difficultyPreset: BarkBattleDifficultyPreset; - themePreset: string; - playerDogSkinPreset: string; - opponentDogSkinPreset: string; + themeDescription: string; + playerImageDescription: string; + opponentImageDescription: string; + onomatopoeia?: BarkBattleOnomatopoeia; playerCharacterImageSrc?: string; opponentCharacterImageSrc?: string; uiBackgroundImageSrc?: string; - barkSoundSrc?: string; - leaderboardEnabled: boolean; updatedAt: string; } diff --git a/public/creation-type-references/bark-battle.webp b/public/creation-type-references/bark-battle.webp new file mode 100644 index 0000000000000000000000000000000000000000..ef48f407befec0507387760ee71f5aa611858e91 GIT binary patch literal 70252 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

aFl8J0SaetAeIb$tT6qSWmQ}fHFoNKz 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�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 literal 0 HcmV?d00001 diff --git a/server-rs/crates/api-server/src/app.rs b/server-rs/crates/api-server/src/app.rs index e5e4f27c..7f390dc6 100644 --- a/server-rs/crates/api-server/src/app.rs +++ b/server-rs/crates/api-server/src/app.rs @@ -1001,6 +1001,7 @@ mod tests { "/generated-puzzle-assets/session-1/candidate/image.png", "/generated-custom-world-scenes/world-1/camp/scene.png", "/generated-custom-world-covers/world-1/cover.webp", + "/generated-bark-battle-assets/draft/player/image.webp", "/generated-qwen-sprites/master/candidate-01.png", ] { let response = app diff --git a/server-rs/crates/api-server/src/bark_battle.rs b/server-rs/crates/api-server/src/bark_battle.rs index 8f6cbe49..f3fb1933 100644 --- a/server-rs/crates/api-server/src/bark_battle.rs +++ b/server-rs/crates/api-server/src/bark_battle.rs @@ -6,15 +6,22 @@ use axum::{ http::{HeaderName, StatusCode, header}, response::Response, }; +use module_assets::{ + AssetObjectAccessPolicy, build_asset_entity_binding_input, build_asset_object_upsert_input, + generate_asset_binding_id, generate_asset_object_id, +}; use module_bark_battle::{BARK_BATTLE_RULESET_VERSION_V1, BarkBattleRuleset}; +use platform_oss::{LegacyAssetPrefix, OssHeadObjectRequest, OssObjectAccess}; use serde::Deserialize; use serde_json::{Value, json}; use shared_contracts::bark_battle::{ - BarkBattleConfigEditorPayload, BarkBattleDerivedMetrics, BarkBattleDifficultyPreset, - BarkBattleDraftConfig, BarkBattleDraftCreateRequest, BarkBattleFinishStatus, - BarkBattlePublishedConfig, BarkBattleRunFinishRequest, BarkBattleRunFinishResponse, - BarkBattleRunStartRequest, BarkBattleRunStartResponse, BarkBattleScoreSummary, - BarkBattleServerResult, BarkBattleWorkPublishRequest, + BarkBattleAssetSlot, BarkBattleConfigEditorPayload, BarkBattleDerivedMetrics, + BarkBattleDifficultyPreset, BarkBattleDraftConfig, BarkBattleDraftConfigUpdateRequest, + BarkBattleDraftCreateRequest, BarkBattleFinishStatus, BarkBattleGeneratedImageAsset, + BarkBattleImageAssetGenerateRequest, BarkBattlePublishedConfig, BarkBattleRunFinishRequest, + BarkBattleRunFinishResponse, BarkBattleRunStartRequest, BarkBattleRunStartResponse, + BarkBattleScoreSummary, BarkBattleServerResult, BarkBattleWorkPublishRequest, + BarkBattleWorkSummary, BarkBattleWorksResponse, }; use shared_kernel::{ build_prefixed_uuid_id, format_rfc3339, format_timestamp_micros, @@ -29,8 +36,19 @@ use time::{Duration as TimeDuration, OffsetDateTime}; use crate::{ api_response::json_success_body, + asset_billing::execute_billable_asset_operation, auth::AuthenticatedAccessToken, + generated_image_assets::{ + GeneratedImageAssetAdapter, GeneratedImageAssetDataUrl, + adapter::{GeneratedImageAssetAdapterMetadata, GeneratedImageAssetPersistInput}, + normalize_generated_image_asset_mime, + }, http_error::AppError, + openai_image_generation::{ + GPT_IMAGE_2_MODEL, build_openai_image_http_client, create_openai_image_generation, + require_openai_image_settings, + }, + platform_errors::map_oss_error, request_context::RequestContext, state::AppState, work_play_tracking::{WorkPlayTrackingDraft, record_work_play_start_after_success}, @@ -38,11 +56,14 @@ use crate::{ const BARK_BATTLE_RUNTIME_PROVIDER: &str = "bark-battle-runtime"; const BARK_BATTLE_DRAFT_ID_PREFIX: &str = "bark-battle-draft-"; -const BARK_BATTLE_WORK_ID_PREFIX: &str = "bark-battle-work-"; +const BARK_BATTLE_WORK_ID_PREFIX: &str = "BB-"; const BARK_BATTLE_RUN_ID_PREFIX: &str = "bark-battle-run-"; const BARK_BATTLE_RUN_TOKEN_PREFIX: &str = "bark-battle-token-"; +const BARK_BATTLE_IMAGE_ID_PREFIX: &str = "bark-battle-image-"; const BARK_BATTLE_PLAY_TYPE_ID: &str = "bark-battle"; const BARK_BATTLE_RUN_TTL_SECONDS: i64 = 10 * 60; +const BARK_BATTLE_CHARACTER_IMAGE_SIZE: &str = "1024*1024"; +const BARK_BATTLE_BACKGROUND_IMAGE_SIZE: &str = "1024*1792"; #[derive(Clone, Debug, Deserialize, serde::Serialize)] #[serde(rename_all = "camelCase")] @@ -106,9 +127,10 @@ pub async fn create_bark_battle_draft( let editor_config = BarkBattleConfigEditorPayload { title: payload.title.clone(), description: payload.description.clone(), - theme_preset: payload.theme_preset.clone(), - player_dog_skin_preset: payload.player_dog_skin_preset.clone(), - opponent_dog_skin_preset: payload.opponent_dog_skin_preset.clone(), + theme_description: payload.theme_description.clone(), + player_image_description: payload.player_image_description.clone(), + opponent_image_description: payload.opponent_image_description.clone(), + onomatopoeia: payload.onomatopoeia.clone(), player_character_image_src: normalize_optional_bark_battle_asset_source( &request_context, payload.player_character_image_src.as_deref(), @@ -124,13 +146,7 @@ pub async fn create_bark_battle_draft( payload.ui_background_image_src.as_deref(), "uiBackgroundImageSrc", )?, - bark_sound_src: normalize_optional_bark_battle_asset_source( - &request_context, - payload.bark_sound_src.as_deref(), - "barkSoundSrc", - )?, difficulty_preset: payload.difficulty_preset.clone(), - leaderboard_enabled: payload.leaderboard_enabled, }; let draft = state .spacetime_client() @@ -140,13 +156,12 @@ pub async fn create_bark_battle_draft( work_id: build_prefixed_uuid_id(BARK_BATTLE_WORK_ID_PREFIX), title: Some(payload.title), description: payload.description, - theme_preset: payload.theme_preset, - player_dog_skin_preset: payload.player_dog_skin_preset, - opponent_dog_skin_preset: payload.opponent_dog_skin_preset, + theme_description: payload.theme_description, + player_image_description: payload.player_image_description, + opponent_image_description: payload.opponent_image_description, difficulty_preset: Some( difficulty_to_spacetime_string(&payload.difficulty_preset).to_string(), ), - leaderboard_enabled: Some(payload.leaderboard_enabled), editor_state_json: Some("{}".to_string()), created_at_micros: now, }) @@ -155,15 +170,7 @@ pub async fn create_bark_battle_draft( bark_battle_error_response(&request_context, map_bark_battle_client_error(error)) })?; let draft_snapshot = parse_draft_snapshot_record(draft, &request_context)?; - let config_json = serde_json::to_string(&editor_config).map_err(|error| { - bark_battle_error_response( - &request_context, - AppError::from_status(StatusCode::BAD_REQUEST).with_details(json!({ - "provider": BARK_BATTLE_RUNTIME_PROVIDER, - "message": format!("Bark Battle config JSON 序列化失败: {error}"), - })), - ) - })?; + let config_json = serialize_bark_battle_editor_config(&request_context, &editor_config)?; let updated = state .spacetime_client() .update_bark_battle_draft_config(BarkBattleDraftConfigUpsertRecordInput { @@ -174,7 +181,6 @@ pub async fn create_bark_battle_draft( ruleset_version: draft_snapshot.ruleset_version, difficulty_preset: difficulty_to_spacetime_string(&editor_config.difficulty_preset) .to_string(), - leaderboard_enabled: editor_config.leaderboard_enabled, config_json, updated_at_micros: now, }) @@ -186,6 +192,140 @@ pub async fn create_bark_battle_draft( Ok(json_success_body(Some(&request_context), draft)) } +pub async fn update_bark_battle_draft_config( + State(state): State, + Path(draft_id): Path, + Extension(request_context): Extension, + Extension(authenticated): Extension, + payload: Result, JsonRejection>, +) -> Result, Response> { + ensure_non_empty(&request_context, &draft_id, "draftId")?; + let Json(payload) = bark_battle_json(payload, &request_context)?; + if payload.draft_id.trim() != draft_id { + return Err(bark_battle_bad_request( + &request_context, + "draftId 与路径参数不一致", + )); + } + let Some(work_id) = payload + .work_id + .as_deref() + .map(str::trim) + .filter(|value| !value.is_empty()) + .map(ToString::to_string) + else { + return Err(bark_battle_bad_request( + &request_context, + "workId 缺失,请重新生成草稿后再保存素材。", + )); + }; + let owner_user_id = authenticated.claims().user_id().to_string(); + let now = current_utc_micros(); + let next_config_version = payload + .config_version + .map(u64::from) + .unwrap_or(1) + .saturating_add(1); + let ruleset_version = payload + .ruleset_version + .as_deref() + .map(str::trim) + .filter(|value| !value.is_empty()) + .unwrap_or(BARK_BATTLE_RULESET_VERSION_V1) + .to_string(); + let editor_config = BarkBattleConfigEditorPayload { + title: payload.title, + description: payload.description, + theme_description: payload.theme_description, + player_image_description: payload.player_image_description, + opponent_image_description: payload.opponent_image_description, + onomatopoeia: payload.onomatopoeia, + player_character_image_src: normalize_optional_bark_battle_asset_source( + &request_context, + payload.player_character_image_src.as_deref(), + "playerCharacterImageSrc", + )?, + opponent_character_image_src: normalize_optional_bark_battle_asset_source( + &request_context, + payload.opponent_character_image_src.as_deref(), + "opponentCharacterImageSrc", + )?, + ui_background_image_src: normalize_optional_bark_battle_asset_source( + &request_context, + payload.ui_background_image_src.as_deref(), + "uiBackgroundImageSrc", + )?, + difficulty_preset: payload.difficulty_preset, + }; + let config_json = serialize_bark_battle_editor_config(&request_context, &editor_config)?; + let updated = state + .spacetime_client() + .update_bark_battle_draft_config(BarkBattleDraftConfigUpsertRecordInput { + draft_id, + owner_user_id, + work_id, + config_version: next_config_version, + ruleset_version, + difficulty_preset: difficulty_to_spacetime_string(&editor_config.difficulty_preset) + .to_string(), + config_json, + updated_at_micros: now, + }) + .await + .map_err(|error| { + bark_battle_error_response(&request_context, map_bark_battle_client_error(error)) + })?; + let mut draft = map_draft_config_record(updated, &request_context)?; + // 中文注释:SpacetimeDB procedure 返回可能早于订阅缓存合并完成;HTTP 回包先以本次请求 + // 的 configJson 为准,避免前端拿到旧快照后误判“草稿没有素材”。 + draft.player_character_image_src = editor_config.player_character_image_src; + draft.opponent_character_image_src = editor_config.opponent_character_image_src; + draft.ui_background_image_src = editor_config.ui_background_image_src; + Ok(json_success_body(Some(&request_context), draft)) +} + +pub async fn generate_bark_battle_image_asset( + State(state): State, + Extension(request_context): Extension, + Extension(authenticated): Extension, + payload: Result, JsonRejection>, +) -> Result, Response> { + let Json(payload) = bark_battle_json(payload, &request_context)?; + let owner_user_id = authenticated.claims().user_id().to_string(); + let asset_id = build_prefixed_uuid_id(BARK_BATTLE_IMAGE_ID_PREFIX); + let prompt = build_bark_battle_image_prompt(&payload.slot, &payload.config); + let size = bark_battle_image_size(&payload.slot).to_string(); + let slot = payload.slot.clone(); + let draft_id = payload + .draft_id + .as_deref() + .map(str::trim) + .filter(|value| !value.is_empty()) + .map(ToString::to_string); + let result = execute_billable_asset_operation( + &state, + &owner_user_id, + bark_battle_slot_asset_kind(&slot), + asset_id.as_str(), + async { + generate_and_persist_bark_battle_image_asset( + &state, + &owner_user_id, + &slot, + draft_id.as_deref(), + asset_id.as_str(), + prompt.as_str(), + size.as_str(), + ) + .await + }, + ) + .await + .map_err(|error| bark_battle_error_response(&request_context, error))?; + + Ok(json_success_body(Some(&request_context), result)) +} + pub async fn publish_bark_battle_work( State(state): State, Extension(request_context): Extension, @@ -199,12 +339,13 @@ pub async fn publish_bark_battle_work( .as_deref() .map(str::trim) .filter(|value| !value.is_empty()) - .map(ToString::to_string) else { - return Err(bark_battle_bad_request( - &request_context, - "workId 缺失,请重新生成草稿后再发布。", - )); - }; + .map(ToString::to_string) + else { + return Err(bark_battle_bad_request( + &request_context, + "workId 缺失,请重新生成草稿后再发布。", + )); + }; let published_snapshot_json = payload .published_snapshot .as_ref() @@ -236,6 +377,59 @@ pub async fn publish_bark_battle_work( Ok(json_success_body(Some(&request_context), published)) } +pub async fn list_bark_battle_works( + State(state): State, + Extension(request_context): Extension, + Extension(authenticated): Extension, +) -> Result, Response> { + let items = state + .spacetime_client() + .list_bark_battle_works(authenticated.claims().user_id().to_string()) + .await + .map_err(|error| { + bark_battle_error_response(&request_context, map_bark_battle_client_error(error)) + })?; + let items = items + .into_iter() + .map(|item| { + let author_display_name = + resolve_bark_battle_author_display_name_for_record(&state, &item); + map_work_summary_record(item, &request_context, author_display_name) + }) + .collect::, _>>()?; + + Ok(json_success_body( + Some(&request_context), + BarkBattleWorksResponse { items }, + )) +} + +pub async fn list_bark_battle_gallery( + State(state): State, + Extension(request_context): Extension, +) -> Result, Response> { + let items = state + .spacetime_client() + .list_bark_battle_gallery() + .await + .map_err(|error| { + bark_battle_error_response(&request_context, map_bark_battle_client_error(error)) + })?; + let items = items + .into_iter() + .map(|item| { + let author_display_name = + resolve_bark_battle_author_display_name_for_record(&state, &item); + map_work_summary_record(item, &request_context, author_display_name) + }) + .collect::, _>>()?; + + Ok(json_success_body( + Some(&request_context), + BarkBattleWorksResponse { items }, + )) +} + pub async fn get_bark_battle_runtime_config( State(state): State, Path(work_id): Path, @@ -537,15 +731,14 @@ fn map_draft_config_record( ruleset_version: Some(snapshot.ruleset_version), title: editor_config.title, description: editor_config.description, - theme_preset: editor_config.theme_preset, - player_dog_skin_preset: editor_config.player_dog_skin_preset, - opponent_dog_skin_preset: editor_config.opponent_dog_skin_preset, + theme_description: editor_config.theme_description, + player_image_description: editor_config.player_image_description, + opponent_image_description: editor_config.opponent_image_description, + onomatopoeia: editor_config.onomatopoeia, player_character_image_src: editor_config.player_character_image_src, opponent_character_image_src: editor_config.opponent_character_image_src, ui_background_image_src: editor_config.ui_background_image_src, - bark_sound_src: editor_config.bark_sound_src, difficulty_preset: editor_config.difficulty_preset, - leaderboard_enabled: editor_config.leaderboard_enabled, updated_at: format_timestamp_micros(snapshot.updated_at_micros), }) } @@ -568,14 +761,13 @@ fn map_runtime_config_record( draw_threshold: ruleset.draw_threshold_energy as f32, min_bark_gap_ms: ruleset.min_bark_gap_ms, difficulty_preset: editor_config.difficulty_preset, - theme_preset: editor_config.theme_preset, - player_dog_skin_preset: editor_config.player_dog_skin_preset, - opponent_dog_skin_preset: editor_config.opponent_dog_skin_preset, + theme_description: editor_config.theme_description, + player_image_description: editor_config.player_image_description, + opponent_image_description: editor_config.opponent_image_description, + onomatopoeia: editor_config.onomatopoeia, player_character_image_src: editor_config.player_character_image_src, opponent_character_image_src: editor_config.opponent_character_image_src, ui_background_image_src: editor_config.ui_background_image_src, - bark_sound_src: editor_config.bark_sound_src, - leaderboard_enabled: editor_config.leaderboard_enabled, updated_at: format_timestamp_micros(snapshot.updated_at_micros), }) } @@ -594,20 +786,288 @@ fn map_published_config_record( play_type_id: BARK_BATTLE_PLAY_TYPE_ID.to_string(), title: editor_config.title, description: editor_config.description, - theme_preset: editor_config.theme_preset, - player_dog_skin_preset: editor_config.player_dog_skin_preset, - opponent_dog_skin_preset: editor_config.opponent_dog_skin_preset, + theme_description: editor_config.theme_description, + player_image_description: editor_config.player_image_description, + opponent_image_description: editor_config.opponent_image_description, + onomatopoeia: editor_config.onomatopoeia, player_character_image_src: editor_config.player_character_image_src, opponent_character_image_src: editor_config.opponent_character_image_src, ui_background_image_src: editor_config.ui_background_image_src, - bark_sound_src: editor_config.bark_sound_src, difficulty_preset: editor_config.difficulty_preset, - leaderboard_enabled: editor_config.leaderboard_enabled, updated_at: format_timestamp_micros(snapshot.updated_at_micros), published_at: format_timestamp_micros(snapshot.published_at_micros), }) } +fn map_work_summary_record( + value: Value, + request_context: &RequestContext, + author_display_name: String, +) -> Result { + let status = value + .get("status") + .and_then(Value::as_str) + .unwrap_or("draft") + .to_string(); + if status == "published" && value.get("configJson").is_none() { + return map_gallery_work_summary_record(value, request_context, author_display_name); + } + + let draft_id = value + .get("draftId") + .and_then(Value::as_str) + .map(ToString::to_string) + .or_else(|| { + value + .get("sourceDraftId") + .and_then(Value::as_str) + .map(ToString::to_string) + }); + let owner_user_id = value + .get("ownerUserId") + .and_then(Value::as_str) + .unwrap_or_default() + .to_string(); + let snapshot = parse_runtime_snapshot_record(value.clone(), request_context).or_else(|_| { + parse_draft_snapshot_record(value.clone(), request_context).map(draft_to_runtime_like) + })?; + let editor_config = resolve_work_summary_editor_config( + &snapshot.config_json, + value.get("publishedSnapshotJson").and_then(Value::as_str), + request_context, + )?; + let is_published = status == "published" || snapshot.published_at_micros > 0; + let generation_status = Some(resolve_generation_status( + editor_config.player_character_image_src.as_deref(), + editor_config.opponent_character_image_src.as_deref(), + editor_config.ui_background_image_src.as_deref(), + )); + let publish_ready = has_all_bark_battle_images(&editor_config); + + Ok(BarkBattleWorkSummary { + work_id: snapshot.work_id, + draft_id, + owner_user_id, + author_display_name, + title: editor_config.title, + summary: editor_config.description.unwrap_or_default(), + theme_description: editor_config.theme_description, + player_image_description: editor_config.player_image_description, + opponent_image_description: editor_config.opponent_image_description, + onomatopoeia: editor_config.onomatopoeia, + player_character_image_src: editor_config.player_character_image_src, + opponent_character_image_src: editor_config.opponent_character_image_src, + ui_background_image_src: editor_config.ui_background_image_src, + difficulty_preset: editor_config.difficulty_preset, + status: if is_published { "published" } else { "draft" }.to_string(), + generation_status, + publish_ready, + play_count: value.get("playCount").and_then(Value::as_u64).unwrap_or(0), + finish_count: value.get("finishCount").and_then(Value::as_u64), + win_count: None, + draw_count: None, + loss_count: None, + recent_play_count_7d: value.get("recentPlayCount7d").and_then(Value::as_u64), + updated_at: format_timestamp_micros(snapshot.updated_at_micros), + published_at: is_published.then(|| format_timestamp_micros(snapshot.published_at_micros)), + }) +} + +fn map_gallery_work_summary_record( + value: Value, + request_context: &RequestContext, + author_display_name: String, +) -> Result { + let difficulty = value + .get("difficultyPreset") + .and_then(Value::as_str) + .map(parse_difficulty) + .transpose() + .map_err(|error| bark_battle_error_response(request_context, error))? + .unwrap_or(BarkBattleDifficultyPreset::Normal); + let player_character_image_src = value + .get("playerCharacterImageSrc") + .and_then(Value::as_str) + .filter(|value| !value.trim().is_empty()) + .map(ToString::to_string); + let opponent_character_image_src = value + .get("opponentCharacterImageSrc") + .and_then(Value::as_str) + .filter(|value| !value.trim().is_empty()) + .map(ToString::to_string); + let ui_background_image_src = value + .get("uiBackgroundImageSrc") + .and_then(Value::as_str) + .filter(|value| !value.trim().is_empty()) + .map(ToString::to_string); + let updated_at_micros = value + .get("updatedAtMicros") + .and_then(Value::as_i64) + .unwrap_or_default(); + let published_at_micros = value + .get("publishedAtMicros") + .and_then(Value::as_i64) + .unwrap_or(updated_at_micros); + + Ok(BarkBattleWorkSummary { + work_id: read_required_json_string(&value, "workId", request_context)?, + draft_id: value + .get("sourceDraftId") + .and_then(Value::as_str) + .map(ToString::to_string), + owner_user_id: read_required_json_string(&value, "ownerUserId", request_context)?, + author_display_name, + title: read_required_json_string(&value, "title", request_context)?, + summary: value + .get("description") + .and_then(Value::as_str) + .unwrap_or_default() + .to_string(), + theme_description: read_required_json_string(&value, "themeDescription", request_context)?, + player_image_description: read_required_json_string( + &value, + "playerImageDescription", + request_context, + )?, + opponent_image_description: read_required_json_string( + &value, + "opponentImageDescription", + request_context, + )?, + onomatopoeia: read_optional_string_array(&value, "onomatopoeia"), + player_character_image_src, + opponent_character_image_src, + ui_background_image_src, + difficulty_preset: difficulty, + status: "published".to_string(), + generation_status: Some("ready".to_string()), + publish_ready: true, + play_count: value.get("playCount").and_then(Value::as_u64).unwrap_or(0), + finish_count: value.get("finishCount").and_then(Value::as_u64), + win_count: None, + draw_count: None, + loss_count: None, + recent_play_count_7d: value.get("recentPlayCount7d").and_then(Value::as_u64), + updated_at: format_timestamp_micros(updated_at_micros), + published_at: Some(format_timestamp_micros(published_at_micros)), + }) +} + +fn resolve_work_summary_editor_config( + config_json: &str, + published_snapshot_json: Option<&str>, + request_context: &RequestContext, +) -> Result { + let snapshot = published_snapshot_json + .map(str::trim) + .filter(|value| !value.is_empty()) + .unwrap_or(config_json); + parse_editor_config_record(snapshot, request_context) +} + +fn draft_to_runtime_like( + draft: BarkBattleDraftConfigSnapshotRecord, +) -> BarkBattleRuntimeConfigSnapshotRecord { + BarkBattleRuntimeConfigSnapshotRecord { + work_id: draft.work_id, + source_draft_id: Some(draft.draft_id), + config_version: draft.config_version, + ruleset_version: draft.ruleset_version, + config_json: draft.config_json, + published_at_micros: 0, + updated_at_micros: draft.updated_at_micros, + } +} + +fn resolve_generation_status( + player_src: Option<&str>, + opponent_src: Option<&str>, + background_src: Option<&str>, +) -> String { + if player_src.is_some() && opponent_src.is_some() && background_src.is_some() { + "ready".to_string() + } else { + "pending_assets".to_string() + } +} + +fn has_all_bark_battle_images(config: &BarkBattleConfigEditorPayload) -> bool { + config + .player_character_image_src + .as_deref() + .is_some_and(|value| !value.trim().is_empty()) + && config + .opponent_character_image_src + .as_deref() + .is_some_and(|value| !value.trim().is_empty()) + && config + .ui_background_image_src + .as_deref() + .is_some_and(|value| !value.trim().is_empty()) +} + +fn resolve_bark_battle_author_display_name_for_record(state: &AppState, value: &Value) -> String { + let owner_user_id = value + .get("ownerUserId") + .and_then(Value::as_str) + .unwrap_or_default(); + resolve_bark_battle_author_display_name(state, owner_user_id) +} + +fn resolve_bark_battle_author_display_name(state: &AppState, owner_user_id: &str) -> String { + let display_name = if owner_user_id.trim().is_empty() { + None + } else { + state + .auth_user_service() + .get_user_by_id(owner_user_id) + .ok() + .flatten() + .map(|user| user.display_name) + }; + normalize_author_display_name(display_name) +} + +fn normalize_author_display_name(display_name: Option) -> String { + display_name + .map(|value| value.trim().to_string()) + .filter(|value| !value.is_empty()) + .unwrap_or_else(|| "玩家".to_string()) +} + +fn read_required_json_string( + value: &Value, + field_name: &str, + request_context: &RequestContext, +) -> Result { + value + .get(field_name) + .and_then(Value::as_str) + .map(ToString::to_string) + .ok_or_else(|| { + bark_battle_error_response( + request_context, + AppError::from_status(StatusCode::BAD_GATEWAY).with_details(json!({ + "provider": BARK_BATTLE_RUNTIME_PROVIDER, + "message": format!("Bark Battle work summary 缺少字段: {field_name}"), + })), + ) + }) +} + +fn read_optional_string_array(value: &Value, field_name: &str) -> Option> { + let words: Vec = value + .get(field_name)? + .as_array()? + .iter() + .filter_map(Value::as_str) + .map(str::trim) + .filter(|word| !word.is_empty()) + .map(ToString::to_string) + .collect(); + (!words.is_empty()).then_some(words) +} + fn parse_editor_config_record( config_json: &str, request_context: &RequestContext, @@ -623,6 +1083,311 @@ fn parse_editor_config_record( }) } +fn serialize_bark_battle_editor_config( + request_context: &RequestContext, + editor_config: &BarkBattleConfigEditorPayload, +) -> Result { + serde_json::to_string(editor_config).map_err(|error| { + bark_battle_error_response( + request_context, + AppError::from_status(StatusCode::BAD_REQUEST).with_details(json!({ + "provider": BARK_BATTLE_RUNTIME_PROVIDER, + "message": format!("Bark Battle config JSON 序列化失败: {error}"), + })), + ) + }) +} + +fn build_bark_battle_image_prompt( + slot: &BarkBattleAssetSlot, + config: &BarkBattleConfigEditorPayload, +) -> String { + let title = config.title.trim(); + let description = config.description.as_deref().unwrap_or_default().trim(); + let theme_description = config.theme_description.trim(); + let player_description = config.player_image_description.trim(); + let opponent_description = config.opponent_image_description.trim(); + let slot_prompt = match slot { + BarkBattleAssetSlot::PlayerCharacter => format!( + "玩家形象描述:{player_description}。输出单个完整角色/形象,正面,主体完整,PNG 透明背景,适合叠加在竖屏声控对战游戏 HUD 中。" + ), + BarkBattleAssetSlot::OpponentCharacter => format!( + "对手形象描述:{opponent_description}。输出单个完整角色/形象,正面,主体完整,PNG 透明背景,适合叠加在竖屏声控对战游戏 HUD 中。" + ), + BarkBattleAssetSlot::UiBackground => format!( + "竞技背景描述:{theme_description}。输出竖屏移动端声浪竞技场背景,留出左右两侧角色站位和中部能量对抗空间,不包含具体角色。" + ), + }; + + let mut parts = vec![ + format!("本作品《{title}》专用素材。"), + format!("整体主题/场景:{theme_description}。"), + ]; + if !description.is_empty() { + parts.push(format!("作品简介:{description}。")); + } + if !matches!(slot, BarkBattleAssetSlot::UiBackground) { + parts.push(format!( + "玩家与对手的关系参考:玩家是「{player_description}」,对手是「{opponent_description}」。" + )); + } + parts.push(slot_prompt); + parts.push(match slot { + BarkBattleAssetSlot::UiBackground => { + "画面要求:9:16 竖屏游戏背景,明亮、有纵深、无文字、无 Logo、无按钮、无 UI 字、无水印、无角色、无狗狗。" + .to_string() + } + _ => { + "画面要求:1:1 角色图,透明背景,边缘清晰,无文字、无 Logo、无按钮、无水印,不要把竞技场背景画成主体。最终画面必须是 PNG 透明背景。" + .to_string() + } + }); + parts + .into_iter() + .map(|part| part.trim().to_string()) + .filter(|part| !part.is_empty()) + .collect::>() + .join("\n") +} + +fn bark_battle_image_size(slot: &BarkBattleAssetSlot) -> &'static str { + match slot { + BarkBattleAssetSlot::UiBackground => BARK_BATTLE_BACKGROUND_IMAGE_SIZE, + BarkBattleAssetSlot::PlayerCharacter | BarkBattleAssetSlot::OpponentCharacter => { + BARK_BATTLE_CHARACTER_IMAGE_SIZE + } + } +} + +fn bark_battle_slot_asset_kind(slot: &BarkBattleAssetSlot) -> &'static str { + match slot { + BarkBattleAssetSlot::PlayerCharacter => "bark_battle_player_character_image", + BarkBattleAssetSlot::OpponentCharacter => "bark_battle_opponent_character_image", + BarkBattleAssetSlot::UiBackground => "bark_battle_ui_background_image", + } +} + +fn bark_battle_slot_name(slot: &BarkBattleAssetSlot) -> &'static str { + match slot { + BarkBattleAssetSlot::PlayerCharacter => "player-character", + BarkBattleAssetSlot::OpponentCharacter => "opponent-character", + BarkBattleAssetSlot::UiBackground => "ui-background", + } +} + +fn bark_battle_slot_storage_segment(slot: &BarkBattleAssetSlot) -> &'static str { + match slot { + BarkBattleAssetSlot::PlayerCharacter => "player", + BarkBattleAssetSlot::OpponentCharacter => "opponent", + BarkBattleAssetSlot::UiBackground => "background", + } +} + +fn bark_battle_sanitize_path_segment(value: &str, fallback: &str) -> String { + let sanitized = value + .trim() + .chars() + .map(|ch| match ch { + '/' | '\\' | ':' | '*' | '?' | '"' | '<' | '>' | '|' => '-', + ch if ch.is_control() => '-', + ch => ch, + }) + .collect::() + .trim_matches('-') + .chars() + .take(72) + .collect::(); + if sanitized.trim().is_empty() { + fallback.to_string() + } else { + sanitized + } +} + +async fn generate_and_persist_bark_battle_image_asset( + state: &AppState, + owner_user_id: &str, + slot: &BarkBattleAssetSlot, + draft_id: Option<&str>, + asset_id: &str, + prompt: &str, + size: &str, +) -> Result { + let settings = require_openai_image_settings(state)?; + let http_client = build_openai_image_http_client(&settings)?; + let generated = create_openai_image_generation( + &http_client, + &settings, + prompt, + Some("文字、Logo、水印、按钮、UI 字、低清晰度、畸形肢体、多余角色、裁切主体"), + size, + 1, + &[], + "汪汪声浪素材生成失败", + ) + .await?; + let task_id = generated.task_id.clone(); + let actual_prompt = generated.actual_prompt.clone(); + let image = generated.images.into_iter().next().ok_or_else(|| { + AppError::from_status(StatusCode::BAD_GATEWAY).with_details(json!({ + "provider": "vector-engine", + "message": "汪汪声浪素材生成成功但未返回图片。", + })) + })?; + let image_src = persist_bark_battle_generated_image( + state, + owner_user_id, + slot, + draft_id, + asset_id, + task_id.as_str(), + image, + ) + .await?; + + Ok(BarkBattleGeneratedImageAsset { + image_src, + asset_id: asset_id.to_string(), + source_type: Some("generated".to_string()), + model: GPT_IMAGE_2_MODEL.to_string(), + size: size.to_string(), + task_id, + prompt: prompt.to_string(), + actual_prompt, + }) +} + +async fn persist_bark_battle_generated_image( + state: &AppState, + owner_user_id: &str, + slot: &BarkBattleAssetSlot, + draft_id: Option<&str>, + asset_id: &str, + task_id: &str, + image: crate::openai_image_generation::DownloadedOpenAiImage, +) -> Result { + let oss_client = state.oss_client().ok_or_else(|| { + AppError::from_status(StatusCode::SERVICE_UNAVAILABLE).with_details(json!({ + "provider": "aliyun-oss", + "reason": "OSS 未完成环境变量配置", + })) + })?; + let entity_id = draft_id + .map(str::trim) + .filter(|value| !value.is_empty()) + .unwrap_or(asset_id) + .to_string(); + let prepared = + GeneratedImageAssetAdapter::prepare_put_object(GeneratedImageAssetPersistInput { + prefix: LegacyAssetPrefix::BarkBattleAssets, + path_segments: vec![ + bark_battle_sanitize_path_segment(entity_id.as_str(), "draft"), + bark_battle_slot_storage_segment(slot).to_string(), + bark_battle_sanitize_path_segment(asset_id, "asset"), + ], + file_stem: "image".to_string(), + image: GeneratedImageAssetDataUrl { + format: normalize_generated_image_asset_mime(image.mime_type.as_str()), + bytes: image.bytes, + }, + access: OssObjectAccess::Private, + metadata: GeneratedImageAssetAdapterMetadata { + asset_kind: Some(bark_battle_slot_asset_kind(slot).to_string()), + owner_user_id: Some(owner_user_id.to_string()), + entity_kind: Some("bark_battle_draft".to_string()), + entity_id: Some(entity_id.clone()), + slot: Some(bark_battle_slot_name(slot).to_string()), + provider: Some("vector-engine".to_string()), + task_id: Some(task_id.to_string()), + }, + extra_metadata: Default::default(), + }) + .map_err(|error| { + AppError::from_status(StatusCode::INTERNAL_SERVER_ERROR).with_details(json!({ + "provider": "generated-image-assets", + "message": format!("准备汪汪声浪图片资产上传请求失败:{error:?}"), + })) + })?; + let persisted_mime_type = prepared.format.mime_type.clone(); + let http_client = reqwest::Client::new(); + let put_result = oss_client + .put_object(&http_client, prepared.request) + .await + .map_err(|error| map_oss_error(error, "aliyun-oss"))?; + let head = oss_client + .head_object( + &http_client, + OssHeadObjectRequest { + object_key: put_result.object_key.clone(), + }, + ) + .await + .map_err(|error| map_oss_error(error, "aliyun-oss"))?; + let now_micros = current_utc_micros(); + let asset_object = state + .spacetime_client() + .confirm_asset_object( + build_asset_object_upsert_input( + generate_asset_object_id(now_micros), + head.bucket, + head.object_key, + AssetObjectAccessPolicy::Private, + head.content_type.or(Some(persisted_mime_type)), + head.content_length, + head.etag, + bark_battle_slot_asset_kind(slot).to_string(), + Some(task_id.to_string()), + Some(owner_user_id.to_string()), + draft_id.map(ToString::to_string), + Some(entity_id.clone()), + now_micros, + ) + .map_err(|error| { + AppError::from_status(StatusCode::BAD_REQUEST).with_details(json!({ + "provider": "asset-object", + "message": error.to_string(), + })) + })?, + ) + .await + .map_err(|error| { + AppError::from_status(StatusCode::BAD_GATEWAY).with_details(json!({ + "provider": "spacetimedb", + "message": error.to_string(), + })) + })?; + state + .spacetime_client() + .bind_asset_object_to_entity( + build_asset_entity_binding_input( + generate_asset_binding_id(now_micros), + asset_object.asset_object_id, + "bark_battle_draft".to_string(), + entity_id, + bark_battle_slot_name(slot).to_string(), + bark_battle_slot_asset_kind(slot).to_string(), + Some(owner_user_id.to_string()), + draft_id.map(ToString::to_string), + now_micros, + ) + .map_err(|error| { + AppError::from_status(StatusCode::BAD_REQUEST).with_details(json!({ + "provider": "asset-entity-binding", + "message": error.to_string(), + })) + })?, + ) + .await + .map_err(|error| { + AppError::from_status(StatusCode::BAD_GATEWAY).with_details(json!({ + "provider": "spacetimedb", + "message": error.to_string(), + })) + })?; + + Ok(put_result.legacy_public_path) +} + fn bark_battle_snapshot_parse_error( request_context: &RequestContext, label: &str, @@ -877,16 +1642,16 @@ mod tests { let config_json = json!({ "title": "汪汪测试杯", "description": "", - "themePreset": "sunny-yard", - "playerDogSkinPreset": "主角", - "opponentDogSkinPreset": "对手", - "difficultyPreset": "normal", - "leaderboardEnabled": true + "themeDescription": "阳光草坪声浪擂台", + "playerImageDescription": "主角柴犬", + "opponentImageDescription": "对手哈士奇", + "onomatopoeia": ["轰汪!", "炸场!", "冲啊!"], + "difficultyPreset": "normal" }) .to_string(); let row = json!({ "draftId": "bark-battle-draft-1", - "workId": "bark-battle-work-1", + "workId": "BB-12345678", "configVersion": 2, "rulesetVersion": "bark-battle-ruleset-v1", "configJson": config_json, @@ -897,11 +1662,222 @@ mod tests { .expect("draft config should map from SpacetimeDB snapshot"); assert_eq!(draft.draft_id, "bark-battle-draft-1"); - assert_eq!(draft.work_id.as_deref(), Some("bark-battle-work-1")); + assert_eq!(draft.work_id.as_deref(), Some("BB-12345678")); assert_eq!(draft.config_version, Some(2)); assert_eq!( draft.ruleset_version.as_deref(), Some("bark-battle-ruleset-v1") ); } + + #[test] + fn bark_battle_image_prompts_are_slot_specific() { + let config = BarkBattleConfigEditorPayload { + title: "星环声浪挑战".to_string(), + description: Some("给朋友玩的声控对战".to_string()), + theme_description: "霓虹城市公园里的声浪擂台".to_string(), + player_image_description: "星际猫骑士".to_string(), + opponent_image_description: "机器人拳手".to_string(), + onomatopoeia: Some(vec![ + "轰!".to_string(), + "炸场!".to_string(), + "冲啊!".to_string(), + ]), + player_character_image_src: None, + opponent_character_image_src: None, + ui_background_image_src: None, + difficulty_preset: BarkBattleDifficultyPreset::Normal, + }; + + let player_prompt = + build_bark_battle_image_prompt(&BarkBattleAssetSlot::PlayerCharacter, &config); + assert!(player_prompt.contains("玩家形象描述:星际猫骑士")); + assert!(player_prompt.contains("正面")); + assert!(player_prompt.contains("PNG 透明背景")); + assert!(player_prompt.contains("透明背景")); + assert!(player_prompt.contains("1:1 角色图")); + assert!(!player_prompt.contains("狗")); + assert!(!player_prompt.contains("狗狗")); + assert!(!player_prompt.contains("小狗")); + assert!(!player_prompt.contains("犬")); + assert!(!player_prompt.contains("汪汪")); + assert!(!player_prompt.contains("横版 16:9 2D RPG 场景背景")); + assert!(!player_prompt.contains("远景剪影")); + + let opponent_prompt = + build_bark_battle_image_prompt(&BarkBattleAssetSlot::OpponentCharacter, &config); + assert!(opponent_prompt.contains("对手形象描述:机器人拳手")); + assert!(opponent_prompt.contains("正面")); + assert!(opponent_prompt.contains("PNG 透明背景")); + assert!(opponent_prompt.contains("透明背景")); + assert!(opponent_prompt.contains("1:1 角色图")); + assert!(!opponent_prompt.contains("狗")); + assert!(!opponent_prompt.contains("狗狗")); + assert!(!opponent_prompt.contains("小狗")); + assert!(!opponent_prompt.contains("犬")); + assert!(!opponent_prompt.contains("汪汪")); + assert!(!opponent_prompt.contains("横版 16:9 2D RPG 场景背景")); + assert!(!opponent_prompt.contains("远景剪影")); + + let background_prompt = + build_bark_battle_image_prompt(&BarkBattleAssetSlot::UiBackground, &config); + assert!(background_prompt.contains("竞技背景描述:霓虹城市公园里的声浪擂台")); + assert!(background_prompt.contains("9:16 竖屏游戏背景")); + assert!(background_prompt.contains("无角色、无狗狗")); + assert!(!background_prompt.contains("玩家与对手的关系参考")); + } + + #[test] + fn bark_battle_work_summary_mapping_uses_resolved_author_display_name() { + let request_context = RequestContext::new( + "test-request".to_string(), + "GET /api/creation/bark-battle/works".to_string(), + Duration::ZERO, + false, + ); + let config_json = json!({ + "title": "声浪测试局", + "description": "映射测试", + "themeDescription": "星环竞技场", + "playerImageDescription": "星际猫骑士", + "opponentImageDescription": "机器人拳手", + "onomatopoeia": ["轰!", "炸场!", "冲啊!"], + "difficultyPreset": "normal" + }) + .to_string(); + let work_row = json!({ + "draftId": "bark-battle-draft-2", + "workId": "BB-22222222", + "ownerUserId": "user-2", + "configVersion": 1, + "rulesetVersion": "bark-battle-ruleset-v1", + "configJson": config_json, + "updatedAtMicros": 1_713_686_401_234_567i64, + "status": "draft" + }); + + let work = map_work_summary_record(work_row, &request_context, " 星环作者 ".to_string()) + .expect("work summary should use provided author display name"); + + assert_eq!(work.author_display_name, " 星环作者 "); + } + + #[test] + fn bark_battle_gallery_mapping_uses_resolved_author_display_name() { + let request_context = RequestContext::new( + "test-request".to_string(), + "GET /api/creation/bark-battle/gallery".to_string(), + Duration::ZERO, + false, + ); + let gallery_row = json!({ + "status": "published", + "workId": "BB-33333333", + "ownerUserId": "user-3", + "sourceDraftId": "bark-battle-draft-3", + "title": "声浪公开赛", + "description": "画廊映射测试", + "themeDescription": "霓虹竞技场", + "playerImageDescription": "星际猫骑士", + "opponentImageDescription": "机器人拳手", + "onomatopoeia": ["轰!", "炸场!", "冲啊!"], + "playerCharacterImageSrc": "/assets/player.png", + "opponentCharacterImageSrc": "/assets/opponent.png", + "uiBackgroundImageSrc": "/assets/background.png", + "difficultyPreset": "normal", + "playCount": 8, + "finishCount": 5, + "recentPlayCount7d": 3, + "updatedAtMicros": 1_713_686_401_234_567i64, + "publishedAtMicros": 1_713_686_401_234_000i64 + }); + + let work = + map_work_summary_record(gallery_row, &request_context, "画廊作者".to_string()) + .expect("gallery summary should use provided author display name"); + + assert_eq!(work.author_display_name, "画廊作者"); + assert_eq!( + work.onomatopoeia, + Some(vec![ + "轰!".to_string(), + "炸场!".to_string(), + "冲啊!".to_string(), + ]) + ); + } + + #[test] + fn bark_battle_published_summary_uses_published_snapshot_assets() { + let request_context = RequestContext::new( + "test-request".to_string(), + "GET /api/creation/bark-battle/works".to_string(), + Duration::ZERO, + false, + ); + let draft_config_json = json!({ + "title": "声浪测试局", + "description": "发布前草稿", + "themeDescription": "草地竞技场", + "playerImageDescription": "柯基选手", + "opponentImageDescription": "哈士奇对手", + "difficultyPreset": "normal" + }) + .to_string(); + let published_snapshot_json = json!({ + "title": "声浪测试局", + "description": "发布后快照", + "themeDescription": "草地竞技场", + "playerImageDescription": "柯基选手", + "opponentImageDescription": "哈士奇对手", + "playerCharacterImageSrc": "/assets/player.png", + "opponentCharacterImageSrc": "/assets/opponent.png", + "uiBackgroundImageSrc": "/assets/background.png", + "difficultyPreset": "normal" + }) + .to_string(); + let work_row = json!({ + "sourceDraftId": "bark-battle-draft-published", + "workId": "BB-44444444", + "ownerUserId": "user-4", + "configVersion": 1, + "rulesetVersion": "bark-battle-ruleset-v1", + "configJson": draft_config_json, + "publishedSnapshotJson": published_snapshot_json, + "publishedAtMicros": 1_713_686_401_234_000i64, + "updatedAtMicros": 1_713_686_401_234_567i64 + }); + + let work = map_work_summary_record(work_row, &request_context, "发布作者".to_string()) + .expect("published summary should use published snapshot assets"); + + assert_eq!(work.status, "published"); + assert_eq!( + work.player_character_image_src.as_deref(), + Some("/assets/player.png") + ); + assert_eq!( + work.opponent_character_image_src.as_deref(), + Some("/assets/opponent.png") + ); + assert_eq!( + work.ui_background_image_src.as_deref(), + Some("/assets/background.png") + ); + assert_eq!(work.summary, "发布后快照"); + assert!(work.publish_ready); + } + + #[test] + fn normalize_author_display_name_trims_and_falls_back_to_player() { + assert_eq!( + normalize_author_display_name(Some(" 小陶 ".to_string())), + "小陶" + ); + assert_eq!( + normalize_author_display_name(Some(" ".to_string())), + "玩家" + ); + assert_eq!(normalize_author_display_name(None), "玩家"); + } } diff --git a/server-rs/crates/api-server/src/creation_entry_config.rs b/server-rs/crates/api-server/src/creation_entry_config.rs index eba4531a..bb1d547e 100644 --- a/server-rs/crates/api-server/src/creation_entry_config.rs +++ b/server-rs/crates/api-server/src/creation_entry_config.rs @@ -174,6 +174,25 @@ mod tests { assert_eq!(resolve_creation_entry_route_id("/healthz"), None); } + #[test] + fn test_creation_entry_config_response_opens_bark_battle() { + let config = test_creation_entry_config_response(); + let bark_battle = config + .creation_types + .iter() + .find(|item| item.id == "bark-battle") + .expect("test creation entry config should include bark-battle"); + + assert_eq!(bark_battle.title, "汪汪声浪"); + assert!(bark_battle.visible); + assert!(bark_battle.open); + assert_eq!(bark_battle.badge, "可创建"); + assert_eq!( + bark_battle.image_src, + "/creation-type-references/bark-battle.webp" + ); + } + #[test] fn test_creation_entry_config_response_keeps_baby_object_match_coming_soon() { let config = test_creation_entry_config_response(); diff --git a/server-rs/crates/api-server/src/match3d/draft.rs b/server-rs/crates/api-server/src/match3d/draft.rs index f4855b69..1f73f265 100644 --- a/server-rs/crates/api-server/src/match3d/draft.rs +++ b/server-rs/crates/api-server/src/match3d/draft.rs @@ -532,7 +532,9 @@ fn build_config_from_message( } } -pub(super) fn resolve_config_or_default(config: Option<&Match3DCreatorConfigRecord>) -> Match3DConfigJson { +pub(super) fn resolve_config_or_default( + config: Option<&Match3DCreatorConfigRecord>, +) -> Match3DConfigJson { config .map(|config| Match3DConfigJson { theme_text: config.theme_text.clone(), @@ -595,7 +597,10 @@ fn build_match3d_assistant_reply(config: &Match3DConfigJson) -> String { ) } -pub(super) fn build_match3d_assistant_reply_for_turn(config: &Match3DConfigJson, current_turn: u32) -> String { +pub(super) fn build_match3d_assistant_reply_for_turn( + config: &Match3DConfigJson, + current_turn: u32, +) -> String { match current_turn { 0 => MATCH3D_QUESTION_THEME.to_string(), 1 => MATCH3D_QUESTION_CLEAR_COUNT.to_string(), diff --git a/server-rs/crates/api-server/src/match3d/item_assets.rs b/server-rs/crates/api-server/src/match3d/item_assets.rs index ab6d59c7..b5f6158c 100644 --- a/server-rs/crates/api-server/src/match3d/item_assets.rs +++ b/server-rs/crates/api-server/src/match3d/item_assets.rs @@ -1040,7 +1040,10 @@ pub(super) fn build_fallback_match3d_background_prompt(config: &Match3DConfigJso ) } -pub(super) fn build_fallback_match3d_item_sound_prompt(config: &Match3DConfigJson, item_name: &str) -> String { +pub(super) fn build_fallback_match3d_item_sound_prompt( + config: &Match3DConfigJson, + item_name: &str, +) -> String { let theme = config.theme_text.trim(); let normalized_theme = if theme.is_empty() { "抓大鹅" } else { theme }; normalize_match3d_audio_prompt( @@ -1416,7 +1419,9 @@ fn resolve_match3d_material_cell_crop( crop.to_crop_tuple() } -pub(super) fn crop_match3d_material_view_edge_matte(image: image::DynamicImage) -> image::DynamicImage { +pub(super) fn crop_match3d_material_view_edge_matte( + image: image::DynamicImage, +) -> image::DynamicImage { let mut image = image.to_rgba8(); let (width, height) = image.dimensions(); remove_match3d_material_view_edge_matte(image.as_mut(), width as usize, height as usize); diff --git a/server-rs/crates/api-server/src/match3d/mappers.rs b/server-rs/crates/api-server/src/match3d/mappers.rs index 983159a8..c3b0067e 100644 --- a/server-rs/crates/api-server/src/match3d/mappers.rs +++ b/server-rs/crates/api-server/src/match3d/mappers.rs @@ -134,12 +134,11 @@ pub(super) fn map_match3d_draft_response( draft: Match3DResultDraftRecord, ) -> 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::>(); + let generated_item_assets = + parse_match3d_generated_item_assets(draft.generated_item_assets_json.as_deref()) + .into_iter() + .map(Match3DGeneratedItemAsset::from) + .collect::>(); let background_asset = find_match3d_generated_background_asset(&generated_item_assets); let mut response = Match3DResultDraftResponse { profile_id: draft.profile_id, diff --git a/server-rs/crates/api-server/src/match3d/tests.rs b/server-rs/crates/api-server/src/match3d/tests.rs index 23bfb659..905a1697 100644 --- a/server-rs/crates/api-server/src/match3d/tests.rs +++ b/server-rs/crates/api-server/src/match3d/tests.rs @@ -1,40 +1,806 @@ use super::*; - use super::*; +use super::*; - fn test_match3d_generated_item_asset(index: u32, name: &str) -> Match3DGeneratedItemAsset { - Match3DGeneratedItemAsset { +fn test_match3d_generated_item_asset(index: u32, name: &str) -> Match3DGeneratedItemAsset { + Match3DGeneratedItemAsset { + item_id: format!("match3d-item-{index}"), + item_name: name.to_string(), + item_size: Some(infer_match3d_item_size(name)), + image_src: Some(format!( + "/generated-match3d-assets/s/p/items/i{index}/views/view-01.png" + )), + image_object_key: Some(format!( + "generated-match3d-assets/s/p/items/i{index}/views/view-01.png" + )), + image_views: (1..=MATCH3D_ITEM_VIEW_COUNT) + .map(|view_index| Match3DGeneratedItemImageView { + view_id: format!("view-{view_index:02}"), + view_index: view_index as u32, + image_src: Some(format!( + "/generated-match3d-assets/s/p/items/i{index}/views/view-{view_index:02}.png" + )), + image_object_key: Some(format!( + "generated-match3d-assets/s/p/items/i{index}/views/view-{view_index:02}.png" + )), + }) + .collect(), + model_src: Some(format!( + "/generated-match3d-assets/s/p/items/i{index}/model/model.glb" + )), + model_object_key: Some(format!( + "generated-match3d-assets/s/p/items/i{index}/model/model.glb" + )), + model_file_name: Some("model.glb".to_string()), + task_uuid: Some(format!("task-{index}")), + subscription_key: Some(format!("sub-{index}")), + sound_prompt: Some(format!("{name}点击音效")), + background_music_title: None, + background_music_style: None, + background_music_prompt: None, + background_music: None, + click_sound: None, + background_asset: None, + status: "image_ready".to_string(), + error: None, + } +} + +fn config(theme_text: &str, clear_count: u32, difficulty: u32) -> Match3DConfigJson { + Match3DConfigJson { + theme_text: theme_text.to_string(), + reference_image_src: None, + clear_count, + difficulty, + asset_style_id: None, + asset_style_label: None, + asset_style_prompt: None, + generate_click_sound: false, + } +} + +#[test] +fn match3d_agent_reply_asks_three_questions_before_confirmation() { + let current = config("水果", 4, 6); + + assert_eq!( + build_match3d_assistant_reply_for_turn(¤t, 0), + MATCH3D_QUESTION_THEME + ); + assert_eq!( + build_match3d_assistant_reply_for_turn(¤t, 1), + MATCH3D_QUESTION_CLEAR_COUNT + ); + assert_eq!( + build_match3d_assistant_reply_for_turn(¤t, 2), + MATCH3D_QUESTION_DIFFICULTY + ); + assert_eq!( + build_match3d_assistant_reply_for_turn(¤t, 3), + "已确认:水果题材,需要消除 4 次,共 12 件物品,难度 6。" + ); +} + +#[test] +fn match3d_agent_progress_follows_question_turns() { + assert_eq!(resolve_progress_percent_for_turn(0), 0); + assert_eq!(resolve_progress_percent_for_turn(1), 33); + assert_eq!(resolve_progress_percent_for_turn(2), 66); + assert_eq!(resolve_progress_percent_for_turn(3), 100); + assert_eq!(resolve_progress_percent_for_turn(8), 100); +} + +#[test] +fn match3d_anchor_pack_masks_uncollected_default_values() { + let 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(), + }, + }; + + let response = map_match3d_anchor_pack_response_for_turn(pack, 0, "Collecting"); + + assert_eq!(response.theme.value, ""); + assert_eq!(response.theme.status, "missing"); + assert_eq!(response.clear_count.value, ""); + assert_eq!(response.clear_count.status, "missing"); + assert_eq!(response.difficulty.value, ""); + assert_eq!(response.difficulty.status, "missing"); +} + +#[test] +fn match3d_item_image_path_segments_stay_unique_for_chinese_names() { + let item_names = ["草莓", "苹果", "香蕉"]; + let slugs = item_names + .iter() + .enumerate() + .map(|(index, item_name)| { + let item_id = format!("match3d-item-{}", index + 1); + format!( + "{item_id}-{}", + sanitize_match3d_asset_segment(item_name, "item") + ) + }) + .collect::>(); + + assert_eq!( + slugs, + vec![ + "match3d-item-1-item", + "match3d-item-2-item", + "match3d-item-3-item", + ] + ); +} + +#[test] +fn match3d_material_sheet_slicing_uses_fixed_five_by_five_rows() { + let width = 500; + let height = 500; + let item_names = vec!["樱桃".to_string(), "苹果".to_string(), "香蕉".to_string()]; + let mut sheet = image::RgbaImage::new(width, height); + for row in 0..5 { + for col in 0..5 { + let color = image::Rgba([ + 32 + row as u8 * 40, + 24 + col as u8 * 36, + 210 - row as u8 * 30, + 255, + ]); + for y in row * 100..(row + 1) * 100 { + for x in col * 100..(col + 1) * 100 { + sheet.put_pixel(x, y, color); + } + } + } + } + let mut encoded = std::io::Cursor::new(Vec::new()); + image::DynamicImage::ImageRgba8(sheet) + .write_to(&mut encoded, ImageFormat::Png) + .expect("sheet should encode"); + let image = DownloadedOpenAiImage { + bytes: encoded.into_inner(), + mime_type: "image/png".to_string(), + extension: "png".to_string(), + }; + + let slices = slice_match3d_material_sheet(&image, &item_names).expect("sheet should slice"); + + assert_eq!(slices.len(), 3); + for (row, views) in slices.iter().enumerate() { + assert_eq!(views.len(), MATCH3D_ITEM_VIEW_COUNT); + for (col, view) in views.iter().enumerate() { + let decoded = image::load_from_memory(view.bytes.as_slice()) + .expect("view should decode") + .to_rgba8(); + let pixel = decoded.get_pixel(decoded.width() / 2, decoded.height() / 2); + assert_eq!( + pixel.0, + [ + 32 + row as u8 * 40, + 24 + col as u8 * 36, + 210 - row as u8 * 30, + 255, + ], + "row {row} col {col} should be cut from the fixed 5*5 grid row" + ); + } + } +} + +#[test] +fn match3d_material_sheet_slicing_keeps_near_edge_foreground_pixels() { + let width = 500; + let height = 500; + let item_names = vec!["樱桃".to_string(), "苹果".to_string(), "香蕉".to_string()]; + let mut sheet = image::RgbaImage::from_pixel(width, height, image::Rgba([255, 255, 255, 255])); + for y in 1..5 { + for x in 18..82 { + sheet.put_pixel(x, y, image::Rgba([20, 80, 240, 255])); + } + } + for y in 5..96 { + for x in 18..82 { + sheet.put_pixel(x, y, image::Rgba([220, 32, 48, 255])); + } + } + for y in 96..99 { + for x in 18..82 { + sheet.put_pixel(x, y, image::Rgba([20, 180, 64, 255])); + } + } + let mut encoded = std::io::Cursor::new(Vec::new()); + image::DynamicImage::ImageRgba8(sheet) + .write_to(&mut encoded, ImageFormat::Png) + .expect("sheet should encode"); + let image = DownloadedOpenAiImage { + bytes: encoded.into_inner(), + mime_type: "image/png".to_string(), + extension: "png".to_string(), + }; + + let slices = slice_match3d_material_sheet(&image, &item_names).expect("sheet should slice"); + let decoded = image::load_from_memory(slices[0][0].bytes.as_slice()) + .expect("view should decode") + .to_rgba8(); + + let pixels = decoded.pixels().map(|pixel| pixel.0).collect::>(); + assert!( + pixels.iter().any(|pixel| *pixel == [20, 80, 240, 255]), + "贴近顶部的前景像素不能被固定内缩切掉" + ); + assert!( + pixels.iter().any(|pixel| *pixel == [20, 180, 64, 255]), + "贴近底部的前景像素不能被固定内缩切掉" + ); +} + +#[test] +fn match3d_material_sheet_slicing_makes_green_screen_transparent_before_crop() { + let width = 500; + let height = 500; + let item_names = vec!["草莓".to_string()]; + let mut sheet = image::RgbaImage::from_pixel(width, height, image::Rgba([0, 255, 0, 255])); + for y in 35..65 { + for x in 35..65 { + sheet.put_pixel(x, y, image::Rgba([220, 32, 48, 255])); + } + } + + let mut encoded = std::io::Cursor::new(Vec::new()); + image::DynamicImage::ImageRgba8(sheet) + .write_to(&mut encoded, ImageFormat::Png) + .expect("sheet should encode"); + let image = DownloadedOpenAiImage { + bytes: encoded.into_inner(), + mime_type: "image/png".to_string(), + extension: "png".to_string(), + }; + + let slices = slice_match3d_material_sheet(&image, &item_names).expect("sheet should slice"); + let decoded = image::load_from_memory(slices[0][0].bytes.as_slice()) + .expect("view should decode") + .to_rgba8(); + + assert!( + decoded.pixels().all(|pixel| { + let [red, green, blue, alpha] = pixel.0; + alpha == 0 || !(green > red.saturating_add(32) && green > blue.saturating_add(32)) + }), + "绿幕背景必须在切割输出中变成透明或被单素材二次裁边移除" + ); + assert!( + decoded.pixels().any(|pixel| pixel.0 == [220, 32, 48, 255]), + "物品主体不能被绿幕去背误删" + ); +} + +#[test] +fn match3d_material_sheet_slicing_removes_isolated_green_cell_background() { + let width = 500; + let height = 500; + let item_names = vec!["葡萄".to_string()]; + let mut sheet = image::RgbaImage::from_pixel(width, height, image::Rgba([245, 245, 245, 255])); + for y in 8..92 { + for x in 8..92 { + sheet.put_pixel(x, y, image::Rgba([0, 236, 18, 255])); + } + } + for y in 35..65 { + for x in 35..65 { + sheet.put_pixel(x, y, image::Rgba([136, 64, 210, 255])); + } + } + + let mut encoded = std::io::Cursor::new(Vec::new()); + image::DynamicImage::ImageRgba8(sheet) + .write_to(&mut encoded, ImageFormat::Png) + .expect("sheet should encode"); + let image = DownloadedOpenAiImage { + bytes: encoded.into_inner(), + mime_type: "image/png".to_string(), + extension: "png".to_string(), + }; + + let slices = slice_match3d_material_sheet(&image, &item_names).expect("sheet should slice"); + let decoded = image::load_from_memory(slices[0][0].bytes.as_slice()) + .expect("view should decode") + .to_rgba8(); + + assert!( + decoded + .pixels() + .all(|pixel| pixel.0[3] == 0 || pixel.0[1] < 180), + "没有连到整张 sheet 外边缘的绿幕块也必须被转成透明" + ); + assert!( + decoded.pixels().any(|pixel| pixel.0 == [136, 64, 210, 255]), + "绿幕清理不能误删物品主体" + ); +} + +#[test] +fn match3d_material_sheet_slicing_removes_soft_green_matte_before_crop() { + let width = 500; + let height = 500; + let item_names = vec!["草莓".to_string()]; + let mut sheet = image::RgbaImage::from_pixel(width, height, image::Rgba([0, 255, 0, 255])); + for y in 28..72 { + for x in 28..72 { + sheet.put_pixel(x, y, image::Rgba([64, 198, 112, 255])); + } + } + for y in 36..64 { + for x in 36..64 { + sheet.put_pixel(x, y, image::Rgba([220, 32, 48, 255])); + } + } + + let mut encoded = std::io::Cursor::new(Vec::new()); + image::DynamicImage::ImageRgba8(sheet) + .write_to(&mut encoded, ImageFormat::Png) + .expect("sheet should encode"); + let image = DownloadedOpenAiImage { + bytes: encoded.into_inner(), + mime_type: "image/png".to_string(), + extension: "png".to_string(), + }; + + let slices = slice_match3d_material_sheet(&image, &item_names).expect("sheet should slice"); + let decoded = image::load_from_memory(slices[0][0].bytes.as_slice()) + .expect("view should decode") + .to_rgba8(); + + assert!( + decoded.pixels().all(|pixel| { + let [red, green, blue, alpha] = pixel.0; + alpha == 0 || green <= red.max(blue).saturating_add(32) + }), + "整张 sheet 去绿后再裁剪,输出 PNG 不能保留可见软绿边" + ); + assert!( + decoded.pixels().any(|pixel| pixel.0 == [220, 32, 48, 255]), + "软绿边清理不能误删物品主体" + ); +} + +#[test] +fn match3d_material_sheet_slicing_crops_single_view_green_antialias_border() { + let width = 500; + let height = 500; + let item_names = vec!["丸子".to_string()]; + let mut sheet = image::RgbaImage::from_pixel(width, height, image::Rgba([0, 255, 0, 255])); + for y in 22..78 { + for x in 22..78 { + if x <= 24 || x >= 75 || y <= 24 || y >= 75 { + sheet.put_pixel(x, y, image::Rgba([168, 246, 176, 255])); + } + } + } + for y in 40..60 { + for x in 40..60 { + sheet.put_pixel(x, y, image::Rgba([174, 92, 72, 255])); + } + } + + let mut encoded = std::io::Cursor::new(Vec::new()); + image::DynamicImage::ImageRgba8(sheet) + .write_to(&mut encoded, ImageFormat::Png) + .expect("sheet should encode"); + let image = DownloadedOpenAiImage { + bytes: encoded.into_inner(), + mime_type: "image/png".to_string(), + extension: "png".to_string(), + }; + + let slices = slice_match3d_material_sheet(&image, &item_names).expect("sheet should slice"); + let decoded = image::load_from_memory(slices[0][0].bytes.as_slice()) + .expect("view should decode") + .to_rgba8(); + + assert!( + decoded.width() <= 24 && decoded.height() <= 24, + "单素材裁剪后必须再吃掉浅绿抗锯齿边,不能把素材自带绿边算进输出尺寸;got {}x{}", + decoded.width(), + decoded.height() + ); + assert!( + decoded + .pixels() + .all(|pixel| pixel.0[3] == 0 || pixel.0 != [168, 246, 176, 255]), + "单素材输出 PNG 不能保留浅绿抗锯齿边像素" + ); + assert!( + decoded.pixels().any(|pixel| pixel.0 == [174, 92, 72, 255]), + "单素材二次裁边不能误删物品主体" + ); +} + +#[test] +fn match3d_material_view_edge_matte_removes_green_border_touching_png_edge() { + let width = 72; + let height = 72; + let mut view = image::RgbaImage::from_pixel(width, height, image::Rgba([168, 246, 176, 255])); + for y in 10..62 { + for x in 10..62 { + view.put_pixel(x, y, image::Rgba([0, 0, 0, 0])); + } + } + for y in 24..48 { + for x in 24..48 { + 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.width() <= 28 && cleaned.height() <= 28, + "单图外缘浅绿框即使贴住 PNG 边界,也必须被透明化并从可见边界中移除;got {}x{}", + cleaned.width(), + cleaned.height() + ); + assert!( + cleaned + .pixels() + .all(|pixel| pixel.0[3] == 0 || pixel.0 != [168, 246, 176, 255]), + "单图外缘浅绿框不能残留为可见像素" + ); + assert!( + cleaned.pixels().any(|pixel| pixel.0 == [174, 92, 72, 255]), + "扩大边缘清理宽度不能误删物品主体" + ); +} + +#[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; + let height = 500; + let item_names = vec!["羽毛".to_string()]; + let mut sheet = image::RgbaImage::from_pixel(width, height, image::Rgba([0, 255, 0, 255])); + for y in 32..68 { + for x in 32..68 { + sheet.put_pixel(x, y, image::Rgba([248, 248, 244, 255])); + } + } + for y in 36..64 { + for x in 36..64 { + sheet.put_pixel(x, y, image::Rgba([225, 174, 58, 255])); + } + } + + let mut encoded = std::io::Cursor::new(Vec::new()); + image::DynamicImage::ImageRgba8(sheet) + .write_to(&mut encoded, ImageFormat::Png) + .expect("sheet should encode"); + let image = DownloadedOpenAiImage { + bytes: encoded.into_inner(), + mime_type: "image/png".to_string(), + extension: "png".to_string(), + }; + + let slices = slice_match3d_material_sheet(&image, &item_names).expect("sheet should slice"); + let decoded = image::load_from_memory(slices[0][0].bytes.as_slice()) + .expect("view should decode") + .to_rgba8(); + + assert!( + decoded.pixels().all(|pixel| { + let [red, green, blue, alpha] = pixel.0; + alpha == 0 || !(red >= 238 && green >= 238 && blue >= 232) + }), + "近白抠图边必须被清成透明或去污染,不能在输出 PNG 中形成白边" + ); + assert!( + decoded.pixels().any(|pixel| pixel.0 == [225, 174, 58, 255]), + "白边清理不能误删物品主体" + ); +} + +#[test] +fn match3d_container_image_postprocess_removes_plain_background() { + let width = 256; + let height = 256; + let mut image = image::RgbaImage::from_pixel(width, height, image::Rgba([248, 248, 246, 255])); + for y in 68..190 { + for x in 38..218 { + image.put_pixel(x, y, image::Rgba([160, 104, 54, 255])); + } + } + + let mut encoded = std::io::Cursor::new(Vec::new()); + image::DynamicImage::ImageRgba8(image) + .write_to(&mut encoded, ImageFormat::Png) + .expect("container should encode"); + let processed = make_match3d_container_image_transparent(DownloadedOpenAiImage { + bytes: encoded.into_inner(), + mime_type: "image/png".to_string(), + extension: "png".to_string(), + }) + .expect("container should postprocess"); + let decoded = image::load_from_memory(processed.bytes.as_slice()) + .expect("processed container should decode") + .to_rgba8(); + + assert_eq!(processed.mime_type, "image/png"); + assert_eq!(processed.extension, "png"); + assert_eq!( + decoded.get_pixel(0, 0).0[3], + 0, + "容器图四周白底必须在入库前转成透明 alpha" + ); + assert_eq!( + decoded.get_pixel(width / 2, height / 2).0[3], + 255, + "容器主体不能被透明化误删" + ); +} + +#[test] +fn match3d_background_image_postprocess_removes_transparent_pixels() { + let width = 16; + let height = 16; + let mut image = image::RgbaImage::from_pixel(width, height, image::Rgba([80, 140, 190, 255])); + image.put_pixel(0, 0, image::Rgba([0, 0, 0, 0])); + image.put_pixel(8, 8, image::Rgba([240, 120, 40, 128])); + + let mut encoded = std::io::Cursor::new(Vec::new()); + image::DynamicImage::ImageRgba8(image) + .write_to(&mut encoded, ImageFormat::Png) + .expect("background should encode"); + let processed = make_match3d_background_image_opaque(DownloadedOpenAiImage { + bytes: encoded.into_inner(), + mime_type: "image/png".to_string(), + extension: "png".to_string(), + }) + .expect("background should postprocess"); + let decoded = image::load_from_memory(processed.bytes.as_slice()) + .expect("processed background should decode") + .to_rgba8(); + + assert_eq!(processed.mime_type, "image/png"); + assert_eq!(processed.extension, "png"); + assert!( + decoded.pixels().all(|pixel| pixel.0[3] == 255), + "抓大鹅 9:16 背景图入库前必须移除所有透明 alpha" + ); + assert_ne!( + decoded.get_pixel(0, 0).0, + [0, 0, 0, 0], + "原透明角落必须被合成到不透明背景色上" + ); +} + +#[test] +fn match3d_work_metadata_parses_gpt4o_json() { + let metadata = parse_match3d_work_metadata( + r#"{"gameName":"果园大鹅宴","summary":"在明亮果园里收集水果小物件,节奏轻快适合随手游玩。","tags":["水果","抓大鹅","经典消除","轻量休闲"],"backgroundMusic":{"title":"果园轻舞","style":"轻快, 休闲","prompt":"果园主题循环背景音乐"},"backgroundPrompt":"果园主题绿色果园竖屏纯背景图","items":[{"name":"草莓","soundPrompt":"草莓点击音效"},{"name":"苹果","soundPrompt":"苹果点击音效"},{"name":"香蕉","soundPrompt":"香蕉点击音效"}]}"#, + ) + .expect("metadata should parse"); + + assert_eq!(metadata.game_name, "果园大鹅宴"); + assert_eq!( + metadata.summary, + "在明亮果园里收集水果小物件,节奏轻快适合随手游玩。" + ); + assert_eq!( + metadata.tags, + vec!["水果", "抓大鹅", "经典消除", "轻量休闲", "2D素材", "收集"] + ); +} + +#[test] +fn match3d_work_metadata_fallback_keeps_empty_description_boundary() { + let metadata = fallback_match3d_work_metadata("水果"); + + assert_eq!(metadata.game_name, "水果抓大鹅"); + assert!(metadata.summary.contains("水果主题")); + assert!(metadata.tags.contains(&"水果".to_string())); + assert!(metadata.tags.contains(&"抓大鹅".to_string())); +} + +#[test] +fn match3d_draft_plan_parses_audio_prompts() { + let plan = parse_match3d_draft_plan( + r#"{"gameName":"果园大鹅宴","summary":"明亮果园里堆满水果小物,轻快收集感突出。","tags":["水果","抓大鹅"],"backgroundMusic":{"title":"果园轻舞","style":"轻快, 休闲","prompt":""},"backgroundPrompt":"果园主题抓大鹅竖屏纯背景,绿色渐变和明亮果园氛围","items":[{"name":"草莓","soundPrompt":"草莓点击消除的清脆音效"},{"name":"苹果","soundPrompt":"苹果落入托盘的弹跳音"},{"name":"香蕉","soundPrompt":"香蕉消除时的轻快提示音"}]}"#, + &config("水果", 3, 3), + ) + .expect("draft plan should parse"); + + assert_eq!(plan.metadata.game_name, "果园大鹅宴"); + assert_eq!( + plan.metadata.summary, + "明亮果园里堆满水果小物,轻快收集感突出。" + ); + assert!(plan.background_prompt.contains("纯背景")); + assert_eq!(plan.items[0].name, "草莓"); + assert_eq!(plan.items[0].item_size, MATCH3D_ITEM_SIZE_SMALL); + assert!(plan.items[0].sound_prompt.contains("草莓")); +} + +#[test] +fn match3d_draft_plan_parses_relative_item_sizes() { + let plan = parse_match3d_draft_plan( + r#"{"gameName":"果园大鹅宴","summary":"果园小物堆满浅盘,轻快明亮适合随手消除。","tags":["水果","抓大鹅"],"backgroundPrompt":"果园主题竖屏纯背景","items":[{"name":"西瓜","itemSize":"大","soundPrompt":""},{"name":"苹果","itemSize":"中","soundPrompt":""},{"name":"糖果","itemSize":"小","soundPrompt":""}]}"#, + &config("水果", 3, 3), + ) + .expect("draft plan should parse"); + + assert_eq!(plan.items[0].item_size, MATCH3D_ITEM_SIZE_LARGE); + assert_eq!(plan.items[1].item_size, MATCH3D_ITEM_SIZE_MEDIUM); + assert_eq!(plan.items[2].item_size, MATCH3D_ITEM_SIZE_SMALL); +} + +#[test] +fn match3d_legacy_item_asset_without_size_defaults_to_large() { + let assets = parse_match3d_generated_item_assets(Some( + r#"[{"itemId":"match3d-item-1","itemName":"草莓","status":"image_ready"}]"#, + )); + let asset = Match3DGeneratedItemAsset::from(assets[0].clone()); + + assert_eq!(asset.item_size.as_deref(), Some(MATCH3D_ITEM_SIZE_LARGE)); +} + +#[test] +fn match3d_draft_item_plan_rounds_up_to_full_five_item_sheets() { + let plan = parse_match3d_draft_plan( + r#"{"gameName":"果园大鹅宴","tags":["水果","抓大鹅"],"backgroundMusic":{"title":"果园轻舞","style":"轻快, 休闲","prompt":""},"backgroundPrompt":"果园主题竖屏纯背景","items":[{"name":"草莓","soundPrompt":"草莓点击音效"},{"name":"苹果","soundPrompt":"苹果点击音效"},{"name":"香蕉","soundPrompt":"香蕉点击音效"},{"name":"葡萄","soundPrompt":"葡萄点击音效"},{"name":"西瓜","soundPrompt":"西瓜点击音效"},{"name":"梨子","soundPrompt":"梨子点击音效"},{"name":"桃子","soundPrompt":"桃子点击音效"},{"name":"橙子","soundPrompt":"橙子点击音效"},{"name":"蓝莓","soundPrompt":"蓝莓点击音效"}]}"#, + &config("水果", 12, 4), + ) + .expect("draft plan should parse"); + + assert_eq!(plan.items.len(), 10); + assert_eq!(plan.items[8].name, "蓝莓"); + assert_ne!(plan.items[9].name, "蓝莓"); +} + +#[test] +fn match3d_generated_item_count_rounds_up_to_five_multiples() { + assert_eq!( + resolve_match3d_generated_item_count(&config("水果", 8, 2)), + 5 + ); + assert_eq!( + resolve_match3d_generated_item_count(&config("水果", 12, 4)), + 10 + ); + assert_eq!( + resolve_match3d_generated_item_count(&config("水果", 16, 6)), + 15 + ); + assert_eq!( + resolve_match3d_generated_item_count(&config("水果", 21, 8)), + 25 + ); +} + +#[test] +fn match3d_generated_assets_require_only_images_when_click_sound_is_closed() { + let assets = vec![test_match3d_generated_item_asset(1, "草莓")]; + + assert!(has_match3d_required_generated_assets( + &assets, + 1, + &config("水果", 3, 3) + )); +} + +#[test] +fn match3d_item_asset_points_cost_counts_five_item_batches() { + assert_eq!(calculate_match3d_item_assets_points_cost(0), 0); + assert_eq!(calculate_match3d_item_assets_points_cost(1), 2); + assert_eq!(calculate_match3d_item_assets_points_cost(5), 2); + assert_eq!(calculate_match3d_item_assets_points_cost(6), 4); + assert_eq!(calculate_match3d_item_assets_points_cost(10), 4); +} + +#[test] +fn match3d_item_asset_append_plan_pads_generation_without_persisting_padding() { + let existing_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: None, + image_object_key: None, + 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: None, + status: "image_ready".to_string(), + error: None, + }]; + + let plan = build_match3d_item_asset_append_plan( + vec![ + "草莓".to_string(), + "苹果".to_string(), + "香蕉".to_string(), + "梨子".to_string(), + ], + &existing_assets, + ); + + assert_eq!(plan.requested_item_names, vec!["苹果", "香蕉", "梨子"]); + assert_eq!(plan.padded_item_names.len(), 5); + assert_eq!(&plan.padded_item_names[..3], ["苹果", "香蕉", "梨子"]); + assert_eq!( + calculate_match3d_item_assets_points_cost(plan.requested_item_names.len()), + 2 + ); +} + +#[test] +fn match3d_item_asset_append_plan_still_generates_full_sheet_when_capacity_is_low() { + let existing_assets = (1..MATCH3D_MAX_GENERATED_ITEM_COUNT) + .map(|index| Match3DGeneratedItemAsset { item_id: format!("match3d-item-{index}"), - item_name: name.to_string(), - item_size: Some(infer_match3d_item_size(name)), - image_src: Some(format!( - "/generated-match3d-assets/s/p/items/i{index}/views/view-01.png" - )), - image_object_key: Some(format!( - "generated-match3d-assets/s/p/items/i{index}/views/view-01.png" - )), - image_views: (1..=MATCH3D_ITEM_VIEW_COUNT) - .map(|view_index| Match3DGeneratedItemImageView { - view_id: format!("view-{view_index:02}"), - view_index: view_index as u32, - image_src: Some(format!( - "/generated-match3d-assets/s/p/items/i{index}/views/view-{view_index:02}.png" - )), - image_object_key: Some(format!( - "generated-match3d-assets/s/p/items/i{index}/views/view-{view_index:02}.png" - )), - }) - .collect(), - model_src: Some(format!( - "/generated-match3d-assets/s/p/items/i{index}/model/model.glb" - )), - model_object_key: Some(format!( - "generated-match3d-assets/s/p/items/i{index}/model/model.glb" - )), - model_file_name: Some("model.glb".to_string()), - task_uuid: Some(format!("task-{index}")), - subscription_key: Some(format!("sub-{index}")), - sound_prompt: Some(format!("{name}点击音效")), + item_name: format!("已有物品{index}"), + item_size: Some(MATCH3D_ITEM_SIZE_LARGE.to_string()), + image_src: None, + image_object_key: None, + 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, @@ -43,65 +809,476 @@ use super::*; background_asset: None, status: "image_ready".to_string(), error: None, - } - } + }) + .collect::>(); - fn config(theme_text: &str, clear_count: u32, difficulty: u32) -> Match3DConfigJson { - Match3DConfigJson { - theme_text: theme_text.to_string(), + let plan = build_match3d_item_asset_append_plan(vec!["新物品".to_string()], &existing_assets); + + assert_eq!(plan.requested_item_names, vec!["新物品"]); + assert_eq!( + plan.padded_item_names.len(), + MATCH3D_MATERIAL_ITEM_BATCH_SIZE + ); + assert_eq!(plan.padded_item_names[0], "新物品"); +} + +#[test] +fn match3d_item_asset_replace_plan_only_targets_existing_names() { + let existing_assets = vec![ + test_match3d_generated_item_asset(1, "草莓"), + test_match3d_generated_item_asset(2, "苹果"), + ]; + let plan = build_match3d_item_asset_replace_plan( + vec!["苹果".to_string(), "不存在".to_string(), "苹果".to_string()], + &existing_assets, + ); + + assert_eq!(plan.requested_item_names, vec!["苹果"]); + assert_eq!(plan.target_assets.len(), 1); + assert_eq!(plan.target_assets[0].item_id, "match3d-item-2"); + assert_eq!( + plan.padded_item_names.len(), + MATCH3D_MATERIAL_ITEM_BATCH_SIZE + ); + assert_eq!(plan.padded_item_names[0], "苹果"); +} + +#[test] +fn match3d_item_assets_generation_mode_defaults_to_append() { + assert!(matches!( + normalize_match3d_item_assets_generation_mode(None), + Match3DItemAssetsGenerationMode::Append + )); + assert!(matches!( + normalize_match3d_item_assets_generation_mode(Some("replace")), + Match3DItemAssetsGenerationMode::Replace + )); + assert!(matches!( + normalize_match3d_item_assets_generation_mode(Some("regenerate")), + Match3DItemAssetsGenerationMode::Replace + )); +} + +#[test] +fn match3d_regenerated_asset_keeps_stable_identity_and_side_assets() { + let mut current_asset = test_match3d_generated_item_asset(1, "草莓"); + current_asset.background_music_title = Some("果园轻舞".to_string()); + current_asset.background_asset = Some(Match3DGeneratedBackgroundAsset { + prompt: "果园背景".to_string(), + image_src: Some("/generated-match3d-assets/s/p/background/bg.png".to_string()), + image_object_key: None, + container_prompt: Some("果园容器".to_string()), + container_image_src: Some( + "/generated-match3d-assets/s/p/ui-container/container.png".to_string(), + ), + container_image_object_key: None, + status: "image_ready".to_string(), + error: None, + }); + let mut generated_asset = test_match3d_generated_item_asset(99, "新草莓"); + generated_asset.image_src = + Some("/generated-match3d-assets/s/p/items/new/views/view-01.png".to_string()); + generated_asset.model_src = None; + generated_asset.model_object_key = None; + + let merged = merge_regenerated_match3d_item_asset(Some(current_asset.clone()), generated_asset); + + assert_eq!(merged.item_id, "match3d-item-1"); + assert_eq!(merged.item_name, "草莓"); + assert_eq!( + merged.image_src.as_deref(), + Some("/generated-match3d-assets/s/p/items/new/views/view-01.png") + ); + assert_eq!( + merged.model_src.as_deref(), + current_asset.model_src.as_deref() + ); + assert_eq!(merged.background_music_title.as_deref(), Some("果园轻舞")); + assert!(merged.background_asset.is_some()); + assert_eq!(merged.status, "image_ready"); +} + +#[test] +fn match3d_material_sheet_prompt_requires_uniform_five_by_five_layout() { + let prompt = build_match3d_material_sheet_prompt( + &config("水果", 12, 4), + &["草莓".to_string(), "苹果".to_string(), "香蕉".to_string()], + ); + + assert!(prompt.contains("5行*5列")); + assert!(prompt.contains("严格5*5均匀排布")); + assert!(prompt.contains("绿幕背景")); + assert!(prompt.contains("#00FF00")); + assert!(prompt.contains("单个素材格宽度的1/4空白间距")); + assert!(prompt.contains("约25%单格宽度")); + assert!(prompt.contains("禁止主体跨格")); + assert!(prompt.contains("贴边或越界")); +} + +#[test] +fn match3d_material_sheet_prompt_hardens_pixel_retro_style() { + let mut config = config("水果", 12, 4); + config.asset_style_id = Some("pixel-retro".to_string()); + config.asset_style_label = Some("像素复古".to_string()); + let prompt = build_match3d_material_sheet_prompt(&config, &["草莓".to_string()]); + let negative_prompt = build_match3d_material_sheet_negative_prompt(&config); + + assert!(prompt.contains("64x64")); + assert!(prompt.contains("整数倍放大")); + assert!(prompt.contains("禁止抗锯齿")); + assert!(prompt.contains("真实 3D 渲染")); + assert!(prompt.contains("PBR 材质")); + assert!(negative_prompt.contains("抗锯齿")); + assert!(negative_prompt.contains("平滑插画")); + assert!(negative_prompt.contains("真实 3D 渲染")); +} + +#[test] +fn match3d_material_sheet_request_uses_vector_engine_gemini_contract() { + let body = build_match3d_vector_engine_gemini_image_request_body( + "生成水果素材图", + "文字、水印", + MATCH3D_MATERIAL_VECTOR_ENGINE_GEMINI_ASPECT_RATIO, + ); + + assert_eq!(body["generationConfig"]["responseModalities"][0], "TEXT"); + assert_eq!(body["generationConfig"]["responseModalities"][1], "IMAGE"); + assert_eq!( + body["generationConfig"]["imageConfig"]["aspectRatio"], + MATCH3D_MATERIAL_VECTOR_ENGINE_GEMINI_ASPECT_RATIO + ); + assert!(body.get("model").is_none()); + assert!(body.get("n").is_none()); + assert!(body.get("official_fallback").is_none()); + assert!(body.get("image").is_none()); + assert!(body.get("image_urls").is_none()); + assert!( + body["contents"][0]["parts"][0]["text"] + .as_str() + .unwrap_or_default() + .contains("文字、水印") + ); +} + +#[test] +fn match3d_extracts_vector_engine_gemini_inline_image_data() { + let payload = json!({ + "candidates": [{ + "content": { + "parts": [ + { "text": "已生成" }, + { + "inlineData": { + "mimeType": "image/png", + "data": "iVBORw0KGgo=" + } + }, + { + "inline_data": { + "mime_type": "image/webp", + "data": "UklGRg==" + } + }, + { + "inlineData": { + "mimeType": "text/plain", + "data": "not-image-data" + } + }, + { + "data": "not-inline-image-data" + } + ] + } + }] + }); + + assert_eq!( + extract_match3d_b64_images(&payload), + vec!["iVBORw0KGgo=", "UklGRg=="] + ); +} + +#[test] +fn match3d_vector_engine_gemini_url_accepts_root_or_v1_base() { + let root_settings = Match3DVectorEngineGeminiImageSettings { + base_url: "https://api.vectorengine.cn".to_string(), + api_key: "test-key".to_string(), + request_timeout_ms: 1_000_000, + }; + let v1_settings = Match3DVectorEngineGeminiImageSettings { + base_url: "https://api.vectorengine.cn/v1".to_string(), + api_key: "test-key".to_string(), + request_timeout_ms: 1_000_000, + }; + + assert_eq!( + build_match3d_vector_engine_gemini_generate_content_url(&root_settings), + "https://api.vectorengine.cn/v1beta/models/gemini-3-pro-image-preview:generateContent" + ); + assert_eq!( + build_match3d_vector_engine_gemini_generate_content_url(&v1_settings), + "https://api.vectorengine.cn/v1beta/models/gemini-3-pro-image-preview:generateContent" + ); +} + +#[test] +fn match3d_background_and_container_prompts_keep_ui_layers_split() { + let config = config("水果", 3, 3); + let background_prompt = + build_match3d_background_generation_prompt(&config, "果园绿色竖屏纯背景"); + let container_prompt = build_match3d_container_generation_prompt(&config, "果园绿色竖屏纯背景"); + + assert!(background_prompt.contains("9:16")); + assert!(background_prompt.contains("纯背景图")); + assert!(background_prompt.contains("不得出现锅")); + assert!(background_prompt.contains("拼图槽")); + assert!(background_prompt.contains("物品槽")); + assert!(background_prompt.contains("全画幅不透明")); + assert!(background_prompt.contains("透明 alpha")); + assert!(background_prompt.contains("默认交互容器")); + + assert!(container_prompt.contains("1:1")); + assert!(container_prompt.contains("中心容器 UI 图")); + assert!(container_prompt.contains("贴合题材设定")); + assert!(container_prompt.contains("占画布宽度约 86%-92%")); + assert!(container_prompt.contains("轻俯视 3/4")); + assert!(container_prompt.contains("横向椭圆形内口")); + assert!(container_prompt.contains("不能画成正俯视扁圆盘")); + assert!(container_prompt.contains("透明 alpha")); + assert!(container_prompt.contains("白底")); + assert!(container_prompt.contains("整页背景")); + assert!(container_prompt.contains("禁止文字")); +} + +#[test] +fn match3d_background_asset_requires_background_and_container_images() { + let background_only = Match3DGeneratedBackgroundAsset { + prompt: "果园背景".to_string(), + image_src: Some("/generated-match3d-assets/session/profile/background/bg.png".to_string()), + image_object_key: None, + container_prompt: None, + container_image_src: None, + container_image_object_key: None, + status: "image_ready".to_string(), + error: None, + }; + let with_container = Match3DGeneratedBackgroundAsset { + container_prompt: Some("果园容器".to_string()), + container_image_src: Some( + "/generated-match3d-assets/session/profile/ui-container/container.png".to_string(), + ), + ..background_only.clone() + }; + + assert!(!is_match3d_background_asset_ready(&background_only)); + assert!(is_match3d_background_asset_ready(&with_container)); +} + +#[test] +fn match3d_default_cover_prefers_generated_container_ui_image() { + 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: None, + image_object_key: None, + 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: None, + container_prompt: Some("果园容器".to_string()), + container_image_src: Some( + "/generated-match3d-assets/session/profile/ui-container/container.png".to_string(), + ), + container_image_object_key: None, + status: "image_ready".to_string(), + error: None, + }), + status: "image_ready".to_string(), + error: None, + }]; + + assert_eq!( + resolve_match3d_default_cover_image_src(&assets).as_deref(), + Some("/generated-match3d-assets/session/profile/ui-container/container.png") + ); +} + +#[test] +fn match3d_cover_reference_sources_are_deduped_and_limited() { + let sources = collect_match3d_cover_reference_image_sources( + Some("/generated-match3d-assets/a.png".to_string()), + vec![ + "/generated-match3d-assets/a.png".to_string(), + "data:image/png;base64,b".to_string(), + "/generated-match3d-assets/c.png".to_string(), + "/generated-match3d-assets/d.png".to_string(), + "/generated-match3d-assets/e.png".to_string(), + "/generated-match3d-assets/f.png".to_string(), + "/generated-match3d-assets/g.png".to_string(), + ], + ); + + assert_eq!(sources.len(), 6); + assert_eq!(sources[0], "/generated-match3d-assets/a.png"); + assert_eq!(sources[1], "data:image/png;base64,b"); + assert!(!sources.contains(&"/generated-match3d-assets/g.png".to_string())); +} + +#[test] +fn match3d_public_reference_image_paths_are_limited_to_known_assets() { + assert_eq!( + normalize_match3d_public_reference_image_path( + "/match3d-background-references/pot-fused-reference.png?cache=1" + ) + .as_deref(), + Some("public/match3d-background-references/pot-fused-reference.png") + ); + assert!(normalize_match3d_public_reference_image_path("/icons/logo.png").is_none()); + assert!( + normalize_match3d_public_reference_image_path( + "/match3d-background-references/../secret.png" + ) + .is_none() + ); +} + +#[test] +fn match3d_cover_reference_prompt_marks_reference_images() { + let prompt = build_match3d_cover_reference_generation_prompt("水果封面", true); + + assert!(prompt.contains("一张或多张图片")); + assert!(prompt.contains("不要拼贴成素材墙")); + assert!(prompt.contains("水果封面")); +} + +#[test] +fn match3d_cover_edit_prompt_preserves_uploaded_image() { + let prompt = build_match3d_cover_edit_prompt("水果封面"); + + assert!(prompt.contains("上传的封面图作为第一优先级")); + assert!(prompt.contains("保留主图的主体、构图、视角和主要配色")); +} + +#[test] +fn match3d_fallback_work_profile_keeps_generated_background_asset() { + 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: None, + image_object_key: None, + 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 profile = build_match3d_work_profile_record_with_assets( + 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, - difficulty, - asset_style_id: None, - asset_style_label: None, - asset_style_prompt: None, - generate_click_sound: false, - } - } + clear_count: 3, + difficulty: 3, + publication_status: "draft".to_string(), + play_count: 0, + updated_at: "2026-05-14T00:00:00Z".to_string(), + published_at: None, + publish_ready: false, + generated_item_assets_json: None, + }, + &assets, + ); + let response = map_match3d_work_summary_response(profile); - #[test] - fn match3d_agent_reply_asks_three_questions_before_confirmation() { - let current = config("水果", 4, 6); + assert_eq!( + response.background_image_src.as_deref(), + Some("/generated-match3d-assets/session/profile/background/background.png") + ); + assert_eq!( + response.cover_image_src.as_deref(), + Some("/generated-match3d-assets/session/profile/ui-container/container.png") + ); + assert_eq!(response.generated_item_assets.len(), 1); + assert_eq!( + response + .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!( - build_match3d_assistant_reply_for_turn(¤t, 0), - MATCH3D_QUESTION_THEME - ); - assert_eq!( - build_match3d_assistant_reply_for_turn(¤t, 1), - MATCH3D_QUESTION_CLEAR_COUNT - ); - assert_eq!( - build_match3d_assistant_reply_for_turn(¤t, 2), - MATCH3D_QUESTION_DIFFICULTY - ); - assert_eq!( - build_match3d_assistant_reply_for_turn(¤t, 3), - "已确认:水果题材,需要消除 4 次,共 12 件物品,难度 6。" - ); - } - - #[test] - fn match3d_agent_progress_follows_question_turns() { - assert_eq!(resolve_progress_percent_for_turn(0), 0); - assert_eq!(resolve_progress_percent_for_turn(1), 33); - assert_eq!(resolve_progress_percent_for_turn(2), 66); - assert_eq!(resolve_progress_percent_for_turn(3), 100); - assert_eq!(resolve_progress_percent_for_turn(8), 100); - } - - #[test] - fn match3d_anchor_pack_masks_uncollected_default_values() { - let pack = Match3DAnchorPackRecord { +#[test] +fn match3d_agent_session_response_hydrates_persisted_ui_assets() { + 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(), + value: "水果".to_string(), status: "confirmed".to_string(), }, clear_count: Match3DAnchorItemRecord { key: "clearCount".to_string(), - label: "需要消除次数".to_string(), + label: "消除次数".to_string(), value: "12".to_string(), status: "confirmed".to_string(), }, @@ -111,648 +1288,382 @@ use super::*; 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: None, + }), + messages: Vec::new(), + last_assistant_reply: None, + published_profile_id: None, + updated_at: "2026-05-15T00:00:00.000Z".to_string(), + }; + 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 response = map_match3d_anchor_pack_response_for_turn(pack, 0, "Collecting"); + let response = map_match3d_agent_session_response_with_assets(session, &assets); + let draft = response.draft.expect("session draft should exist"); - assert_eq!(response.theme.value, ""); - assert_eq!(response.theme.status, "missing"); - assert_eq!(response.clear_count.value, ""); - assert_eq!(response.clear_count.status, "missing"); - assert_eq!(response.difficulty.value, ""); - assert_eq!(response.difficulty.status, "missing"); - } + assert_eq!(draft.generated_item_assets.len(), 1); + assert_eq!(draft.background_prompt.as_deref(), Some("果园背景")); + assert_eq!( + draft.background_image_src.as_deref(), + Some("/generated-match3d-assets/session/profile/background/background.png") + ); + assert_eq!( + draft.cover_image_src.as_deref(), + Some("/generated-match3d-assets/session/profile/ui-container/container.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_item_image_path_segments_stay_unique_for_chinese_names() { - let item_names = ["草莓", "苹果", "香蕉"]; - let slugs = item_names - .iter() - .enumerate() - .map(|(index, item_name)| { - let item_id = format!("match3d-item-{}", index + 1); - format!( - "{item_id}-{}", - sanitize_match3d_asset_segment(item_name, "item") - ) - }) - .collect::>(); +#[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(), + }; - assert_eq!( - slugs, - vec![ - "match3d-item-1-item", - "match3d-item-2-item", - "match3d-item-3-item", - ] - ); - } + let response = map_match3d_agent_session_response_with_assets(session, &[]); + let draft = response.draft.expect("session draft should exist"); - #[test] - fn match3d_material_sheet_slicing_uses_fixed_five_by_five_rows() { - let width = 500; - let height = 500; - let item_names = vec!["樱桃".to_string(), "苹果".to_string(), "香蕉".to_string()]; - let mut sheet = image::RgbaImage::new(width, height); - for row in 0..5 { - for col in 0..5 { - let color = image::Rgba([ - 32 + row as u8 * 40, - 24 + col as u8 * 36, - 210 - row as u8 * 30, - 255, - ]); - for y in row * 100..(row + 1) * 100 { - for x in col * 100..(col + 1) * 100 { - sheet.put_pixel(x, y, color); - } - } - } - } - let mut encoded = std::io::Cursor::new(Vec::new()); - image::DynamicImage::ImageRgba8(sheet) - .write_to(&mut encoded, ImageFormat::Png) - .expect("sheet should encode"); - let image = DownloadedOpenAiImage { - bytes: encoded.into_inner(), - mime_type: "image/png".to_string(), - extension: "png".to_string(), - }; + 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") + ); +} - let slices = slice_match3d_material_sheet(&image, &item_names).expect("sheet should slice"); +#[test] +fn match3d_tag_normalization_only_strips_numbered_list_prefix() { + assert_eq!(normalize_match3d_tag("3D素材"), "3D素材"); + assert_eq!(normalize_match3d_tag("1. 3D素材"), "3D素材"); + assert_eq!(normalize_match3d_tag("2、轻量休闲"), "轻量休闲"); +} - assert_eq!(slices.len(), 3); - for (row, views) in slices.iter().enumerate() { - assert_eq!(views.len(), MATCH3D_ITEM_VIEW_COUNT); - for (col, view) in views.iter().enumerate() { - let decoded = image::load_from_memory(view.bytes.as_slice()) - .expect("view should decode") - .to_rgba8(); - let pixel = decoded.get_pixel(decoded.width() / 2, decoded.height() / 2); - assert_eq!( - pixel.0, - [ - 32 + row as u8 * 40, - 24 + col as u8 * 36, - 210 - row as u8 * 30, - 255, - ], - "row {row} col {col} should be cut from the fixed 5*5 grid row" - ); - } - } - } +#[test] +fn match3d_plan_tags_are_kept_before_local_fallback_tags() { + let tags = merge_match3d_plan_tags_with_fallback( + "果园大鹅宴", + "水果", + &["果园".to_string(), "轻快".to_string(), "抓大鹅".to_string()], + ); - #[test] - fn match3d_material_sheet_slicing_keeps_near_edge_foreground_pixels() { - let width = 500; - let height = 500; - let item_names = vec!["樱桃".to_string(), "苹果".to_string(), "香蕉".to_string()]; - let mut sheet = - image::RgbaImage::from_pixel(width, height, image::Rgba([255, 255, 255, 255])); - for y in 1..5 { - for x in 18..82 { - sheet.put_pixel(x, y, image::Rgba([20, 80, 240, 255])); - } - } - for y in 5..96 { - for x in 18..82 { - sheet.put_pixel(x, y, image::Rgba([220, 32, 48, 255])); - } - } - for y in 96..99 { - for x in 18..82 { - sheet.put_pixel(x, y, image::Rgba([20, 180, 64, 255])); - } - } - let mut encoded = std::io::Cursor::new(Vec::new()); - image::DynamicImage::ImageRgba8(sheet) - .write_to(&mut encoded, ImageFormat::Png) - .expect("sheet should encode"); - let image = DownloadedOpenAiImage { - bytes: encoded.into_inner(), - mime_type: "image/png".to_string(), - extension: "png".to_string(), - }; + assert_eq!(tags[0], "果园"); + assert_eq!(tags[1], "轻快"); + assert_eq!(tags[2], "抓大鹅"); + assert!(tags.contains(&"水果".to_string())); + assert!(tags.contains(&"经典消除".to_string())); +} - let slices = slice_match3d_material_sheet(&image, &item_names).expect("sheet should slice"); - let decoded = image::load_from_memory(slices[0][0].bytes.as_slice()) - .expect("view should decode") - .to_rgba8(); +#[test] +fn match3d_model_download_metadata_normalizes_to_glb() { + assert_eq!( + normalize_match3d_model_file_name("https://example.com/Fruit Model.GLB?token=1"), + "fruit-model.glb" + ); + assert_eq!(normalize_match3d_model_file_name("模型文件"), "model.glb"); + assert_eq!( + normalize_match3d_model_content_type("application/octet-stream"), + "model/gltf-binary" + ); + assert_eq!( + normalize_match3d_model_content_type("model/gltf-binary; charset=utf-8"), + "model/gltf-binary" + ); +} - let pixels = decoded.pixels().map(|pixel| pixel.0).collect::>(); - assert!( - pixels.iter().any(|pixel| *pixel == [20, 80, 240, 255]), - "贴近顶部的前景像素不能被固定内缩切掉" - ); - assert!( - pixels.iter().any(|pixel| *pixel == [20, 180, 64, 255]), - "贴近底部的前景像素不能被固定内缩切掉" - ); - } +#[test] +fn match3d_model_download_requires_valid_glb_header() { + let mut glb = Vec::new(); + glb.extend_from_slice(&0x4654_6c67_u32.to_le_bytes()); + glb.extend_from_slice(&2_u32.to_le_bytes()); + glb.extend_from_slice(&12_u32.to_le_bytes()); - #[test] - fn match3d_material_sheet_slicing_makes_green_screen_transparent_before_crop() { - let width = 500; - let height = 500; - let item_names = vec!["草莓".to_string()]; - let mut sheet = image::RgbaImage::from_pixel(width, height, image::Rgba([0, 255, 0, 255])); - for y in 35..65 { - for x in 35..65 { - sheet.put_pixel(x, y, image::Rgba([220, 32, 48, 255])); - } - } + assert!(is_match3d_glb_binary_payload(&glb)); + assert!(!is_match3d_glb_binary_payload(b"expired")); - let mut encoded = std::io::Cursor::new(Vec::new()); - image::DynamicImage::ImageRgba8(sheet) - .write_to(&mut encoded, ImageFormat::Png) - .expect("sheet should encode"); - let image = DownloadedOpenAiImage { - bytes: encoded.into_inner(), - mime_type: "image/png".to_string(), - extension: "png".to_string(), - }; + let mut wrong_length = glb.clone(); + wrong_length[8..12].copy_from_slice(&16_u32.to_le_bytes()); + assert!(!is_match3d_glb_binary_payload(&wrong_length)); +} - let slices = slice_match3d_material_sheet(&image, &item_names).expect("sheet should slice"); - let decoded = image::load_from_memory(slices[0][0].bytes.as_slice()) - .expect("view should decode") - .to_rgba8(); - - assert!( - decoded.pixels().all(|pixel| { - let [red, green, blue, alpha] = pixel.0; - alpha == 0 || !(green > red.saturating_add(32) && green > blue.saturating_add(32)) - }), - "绿幕背景必须在切割输出中变成透明或被单素材二次裁边移除" - ); - assert!( - decoded.pixels().any(|pixel| pixel.0 == [220, 32, 48, 255]), - "物品主体不能被绿幕去背误删" - ); - } - - #[test] - fn match3d_material_sheet_slicing_removes_isolated_green_cell_background() { - let width = 500; - let height = 500; - let item_names = vec!["葡萄".to_string()]; - let mut sheet = - image::RgbaImage::from_pixel(width, height, image::Rgba([245, 245, 245, 255])); - for y in 8..92 { - for x in 8..92 { - sheet.put_pixel(x, y, image::Rgba([0, 236, 18, 255])); - } - } - for y in 35..65 { - for x in 35..65 { - sheet.put_pixel(x, y, image::Rgba([136, 64, 210, 255])); - } - } - - let mut encoded = std::io::Cursor::new(Vec::new()); - image::DynamicImage::ImageRgba8(sheet) - .write_to(&mut encoded, ImageFormat::Png) - .expect("sheet should encode"); - let image = DownloadedOpenAiImage { - bytes: encoded.into_inner(), - mime_type: "image/png".to_string(), - extension: "png".to_string(), - }; - - let slices = slice_match3d_material_sheet(&image, &item_names).expect("sheet should slice"); - let decoded = image::load_from_memory(slices[0][0].bytes.as_slice()) - .expect("view should decode") - .to_rgba8(); - - assert!( - decoded - .pixels() - .all(|pixel| pixel.0[3] == 0 || pixel.0[1] < 180), - "没有连到整张 sheet 外边缘的绿幕块也必须被转成透明" - ); - assert!( - decoded.pixels().any(|pixel| pixel.0 == [136, 64, 210, 255]), - "绿幕清理不能误删物品主体" - ); - } - - #[test] - fn match3d_material_sheet_slicing_removes_soft_green_matte_before_crop() { - let width = 500; - let height = 500; - let item_names = vec!["草莓".to_string()]; - let mut sheet = image::RgbaImage::from_pixel(width, height, image::Rgba([0, 255, 0, 255])); - for y in 28..72 { - for x in 28..72 { - sheet.put_pixel(x, y, image::Rgba([64, 198, 112, 255])); - } - } - for y in 36..64 { - for x in 36..64 { - sheet.put_pixel(x, y, image::Rgba([220, 32, 48, 255])); - } - } - - let mut encoded = std::io::Cursor::new(Vec::new()); - image::DynamicImage::ImageRgba8(sheet) - .write_to(&mut encoded, ImageFormat::Png) - .expect("sheet should encode"); - let image = DownloadedOpenAiImage { - bytes: encoded.into_inner(), - mime_type: "image/png".to_string(), - extension: "png".to_string(), - }; - - let slices = slice_match3d_material_sheet(&image, &item_names).expect("sheet should slice"); - let decoded = image::load_from_memory(slices[0][0].bytes.as_slice()) - .expect("view should decode") - .to_rgba8(); - - assert!( - decoded.pixels().all(|pixel| { - let [red, green, blue, alpha] = pixel.0; - alpha == 0 || green <= red.max(blue).saturating_add(32) - }), - "整张 sheet 去绿后再裁剪,输出 PNG 不能保留可见软绿边" - ); - assert!( - decoded.pixels().any(|pixel| pixel.0 == [220, 32, 48, 255]), - "软绿边清理不能误删物品主体" - ); - } - - #[test] - fn match3d_material_sheet_slicing_crops_single_view_green_antialias_border() { - let width = 500; - let height = 500; - let item_names = vec!["丸子".to_string()]; - let mut sheet = image::RgbaImage::from_pixel(width, height, image::Rgba([0, 255, 0, 255])); - for y in 22..78 { - for x in 22..78 { - if x <= 24 || x >= 75 || y <= 24 || y >= 75 { - sheet.put_pixel(x, y, image::Rgba([168, 246, 176, 255])); - } - } - } - for y in 40..60 { - for x in 40..60 { - sheet.put_pixel(x, y, image::Rgba([174, 92, 72, 255])); - } - } - - let mut encoded = std::io::Cursor::new(Vec::new()); - image::DynamicImage::ImageRgba8(sheet) - .write_to(&mut encoded, ImageFormat::Png) - .expect("sheet should encode"); - let image = DownloadedOpenAiImage { - bytes: encoded.into_inner(), - mime_type: "image/png".to_string(), - extension: "png".to_string(), - }; - - let slices = slice_match3d_material_sheet(&image, &item_names).expect("sheet should slice"); - let decoded = image::load_from_memory(slices[0][0].bytes.as_slice()) - .expect("view should decode") - .to_rgba8(); - - assert!( - decoded.width() <= 24 && decoded.height() <= 24, - "单素材裁剪后必须再吃掉浅绿抗锯齿边,不能把素材自带绿边算进输出尺寸;got {}x{}", - decoded.width(), - decoded.height() - ); - assert!( - decoded - .pixels() - .all(|pixel| pixel.0[3] == 0 || pixel.0 != [168, 246, 176, 255]), - "单素材输出 PNG 不能保留浅绿抗锯齿边像素" - ); - assert!( - decoded.pixels().any(|pixel| pixel.0 == [174, 92, 72, 255]), - "单素材二次裁边不能误删物品主体" - ); - } - - #[test] - fn match3d_material_view_edge_matte_removes_green_border_touching_png_edge() { - let width = 72; - let height = 72; - let mut view = - image::RgbaImage::from_pixel(width, height, image::Rgba([168, 246, 176, 255])); - for y in 10..62 { - for x in 10..62 { - view.put_pixel(x, y, image::Rgba([0, 0, 0, 0])); - } - } - for y in 24..48 { - for x in 24..48 { - 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.width() <= 28 && cleaned.height() <= 28, - "单图外缘浅绿框即使贴住 PNG 边界,也必须被透明化并从可见边界中移除;got {}x{}", - cleaned.width(), - cleaned.height() - ); - assert!( - cleaned - .pixels() - .all(|pixel| pixel.0[3] == 0 || pixel.0 != [168, 246, 176, 255]), - "单图外缘浅绿框不能残留为可见像素" - ); - assert!( - cleaned.pixels().any(|pixel| pixel.0 == [174, 92, 72, 255]), - "扩大边缘清理宽度不能误删物品主体" - ); - } - - #[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; - let height = 500; - let item_names = vec!["羽毛".to_string()]; - let mut sheet = image::RgbaImage::from_pixel(width, height, image::Rgba([0, 255, 0, 255])); - for y in 32..68 { - for x in 32..68 { - sheet.put_pixel(x, y, image::Rgba([248, 248, 244, 255])); - } - } - for y in 36..64 { - for x in 36..64 { - sheet.put_pixel(x, y, image::Rgba([225, 174, 58, 255])); - } - } - - let mut encoded = std::io::Cursor::new(Vec::new()); - image::DynamicImage::ImageRgba8(sheet) - .write_to(&mut encoded, ImageFormat::Png) - .expect("sheet should encode"); - let image = DownloadedOpenAiImage { - bytes: encoded.into_inner(), - mime_type: "image/png".to_string(), - extension: "png".to_string(), - }; - - let slices = slice_match3d_material_sheet(&image, &item_names).expect("sheet should slice"); - let decoded = image::load_from_memory(slices[0][0].bytes.as_slice()) - .expect("view should decode") - .to_rgba8(); - - assert!( - decoded.pixels().all(|pixel| { - let [red, green, blue, alpha] = pixel.0; - alpha == 0 || !(red >= 238 && green >= 238 && blue >= 232) - }), - "近白抠图边必须被清成透明或去污染,不能在输出 PNG 中形成白边" - ); - assert!( - decoded.pixels().any(|pixel| pixel.0 == [225, 174, 58, 255]), - "白边清理不能误删物品主体" - ); - } - - #[test] - fn match3d_container_image_postprocess_removes_plain_background() { - let width = 256; - let height = 256; - let mut image = - image::RgbaImage::from_pixel(width, height, image::Rgba([248, 248, 246, 255])); - for y in 68..190 { - for x in 38..218 { - image.put_pixel(x, y, image::Rgba([160, 104, 54, 255])); - } - } - - let mut encoded = std::io::Cursor::new(Vec::new()); - image::DynamicImage::ImageRgba8(image) - .write_to(&mut encoded, ImageFormat::Png) - .expect("container should encode"); - let processed = make_match3d_container_image_transparent(DownloadedOpenAiImage { - bytes: encoded.into_inner(), - mime_type: "image/png".to_string(), - extension: "png".to_string(), - }) - .expect("container should postprocess"); - let decoded = image::load_from_memory(processed.bytes.as_slice()) - .expect("processed container should decode") - .to_rgba8(); - - assert_eq!(processed.mime_type, "image/png"); - assert_eq!(processed.extension, "png"); - assert_eq!( - decoded.get_pixel(0, 0).0[3], - 0, - "容器图四周白底必须在入库前转成透明 alpha" - ); - assert_eq!( - decoded.get_pixel(width / 2, height / 2).0[3], - 255, - "容器主体不能被透明化误删" - ); - } - - #[test] - fn match3d_background_image_postprocess_removes_transparent_pixels() { - let width = 16; - let height = 16; - let mut image = - image::RgbaImage::from_pixel(width, height, image::Rgba([80, 140, 190, 255])); - image.put_pixel(0, 0, image::Rgba([0, 0, 0, 0])); - image.put_pixel(8, 8, image::Rgba([240, 120, 40, 128])); - - let mut encoded = std::io::Cursor::new(Vec::new()); - image::DynamicImage::ImageRgba8(image) - .write_to(&mut encoded, ImageFormat::Png) - .expect("background should encode"); - let processed = make_match3d_background_image_opaque(DownloadedOpenAiImage { - bytes: encoded.into_inner(), - mime_type: "image/png".to_string(), - extension: "png".to_string(), - }) - .expect("background should postprocess"); - let decoded = image::load_from_memory(processed.bytes.as_slice()) - .expect("processed background should decode") - .to_rgba8(); - - assert_eq!(processed.mime_type, "image/png"); - assert_eq!(processed.extension, "png"); - assert!( - decoded.pixels().all(|pixel| pixel.0[3] == 255), - "抓大鹅 9:16 背景图入库前必须移除所有透明 alpha" - ); - assert_ne!( - decoded.get_pixel(0, 0).0, - [0, 0, 0, 0], - "原透明角落必须被合成到不透明背景色上" - ); - } - - #[test] - fn match3d_work_metadata_parses_gpt4o_json() { - let metadata = parse_match3d_work_metadata( - r#"{"gameName":"果园大鹅宴","summary":"在明亮果园里收集水果小物件,节奏轻快适合随手游玩。","tags":["水果","抓大鹅","经典消除","轻量休闲"],"backgroundMusic":{"title":"果园轻舞","style":"轻快, 休闲","prompt":"果园主题循环背景音乐"},"backgroundPrompt":"果园主题绿色果园竖屏纯背景图","items":[{"name":"草莓","soundPrompt":"草莓点击音效"},{"name":"苹果","soundPrompt":"苹果点击音效"},{"name":"香蕉","soundPrompt":"香蕉点击音效"}]}"#, - ) - .expect("metadata should parse"); - - assert_eq!(metadata.game_name, "果园大鹅宴"); - assert_eq!( - metadata.summary, - "在明亮果园里收集水果小物件,节奏轻快适合随手游玩。" - ); - assert_eq!( - metadata.tags, - vec!["水果", "抓大鹅", "经典消除", "轻量休闲", "2D素材", "收集"] - ); - } - - #[test] - fn match3d_work_metadata_fallback_keeps_empty_description_boundary() { - let metadata = fallback_match3d_work_metadata("水果"); - - assert_eq!(metadata.game_name, "水果抓大鹅"); - assert!(metadata.summary.contains("水果主题")); - assert!(metadata.tags.contains(&"水果".to_string())); - assert!(metadata.tags.contains(&"抓大鹅".to_string())); - } - - #[test] - fn match3d_draft_plan_parses_audio_prompts() { - let plan = parse_match3d_draft_plan( - r#"{"gameName":"果园大鹅宴","summary":"明亮果园里堆满水果小物,轻快收集感突出。","tags":["水果","抓大鹅"],"backgroundMusic":{"title":"果园轻舞","style":"轻快, 休闲","prompt":""},"backgroundPrompt":"果园主题抓大鹅竖屏纯背景,绿色渐变和明亮果园氛围","items":[{"name":"草莓","soundPrompt":"草莓点击消除的清脆音效"},{"name":"苹果","soundPrompt":"苹果落入托盘的弹跳音"},{"name":"香蕉","soundPrompt":"香蕉消除时的轻快提示音"}]}"#, - &config("水果", 3, 3), - ) - .expect("draft plan should parse"); - - assert_eq!(plan.metadata.game_name, "果园大鹅宴"); - assert_eq!( - plan.metadata.summary, - "明亮果园里堆满水果小物,轻快收集感突出。" - ); - assert!(plan.background_prompt.contains("纯背景")); - assert_eq!(plan.items[0].name, "草莓"); - assert_eq!(plan.items[0].item_size, MATCH3D_ITEM_SIZE_SMALL); - assert!(plan.items[0].sound_prompt.contains("草莓")); - } - - #[test] - fn match3d_draft_plan_parses_relative_item_sizes() { - let plan = parse_match3d_draft_plan( - r#"{"gameName":"果园大鹅宴","summary":"果园小物堆满浅盘,轻快明亮适合随手消除。","tags":["水果","抓大鹅"],"backgroundPrompt":"果园主题竖屏纯背景","items":[{"name":"西瓜","itemSize":"大","soundPrompt":""},{"name":"苹果","itemSize":"中","soundPrompt":""},{"name":"糖果","itemSize":"小","soundPrompt":""}]}"#, - &config("水果", 3, 3), - ) - .expect("draft plan should parse"); - - assert_eq!(plan.items[0].item_size, MATCH3D_ITEM_SIZE_LARGE); - assert_eq!(plan.items[1].item_size, MATCH3D_ITEM_SIZE_MEDIUM); - assert_eq!(plan.items[2].item_size, MATCH3D_ITEM_SIZE_SMALL); - } - - #[test] - fn match3d_legacy_item_asset_without_size_defaults_to_large() { - let assets = parse_match3d_generated_item_assets(Some( - r#"[{"itemId":"match3d-item-1","itemName":"草莓","status":"image_ready"}]"#, - )); - let asset = Match3DGeneratedItemAsset::from(assets[0].clone()); - - assert_eq!(asset.item_size.as_deref(), Some(MATCH3D_ITEM_SIZE_LARGE)); - } - - #[test] - fn match3d_draft_item_plan_rounds_up_to_full_five_item_sheets() { - let plan = parse_match3d_draft_plan( - r#"{"gameName":"果园大鹅宴","tags":["水果","抓大鹅"],"backgroundMusic":{"title":"果园轻舞","style":"轻快, 休闲","prompt":""},"backgroundPrompt":"果园主题竖屏纯背景","items":[{"name":"草莓","soundPrompt":"草莓点击音效"},{"name":"苹果","soundPrompt":"苹果点击音效"},{"name":"香蕉","soundPrompt":"香蕉点击音效"},{"name":"葡萄","soundPrompt":"葡萄点击音效"},{"name":"西瓜","soundPrompt":"西瓜点击音效"},{"name":"梨子","soundPrompt":"梨子点击音效"},{"name":"桃子","soundPrompt":"桃子点击音效"},{"name":"橙子","soundPrompt":"橙子点击音效"},{"name":"蓝莓","soundPrompt":"蓝莓点击音效"}]}"#, - &config("水果", 12, 4), - ) - .expect("draft plan should parse"); - - assert_eq!(plan.items.len(), 10); - assert_eq!(plan.items[8].name, "蓝莓"); - assert_ne!(plan.items[9].name, "蓝莓"); - } - - #[test] - fn match3d_generated_item_count_rounds_up_to_five_multiples() { - assert_eq!( - resolve_match3d_generated_item_count(&config("水果", 8, 2)), - 5 - ); - assert_eq!( - resolve_match3d_generated_item_count(&config("水果", 12, 4)), - 10 - ); - assert_eq!( - resolve_match3d_generated_item_count(&config("水果", 16, 6)), - 15 - ); - assert_eq!( - resolve_match3d_generated_item_count(&config("水果", 21, 8)), - 25 - ); - } - - #[test] - fn match3d_generated_assets_require_only_images_when_click_sound_is_closed() { - let assets = vec![test_match3d_generated_item_asset(1, "草莓")]; - - assert!(has_match3d_required_generated_assets( - &assets, - 1, - &config("水果", 3, 3) - )); - } - - #[test] - fn match3d_item_asset_points_cost_counts_five_item_batches() { - assert_eq!(calculate_match3d_item_assets_points_cost(0), 0); - assert_eq!(calculate_match3d_item_assets_points_cost(1), 2); - assert_eq!(calculate_match3d_item_assets_points_cost(5), 2); - assert_eq!(calculate_match3d_item_assets_points_cost(6), 4); - assert_eq!(calculate_match3d_item_assets_points_cost(10), 4); - } - - #[test] - fn match3d_item_asset_append_plan_pads_generation_without_persisting_padding() { - let existing_assets = vec![Match3DGeneratedItemAsset { +#[test] +fn match3d_generated_asset_resume_keeps_stable_item_order() { + let assets = normalize_match3d_generated_item_assets_for_resume(vec![ + Match3DGeneratedItemAsset { + item_id: "match3d-item-2".to_string(), + item_name: "苹果".to_string(), + item_size: Some(MATCH3D_ITEM_SIZE_MEDIUM.to_string()), + image_src: Some("/generated-match3d-assets/s/p/items/i2/image.png".to_string()), + image_object_key: Some("generated-match3d-assets/s/p/items/i2/image.png".to_string()), + image_views: Vec::new(), + model_src: Some("/generated-match3d-assets/s/p/items/i2/model/model.glb".to_string()), + model_object_key: Some( + "generated-match3d-assets/s/p/items/i2/model/model.glb".to_string(), + ), + model_file_name: Some("model.glb".to_string()), + task_uuid: Some("task-2".to_string()), + subscription_key: Some("sub-2".to_string()), + sound_prompt: None, + background_music_title: None, + background_music_style: None, + background_music_prompt: None, + background_music: None, + click_sound: None, + background_asset: None, + status: "model_ready".to_string(), + error: None, + }, + Match3DGeneratedItemAsset { item_id: "match3d-item-1".to_string(), item_name: "草莓".to_string(), item_size: Some(MATCH3D_ITEM_SIZE_SMALL.to_string()), - image_src: None, + image_src: Some("/generated-match3d-assets/s/p/items/i1/image.png".to_string()), + image_object_key: Some("generated-match3d-assets/s/p/items/i1/image.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: None, + status: "image_ready".to_string(), + error: None, + }, + ]); + + assert_eq!(assets[0].item_id, "match3d-item-1"); + assert_eq!(assets[1].item_id, "match3d-item-2"); +} + +#[test] +fn match3d_required_item_images_require_five_views() { + 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/s/p/items/i1/image.png".to_string()), + image_object_key: Some("generated-match3d-assets/s/p/items/i1/image.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: None, + status: "image_ready".to_string(), + error: None, + }, + Match3DGeneratedItemAsset { + item_id: "match3d-item-2".to_string(), + item_name: "苹果".to_string(), + item_size: Some(MATCH3D_ITEM_SIZE_MEDIUM.to_string()), + image_src: Some("/generated-match3d-assets/s/p/items/i2/image.png".to_string()), + image_object_key: Some("generated-match3d-assets/s/p/items/i2/image.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: None, + status: "image_ready".to_string(), + error: None, + }, + Match3DGeneratedItemAsset { + item_id: "match3d-item-3".to_string(), + item_name: "香蕉".to_string(), + item_size: Some(MATCH3D_ITEM_SIZE_MEDIUM.to_string()), + image_src: Some("/generated-match3d-assets/s/p/items/i3/image.png".to_string()), image_object_key: None, image_views: Vec::new(), model_src: None, @@ -769,959 +1680,12 @@ use super::*; background_asset: None, status: "image_ready".to_string(), error: None, - }]; + }, + ]; - let plan = build_match3d_item_asset_append_plan( - vec![ - "草莓".to_string(), - "苹果".to_string(), - "香蕉".to_string(), - "梨子".to_string(), - ], - &existing_assets, - ); + assert!(!has_match3d_required_item_images(&assets, 3)); - assert_eq!(plan.requested_item_names, vec!["苹果", "香蕉", "梨子"]); - assert_eq!(plan.padded_item_names.len(), 5); - assert_eq!(&plan.padded_item_names[..3], ["苹果", "香蕉", "梨子"]); - assert_eq!( - calculate_match3d_item_assets_points_cost(plan.requested_item_names.len()), - 2 - ); - } - - #[test] - fn match3d_item_asset_append_plan_still_generates_full_sheet_when_capacity_is_low() { - let existing_assets = (1..MATCH3D_MAX_GENERATED_ITEM_COUNT) - .map(|index| Match3DGeneratedItemAsset { - item_id: format!("match3d-item-{index}"), - item_name: format!("已有物品{index}"), - item_size: Some(MATCH3D_ITEM_SIZE_LARGE.to_string()), - image_src: None, - image_object_key: None, - 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: None, - status: "image_ready".to_string(), - error: None, - }) - .collect::>(); - - let plan = - build_match3d_item_asset_append_plan(vec!["新物品".to_string()], &existing_assets); - - assert_eq!(plan.requested_item_names, vec!["新物品"]); - assert_eq!( - plan.padded_item_names.len(), - MATCH3D_MATERIAL_ITEM_BATCH_SIZE - ); - assert_eq!(plan.padded_item_names[0], "新物品"); - } - - #[test] - fn match3d_item_asset_replace_plan_only_targets_existing_names() { - let existing_assets = vec![ - test_match3d_generated_item_asset(1, "草莓"), - test_match3d_generated_item_asset(2, "苹果"), - ]; - let plan = build_match3d_item_asset_replace_plan( - vec!["苹果".to_string(), "不存在".to_string(), "苹果".to_string()], - &existing_assets, - ); - - assert_eq!(plan.requested_item_names, vec!["苹果"]); - assert_eq!(plan.target_assets.len(), 1); - assert_eq!(plan.target_assets[0].item_id, "match3d-item-2"); - assert_eq!( - plan.padded_item_names.len(), - MATCH3D_MATERIAL_ITEM_BATCH_SIZE - ); - assert_eq!(plan.padded_item_names[0], "苹果"); - } - - #[test] - fn match3d_item_assets_generation_mode_defaults_to_append() { - assert!(matches!( - normalize_match3d_item_assets_generation_mode(None), - Match3DItemAssetsGenerationMode::Append - )); - assert!(matches!( - normalize_match3d_item_assets_generation_mode(Some("replace")), - Match3DItemAssetsGenerationMode::Replace - )); - assert!(matches!( - normalize_match3d_item_assets_generation_mode(Some("regenerate")), - Match3DItemAssetsGenerationMode::Replace - )); - } - - #[test] - fn match3d_regenerated_asset_keeps_stable_identity_and_side_assets() { - let mut current_asset = test_match3d_generated_item_asset(1, "草莓"); - current_asset.background_music_title = Some("果园轻舞".to_string()); - current_asset.background_asset = Some(Match3DGeneratedBackgroundAsset { - prompt: "果园背景".to_string(), - image_src: Some("/generated-match3d-assets/s/p/background/bg.png".to_string()), - image_object_key: None, - container_prompt: Some("果园容器".to_string()), - container_image_src: Some( - "/generated-match3d-assets/s/p/ui-container/container.png".to_string(), - ), - container_image_object_key: None, - status: "image_ready".to_string(), - error: None, - }); - let mut generated_asset = test_match3d_generated_item_asset(99, "新草莓"); - generated_asset.image_src = - Some("/generated-match3d-assets/s/p/items/new/views/view-01.png".to_string()); - generated_asset.model_src = None; - generated_asset.model_object_key = None; - - let merged = - merge_regenerated_match3d_item_asset(Some(current_asset.clone()), generated_asset); - - assert_eq!(merged.item_id, "match3d-item-1"); - assert_eq!(merged.item_name, "草莓"); - assert_eq!( - merged.image_src.as_deref(), - Some("/generated-match3d-assets/s/p/items/new/views/view-01.png") - ); - assert_eq!( - merged.model_src.as_deref(), - current_asset.model_src.as_deref() - ); - assert_eq!(merged.background_music_title.as_deref(), Some("果园轻舞")); - assert!(merged.background_asset.is_some()); - assert_eq!(merged.status, "image_ready"); - } - - #[test] - fn match3d_material_sheet_prompt_requires_uniform_five_by_five_layout() { - let prompt = build_match3d_material_sheet_prompt( - &config("水果", 12, 4), - &["草莓".to_string(), "苹果".to_string(), "香蕉".to_string()], - ); - - assert!(prompt.contains("5行*5列")); - assert!(prompt.contains("严格5*5均匀排布")); - assert!(prompt.contains("绿幕背景")); - assert!(prompt.contains("#00FF00")); - assert!(prompt.contains("单个素材格宽度的1/4空白间距")); - assert!(prompt.contains("约25%单格宽度")); - assert!(prompt.contains("禁止主体跨格")); - assert!(prompt.contains("贴边或越界")); - } - - #[test] - fn match3d_material_sheet_prompt_hardens_pixel_retro_style() { - let mut config = config("水果", 12, 4); - config.asset_style_id = Some("pixel-retro".to_string()); - config.asset_style_label = Some("像素复古".to_string()); - let prompt = build_match3d_material_sheet_prompt(&config, &["草莓".to_string()]); - let negative_prompt = build_match3d_material_sheet_negative_prompt(&config); - - assert!(prompt.contains("64x64")); - assert!(prompt.contains("整数倍放大")); - assert!(prompt.contains("禁止抗锯齿")); - assert!(prompt.contains("真实 3D 渲染")); - assert!(prompt.contains("PBR 材质")); - assert!(negative_prompt.contains("抗锯齿")); - assert!(negative_prompt.contains("平滑插画")); - assert!(negative_prompt.contains("真实 3D 渲染")); - } - - #[test] - fn match3d_material_sheet_request_uses_vector_engine_gemini_contract() { - let body = build_match3d_vector_engine_gemini_image_request_body( - "生成水果素材图", - "文字、水印", - MATCH3D_MATERIAL_VECTOR_ENGINE_GEMINI_ASPECT_RATIO, - ); - - assert_eq!(body["generationConfig"]["responseModalities"][0], "TEXT"); - assert_eq!(body["generationConfig"]["responseModalities"][1], "IMAGE"); - assert_eq!( - body["generationConfig"]["imageConfig"]["aspectRatio"], - MATCH3D_MATERIAL_VECTOR_ENGINE_GEMINI_ASPECT_RATIO - ); - assert!(body.get("model").is_none()); - assert!(body.get("n").is_none()); - assert!(body.get("official_fallback").is_none()); - assert!(body.get("image").is_none()); - assert!(body.get("image_urls").is_none()); - assert!( - body["contents"][0]["parts"][0]["text"] - .as_str() - .unwrap_or_default() - .contains("文字、水印") - ); - } - - #[test] - fn match3d_extracts_vector_engine_gemini_inline_image_data() { - let payload = json!({ - "candidates": [{ - "content": { - "parts": [ - { "text": "已生成" }, - { - "inlineData": { - "mimeType": "image/png", - "data": "iVBORw0KGgo=" - } - }, - { - "inline_data": { - "mime_type": "image/webp", - "data": "UklGRg==" - } - }, - { - "inlineData": { - "mimeType": "text/plain", - "data": "not-image-data" - } - }, - { - "data": "not-inline-image-data" - } - ] - } - }] - }); - - assert_eq!( - extract_match3d_b64_images(&payload), - vec!["iVBORw0KGgo=", "UklGRg=="] - ); - } - - #[test] - fn match3d_vector_engine_gemini_url_accepts_root_or_v1_base() { - let root_settings = Match3DVectorEngineGeminiImageSettings { - base_url: "https://api.vectorengine.cn".to_string(), - api_key: "test-key".to_string(), - request_timeout_ms: 1_000_000, - }; - let v1_settings = Match3DVectorEngineGeminiImageSettings { - base_url: "https://api.vectorengine.cn/v1".to_string(), - api_key: "test-key".to_string(), - request_timeout_ms: 1_000_000, - }; - - assert_eq!( - build_match3d_vector_engine_gemini_generate_content_url(&root_settings), - "https://api.vectorengine.cn/v1beta/models/gemini-3-pro-image-preview:generateContent" - ); - assert_eq!( - build_match3d_vector_engine_gemini_generate_content_url(&v1_settings), - "https://api.vectorengine.cn/v1beta/models/gemini-3-pro-image-preview:generateContent" - ); - } - - #[test] - fn match3d_background_and_container_prompts_keep_ui_layers_split() { - let config = config("水果", 3, 3); - let background_prompt = - build_match3d_background_generation_prompt(&config, "果园绿色竖屏纯背景"); - let container_prompt = - build_match3d_container_generation_prompt(&config, "果园绿色竖屏纯背景"); - - assert!(background_prompt.contains("9:16")); - assert!(background_prompt.contains("纯背景图")); - assert!(background_prompt.contains("不得出现锅")); - assert!(background_prompt.contains("拼图槽")); - assert!(background_prompt.contains("物品槽")); - assert!(background_prompt.contains("全画幅不透明")); - assert!(background_prompt.contains("透明 alpha")); - assert!(background_prompt.contains("默认交互容器")); - - assert!(container_prompt.contains("1:1")); - assert!(container_prompt.contains("中心容器 UI 图")); - assert!(container_prompt.contains("贴合题材设定")); - assert!(container_prompt.contains("占画布宽度约 86%-92%")); - assert!(container_prompt.contains("轻俯视 3/4")); - assert!(container_prompt.contains("横向椭圆形内口")); - assert!(container_prompt.contains("不能画成正俯视扁圆盘")); - assert!(container_prompt.contains("透明 alpha")); - assert!(container_prompt.contains("白底")); - assert!(container_prompt.contains("整页背景")); - assert!(container_prompt.contains("禁止文字")); - } - - #[test] - fn match3d_background_asset_requires_background_and_container_images() { - let background_only = Match3DGeneratedBackgroundAsset { - prompt: "果园背景".to_string(), - image_src: Some( - "/generated-match3d-assets/session/profile/background/bg.png".to_string(), - ), - image_object_key: None, - container_prompt: None, - container_image_src: None, - container_image_object_key: None, - status: "image_ready".to_string(), - error: None, - }; - let with_container = Match3DGeneratedBackgroundAsset { - container_prompt: Some("果园容器".to_string()), - container_image_src: Some( - "/generated-match3d-assets/session/profile/ui-container/container.png".to_string(), - ), - ..background_only.clone() - }; - - assert!(!is_match3d_background_asset_ready(&background_only)); - assert!(is_match3d_background_asset_ready(&with_container)); - } - - #[test] - fn match3d_default_cover_prefers_generated_container_ui_image() { - 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: None, - image_object_key: None, - 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: None, - container_prompt: Some("果园容器".to_string()), - container_image_src: Some( - "/generated-match3d-assets/session/profile/ui-container/container.png" - .to_string(), - ), - container_image_object_key: None, - status: "image_ready".to_string(), - error: None, - }), - status: "image_ready".to_string(), - error: None, - }]; - - assert_eq!( - resolve_match3d_default_cover_image_src(&assets).as_deref(), - Some("/generated-match3d-assets/session/profile/ui-container/container.png") - ); - } - - #[test] - fn match3d_cover_reference_sources_are_deduped_and_limited() { - let sources = collect_match3d_cover_reference_image_sources( - Some("/generated-match3d-assets/a.png".to_string()), - vec![ - "/generated-match3d-assets/a.png".to_string(), - "data:image/png;base64,b".to_string(), - "/generated-match3d-assets/c.png".to_string(), - "/generated-match3d-assets/d.png".to_string(), - "/generated-match3d-assets/e.png".to_string(), - "/generated-match3d-assets/f.png".to_string(), - "/generated-match3d-assets/g.png".to_string(), - ], - ); - - assert_eq!(sources.len(), 6); - assert_eq!(sources[0], "/generated-match3d-assets/a.png"); - assert_eq!(sources[1], "data:image/png;base64,b"); - assert!(!sources.contains(&"/generated-match3d-assets/g.png".to_string())); - } - - #[test] - fn match3d_public_reference_image_paths_are_limited_to_known_assets() { - assert_eq!( - normalize_match3d_public_reference_image_path( - "/match3d-background-references/pot-fused-reference.png?cache=1" - ) - .as_deref(), - Some("public/match3d-background-references/pot-fused-reference.png") - ); - assert!(normalize_match3d_public_reference_image_path("/icons/logo.png").is_none()); - assert!( - normalize_match3d_public_reference_image_path( - "/match3d-background-references/../secret.png" - ) - .is_none() - ); - } - - #[test] - fn match3d_cover_reference_prompt_marks_reference_images() { - let prompt = build_match3d_cover_reference_generation_prompt("水果封面", true); - - assert!(prompt.contains("一张或多张图片")); - assert!(prompt.contains("不要拼贴成素材墙")); - assert!(prompt.contains("水果封面")); - } - - #[test] - fn match3d_cover_edit_prompt_preserves_uploaded_image() { - let prompt = build_match3d_cover_edit_prompt("水果封面"); - - assert!(prompt.contains("上传的封面图作为第一优先级")); - assert!(prompt.contains("保留主图的主体、构图、视角和主要配色")); - } - - #[test] - fn match3d_fallback_work_profile_keeps_generated_background_asset() { - 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: None, - image_object_key: None, - 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 profile = build_match3d_work_profile_record_with_assets( - 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-14T00:00:00Z".to_string(), - published_at: None, - publish_ready: false, - generated_item_assets_json: None, - }, - &assets, - ); - let response = map_match3d_work_summary_response(profile); - - assert_eq!( - response.background_image_src.as_deref(), - Some("/generated-match3d-assets/session/profile/background/background.png") - ); - assert_eq!( - response.cover_image_src.as_deref(), - Some("/generated-match3d-assets/session/profile/ui-container/container.png") - ); - assert_eq!(response.generated_item_assets.len(), 1); - assert_eq!( - response - .generated_background_asset - .as_ref() - .and_then(|asset| asset.container_image_src.as_deref()), - Some("/generated-match3d-assets/session/profile/ui-container/container.png") - ); - } - - #[test] - fn match3d_agent_session_response_hydrates_persisted_ui_assets() { - 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: None, - }), - messages: Vec::new(), - last_assistant_reply: None, - published_profile_id: None, - updated_at: "2026-05-15T00:00:00.000Z".to_string(), - }; - 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 response = map_match3d_agent_session_response_with_assets(session, &assets); - let draft = response.draft.expect("session draft should exist"); - - assert_eq!(draft.generated_item_assets.len(), 1); - assert_eq!(draft.background_prompt.as_deref(), Some("果园背景")); - assert_eq!( - draft.background_image_src.as_deref(), - Some("/generated-match3d-assets/session/profile/background/background.png") - ); - assert_eq!( - draft.cover_image_src.as_deref(), - Some("/generated-match3d-assets/session/profile/ui-container/container.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_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素材"); - assert_eq!(normalize_match3d_tag("1. 3D素材"), "3D素材"); - assert_eq!(normalize_match3d_tag("2、轻量休闲"), "轻量休闲"); - } - - #[test] - fn match3d_plan_tags_are_kept_before_local_fallback_tags() { - let tags = merge_match3d_plan_tags_with_fallback( - "果园大鹅宴", - "水果", - &["果园".to_string(), "轻快".to_string(), "抓大鹅".to_string()], - ); - - assert_eq!(tags[0], "果园"); - assert_eq!(tags[1], "轻快"); - assert_eq!(tags[2], "抓大鹅"); - assert!(tags.contains(&"水果".to_string())); - assert!(tags.contains(&"经典消除".to_string())); - } - - #[test] - fn match3d_model_download_metadata_normalizes_to_glb() { - assert_eq!( - normalize_match3d_model_file_name("https://example.com/Fruit Model.GLB?token=1"), - "fruit-model.glb" - ); - assert_eq!(normalize_match3d_model_file_name("模型文件"), "model.glb"); - assert_eq!( - normalize_match3d_model_content_type("application/octet-stream"), - "model/gltf-binary" - ); - assert_eq!( - normalize_match3d_model_content_type("model/gltf-binary; charset=utf-8"), - "model/gltf-binary" - ); - } - - #[test] - fn match3d_model_download_requires_valid_glb_header() { - let mut glb = Vec::new(); - glb.extend_from_slice(&0x4654_6c67_u32.to_le_bytes()); - glb.extend_from_slice(&2_u32.to_le_bytes()); - glb.extend_from_slice(&12_u32.to_le_bytes()); - - assert!(is_match3d_glb_binary_payload(&glb)); - assert!(!is_match3d_glb_binary_payload(b"expired")); - - let mut wrong_length = glb.clone(); - wrong_length[8..12].copy_from_slice(&16_u32.to_le_bytes()); - assert!(!is_match3d_glb_binary_payload(&wrong_length)); - } - - #[test] - fn match3d_generated_asset_resume_keeps_stable_item_order() { - let assets = normalize_match3d_generated_item_assets_for_resume(vec![ - Match3DGeneratedItemAsset { - item_id: "match3d-item-2".to_string(), - item_name: "苹果".to_string(), - item_size: Some(MATCH3D_ITEM_SIZE_MEDIUM.to_string()), - image_src: Some("/generated-match3d-assets/s/p/items/i2/image.png".to_string()), - image_object_key: Some( - "generated-match3d-assets/s/p/items/i2/image.png".to_string(), - ), - image_views: Vec::new(), - model_src: Some( - "/generated-match3d-assets/s/p/items/i2/model/model.glb".to_string(), - ), - model_object_key: Some( - "generated-match3d-assets/s/p/items/i2/model/model.glb".to_string(), - ), - model_file_name: Some("model.glb".to_string()), - task_uuid: Some("task-2".to_string()), - subscription_key: Some("sub-2".to_string()), - sound_prompt: None, - background_music_title: None, - background_music_style: None, - background_music_prompt: None, - background_music: None, - click_sound: None, - background_asset: None, - status: "model_ready".to_string(), - error: None, - }, - 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/s/p/items/i1/image.png".to_string()), - image_object_key: Some( - "generated-match3d-assets/s/p/items/i1/image.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: None, - status: "image_ready".to_string(), - error: None, - }, - ]); - - assert_eq!(assets[0].item_id, "match3d-item-1"); - assert_eq!(assets[1].item_id, "match3d-item-2"); - } - - #[test] - fn match3d_required_item_images_require_five_views() { - 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/s/p/items/i1/image.png".to_string()), - image_object_key: Some( - "generated-match3d-assets/s/p/items/i1/image.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: None, - status: "image_ready".to_string(), - error: None, - }, - Match3DGeneratedItemAsset { - item_id: "match3d-item-2".to_string(), - item_name: "苹果".to_string(), - item_size: Some(MATCH3D_ITEM_SIZE_MEDIUM.to_string()), - image_src: Some("/generated-match3d-assets/s/p/items/i2/image.png".to_string()), - image_object_key: Some( - "generated-match3d-assets/s/p/items/i2/image.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: None, - status: "image_ready".to_string(), - error: None, - }, - Match3DGeneratedItemAsset { - item_id: "match3d-item-3".to_string(), - item_name: "香蕉".to_string(), - item_size: Some(MATCH3D_ITEM_SIZE_MEDIUM.to_string()), - image_src: Some("/generated-match3d-assets/s/p/items/i3/image.png".to_string()), - image_object_key: None, - 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: None, - status: "image_ready".to_string(), - error: None, - }, - ]; - - assert!(!has_match3d_required_item_images(&assets, 3)); - - let five_view_assets = (1..=3) + let five_view_assets = (1..=3) .map(|index| Match3DGeneratedItemAsset { item_id: format!("match3d-item-{index}"), item_name: format!("物品{index}"), @@ -1761,35 +1725,35 @@ use super::*; }) .collect::>(); - assert!(has_match3d_required_item_images(&five_view_assets, 3)); - } + assert!(has_match3d_required_item_images(&five_view_assets, 3)); +} - #[test] - fn match3d_oss_config_error_lists_missing_env_keys() { - let mut app_config = AppConfig { - oss_bucket: Some("genarrative-assets".to_string()), - oss_endpoint: Some("oss-cn-shanghai.aliyuncs.com".to_string()), - ..AppConfig::default() - }; +#[test] +fn match3d_oss_config_error_lists_missing_env_keys() { + let mut app_config = AppConfig { + oss_bucket: Some("genarrative-assets".to_string()), + oss_endpoint: Some("oss-cn-shanghai.aliyuncs.com".to_string()), + ..AppConfig::default() + }; - let missing = missing_match3d_oss_env_keys(&app_config); - assert_eq!( - missing, - vec!["ALIYUN_OSS_ACCESS_KEY_ID", "ALIYUN_OSS_ACCESS_KEY_SECRET"] - ); - assert_eq!( - match3d_oss_missing_reason(&missing), - "OSS 未完成环境变量配置,缺少:ALIYUN_OSS_ACCESS_KEY_ID, ALIYUN_OSS_ACCESS_KEY_SECRET" - ); + let missing = missing_match3d_oss_env_keys(&app_config); + assert_eq!( + missing, + vec!["ALIYUN_OSS_ACCESS_KEY_ID", "ALIYUN_OSS_ACCESS_KEY_SECRET"] + ); + assert_eq!( + match3d_oss_missing_reason(&missing), + "OSS 未完成环境变量配置,缺少:ALIYUN_OSS_ACCESS_KEY_ID, ALIYUN_OSS_ACCESS_KEY_SECRET" + ); - app_config.oss_access_key_id = Some("ak".to_string()); - app_config.oss_access_key_secret = Some("sk".to_string()); - assert!(missing_match3d_oss_env_keys(&app_config).is_empty()); - } + app_config.oss_access_key_id = Some("ak".to_string()); + app_config.oss_access_key_secret = Some("sk".to_string()); + assert!(missing_match3d_oss_env_keys(&app_config).is_empty()); +} - #[test] - fn match3d_work_summary_maps_persisted_generated_item_assets() { - let response = map_match3d_work_summary_response(Match3DWorkProfileRecord { +#[test] +fn match3d_work_summary_maps_persisted_generated_item_assets() { + 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(), @@ -1815,61 +1779,59 @@ use super::*; ), }); - 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") - ); - } + 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), - }); +#[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")); - } + assert_eq!(response.generation_status.as_deref(), Some("ready")); +} diff --git a/server-rs/crates/api-server/src/match3d/works.rs b/server-rs/crates/api-server/src/match3d/works.rs index 0db5d0ef..67bbe7eb 100644 --- a/server-rs/crates/api-server/src/match3d/works.rs +++ b/server-rs/crates/api-server/src/match3d/works.rs @@ -587,7 +587,10 @@ async fn load_match3d_container_reference_image() -> Result String { +pub(super) fn build_match3d_background_generation_prompt( + config: &Match3DConfigJson, + prompt: &str, +) -> String { let style_clause = resolve_match3d_asset_style_prompt(config) .map(|style| format!("整体美术风格参考:{style}。")) .unwrap_or_default(); @@ -596,7 +599,10 @@ pub(super) fn build_match3d_background_generation_prompt(config: &Match3DConfigJ ) } -pub(super) fn build_match3d_container_generation_prompt(config: &Match3DConfigJson, prompt: &str) -> String { +pub(super) fn build_match3d_container_generation_prompt( + config: &Match3DConfigJson, + prompt: &str, +) -> String { let style_clause = resolve_match3d_asset_style_prompt(config) .map(|style| format!("整体美术风格参考:{style}。")) .unwrap_or_default(); @@ -1183,7 +1189,9 @@ pub(super) async fn persist_match3d_generated_bytes( }) } -pub(super) fn require_match3d_oss_client(state: &AppState) -> Result<&platform_oss::OssClient, AppError> { +pub(super) fn require_match3d_oss_client( + state: &AppState, +) -> Result<&platform_oss::OssClient, AppError> { state .oss_client() .ok_or_else(|| match3d_oss_config_error(&state.config)) diff --git a/server-rs/crates/api-server/src/modules/bark_battle.rs b/server-rs/crates/api-server/src/modules/bark_battle.rs index ea7c8219..3cd4f6b8 100644 --- a/server-rs/crates/api-server/src/modules/bark_battle.rs +++ b/server-rs/crates/api-server/src/modules/bark_battle.rs @@ -7,7 +7,9 @@ use crate::{ auth::require_bearer_auth, bark_battle::{ create_bark_battle_draft, finish_bark_battle_run, get_bark_battle_run, - get_bark_battle_runtime_config, publish_bark_battle_work, start_bark_battle_run, + generate_bark_battle_image_asset, get_bark_battle_runtime_config, + list_bark_battle_gallery, list_bark_battle_works, publish_bark_battle_work, + start_bark_battle_run, update_bark_battle_draft_config, }, state::AppState, }; @@ -21,6 +23,20 @@ pub fn router(state: AppState) -> Router { require_bearer_auth, )), ) + .route( + "/api/creation/bark-battle/drafts/{draft_id}/config", + post(update_bark_battle_draft_config).route_layer(middleware::from_fn_with_state( + state.clone(), + require_bearer_auth, + )), + ) + .route( + "/api/creation/bark-battle/images/generate", + post(generate_bark_battle_image_asset).route_layer(middleware::from_fn_with_state( + state.clone(), + require_bearer_auth, + )), + ) .route( "/api/creation/bark-battle/works/publish", post(publish_bark_battle_work).route_layer(middleware::from_fn_with_state( @@ -28,6 +44,17 @@ pub fn router(state: AppState) -> Router { require_bearer_auth, )), ) + .route( + "/api/runtime/bark-battle/works", + get(list_bark_battle_works).route_layer(middleware::from_fn_with_state( + state.clone(), + require_bearer_auth, + )), + ) + .route( + "/api/runtime/bark-battle/gallery", + get(list_bark_battle_gallery), + ) .route( "/api/runtime/bark-battle/works/{work_id}/config", get(get_bark_battle_runtime_config).route_layer(middleware::from_fn_with_state( diff --git a/server-rs/crates/api-server/src/process_metrics.rs b/server-rs/crates/api-server/src/process_metrics.rs index 4d3adad2..d61a7b15 100644 --- a/server-rs/crates/api-server/src/process_metrics.rs +++ b/server-rs/crates/api-server/src/process_metrics.rs @@ -199,11 +199,9 @@ fn cpu_usage_ratio_between_samples( #[cfg(windows)] fn collect_process_metrics() -> Result { - use windows_sys::Win32::{ - System::{ - ProcessStatus::{GetProcessMemoryInfo, PROCESS_MEMORY_COUNTERS_EX}, - Threading::{GetCurrentProcess, GetCurrentProcessId, GetProcessHandleCount}, - }, + use windows_sys::Win32::System::{ + ProcessStatus::{GetProcessMemoryInfo, PROCESS_MEMORY_COUNTERS_EX}, + Threading::{GetCurrentProcess, GetCurrentProcessId, GetProcessHandleCount}, }; let handle = unsafe { GetCurrentProcess() }; @@ -212,11 +210,7 @@ fn collect_process_metrics() -> Result { ..Default::default() }; let ok = unsafe { - GetProcessMemoryInfo( - handle, - std::ptr::addr_of_mut!(counters).cast(), - counters.cb, - ) + GetProcessMemoryInfo(handle, std::ptr::addr_of_mut!(counters).cast(), counters.cb) }; if ok == 0 { return Err("GetProcessMemoryInfo returned false".to_string()); @@ -244,10 +238,7 @@ fn collect_process_metrics() -> Result { #[cfg(windows)] fn windows_process_cpu_time_seconds(handle: windows_sys::Win32::Foundation::HANDLE) -> Option { - use windows_sys::Win32::{ - Foundation::FILETIME, - System::Threading::GetProcessTimes, - }; + use windows_sys::Win32::{Foundation::FILETIME, System::Threading::GetProcessTimes}; let mut creation_time = FILETIME::default(); let mut exit_time = FILETIME::default(); @@ -337,8 +328,8 @@ fn collect_process_metrics() -> Result { .ok_or_else(|| "missing VmSize/statm size field".to_string())?; let private_bytes = parse_status_kb(&status, "VmData:").map(|value| value * 1024); let cpu_time_seconds = linux_cpu_time_seconds(&stat)?; - let thread_count = parse_status_u64(&status, "Threads:") - .ok_or_else(|| "missing Threads field".to_string())?; + let thread_count = + parse_status_u64(&status, "Threads:").ok_or_else(|| "missing Threads field".to_string())?; Ok(ProcessMetricsSnapshot { rss_bytes, @@ -427,11 +418,7 @@ fn parse_status_u64(status: &str, key: &str) -> Option { #[cfg(target_os = "linux")] fn parse_statm_pages(statm: &str, index: usize) -> Option { - statm - .split_whitespace() - .nth(index)? - .parse::() - .ok() + statm.split_whitespace().nth(index)?.parse::().ok() } #[cfg(not(any(windows, target_os = "linux")))] diff --git a/server-rs/crates/module-bark-battle/src/domain.rs b/server-rs/crates/module-bark-battle/src/domain.rs index 7436cb5a..909fd4eb 100644 --- a/server-rs/crates/module-bark-battle/src/domain.rs +++ b/server-rs/crates/module-bark-battle/src/domain.rs @@ -72,7 +72,7 @@ impl BarkBattleRuleset { standard_duration_ms: 30_000, min_duration_ms: 28_000, max_duration_ms: 35_000, - min_bark_gap_ms: 250, + min_bark_gap_ms: 150, trigger_count_tolerance: 2, min_volume: 0.0, max_volume: 1.0, diff --git a/server-rs/crates/module-bark-battle/src/scoring.rs b/server-rs/crates/module-bark-battle/src/scoring.rs index 12aff9c2..57fdf8b5 100644 --- a/server-rs/crates/module-bark-battle/src/scoring.rs +++ b/server-rs/crates/module-bark-battle/src/scoring.rs @@ -174,6 +174,7 @@ mod tests { #[test] fn flags_trigger_count_above_physical_limit_with_tolerance() { let ruleset = BarkBattleRuleset::v1(); + assert_eq!(ruleset.min_bark_gap_ms, 150); let mut input = metrics(30_000); input.trigger_count = input.duration_ms / ruleset.min_bark_gap_ms + u64::from(ruleset.trigger_count_tolerance) diff --git a/server-rs/crates/module-runtime/src/application.rs b/server-rs/crates/module-runtime/src/application.rs index 905a72e7..8a578c5e 100644 --- a/server-rs/crates/module-runtime/src/application.rs +++ b/server-rs/crates/module-runtime/src/application.rs @@ -142,10 +142,10 @@ pub fn default_creation_entry_type_snapshots( "bark-battle", "汪汪声浪", "声控对战挑战", - "敬请期待", - "/creation-type-references/creative-agent.webp", + "可创建", + "/creation-type-references/bark-battle.webp", + true, true, - false, 85, updated_at_micros, ), diff --git a/server-rs/crates/module-runtime/src/lib.rs b/server-rs/crates/module-runtime/src/lib.rs index 3d182426..6f5d75e4 100644 --- a/server-rs/crates/module-runtime/src/lib.rs +++ b/server-rs/crates/module-runtime/src/lib.rs @@ -246,9 +246,13 @@ mod tests { assert_eq!(bark_battle.title, "汪汪声浪"); assert!(bark_battle.visible); - assert!(!bark_battle.open); - assert_eq!(bark_battle.badge, "敬请期待"); + assert!(bark_battle.open); + assert_eq!(bark_battle.badge, "可创建"); assert_eq!(bark_battle.sort_order, 85); + assert_eq!( + bark_battle.image_src, + "/creation-type-references/bark-battle.webp" + ); } #[test] @@ -522,8 +526,9 @@ mod tests { #[test] fn runtime_profile_beijing_day_key_uses_business_day_boundary() { - let before_beijing_midnight = 1_714_927_999_999_999; - let after_beijing_midnight = 1_714_928_000_000_000; + // 中文注释:2024-05-06 00:00:00 Asia/Shanghai 前后 1 微秒。 + let before_beijing_midnight = 1_714_924_799_999_999; + let after_beijing_midnight = 1_714_924_800_000_000; assert_eq!( runtime_profile_beijing_day_key(before_beijing_midnight), diff --git a/server-rs/crates/platform-oss/src/lib.rs b/server-rs/crates/platform-oss/src/lib.rs index 61a4f301..59042f3c 100644 --- a/server-rs/crates/platform-oss/src/lib.rs +++ b/server-rs/crates/platform-oss/src/lib.rs @@ -20,7 +20,7 @@ const OSS_V4_REQUEST: &str = "aliyun_v4_request"; const OSS_V4_SERVICE: &str = "oss"; const OSS_UNSIGNED_PAYLOAD: &str = "UNSIGNED-PAYLOAD"; -pub const LEGACY_PUBLIC_PREFIXES: [&str; 10] = [ +pub const LEGACY_PUBLIC_PREFIXES: [&str; 11] = [ "generated-character-drafts", "generated-characters", "generated-animations", @@ -30,6 +30,7 @@ pub const LEGACY_PUBLIC_PREFIXES: [&str; 10] = [ "generated-puzzle-assets", "generated-custom-world-scenes", "generated-custom-world-covers", + "generated-bark-battle-assets", "generated-qwen-sprites", ]; @@ -51,6 +52,7 @@ pub enum LegacyAssetPrefix { PuzzleAssets, CustomWorldScenes, CustomWorldCovers, + BarkBattleAssets, QwenSprites, } @@ -238,6 +240,7 @@ impl LegacyAssetPrefix { "generated-puzzle-assets" => Some(Self::PuzzleAssets), "generated-custom-world-scenes" => Some(Self::CustomWorldScenes), "generated-custom-world-covers" => Some(Self::CustomWorldCovers), + "generated-bark-battle-assets" => Some(Self::BarkBattleAssets), "generated-qwen-sprites" => Some(Self::QwenSprites), _ => None, } @@ -254,6 +257,7 @@ impl LegacyAssetPrefix { Self::PuzzleAssets => "generated-puzzle-assets", Self::CustomWorldScenes => "generated-custom-world-scenes", Self::CustomWorldCovers => "generated-custom-world-covers", + Self::BarkBattleAssets => "generated-bark-battle-assets", Self::QwenSprites => "generated-qwen-sprites", } } @@ -1315,6 +1319,7 @@ mod tests { ); assert!(LEGACY_PUBLIC_PREFIXES.contains(&"generated-puzzle-assets")); assert!(LEGACY_PUBLIC_PREFIXES.contains(&"generated-match3d-assets")); + assert!(LEGACY_PUBLIC_PREFIXES.contains(&"generated-bark-battle-assets")); assert_eq!(LegacyAssetPrefix::parse("unknown"), None); } diff --git a/server-rs/crates/shared-contracts/src/bark_battle.rs b/server-rs/crates/shared-contracts/src/bark_battle.rs index d86abae2..5fbef4f0 100644 --- a/server-rs/crates/shared-contracts/src/bark_battle.rs +++ b/server-rs/crates/shared-contracts/src/bark_battle.rs @@ -30,6 +30,14 @@ pub enum BarkBattleFinishStatus { Rejected, } +#[derive(Clone, Debug, Serialize, Deserialize, PartialEq, Eq)] +#[serde(rename_all = "kebab-case")] +pub enum BarkBattleAssetSlot { + PlayerCharacter, + OpponentCharacter, + UiBackground, +} + #[derive(Clone, Debug, Default, Serialize, Deserialize, PartialEq)] #[serde(rename_all = "camelCase")] pub struct BarkBattleReplacementConfig { @@ -39,8 +47,6 @@ pub struct BarkBattleReplacementConfig { pub opponent_character_image_src: Option, #[serde(default, skip_serializing_if = "Option::is_none")] pub ui_background_image_src: Option, - #[serde(default, skip_serializing_if = "Option::is_none")] - pub bark_sound_src: Option, } #[derive(Clone, Debug, Serialize, Deserialize, PartialEq)] @@ -49,20 +55,19 @@ pub struct BarkBattleConfigEditorPayload { pub title: String, #[serde(default, skip_serializing_if = "Option::is_none")] pub description: Option, - pub theme_preset: String, - pub player_dog_skin_preset: String, - pub opponent_dog_skin_preset: String, + pub theme_description: String, + pub player_image_description: String, + pub opponent_image_description: String, + #[serde(default, skip_serializing_if = "Option::is_none")] + pub onomatopoeia: Option>, #[serde(default, skip_serializing_if = "Option::is_none")] pub player_character_image_src: Option, #[serde(default, skip_serializing_if = "Option::is_none")] pub opponent_character_image_src: Option, #[serde(default, skip_serializing_if = "Option::is_none")] pub ui_background_image_src: Option, - #[serde(default, skip_serializing_if = "Option::is_none")] - pub bark_sound_src: Option, #[serde(default)] pub difficulty_preset: BarkBattleDifficultyPreset, - pub leaderboard_enabled: bool, } #[derive(Clone, Debug, Serialize, Deserialize, PartialEq)] @@ -71,20 +76,19 @@ pub struct BarkBattleDraftCreateRequest { pub title: String, #[serde(default, skip_serializing_if = "Option::is_none")] pub description: Option, - pub theme_preset: String, - pub player_dog_skin_preset: String, - pub opponent_dog_skin_preset: String, + pub theme_description: String, + pub player_image_description: String, + pub opponent_image_description: String, + #[serde(default, skip_serializing_if = "Option::is_none")] + pub onomatopoeia: Option>, #[serde(default, skip_serializing_if = "Option::is_none")] pub player_character_image_src: Option, #[serde(default, skip_serializing_if = "Option::is_none")] pub opponent_character_image_src: Option, #[serde(default, skip_serializing_if = "Option::is_none")] pub ui_background_image_src: Option, - #[serde(default, skip_serializing_if = "Option::is_none")] - pub bark_sound_src: Option, #[serde(default)] pub difficulty_preset: BarkBattleDifficultyPreset, - pub leaderboard_enabled: bool, } impl From for BarkBattleConfigEditorPayload { @@ -92,15 +96,59 @@ impl From for BarkBattleConfigEditorPayload { Self { title: value.title, description: value.description, - theme_preset: value.theme_preset, - player_dog_skin_preset: value.player_dog_skin_preset, - opponent_dog_skin_preset: value.opponent_dog_skin_preset, + theme_description: value.theme_description, + player_image_description: value.player_image_description, + opponent_image_description: value.opponent_image_description, + onomatopoeia: value.onomatopoeia, + player_character_image_src: value.player_character_image_src, + opponent_character_image_src: value.opponent_character_image_src, + ui_background_image_src: value.ui_background_image_src, + difficulty_preset: value.difficulty_preset, + } + } +} + +#[derive(Clone, Debug, Serialize, Deserialize, PartialEq)] +#[serde(rename_all = "camelCase")] +pub struct BarkBattleDraftConfigUpdateRequest { + pub draft_id: String, + #[serde(default, skip_serializing_if = "Option::is_none")] + pub work_id: Option, + #[serde(default, skip_serializing_if = "Option::is_none")] + pub config_version: Option, + #[serde(default, skip_serializing_if = "Option::is_none")] + pub ruleset_version: Option, + pub title: String, + #[serde(default, skip_serializing_if = "Option::is_none")] + pub description: Option, + pub theme_description: String, + pub player_image_description: String, + pub opponent_image_description: String, + #[serde(default, skip_serializing_if = "Option::is_none")] + pub onomatopoeia: Option>, + #[serde(default, skip_serializing_if = "Option::is_none")] + pub player_character_image_src: Option, + #[serde(default, skip_serializing_if = "Option::is_none")] + pub opponent_character_image_src: Option, + #[serde(default, skip_serializing_if = "Option::is_none")] + pub ui_background_image_src: Option, + #[serde(default)] + pub difficulty_preset: BarkBattleDifficultyPreset, +} + +impl From for BarkBattleConfigEditorPayload { + fn from(value: BarkBattleDraftConfigUpdateRequest) -> Self { + Self { + title: value.title, + description: value.description, + theme_description: value.theme_description, + player_image_description: value.player_image_description, + opponent_image_description: value.opponent_image_description, + onomatopoeia: value.onomatopoeia, player_character_image_src: value.player_character_image_src, opponent_character_image_src: value.opponent_character_image_src, ui_background_image_src: value.ui_background_image_src, - bark_sound_src: value.bark_sound_src, difficulty_preset: value.difficulty_preset, - leaderboard_enabled: value.leaderboard_enabled, } } } @@ -115,6 +163,30 @@ pub struct BarkBattleWorkPublishRequest { pub published_snapshot: Option, } +#[derive(Clone, Debug, Serialize, Deserialize, PartialEq)] +#[serde(rename_all = "camelCase")] +pub struct BarkBattleImageAssetGenerateRequest { + pub slot: BarkBattleAssetSlot, + #[serde(default, skip_serializing_if = "Option::is_none")] + pub draft_id: Option, + pub config: BarkBattleConfigEditorPayload, +} + +#[derive(Clone, Debug, Serialize, Deserialize, PartialEq)] +#[serde(rename_all = "camelCase")] +pub struct BarkBattleGeneratedImageAsset { + pub image_src: String, + pub asset_id: String, + #[serde(default, skip_serializing_if = "Option::is_none")] + pub source_type: Option, + pub model: String, + pub size: String, + pub task_id: String, + pub prompt: String, + #[serde(default, skip_serializing_if = "Option::is_none")] + pub actual_prompt: Option, +} + #[derive(Clone, Debug, Serialize, Deserialize, PartialEq)] #[serde(rename_all = "camelCase")] pub struct BarkBattleDraftConfig { @@ -128,20 +200,19 @@ pub struct BarkBattleDraftConfig { pub title: String, #[serde(default, skip_serializing_if = "Option::is_none")] pub description: Option, - pub theme_preset: String, - pub player_dog_skin_preset: String, - pub opponent_dog_skin_preset: String, + pub theme_description: String, + pub player_image_description: String, + pub opponent_image_description: String, + #[serde(default, skip_serializing_if = "Option::is_none")] + pub onomatopoeia: Option>, #[serde(default, skip_serializing_if = "Option::is_none")] pub player_character_image_src: Option, #[serde(default, skip_serializing_if = "Option::is_none")] pub opponent_character_image_src: Option, #[serde(default, skip_serializing_if = "Option::is_none")] pub ui_background_image_src: Option, - #[serde(default, skip_serializing_if = "Option::is_none")] - pub bark_sound_src: Option, #[serde(default)] pub difficulty_preset: BarkBattleDifficultyPreset, - pub leaderboard_enabled: bool, pub updated_at: String, } @@ -154,15 +225,14 @@ impl Default for BarkBattleDraftConfig { ruleset_version: None, title: String::new(), description: None, - theme_preset: String::new(), - player_dog_skin_preset: String::new(), - opponent_dog_skin_preset: String::new(), + theme_description: String::new(), + player_image_description: String::new(), + opponent_image_description: String::new(), + onomatopoeia: None, player_character_image_src: None, opponent_character_image_src: None, ui_background_image_src: None, - bark_sound_src: None, difficulty_preset: BarkBattleDifficultyPreset::Normal, - leaderboard_enabled: true, updated_at: String::new(), } } @@ -180,19 +250,18 @@ pub struct BarkBattlePublishedConfig { pub title: String, #[serde(default, skip_serializing_if = "Option::is_none")] pub description: Option, - pub theme_preset: String, - pub player_dog_skin_preset: String, - pub opponent_dog_skin_preset: String, + pub theme_description: String, + pub player_image_description: String, + pub opponent_image_description: String, + #[serde(default, skip_serializing_if = "Option::is_none")] + pub onomatopoeia: Option>, #[serde(default, skip_serializing_if = "Option::is_none")] pub player_character_image_src: Option, #[serde(default, skip_serializing_if = "Option::is_none")] pub opponent_character_image_src: Option, #[serde(default, skip_serializing_if = "Option::is_none")] pub ui_background_image_src: Option, - #[serde(default, skip_serializing_if = "Option::is_none")] - pub bark_sound_src: Option, pub difficulty_preset: BarkBattleDifficultyPreset, - pub leaderboard_enabled: bool, pub updated_at: String, pub published_at: String, } @@ -210,21 +279,75 @@ pub struct BarkBattleRuntimeConfig { pub draw_threshold: f32, pub min_bark_gap_ms: u64, pub difficulty_preset: BarkBattleDifficultyPreset, - pub theme_preset: String, - pub player_dog_skin_preset: String, - pub opponent_dog_skin_preset: String, + pub theme_description: String, + pub player_image_description: String, + pub opponent_image_description: String, + #[serde(default, skip_serializing_if = "Option::is_none")] + pub onomatopoeia: Option>, #[serde(default, skip_serializing_if = "Option::is_none")] pub player_character_image_src: Option, #[serde(default, skip_serializing_if = "Option::is_none")] pub opponent_character_image_src: Option, #[serde(default, skip_serializing_if = "Option::is_none")] pub ui_background_image_src: Option, - #[serde(default, skip_serializing_if = "Option::is_none")] - pub bark_sound_src: Option, - pub leaderboard_enabled: bool, pub updated_at: String, } +#[derive(Clone, Debug, Serialize, Deserialize, PartialEq)] +#[serde(rename_all = "camelCase")] +pub struct BarkBattleWorkSummary { + pub work_id: String, + #[serde(default, skip_serializing_if = "Option::is_none")] + pub draft_id: Option, + pub owner_user_id: String, + pub author_display_name: String, + pub title: String, + pub summary: String, + pub theme_description: String, + pub player_image_description: String, + pub opponent_image_description: String, + #[serde(default, skip_serializing_if = "Option::is_none")] + pub onomatopoeia: Option>, + #[serde(default, skip_serializing_if = "Option::is_none")] + pub player_character_image_src: Option, + #[serde(default, skip_serializing_if = "Option::is_none")] + pub opponent_character_image_src: Option, + #[serde(default, skip_serializing_if = "Option::is_none")] + pub ui_background_image_src: Option, + pub difficulty_preset: BarkBattleDifficultyPreset, + pub status: String, + #[serde(default, skip_serializing_if = "Option::is_none")] + pub generation_status: Option, + pub publish_ready: bool, + pub play_count: u64, + #[serde(default, skip_serializing_if = "Option::is_none")] + pub finish_count: Option, + #[serde(default, skip_serializing_if = "Option::is_none")] + pub win_count: Option, + #[serde(default, skip_serializing_if = "Option::is_none")] + pub draw_count: Option, + #[serde(default, skip_serializing_if = "Option::is_none")] + pub loss_count: Option, + #[serde(default, skip_serializing_if = "Option::is_none")] + pub recent_play_count_7d: Option, + pub updated_at: String, + #[serde(default, skip_serializing_if = "Option::is_none")] + pub published_at: Option, +} + +#[derive(Clone, Debug, Default, Serialize, Deserialize, PartialEq)] +#[serde(rename_all = "camelCase")] +pub struct BarkBattleWorksResponse { + #[serde(default)] + pub items: Vec, +} + +#[derive(Clone, Debug, Serialize, Deserialize, PartialEq)] +#[serde(rename_all = "camelCase")] +pub struct BarkBattleWorkDetailResponse { + pub item: BarkBattleWorkSummary, +} + #[derive(Clone, Debug, Serialize, Deserialize, PartialEq)] #[serde(rename_all = "camelCase")] pub struct BarkBattleRunStartRequest { @@ -425,6 +548,115 @@ mod tests { use super::*; use serde_json::json; + #[test] + fn editor_and_runtime_contract_use_description_fields_only() { + let editor = BarkBattleConfigEditorPayload { + title: "周末狗狗杯".to_string(), + description: Some("轻配置草稿".to_string()), + theme_description: "霓虹公园里的欢乐擂台".to_string(), + player_image_description: "戴红围巾的柴犬主角".to_string(), + opponent_image_description: "蓝色护目镜哈士奇对手".to_string(), + onomatopoeia: Some(vec!["轰汪!".to_string(), "炸场!".to_string()]), + player_character_image_src: Some("/generated-bark-battle/player.png".to_string()), + opponent_character_image_src: Some("https://example.test/opponent.png".to_string()), + ui_background_image_src: Some("/generated-bark-battle/ui.png".to_string()), + difficulty_preset: BarkBattleDifficultyPreset::Hard, + }; + let payload = serde_json::to_value(editor).expect("config should serialize"); + + assert_eq!(payload["themeDescription"], json!("霓虹公园里的欢乐擂台")); + assert_eq!( + payload["playerImageDescription"], + json!("戴红围巾的柴犬主角") + ); + assert_eq!( + payload["opponentImageDescription"], + json!("蓝色护目镜哈士奇对手") + ); + assert_eq!(payload["onomatopoeia"], json!(["轰汪!", "炸场!"])); + for removed in [ + "themePreset", + "playerDogSkinPreset", + "opponentDogSkinPreset", + "barkSoundSrc", + "leaderboardEnabled", + ] { + assert!( + !payload.as_object().unwrap().contains_key(removed), + "{removed} must not remain in v1 public config payload" + ); + } + + let runtime = BarkBattleRuntimeConfig { + work_id: "bark-battle-work-1".to_string(), + config_version: 1, + ruleset_version: "bark-battle-ruleset-v1".to_string(), + play_type_id: "bark-battle".to_string(), + duration_ms: 30_000, + energy_min: 0.0, + energy_max: 100.0, + draw_threshold: 5.0, + min_bark_gap_ms: 220, + difficulty_preset: BarkBattleDifficultyPreset::Normal, + theme_description: "阳光草坪".to_string(), + player_image_description: "小柴犬".to_string(), + opponent_image_description: "大金毛".to_string(), + onomatopoeia: Some(vec!["轰汪!".to_string(), "燃起来!".to_string()]), + player_character_image_src: None, + opponent_character_image_src: None, + ui_background_image_src: None, + updated_at: "2026-05-20T00:00:00Z".to_string(), + }; + let payload = serde_json::to_value(runtime).expect("runtime should serialize"); + assert_eq!(payload["themeDescription"], json!("阳光草坪")); + assert!( + !payload + .as_object() + .unwrap() + .contains_key("leaderboardEnabled") + ); + assert!(!payload.as_object().unwrap().contains_key("barkSoundSrc")); + } + + #[test] + fn work_summary_responses_use_public_gallery_contract() { + let response = BarkBattleWorksResponse { + items: vec![BarkBattleWorkSummary { + work_id: "bark-battle-work-1".to_string(), + draft_id: Some("bark-battle-draft-1".to_string()), + owner_user_id: "user-1".to_string(), + author_display_name: "玩家".to_string(), + title: "汪汪测试杯".to_string(), + summary: "轻量公开卡片".to_string(), + theme_description: "阳光草坪".to_string(), + player_image_description: "小柴犬".to_string(), + opponent_image_description: "大金毛".to_string(), + onomatopoeia: Some(vec!["轰汪!".to_string(), "燃起来!".to_string()]), + player_character_image_src: None, + opponent_character_image_src: None, + ui_background_image_src: None, + difficulty_preset: BarkBattleDifficultyPreset::Normal, + status: "published".to_string(), + generation_status: Some("ready".to_string()), + publish_ready: true, + play_count: 3, + finish_count: Some(2), + win_count: Some(1), + draw_count: Some(1), + loss_count: Some(0), + recent_play_count_7d: Some(2), + updated_at: "2026-05-20T00:00:00Z".to_string(), + published_at: Some("2026-05-20T00:00:00Z".to_string()), + }], + }; + + let payload = serde_json::to_value(response).expect("works response should serialize"); + + assert_eq!(payload["items"][0]["themeDescription"], json!("阳光草坪")); + assert_eq!(payload["items"][0]["recentPlayCount7d"], json!(2)); + assert_eq!(payload["items"][0]["status"], json!("published")); + } + #[test] fn draft_config_defaults_to_normal_difficulty() { let config = BarkBattleDraftConfig::default(); @@ -523,15 +755,14 @@ mod tests { ruleset_version: Some("bark-battle-ruleset-v1".to_string()), title: "汪汪测试杯".to_string(), description: None, - theme_preset: "sunny-yard".to_string(), - player_dog_skin_preset: "主角".to_string(), - opponent_dog_skin_preset: "对手".to_string(), + theme_description: "阳光草坪".to_string(), + player_image_description: "主角".to_string(), + opponent_image_description: "对手".to_string(), + onomatopoeia: None, player_character_image_src: None, opponent_character_image_src: None, ui_background_image_src: None, - bark_sound_src: None, difficulty_preset: BarkBattleDifficultyPreset::Normal, - leaderboard_enabled: true, updated_at: "2026-05-14T10:00:00.000Z".to_string(), }; @@ -540,10 +771,96 @@ mod tests { assert_eq!(payload["draftId"], json!("bark-battle-draft-1")); assert_eq!(payload["workId"], json!("bark-battle-work-1")); assert_eq!(payload["configVersion"], json!(2)); + assert_eq!(payload["rulesetVersion"], json!("bark-battle-ruleset-v1")); + } + + #[test] + fn draft_config_update_request_serializes_generated_assets() { + let update = BarkBattleDraftConfigUpdateRequest { + draft_id: "bark-battle-draft-1".to_string(), + work_id: Some("BB-12345678".to_string()), + config_version: Some(2), + ruleset_version: Some("bark-battle-ruleset-v1".to_string()), + title: "汪汪测试杯".to_string(), + description: None, + theme_description: "阳光草坪".to_string(), + player_image_description: "主角".to_string(), + opponent_image_description: "对手".to_string(), + onomatopoeia: Some(vec!["炸场!".to_string(), "破阵!".to_string()]), + player_character_image_src: Some("/generated-bark-battle/player.png".to_string()), + opponent_character_image_src: Some("/generated-bark-battle/opponent.png".to_string()), + ui_background_image_src: Some("/generated-bark-battle/background.png".to_string()), + difficulty_preset: BarkBattleDifficultyPreset::Normal, + }; + + let payload = serde_json::to_value(update).expect("draft update should serialize"); + + assert_eq!(payload["draftId"], json!("bark-battle-draft-1")); + assert_eq!(payload["workId"], json!("BB-12345678")); assert_eq!( - payload["rulesetVersion"], - json!("bark-battle-ruleset-v1") + payload["playerCharacterImageSrc"], + json!("/generated-bark-battle/player.png") ); + assert_eq!( + payload["opponentCharacterImageSrc"], + json!("/generated-bark-battle/opponent.png") + ); + assert_eq!( + payload["uiBackgroundImageSrc"], + json!("/generated-bark-battle/background.png") + ); + assert!(!payload.as_object().unwrap().contains_key("barkSoundSrc")); + assert!( + !payload + .as_object() + .unwrap() + .contains_key("leaderboardEnabled") + ); + } + + #[test] + fn image_generation_request_uses_dedicated_asset_slot_and_result_prompt() { + let request = BarkBattleImageAssetGenerateRequest { + slot: BarkBattleAssetSlot::OpponentCharacter, + draft_id: Some("bark-battle-draft-1".to_string()), + config: BarkBattleConfigEditorPayload { + title: "汪汪冠军杯".to_string(), + description: Some(String::new()), + theme_description: "霓虹公园擂台".to_string(), + player_image_description: "红围巾柴犬".to_string(), + opponent_image_description: "蓝头带哈士奇".to_string(), + onomatopoeia: Some(vec!["轰汪!".to_string(), "炸场!".to_string()]), + player_character_image_src: None, + opponent_character_image_src: None, + ui_background_image_src: None, + difficulty_preset: BarkBattleDifficultyPreset::Normal, + }, + }; + let payload = serde_json::to_value(request).expect("request should serialize"); + + assert_eq!(payload["slot"], json!("opponent-character")); + assert_eq!( + payload["config"]["opponentImageDescription"], + json!("蓝头带哈士奇") + ); + + let response = BarkBattleGeneratedImageAsset { + image_src: "/generated-bark-battle-assets/draft/opponent/image.webp".to_string(), + asset_id: "asset-1".to_string(), + source_type: Some("generated".to_string()), + model: "gpt-image-2".to_string(), + size: "1024*1024".to_string(), + task_id: "task-1".to_string(), + prompt: "后端拼装后的对手形象 prompt".to_string(), + actual_prompt: None, + }; + let payload = serde_json::to_value(response).expect("response should serialize"); + + assert_eq!( + payload["imageSrc"], + json!("/generated-bark-battle-assets/draft/opponent/image.webp") + ); + assert_eq!(payload["prompt"], json!("后端拼装后的对手形象 prompt")); } #[test] @@ -551,15 +868,14 @@ mod tests { let config = BarkBattleConfigEditorPayload { title: "周末狗狗杯".to_string(), description: Some("轻配置草稿".to_string()), - theme_preset: "neon-park".to_string(), - player_dog_skin_preset: "shiba".to_string(), - opponent_dog_skin_preset: "husky".to_string(), + theme_description: "霓虹公园".to_string(), + player_image_description: "柴犬主角".to_string(), + opponent_image_description: "哈士奇对手".to_string(), + onomatopoeia: Some(vec!["轰汪!".to_string(), "冲啊!".to_string()]), player_character_image_src: Some("/generated-bark-battle/player.png".to_string()), opponent_character_image_src: Some("https://example.test/opponent.png".to_string()), ui_background_image_src: Some("/generated-bark-battle/ui.png".to_string()), - bark_sound_src: Some("/generated-bark-battle/bark.mp3".to_string()), difficulty_preset: BarkBattleDifficultyPreset::Hard, - leaderboard_enabled: true, }; let payload = serde_json::to_value(config).expect("config should serialize"); @@ -576,10 +892,7 @@ mod tests { payload["uiBackgroundImageSrc"], json!("/generated-bark-battle/ui.png") ); - assert_eq!( - payload["barkSoundSrc"], - json!("/generated-bark-battle/bark.mp3") - ); + assert!(!payload.as_object().unwrap().contains_key("barkSoundSrc")); } #[test] diff --git a/server-rs/crates/spacetime-client/src/bark_battle.rs b/server-rs/crates/spacetime-client/src/bark_battle.rs index 19af1cb5..1f47242c 100644 --- a/server-rs/crates/spacetime-client/src/bark_battle.rs +++ b/server-rs/crates/spacetime-client/src/bark_battle.rs @@ -1,4 +1,5 @@ use super::*; +use std::collections::HashMap; pub type BarkBattleDraftCreateRecordInput = BarkBattleDraftCreateInput; pub type BarkBattleDraftConfigUpsertRecordInput = BarkBattleDraftConfigUpsertInput; @@ -44,6 +45,32 @@ impl SpacetimeClient { .await } + pub async fn get_bark_battle_draft_config( + &self, + draft_id: String, + owner_user_id: String, + ) -> Result { + self.read_after_connect("get_bark_battle_draft_config", move |connection| { + let row = connection + .db() + .bark_battle_draft_config() + .draft_id() + .find(&draft_id) + .ok_or_else(|| { + SpacetimeClientError::procedure_failed(Some( + "bark_battle draft 不存在".to_string(), + )) + })?; + if row.owner_user_id != owner_user_id { + return Err(SpacetimeClientError::procedure_failed(Some( + "bark_battle draft owner 不匹配".to_string(), + ))); + } + Ok(map_bark_battle_draft_config_row(row)) + }) + .await + } + pub async fn publish_bark_battle_work( &self, input: BarkBattleWorkPublishRecordInput, @@ -142,4 +169,83 @@ impl SpacetimeClient { }) .await } + + pub async fn list_bark_battle_works( + &self, + owner_user_id: String, + ) -> Result, SpacetimeClientError> { + self.read_after_connect("list_bark_battle_works", move |connection| { + let owner_user_id = owner_user_id.as_str(); + let drafts: Vec = connection + .db() + .bark_battle_draft_config() + .iter() + .filter(|row| row.owner_user_id == owner_user_id) + .map(map_bark_battle_draft_config_row) + .collect(); + let published: Vec = connection + .db() + .bark_battle_published_config() + .iter() + .filter(|row| row.owner_user_id == owner_user_id) + .map(map_bark_battle_published_config_row) + .collect(); + + let mut works_by_id: HashMap = HashMap::new(); + for work in published.into_iter().chain(drafts) { + let Some(work_id) = work + .get("workId") + .and_then(serde_json::Value::as_str) + .filter(|value| !value.trim().is_empty()) + .map(ToString::to_string) + else { + continue; + }; + works_by_id.entry(work_id).or_insert(work); + } + + let mut works: Vec = works_by_id.into_values().collect(); + works.sort_by(|left: &serde_json::Value, right: &serde_json::Value| { + let left_updated_at = left + .get("updatedAtMicros") + .and_then(serde_json::Value::as_i64) + .unwrap_or_default(); + let right_updated_at = right + .get("updatedAtMicros") + .and_then(serde_json::Value::as_i64) + .unwrap_or_default(); + right_updated_at.cmp(&left_updated_at) + }); + Ok(works) + }) + .await + } + + pub async fn list_bark_battle_gallery( + &self, + ) -> Result, SpacetimeClientError> { + self.read_after_connect("list_bark_battle_gallery", move |connection| { + let recent_play_counts = public_work_recent_play_counts(connection, "bark-battle"); + let mut items = connection + .db() + .bark_battle_gallery_view() + .iter() + .collect::>(); + items.sort_by(|left, right| { + right + .updated_at_micros + .cmp(&left.updated_at_micros) + .then_with(|| left.work_id.cmp(&right.work_id)) + }); + Ok(items + .into_iter() + .map(|item| { + let recent_play_count_7d = + recent_play_counts.get(&item.work_id).copied().unwrap_or(0); + map_bark_battle_gallery_view_row(item, recent_play_count_7d) + }) + .collect()) + }) + .await + } } diff --git a/server-rs/crates/spacetime-client/src/lib.rs b/server-rs/crates/spacetime-client/src/lib.rs index b3b33e7d..4b39be01 100644 --- a/server-rs/crates/spacetime-client/src/lib.rs +++ b/server-rs/crates/spacetime-client/src/lib.rs @@ -550,6 +550,7 @@ impl SpacetimeClient { ) -> Result, SpacetimeClientError> { let mut subscriptions = Vec::new(); for query in [ + "SELECT * FROM bark_battle_gallery_view", "SELECT * FROM puzzle_gallery_card_view", "SELECT * FROM custom_world_gallery_entry", "SELECT * FROM match_3_d_gallery_view", @@ -570,6 +571,7 @@ impl SpacetimeClient { "SELECT * FROM public_work_play_daily_stat WHERE source_type = 'square-hole'", "SELECT * FROM public_work_play_daily_stat WHERE source_type = 'visual-novel'", "SELECT * FROM public_work_play_daily_stat WHERE source_type = 'big-fish'", + "SELECT * FROM public_work_play_daily_stat WHERE source_type = 'bark-battle'", "SELECT * FROM creation_entry_config", "SELECT * FROM creation_entry_type_config", ] { diff --git a/server-rs/crates/spacetime-client/src/mapper.rs b/server-rs/crates/spacetime-client/src/mapper.rs index 5f8af651..700a8b8f 100644 --- a/server-rs/crates/spacetime-client/src/mapper.rs +++ b/server-rs/crates/spacetime-client/src/mapper.rs @@ -112,8 +112,9 @@ pub(crate) use self::auth::{ map_auth_store_snapshot_import_procedure_result, map_auth_store_snapshot_procedure_result, }; pub(crate) use self::bark_battle::{ - map_bark_battle_draft_config_procedure_result, map_bark_battle_run_procedure_result, - map_bark_battle_runtime_config_procedure_result, + map_bark_battle_draft_config_procedure_result, map_bark_battle_draft_config_row, + map_bark_battle_gallery_view_row, map_bark_battle_published_config_row, + map_bark_battle_run_procedure_result, map_bark_battle_runtime_config_procedure_result, }; pub(crate) use self::big_fish::{ map_big_fish_gallery_view_row, map_big_fish_run_procedure_result, diff --git a/server-rs/crates/spacetime-client/src/mapper/bark_battle.rs b/server-rs/crates/spacetime-client/src/mapper/bark_battle.rs index b8a5c090..ae4c3253 100644 --- a/server-rs/crates/spacetime-client/src/mapper/bark_battle.rs +++ b/server-rs/crates/spacetime-client/src/mapper/bark_battle.rs @@ -36,6 +36,70 @@ pub(crate) fn map_bark_battle_run_procedure_result( .map(bark_battle_run_to_value) } +pub(crate) fn map_bark_battle_draft_config_row( + row: BarkBattleDraftConfigRow, +) -> BarkBattleDraftConfigRecord { + serde_json::json!({ + "draftId": row.draft_id, + "ownerUserId": row.owner_user_id, + "workId": row.work_id, + "configVersion": row.config_version, + "rulesetVersion": row.ruleset_version, + "difficultyPreset": row.difficulty_preset, + "configJson": row.config_json, + "editorStateJson": row.editor_state_json, + "createdAtMicros": row.created_at.to_micros_since_unix_epoch(), + "updatedAtMicros": row.updated_at.to_micros_since_unix_epoch(), + }) +} + +pub(crate) fn map_bark_battle_published_config_row( + row: BarkBattlePublishedConfigRow, +) -> BarkBattleRuntimeConfigRecord { + serde_json::json!({ + "workId": row.work_id, + "ownerUserId": row.owner_user_id, + "sourceDraftId": row.source_draft_id, + "configVersion": row.config_version, + "rulesetVersion": row.ruleset_version, + "difficultyPreset": row.difficulty_preset, + "configJson": row.config_json, + "publishedSnapshotJson": row.published_snapshot_json, + "publishedAtMicros": row.published_at.to_micros_since_unix_epoch(), + "updatedAtMicros": row.updated_at.to_micros_since_unix_epoch(), + }) +} + +pub(crate) fn map_bark_battle_gallery_view_row( + row: BarkBattleGalleryViewRow, + recent_play_count_7d: u32, +) -> serde_json::Value { + serde_json::json!({ + "workId": row.work_id, + "ownerUserId": row.owner_user_id, + "sourceDraftId": row.source_draft_id, + "configVersion": row.config_version, + "rulesetVersion": row.ruleset_version, + "difficultyPreset": row.difficulty_preset, + "title": row.title, + "description": row.description, + "themeDescription": row.theme_description, + "playerImageDescription": row.player_image_description, + "opponentImageDescription": row.opponent_image_description, + "onomatopoeia": row.onomatopoeia, + "playerCharacterImageSrc": row.player_character_image_src, + "opponentCharacterImageSrc": row.opponent_character_image_src, + "uiBackgroundImageSrc": row.ui_background_image_src, + "status": "published", + "publishReady": true, + "playCount": row.play_count, + "finishCount": row.finish_count, + "recentPlayCount7d": recent_play_count_7d, + "updatedAtMicros": row.updated_at_micros, + "publishedAtMicros": row.published_at_micros, + }) +} + fn bark_battle_draft_config_to_value(snapshot: BarkBattleDraftConfigSnapshot) -> serde_json::Value { serde_json::json!({ "draftId": snapshot.draft_id, @@ -44,7 +108,6 @@ fn bark_battle_draft_config_to_value(snapshot: BarkBattleDraftConfigSnapshot) -> "configVersion": snapshot.config_version, "rulesetVersion": snapshot.ruleset_version, "difficultyPreset": snapshot.difficulty_preset, - "leaderboardEnabled": snapshot.leaderboard_enabled, "configJson": snapshot.config_json, "editorStateJson": snapshot.editor_state_json, "createdAtMicros": snapshot.created_at_micros, @@ -62,7 +125,6 @@ fn bark_battle_runtime_config_to_value( "configVersion": snapshot.config_version, "rulesetVersion": snapshot.ruleset_version, "difficultyPreset": snapshot.difficulty_preset, - "leaderboardEnabled": snapshot.leaderboard_enabled, "configJson": snapshot.config_json, "publishedSnapshotJson": snapshot.published_snapshot_json, "publishedAtMicros": snapshot.published_at_micros, @@ -78,7 +140,6 @@ fn bark_battle_run_to_value(snapshot: BarkBattleRunSnapshot) -> serde_json::Valu "configVersion": snapshot.config_version, "rulesetVersion": snapshot.ruleset_version, "difficultyPreset": snapshot.difficulty_preset, - "leaderboardEnabled": snapshot.leaderboard_enabled, "status": snapshot.status, "clientStartedAtMicros": snapshot.client_started_at_micros, "serverStartedAtMicros": snapshot.server_started_at_micros, @@ -92,3 +153,38 @@ fn bark_battle_run_to_value(snapshot: BarkBattleRunSnapshot) -> serde_json::Valu "scoreId": snapshot.score_id, }) } + +#[cfg(test)] +mod tests { + use super::*; + + #[test] + fn bark_battle_gallery_mapper_keeps_custom_onomatopoeia() { + let row = BarkBattleGalleryViewRow { + work_id: "BB-33333333".to_string(), + owner_user_id: "user-3".to_string(), + source_draft_id: Some("bark-battle-draft-3".to_string()), + config_version: 1, + ruleset_version: "bark-battle-ruleset-v1".to_string(), + difficulty_preset: "normal".to_string(), + title: "声浪公开赛".to_string(), + description: "画廊映射测试".to_string(), + theme_description: "霓虹竞技场".to_string(), + player_image_description: "星际猫骑士".to_string(), + opponent_image_description: "机器人拳手".to_string(), + onomatopoeia: vec!["轰!".to_string(), "炸场!".to_string()], + player_character_image_src: Some("/assets/player.png".to_string()), + opponent_character_image_src: Some("/assets/opponent.png".to_string()), + ui_background_image_src: Some("/assets/background.png".to_string()), + play_count: 8, + finish_count: 5, + updated_at_micros: 1_713_686_401_234_567, + published_at_micros: 1_713_686_401_234_000, + }; + + let value = map_bark_battle_gallery_view_row(row, 3); + + assert_eq!(value["onomatopoeia"], serde_json::json!(["轰!", "炸场!"])); + assert_eq!(value["recentPlayCount7d"], serde_json::json!(3)); + } +} diff --git a/server-rs/crates/spacetime-client/src/module_bindings.rs b/server-rs/crates/spacetime-client/src/module_bindings.rs index 6a53dc72..f6725232 100644 --- a/server-rs/crates/spacetime-client/src/module_bindings.rs +++ b/server-rs/crates/spacetime-client/src/module_bindings.rs @@ -99,6 +99,8 @@ pub mod bark_battle_draft_config_snapshot_type; pub mod bark_battle_draft_config_table; pub mod bark_battle_draft_config_upsert_input_type; pub mod bark_battle_draft_create_input_type; +pub mod bark_battle_gallery_view_row_type; +pub mod bark_battle_gallery_view_table; pub mod bark_battle_leaderboard_entry_row_type; pub mod bark_battle_leaderboard_entry_table; pub mod bark_battle_personal_best_projection_row_type; @@ -1025,6 +1027,8 @@ pub use bark_battle_draft_config_snapshot_type::BarkBattleDraftConfigSnapshot; pub use bark_battle_draft_config_table::*; pub use bark_battle_draft_config_upsert_input_type::BarkBattleDraftConfigUpsertInput; pub use bark_battle_draft_create_input_type::BarkBattleDraftCreateInput; +pub use bark_battle_gallery_view_row_type::BarkBattleGalleryViewRow; +pub use bark_battle_gallery_view_table::*; pub use bark_battle_leaderboard_entry_row_type::BarkBattleLeaderboardEntryRow; pub use bark_battle_leaderboard_entry_table::*; pub use bark_battle_personal_best_projection_row_type::BarkBattlePersonalBestProjectionRow; @@ -2143,6 +2147,7 @@ pub struct DbUpdate { auth_store_projection_meta: __sdk::TableUpdate, auth_store_snapshot: __sdk::TableUpdate, bark_battle_draft_config: __sdk::TableUpdate, + bark_battle_gallery_view: __sdk::TableUpdate, bark_battle_leaderboard_entry: __sdk::TableUpdate, bark_battle_personal_best_projection: __sdk::TableUpdate, bark_battle_published_config: __sdk::TableUpdate, @@ -2272,6 +2277,9 @@ impl TryFrom<__ws::v2::TransactionUpdate> for DbUpdate { "bark_battle_draft_config" => db_update.bark_battle_draft_config.append( bark_battle_draft_config_table::parse_table_update(table_update)?, ), + "bark_battle_gallery_view" => db_update.bark_battle_gallery_view.append( + bark_battle_gallery_view_table::parse_table_update(table_update)?, + ), "bark_battle_leaderboard_entry" => db_update.bark_battle_leaderboard_entry.append( bark_battle_leaderboard_entry_table::parse_table_update(table_update)?, ), @@ -3008,6 +3016,10 @@ impl __sdk::DbUpdate for DbUpdate { &self.visual_novel_work_profile, ) .with_updates_by_pk(|row| &row.profile_id); + diff.bark_battle_gallery_view = cache.apply_diff_to_table::( + "bark_battle_gallery_view", + &self.bark_battle_gallery_view, + ); diff.big_fish_gallery_view = cache.apply_diff_to_table::( "big_fish_gallery_view", &self.big_fish_gallery_view, @@ -3078,6 +3090,9 @@ impl __sdk::DbUpdate for DbUpdate { "bark_battle_draft_config" => db_update .bark_battle_draft_config .append(__sdk::parse_row_list_as_inserts(table_rows.rows)?), + "bark_battle_gallery_view" => db_update + .bark_battle_gallery_view + .append(__sdk::parse_row_list_as_inserts(table_rows.rows)?), "bark_battle_leaderboard_entry" => db_update .bark_battle_leaderboard_entry .append(__sdk::parse_row_list_as_inserts(table_rows.rows)?), @@ -3376,6 +3391,9 @@ impl __sdk::DbUpdate for DbUpdate { "bark_battle_draft_config" => db_update .bark_battle_draft_config .append(__sdk::parse_row_list_as_deletes(table_rows.rows)?), + "bark_battle_gallery_view" => db_update + .bark_battle_gallery_view + .append(__sdk::parse_row_list_as_deletes(table_rows.rows)?), "bark_battle_leaderboard_entry" => db_update .bark_battle_leaderboard_entry .append(__sdk::parse_row_list_as_deletes(table_rows.rows)?), @@ -3650,6 +3668,7 @@ pub struct AppliedDiff<'r> { auth_store_projection_meta: __sdk::TableAppliedDiff<'r, AuthStoreProjectionMeta>, auth_store_snapshot: __sdk::TableAppliedDiff<'r, AuthStoreSnapshot>, bark_battle_draft_config: __sdk::TableAppliedDiff<'r, BarkBattleDraftConfigRow>, + bark_battle_gallery_view: __sdk::TableAppliedDiff<'r, BarkBattleGalleryViewRow>, bark_battle_leaderboard_entry: __sdk::TableAppliedDiff<'r, BarkBattleLeaderboardEntryRow>, bark_battle_personal_best_projection: __sdk::TableAppliedDiff<'r, BarkBattlePersonalBestProjectionRow>, @@ -3805,6 +3824,11 @@ impl<'r> __sdk::AppliedDiff<'r> for AppliedDiff<'r> { &self.bark_battle_draft_config, event, ); + callbacks.invoke_table_row_callbacks::( + "bark_battle_gallery_view", + &self.bark_battle_gallery_view, + event, + ); callbacks.invoke_table_row_callbacks::( "bark_battle_leaderboard_entry", &self.bark_battle_leaderboard_entry, @@ -4876,6 +4900,7 @@ impl __sdk::SpacetimeModule for RemoteModule { auth_store_projection_meta_table::register_table(client_cache); auth_store_snapshot_table::register_table(client_cache); bark_battle_draft_config_table::register_table(client_cache); + bark_battle_gallery_view_table::register_table(client_cache); bark_battle_leaderboard_entry_table::register_table(client_cache); bark_battle_personal_best_projection_table::register_table(client_cache); bark_battle_published_config_table::register_table(client_cache); @@ -4973,6 +4998,7 @@ impl __sdk::SpacetimeModule for RemoteModule { "auth_store_projection_meta", "auth_store_snapshot", "bark_battle_draft_config", + "bark_battle_gallery_view", "bark_battle_leaderboard_entry", "bark_battle_personal_best_projection", "bark_battle_published_config", diff --git a/server-rs/crates/spacetime-client/src/module_bindings/bark_battle_draft_config_snapshot_type.rs b/server-rs/crates/spacetime-client/src/module_bindings/bark_battle_draft_config_snapshot_type.rs index 1271082f..53a978e6 100644 --- a/server-rs/crates/spacetime-client/src/module_bindings/bark_battle_draft_config_snapshot_type.rs +++ b/server-rs/crates/spacetime-client/src/module_bindings/bark_battle_draft_config_snapshot_type.rs @@ -13,7 +13,6 @@ pub struct BarkBattleDraftConfigSnapshot { pub config_version: u64, pub ruleset_version: String, pub difficulty_preset: String, - pub leaderboard_enabled: bool, pub config_json: String, pub editor_state_json: String, pub created_at_micros: i64, diff --git a/server-rs/crates/spacetime-client/src/module_bindings/bark_battle_draft_config_upsert_input_type.rs b/server-rs/crates/spacetime-client/src/module_bindings/bark_battle_draft_config_upsert_input_type.rs index ee07d0c0..15331a91 100644 --- a/server-rs/crates/spacetime-client/src/module_bindings/bark_battle_draft_config_upsert_input_type.rs +++ b/server-rs/crates/spacetime-client/src/module_bindings/bark_battle_draft_config_upsert_input_type.rs @@ -13,7 +13,6 @@ pub struct BarkBattleDraftConfigUpsertInput { pub config_version: u64, pub ruleset_version: String, pub difficulty_preset: String, - pub leaderboard_enabled: bool, pub config_json: String, pub updated_at_micros: i64, } diff --git a/server-rs/crates/spacetime-client/src/module_bindings/bark_battle_draft_create_input_type.rs b/server-rs/crates/spacetime-client/src/module_bindings/bark_battle_draft_create_input_type.rs index 7e165bfb..017388c8 100644 --- a/server-rs/crates/spacetime-client/src/module_bindings/bark_battle_draft_create_input_type.rs +++ b/server-rs/crates/spacetime-client/src/module_bindings/bark_battle_draft_create_input_type.rs @@ -12,11 +12,10 @@ pub struct BarkBattleDraftCreateInput { pub work_id: String, pub title: Option, pub description: Option, - pub theme_preset: String, - pub player_dog_skin_preset: String, - pub opponent_dog_skin_preset: String, + pub theme_description: String, + pub player_image_description: String, + pub opponent_image_description: String, pub difficulty_preset: Option, - pub leaderboard_enabled: Option, pub editor_state_json: Option, pub created_at_micros: i64, } diff --git a/server-rs/crates/spacetime-client/src/module_bindings/bark_battle_gallery_view_row_type.rs b/server-rs/crates/spacetime-client/src/module_bindings/bark_battle_gallery_view_row_type.rs new file mode 100644 index 00000000..f26c77e4 --- /dev/null +++ b/server-rs/crates/spacetime-client/src/module_bindings/bark_battle_gallery_view_row_type.rs @@ -0,0 +1,106 @@ +// THIS FILE IS AUTOMATICALLY GENERATED BY SPACETIMEDB. EDITS TO THIS FILE +// WILL NOT BE SAVED. MODIFY TABLES IN YOUR MODULE SOURCE CODE INSTEAD. + +#![allow(unused, clippy::all)] +use spacetimedb_sdk::__codegen::{self as __sdk, __lib, __sats, __ws}; + +#[derive(__lib::ser::Serialize, __lib::de::Deserialize, Clone, PartialEq, Debug)] +#[sats(crate = __lib)] +pub struct BarkBattleGalleryViewRow { + pub work_id: String, + pub owner_user_id: String, + pub source_draft_id: Option, + pub config_version: u64, + pub ruleset_version: String, + pub difficulty_preset: String, + pub title: String, + pub description: String, + pub theme_description: String, + pub player_image_description: String, + pub opponent_image_description: String, + pub onomatopoeia: Vec, + pub player_character_image_src: Option, + pub opponent_character_image_src: Option, + pub ui_background_image_src: Option, + pub play_count: u64, + pub finish_count: u64, + pub updated_at_micros: i64, + pub published_at_micros: i64, +} + +impl __sdk::InModule for BarkBattleGalleryViewRow { + type Module = super::RemoteModule; +} + +/// Column accessor struct for the table `BarkBattleGalleryViewRow`. +/// +/// Provides typed access to columns for query building. +pub struct BarkBattleGalleryViewRowCols { + pub work_id: __sdk::__query_builder::Col, + pub owner_user_id: __sdk::__query_builder::Col, + pub source_draft_id: __sdk::__query_builder::Col>, + pub config_version: __sdk::__query_builder::Col, + pub ruleset_version: __sdk::__query_builder::Col, + pub difficulty_preset: __sdk::__query_builder::Col, + pub title: __sdk::__query_builder::Col, + pub description: __sdk::__query_builder::Col, + pub theme_description: __sdk::__query_builder::Col, + pub player_image_description: __sdk::__query_builder::Col, + pub opponent_image_description: __sdk::__query_builder::Col, + pub onomatopoeia: __sdk::__query_builder::Col>, + pub player_character_image_src: + __sdk::__query_builder::Col>, + pub opponent_character_image_src: + __sdk::__query_builder::Col>, + pub ui_background_image_src: + __sdk::__query_builder::Col>, + pub play_count: __sdk::__query_builder::Col, + pub finish_count: __sdk::__query_builder::Col, + pub updated_at_micros: __sdk::__query_builder::Col, + pub published_at_micros: __sdk::__query_builder::Col, +} + +impl __sdk::__query_builder::HasCols for BarkBattleGalleryViewRow { + type Cols = BarkBattleGalleryViewRowCols; + fn cols(table_name: &'static str) -> Self::Cols { + BarkBattleGalleryViewRowCols { + work_id: __sdk::__query_builder::Col::new(table_name, "work_id"), + owner_user_id: __sdk::__query_builder::Col::new(table_name, "owner_user_id"), + source_draft_id: __sdk::__query_builder::Col::new(table_name, "source_draft_id"), + config_version: __sdk::__query_builder::Col::new(table_name, "config_version"), + ruleset_version: __sdk::__query_builder::Col::new(table_name, "ruleset_version"), + difficulty_preset: __sdk::__query_builder::Col::new(table_name, "difficulty_preset"), + title: __sdk::__query_builder::Col::new(table_name, "title"), + description: __sdk::__query_builder::Col::new(table_name, "description"), + theme_description: __sdk::__query_builder::Col::new(table_name, "theme_description"), + player_image_description: __sdk::__query_builder::Col::new( + table_name, + "player_image_description", + ), + opponent_image_description: __sdk::__query_builder::Col::new( + table_name, + "opponent_image_description", + ), + onomatopoeia: __sdk::__query_builder::Col::new(table_name, "onomatopoeia"), + player_character_image_src: __sdk::__query_builder::Col::new( + table_name, + "player_character_image_src", + ), + opponent_character_image_src: __sdk::__query_builder::Col::new( + table_name, + "opponent_character_image_src", + ), + ui_background_image_src: __sdk::__query_builder::Col::new( + table_name, + "ui_background_image_src", + ), + play_count: __sdk::__query_builder::Col::new(table_name, "play_count"), + finish_count: __sdk::__query_builder::Col::new(table_name, "finish_count"), + updated_at_micros: __sdk::__query_builder::Col::new(table_name, "updated_at_micros"), + published_at_micros: __sdk::__query_builder::Col::new( + table_name, + "published_at_micros", + ), + } + } +} diff --git a/server-rs/crates/spacetime-client/src/module_bindings/bark_battle_gallery_view_table.rs b/server-rs/crates/spacetime-client/src/module_bindings/bark_battle_gallery_view_table.rs new file mode 100644 index 00000000..7cd139c0 --- /dev/null +++ b/server-rs/crates/spacetime-client/src/module_bindings/bark_battle_gallery_view_table.rs @@ -0,0 +1,114 @@ +// THIS FILE IS AUTOMATICALLY GENERATED BY SPACETIMEDB. EDITS TO THIS FILE +// WILL NOT BE SAVED. MODIFY TABLES IN YOUR MODULE SOURCE CODE INSTEAD. + +#![allow(unused, clippy::all)] +use super::bark_battle_gallery_view_row_type::BarkBattleGalleryViewRow; +use spacetimedb_sdk::__codegen::{self as __sdk, __lib, __sats, __ws}; + +/// Table handle for the table `bark_battle_gallery_view`. +/// +/// Obtain a handle from the [`BarkBattleGalleryViewTableAccess::bark_battle_gallery_view`] method on [`super::RemoteTables`], +/// like `ctx.db.bark_battle_gallery_view()`. +/// +/// Users are encouraged not to explicitly reference this type, +/// but to directly chain method calls, +/// like `ctx.db.bark_battle_gallery_view().on_insert(...)`. +pub struct BarkBattleGalleryViewTableHandle<'ctx> { + imp: __sdk::TableHandle, + ctx: std::marker::PhantomData<&'ctx super::RemoteTables>, +} + +#[allow(non_camel_case_types)] +/// Extension trait for access to the table `bark_battle_gallery_view`. +/// +/// Implemented for [`super::RemoteTables`]. +pub trait BarkBattleGalleryViewTableAccess { + #[allow(non_snake_case)] + /// Obtain a [`BarkBattleGalleryViewTableHandle`], which mediates access to the table `bark_battle_gallery_view`. + fn bark_battle_gallery_view(&self) -> BarkBattleGalleryViewTableHandle<'_>; +} + +impl BarkBattleGalleryViewTableAccess for super::RemoteTables { + fn bark_battle_gallery_view(&self) -> BarkBattleGalleryViewTableHandle<'_> { + BarkBattleGalleryViewTableHandle { + imp: self + .imp + .get_table::("bark_battle_gallery_view"), + ctx: std::marker::PhantomData, + } + } +} + +pub struct BarkBattleGalleryViewInsertCallbackId(__sdk::CallbackId); +pub struct BarkBattleGalleryViewDeleteCallbackId(__sdk::CallbackId); + +impl<'ctx> __sdk::Table for BarkBattleGalleryViewTableHandle<'ctx> { + type Row = BarkBattleGalleryViewRow; + type EventContext = super::EventContext; + + fn count(&self) -> u64 { + self.imp.count() + } + fn iter(&self) -> impl Iterator + '_ { + self.imp.iter() + } + + type InsertCallbackId = BarkBattleGalleryViewInsertCallbackId; + + fn on_insert( + &self, + callback: impl FnMut(&Self::EventContext, &Self::Row) + Send + 'static, + ) -> BarkBattleGalleryViewInsertCallbackId { + BarkBattleGalleryViewInsertCallbackId(self.imp.on_insert(Box::new(callback))) + } + + fn remove_on_insert(&self, callback: BarkBattleGalleryViewInsertCallbackId) { + self.imp.remove_on_insert(callback.0) + } + + type DeleteCallbackId = BarkBattleGalleryViewDeleteCallbackId; + + fn on_delete( + &self, + callback: impl FnMut(&Self::EventContext, &Self::Row) + Send + 'static, + ) -> BarkBattleGalleryViewDeleteCallbackId { + BarkBattleGalleryViewDeleteCallbackId(self.imp.on_delete(Box::new(callback))) + } + + fn remove_on_delete(&self, callback: BarkBattleGalleryViewDeleteCallbackId) { + self.imp.remove_on_delete(callback.0) + } +} + +#[doc(hidden)] +pub(super) fn register_table(client_cache: &mut __sdk::ClientCache) { + let _table = + client_cache.get_or_make_table::("bark_battle_gallery_view"); +} + +#[doc(hidden)] +pub(super) fn parse_table_update( + raw_updates: __ws::v2::TableUpdate, +) -> __sdk::Result<__sdk::TableUpdate> { + __sdk::TableUpdate::parse_table_update(raw_updates).map_err(|e| { + __sdk::InternalError::failed_parse("TableUpdate", "TableUpdate") + .with_cause(e) + .into() + }) +} + +#[allow(non_camel_case_types)] +/// Extension trait for query builder access to the table `BarkBattleGalleryViewRow`. +/// +/// Implemented for [`__sdk::QueryTableAccessor`]. +pub trait bark_battle_gallery_viewQueryTableAccess { + #[allow(non_snake_case)] + /// Get a query builder for the table `BarkBattleGalleryViewRow`. + fn bark_battle_gallery_view(&self) -> __sdk::__query_builder::Table; +} + +impl bark_battle_gallery_viewQueryTableAccess for __sdk::QueryTableAccessor { + fn bark_battle_gallery_view(&self) -> __sdk::__query_builder::Table { + __sdk::__query_builder::Table::new("bark_battle_gallery_view") + } +} diff --git a/server-rs/crates/spacetime-client/src/module_bindings/bark_battle_run_snapshot_type.rs b/server-rs/crates/spacetime-client/src/module_bindings/bark_battle_run_snapshot_type.rs index 474af775..71bb833d 100644 --- a/server-rs/crates/spacetime-client/src/module_bindings/bark_battle_run_snapshot_type.rs +++ b/server-rs/crates/spacetime-client/src/module_bindings/bark_battle_run_snapshot_type.rs @@ -13,7 +13,6 @@ pub struct BarkBattleRunSnapshot { pub config_version: u64, pub ruleset_version: String, pub difficulty_preset: String, - pub leaderboard_enabled: bool, pub status: String, pub client_started_at_micros: i64, pub server_started_at_micros: i64, diff --git a/server-rs/crates/spacetime-client/src/module_bindings/bark_battle_runtime_config_snapshot_type.rs b/server-rs/crates/spacetime-client/src/module_bindings/bark_battle_runtime_config_snapshot_type.rs index e176ca63..a707ad15 100644 --- a/server-rs/crates/spacetime-client/src/module_bindings/bark_battle_runtime_config_snapshot_type.rs +++ b/server-rs/crates/spacetime-client/src/module_bindings/bark_battle_runtime_config_snapshot_type.rs @@ -13,7 +13,6 @@ pub struct BarkBattleRuntimeConfigSnapshot { pub config_version: u64, pub ruleset_version: String, pub difficulty_preset: String, - pub leaderboard_enabled: bool, pub config_json: String, pub published_snapshot_json: String, pub published_at_micros: i64, diff --git a/server-rs/crates/spacetime-client/src/telemetry.rs b/server-rs/crates/spacetime-client/src/telemetry.rs index c89e0f19..d75fc4a1 100644 --- a/server-rs/crates/spacetime-client/src/telemetry.rs +++ b/server-rs/crates/spacetime-client/src/telemetry.rs @@ -81,7 +81,9 @@ fn spacetime_metrics() -> &'static SpacetimeMetrics { read_duration_ms: meter .f64_histogram("genarrative.spacetime.read.duration_ms") .with_unit("ms") - .with_description("SpacetimeDB local subscription cache read duration in milliseconds") + .with_description( + "SpacetimeDB local subscription cache read duration in milliseconds", + ) .build(), } }) diff --git a/server-rs/crates/spacetime-module/src/bark_battle.rs b/server-rs/crates/spacetime-module/src/bark_battle.rs index be16ffcc..da981776 100644 --- a/server-rs/crates/spacetime-module/src/bark_battle.rs +++ b/server-rs/crates/spacetime-module/src/bark_battle.rs @@ -1,6 +1,7 @@ use crate::*; use serde::{Serialize, de::DeserializeOwned}; use sha2::{Digest, Sha256}; +use spacetimedb::AnonymousViewContext; pub(crate) mod tables; mod types; @@ -8,6 +9,38 @@ mod types; pub use tables::*; pub use types::*; +/// Bark Battle 公开广场列表投影。 +/// +/// HTTP gallery 订阅该 public view 后读取本地 cache;view 只从已发布配置和统计投影 +/// 组装 v1 公开字段,避免每个公开列表请求重新调用 procedure 热路径。 +#[spacetimedb::view(accessor = bark_battle_gallery_view, public)] +pub fn bark_battle_gallery_view(ctx: &AnonymousViewContext) -> Vec { + let mut items = ctx + .db + .bark_battle_published_config() + .by_bark_battle_published_owner_user_id() + .filter(""..) + .filter_map(|row| match build_bark_battle_gallery_view_row(ctx, &row) { + Ok(item) => Some(item), + Err(error) => { + log::warn!( + "汪汪声浪公开广场 view 跳过损坏的作品投影 work_id={}: {}", + row.work_id, + error + ); + None + } + }) + .collect::>(); + items.sort_by(|left, right| { + right + .updated_at_micros + .cmp(&left.updated_at_micros) + .then_with(|| left.work_id.cmp(&right.work_id)) + }); + items +} + #[spacetimedb::procedure] pub fn create_bark_battle_draft( ctx: &mut ProcedureContext, @@ -106,21 +139,23 @@ fn create_bark_battle_draft_tx( let config = BarkBattleEditorConfigSnapshot { title: normalize_title(input.title.as_deref())?, description: normalize_optional_text(input.description.as_deref()), - theme_preset: normalize_required_preset(&input.theme_preset, "theme_preset")?, - player_dog_skin_preset: normalize_required_preset( - &input.player_dog_skin_preset, - "player_dog_skin_preset", + theme_description: normalize_required_description( + &input.theme_description, + "theme_description", )?, - opponent_dog_skin_preset: normalize_required_preset( - &input.opponent_dog_skin_preset, - "opponent_dog_skin_preset", + player_image_description: normalize_required_description( + &input.player_image_description, + "player_image_description", )?, + opponent_image_description: normalize_required_description( + &input.opponent_image_description, + "opponent_image_description", + )?, + onomatopoeia: Vec::new(), player_character_image_src: None, opponent_character_image_src: None, ui_background_image_src: None, - bark_sound_src: None, difficulty_preset: normalize_difficulty(input.difficulty_preset.as_deref())?, - leaderboard_enabled: input.leaderboard_enabled.unwrap_or(true), }; let row = BarkBattleDraftConfigRow { draft_id: input.draft_id.clone(), @@ -129,7 +164,7 @@ fn create_bark_battle_draft_tx( config_version: 1, ruleset_version: BARK_BATTLE_DEFAULT_RULESET_VERSION.to_string(), difficulty_preset: config.difficulty_preset.clone(), - leaderboard_enabled: config.leaderboard_enabled, + leaderboard_enabled: true, config_json: to_json_string(&config), editor_state_json: normalize_json_string( input.editor_state_json.as_deref(), @@ -151,10 +186,8 @@ fn update_bark_battle_draft_config_tx( require_non_empty(&input.work_id, "bark_battle work_id")?; let mut editor_config = parse_editor_config(&input.config_json)?; normalize_editor_config_snapshot(&mut editor_config)?; - if editor_config.difficulty_preset != input.difficulty_preset - || editor_config.leaderboard_enabled != input.leaderboard_enabled - { - return Err("bark_battle config_json 与行字段不一致".to_string()); + if editor_config.difficulty_preset != input.difficulty_preset { + return Err("bark_battle config_json 与 difficulty_preset 不匹配".to_string()); } let updated_at = Timestamp::from_micros_since_unix_epoch(input.updated_at_micros); let existing = ctx @@ -166,14 +199,14 @@ fn update_bark_battle_draft_config_tx( if existing.owner_user_id != input.owner_user_id || existing.work_id != input.work_id { return Err("bark_battle draft owner/work 不匹配".to_string()); } - if input.config_version <= existing.config_version { - return Err("bark_battle draft config_version 必须递增".to_string()); - } let mut row = existing; - row.config_version = input.config_version; + // 中文注释:HTTP BFF 会先读缓存再发更新,订阅缓存可能短暂落后; + // 这里按“至少递增 1”兜底,避免前端重复保存素材时被版本号误伤。 + row.config_version = input + .config_version + .max(row.config_version.saturating_add(1)); row.ruleset_version = normalize_ruleset_version(&input.ruleset_version)?; row.difficulty_preset = normalize_difficulty(Some(&input.difficulty_preset))?; - row.leaderboard_enabled = input.leaderboard_enabled; row.config_json = to_json_string(&editor_config); row.updated_at = updated_at; ctx.db @@ -200,6 +233,20 @@ fn publish_bark_battle_work_tx( if draft.owner_user_id != input.owner_user_id || draft.work_id != input.work_id { return Err("bark_battle draft owner/work 不匹配".to_string()); } + let published_snapshot_json = match input.published_snapshot_json.as_deref() { + Some(value) => { + let mut editor_config = parse_editor_config(value)?; + normalize_editor_config_snapshot(&mut editor_config)?; + if editor_config.difficulty_preset != draft.difficulty_preset { + return Err( + "bark_battle published_snapshot_json 与草稿 difficulty_preset 不匹配" + .to_string(), + ); + } + to_json_string(&editor_config) + } + None => draft.config_json.clone(), + }; let published = BarkBattlePublishedConfigRow { work_id: draft.work_id.clone(), owner_user_id: draft.owner_user_id.clone(), @@ -207,12 +254,9 @@ fn publish_bark_battle_work_tx( config_version: draft.config_version, ruleset_version: normalize_ruleset_version(&draft.ruleset_version)?, difficulty_preset: normalize_difficulty(Some(&draft.difficulty_preset))?, - leaderboard_enabled: draft.leaderboard_enabled, - config_json: draft.config_json.clone(), - published_snapshot_json: match input.published_snapshot_json.as_deref() { - Some(value) => normalize_json_string(Some(value), "published_snapshot_json")?, - None => draft.config_json.clone(), - }, + leaderboard_enabled: true, + config_json: published_snapshot_json.clone(), + published_snapshot_json, created_at: published_at, updated_at: published_at, published_at, @@ -297,7 +341,7 @@ fn start_bark_battle_run_tx( config_version: input.config_version, ruleset_version: input.ruleset_version, difficulty_preset: input.difficulty_preset, - leaderboard_enabled: published.leaderboard_enabled, + leaderboard_enabled: true, status: BARK_BATTLE_RUN_RUNNING.to_string(), client_started_at_micros: input.client_started_at_micros, server_started_at: started_at, @@ -483,7 +527,6 @@ fn draft_snapshot(row: &BarkBattleDraftConfigRow) -> BarkBattleDraftConfigSnapsh config_version: row.config_version, ruleset_version: row.ruleset_version.clone(), difficulty_preset: row.difficulty_preset.clone(), - leaderboard_enabled: row.leaderboard_enabled, config_json: row.config_json.clone(), editor_state_json: row.editor_state_json.clone(), created_at_micros: row.created_at.to_micros_since_unix_epoch(), @@ -499,7 +542,6 @@ fn runtime_config_snapshot(row: &BarkBattlePublishedConfigRow) -> BarkBattleRunt config_version: row.config_version, ruleset_version: row.ruleset_version.clone(), difficulty_preset: row.difficulty_preset.clone(), - leaderboard_enabled: row.leaderboard_enabled, config_json: row.config_json.clone(), published_snapshot_json: row.published_snapshot_json.clone(), published_at_micros: row.published_at.to_micros_since_unix_epoch(), @@ -507,6 +549,43 @@ fn runtime_config_snapshot(row: &BarkBattlePublishedConfigRow) -> BarkBattleRunt } } +fn build_bark_battle_gallery_view_row( + ctx: &AnonymousViewContext, + row: &BarkBattlePublishedConfigRow, +) -> Result { + let mut editor_config = parse_editor_config(&row.config_json)?; + normalize_editor_config_snapshot(&mut editor_config)?; + let stats = ctx + .db + .bark_battle_work_stats_projection() + .work_id() + .find(&row.work_id); + Ok(BarkBattleGalleryViewRow { + work_id: row.work_id.clone(), + owner_user_id: row.owner_user_id.clone(), + source_draft_id: row.source_draft_id.clone(), + config_version: row.config_version, + ruleset_version: row.ruleset_version.clone(), + difficulty_preset: row.difficulty_preset.clone(), + title: editor_config.title, + description: editor_config.description, + theme_description: editor_config.theme_description, + player_image_description: editor_config.player_image_description, + opponent_image_description: editor_config.opponent_image_description, + onomatopoeia: editor_config.onomatopoeia, + player_character_image_src: editor_config.player_character_image_src, + opponent_character_image_src: editor_config.opponent_character_image_src, + ui_background_image_src: editor_config.ui_background_image_src, + play_count: stats.as_ref().map(|stats| stats.play_count).unwrap_or(0), + finish_count: stats + .as_ref() + .map(|stats| stats.finished_count) + .unwrap_or(0), + updated_at_micros: row.updated_at.to_micros_since_unix_epoch(), + published_at_micros: row.published_at.to_micros_since_unix_epoch(), + }) +} + fn hash_run_token(token: &str) -> String { let digest = Sha256::digest(token.as_bytes()); digest.iter().map(|byte| format!("{byte:02x}")).collect() @@ -530,11 +609,17 @@ fn normalize_editor_config_snapshot( config: &mut BarkBattleEditorConfigSnapshot, ) -> Result<(), String> { config.title = normalize_title(Some(&config.title))?; - config.theme_preset = normalize_required_preset(&config.theme_preset, "theme_preset")?; - config.player_dog_skin_preset = - normalize_required_preset(&config.player_dog_skin_preset, "player_dog_skin_preset")?; - config.opponent_dog_skin_preset = - normalize_required_preset(&config.opponent_dog_skin_preset, "opponent_dog_skin_preset")?; + config.theme_description = + normalize_required_description(&config.theme_description, "theme_description")?; + config.player_image_description = normalize_required_description( + &config.player_image_description, + "player_image_description", + )?; + config.opponent_image_description = normalize_required_description( + &config.opponent_image_description, + "opponent_image_description", + )?; + config.onomatopoeia = normalize_onomatopoeia(std::mem::take(&mut config.onomatopoeia)); config.player_character_image_src = normalize_optional_asset_source( config.player_character_image_src.as_deref(), "player_character_image_src", @@ -547,8 +632,6 @@ fn normalize_editor_config_snapshot( config.ui_background_image_src.as_deref(), "ui_background_image_src", )?; - config.bark_sound_src = - normalize_optional_asset_source(config.bark_sound_src.as_deref(), "bark_sound_src")?; config.difficulty_preset = normalize_difficulty(Some(&config.difficulty_preset))?; Ok(()) } @@ -568,12 +651,24 @@ fn normalize_optional_text(value: Option<&str>) -> String { value.unwrap_or_default().trim().chars().take(120).collect() } -fn normalize_required_preset(value: &str, field_name: &str) -> Result { - let preset = value.trim(); - if preset.is_empty() { +fn normalize_required_description(value: &str, field_name: &str) -> Result { + let description = value.trim(); + if description.is_empty() { return Err(format!("bark_battle {field_name} 不能为空")); } - Ok(preset.to_string()) + if description.chars().count() > 240 { + return Err(format!("bark_battle {field_name} 不能超过 240 个字符")); + } + Ok(description.to_string()) +} + +fn normalize_onomatopoeia(words: Vec) -> Vec { + words + .into_iter() + .map(|word| word.trim().chars().take(12).collect::()) + .filter(|word| !word.is_empty()) + .take(24) + .collect() } fn normalize_optional_asset_source( @@ -674,7 +769,6 @@ fn run_snapshot(row: &BarkBattleRuntimeRunRow) -> BarkBattleRunSnapshot { config_version: row.config_version, ruleset_version: row.ruleset_version.clone(), difficulty_preset: row.difficulty_preset.clone(), - leaderboard_enabled: row.leaderboard_enabled, status: row.status.clone(), client_started_at_micros: row.client_started_at_micros, server_started_at_micros: row.server_started_at.to_micros_since_unix_epoch(), @@ -905,7 +999,6 @@ mod tests { config_version: 1, ruleset_version: BARK_BATTLE_DEFAULT_RULESET_VERSION.to_string(), difficulty_preset: BARK_BATTLE_DIFFICULTY_NORMAL.to_string(), - leaderboard_enabled: true, config_json: "{}".to_string(), updated_at_micros: 1_700_000, }; @@ -919,7 +1012,6 @@ mod tests { config_version: input.config_version, ruleset_version: input.ruleset_version.clone(), difficulty_preset: input.difficulty_preset.clone(), - leaderboard_enabled: input.leaderboard_enabled, config_json: input.config_json.clone(), editor_state_json: "{}".to_string(), created_at_micros: 1_700_000, @@ -945,4 +1037,84 @@ mod tests { assert!(normalize_title(Some(" 标题 ")).is_ok()); assert!(normalize_title(Some(" ")).is_err()); } + + #[test] + fn published_snapshot_is_normalized_as_runtime_config() { + let mut editor_config = parse_editor_config( + &serde_json::json!({ + "title": " 汪汪测试杯 ", + "description": "", + "themeDescription": " 阳光草坪 ", + "playerImageDescription": " 主角柴犬 ", + "opponentImageDescription": " 对手哈士奇 ", + "onomatopoeia": [" 轰汪! ", "冲啊冲啊冲啊冲啊冲啊!", ""], + "playerCharacterImageSrc": "/generated-bark-battle-assets/player.png", + "opponentCharacterImageSrc": "/generated-bark-battle-assets/opponent.png", + "uiBackgroundImageSrc": "/generated-bark-battle-assets/ui.png", + "difficultyPreset": "normal" + }) + .to_string(), + ) + .expect("published snapshot should parse"); + + normalize_editor_config_snapshot(&mut editor_config) + .expect("published snapshot should normalize"); + let config_json = to_json_string(&editor_config); + + assert!(config_json.contains("/generated-bark-battle-assets/player.png")); + assert!(config_json.contains("/generated-bark-battle-assets/opponent.png")); + assert!(config_json.contains("/generated-bark-battle-assets/ui.png")); + assert!(config_json.contains("阳光草坪")); + assert!(config_json.contains("轰汪!")); + assert!(config_json.contains("冲啊冲啊冲啊冲啊")); + assert!(!config_json.contains("冲啊冲啊冲啊冲啊冲啊!")); + assert!(!config_json.contains("\"title\":\" 汪汪测试杯 \"")); + assert!(!config_json.contains("themePreset")); + assert!(!config_json.contains("playerDogSkinPreset")); + assert!(!config_json.contains("opponentDogSkinPreset")); + } + + #[test] + fn bark_battle_gallery_view_row_exposes_custom_onomatopoeia() { + let mut editor_config = parse_editor_config( + &serde_json::json!({ + "title": "声浪公开赛", + "description": "画廊映射测试", + "themeDescription": "霓虹竞技场", + "playerImageDescription": "星际猫骑士", + "opponentImageDescription": "机器人拳手", + "onomatopoeia": [" 轰! ", "炸场!", ""], + "difficultyPreset": "normal" + }) + .to_string(), + ) + .expect("gallery config should parse"); + + normalize_editor_config_snapshot(&mut editor_config) + .expect("gallery config should normalize"); + + let row = BarkBattleGalleryViewRow { + work_id: "BB-33333333".to_string(), + owner_user_id: "user-3".to_string(), + source_draft_id: Some("bark-battle-draft-3".to_string()), + config_version: 1, + ruleset_version: BARK_BATTLE_DEFAULT_RULESET_VERSION.to_string(), + difficulty_preset: editor_config.difficulty_preset.clone(), + title: editor_config.title, + description: editor_config.description, + theme_description: editor_config.theme_description, + player_image_description: editor_config.player_image_description, + opponent_image_description: editor_config.opponent_image_description, + onomatopoeia: editor_config.onomatopoeia, + player_character_image_src: editor_config.player_character_image_src, + opponent_character_image_src: editor_config.opponent_character_image_src, + ui_background_image_src: editor_config.ui_background_image_src, + play_count: 8, + finish_count: 5, + updated_at_micros: 1_713_686_401_234_567, + published_at_micros: 1_713_686_401_234_000, + }; + + assert_eq!(row.onomatopoeia, vec!["轰!".to_string(), "炸场!".to_string()]); + } } diff --git a/server-rs/crates/spacetime-module/src/bark_battle/types.rs b/server-rs/crates/spacetime-module/src/bark_battle/types.rs index 771fc093..380d9b1f 100644 --- a/server-rs/crates/spacetime-module/src/bark_battle/types.rs +++ b/server-rs/crates/spacetime-module/src/bark_battle/types.rs @@ -24,11 +24,10 @@ pub struct BarkBattleDraftCreateInput { pub work_id: String, pub title: Option, pub description: Option, - pub theme_preset: String, - pub player_dog_skin_preset: String, - pub opponent_dog_skin_preset: String, + pub theme_description: String, + pub player_image_description: String, + pub opponent_image_description: String, pub difficulty_preset: Option, - pub leaderboard_enabled: Option, pub editor_state_json: Option, pub created_at_micros: i64, } @@ -41,7 +40,6 @@ pub struct BarkBattleDraftConfigUpsertInput { pub config_version: u64, pub ruleset_version: String, pub difficulty_preset: String, - pub leaderboard_enabled: bool, pub config_json: String, pub updated_at_micros: i64, } @@ -116,19 +114,18 @@ pub struct BarkBattleProcedureResult { pub struct BarkBattleEditorConfigSnapshot { pub title: String, pub description: String, - pub theme_preset: String, - pub player_dog_skin_preset: String, - pub opponent_dog_skin_preset: String, + pub theme_description: String, + pub player_image_description: String, + pub opponent_image_description: String, + #[serde(default, skip_serializing_if = "Vec::is_empty")] + pub onomatopoeia: Vec, #[serde(default, skip_serializing_if = "Option::is_none")] pub player_character_image_src: Option, #[serde(default, skip_serializing_if = "Option::is_none")] pub opponent_character_image_src: Option, #[serde(default, skip_serializing_if = "Option::is_none")] pub ui_background_image_src: Option, - #[serde(default, skip_serializing_if = "Option::is_none")] - pub bark_sound_src: Option, pub difficulty_preset: String, - pub leaderboard_enabled: bool, } #[derive(Clone, Debug, PartialEq, Eq, Serialize, Deserialize, SpacetimeType)] @@ -140,7 +137,6 @@ pub struct BarkBattleDraftConfigSnapshot { pub config_version: u64, pub ruleset_version: String, pub difficulty_preset: String, - pub leaderboard_enabled: bool, pub config_json: String, pub editor_state_json: String, pub created_at_micros: i64, @@ -156,7 +152,6 @@ pub struct BarkBattleRuntimeConfigSnapshot { pub config_version: u64, pub ruleset_version: String, pub difficulty_preset: String, - pub leaderboard_enabled: bool, pub config_json: String, pub published_snapshot_json: String, pub published_at_micros: i64, @@ -172,7 +167,6 @@ pub struct BarkBattleRunSnapshot { pub config_version: u64, pub ruleset_version: String, pub difficulty_preset: String, - pub leaderboard_enabled: bool, pub status: String, pub client_started_at_micros: i64, pub server_started_at_micros: i64, @@ -185,3 +179,31 @@ pub struct BarkBattleRunSnapshot { pub leaderboard_score: Option, pub score_id: Option, } + +/// Bark Battle 公开广场只读投影行。 +/// +/// 该结构只暴露 v1 公共卡片需要的配置和基础统计,不把内部排行榜开关或旧皮肤 / +/// 音效预设重新带回公开语义。 +#[derive(Clone, Debug, PartialEq, Serialize, Deserialize, SpacetimeType)] +#[serde(rename_all = "camelCase")] +pub struct BarkBattleGalleryViewRow { + pub work_id: String, + pub owner_user_id: String, + pub source_draft_id: Option, + pub config_version: u64, + pub ruleset_version: String, + pub difficulty_preset: String, + pub title: String, + pub description: String, + pub theme_description: String, + pub player_image_description: String, + pub opponent_image_description: String, + pub onomatopoeia: Vec, + pub player_character_image_src: Option, + pub opponent_character_image_src: Option, + pub ui_background_image_src: Option, + pub play_count: u64, + pub finish_count: u64, + pub updated_at_micros: i64, + pub published_at_micros: i64, +} diff --git a/server-rs/crates/spacetime-module/src/runtime/creation_entry_config.rs b/server-rs/crates/spacetime-module/src/runtime/creation_entry_config.rs index 05c9db50..e4435dbb 100644 --- a/server-rs/crates/spacetime-module/src/runtime/creation_entry_config.rs +++ b/server-rs/crates/spacetime-module/src/runtime/creation_entry_config.rs @@ -180,17 +180,7 @@ fn seed_creation_entry_config_if_missing(ctx: &ReducerContext) { } migrate_visual_novel_entry_from_old_visible_default(ctx, now); - migrate_coming_soon_entry_from_old_open_default( - ctx, - now, - ComingSoonEntryDefault { - id: "bark-battle", - title: "汪汪声浪", - subtitle: "声控对战挑战", - image_src: "/creation-type-references/creative-agent.webp", - sort_order: 85, - }, - ); + migrate_bark_battle_entry_to_open_default(ctx, now); migrate_coming_soon_entry_from_old_open_default( ctx, now, @@ -204,6 +194,36 @@ fn seed_creation_entry_config_if_missing(ctx: &ReducerContext) { ); } +fn migrate_bark_battle_entry_to_open_default(ctx: &ReducerContext, now: Timestamp) { + let id = "bark-battle".to_string(); + let Some(row) = ctx.db.creation_entry_type_config().id().find(&id) else { + return; + }; + + // 中文注释:只纠偏系统默认汪汪声浪入口,不覆盖后台手动改过标题、排序或可见性的配置。 + let still_system_default = row.title == "汪汪声浪" + && row.subtitle == "声控对战挑战" + && row.visible + && row.sort_order == 85 + && (row.image_src == "/creation-type-references/creative-agent.webp" + || row.image_src == "/creation-type-references/bark-battle.webp") + && ((row.badge == "敬请期待" && !row.open) || (row.badge == "可创建" && row.open)); + if !still_system_default { + return; + } + + ctx.db + .creation_entry_type_config() + .id() + .update(CreationEntryTypeConfig { + badge: "可创建".to_string(), + image_src: "/creation-type-references/bark-battle.webp".to_string(), + open: true, + updated_at: now, + ..row + }); +} + fn migrate_visual_novel_entry_from_old_visible_default(ctx: &ReducerContext, now: Timestamp) { let id = "visual-novel".to_string(); let Some(row) = ctx.db.creation_entry_type_config().id().find(&id) else { diff --git a/src/components/bark-battle-creation/BarkBattleConfigEditor.test.tsx b/src/components/bark-battle-creation/BarkBattleConfigEditor.test.tsx index 77fd197e..cc6619fb 100644 --- a/src/components/bark-battle-creation/BarkBattleConfigEditor.test.tsx +++ b/src/components/bark-battle-creation/BarkBattleConfigEditor.test.tsx @@ -7,7 +7,7 @@ import { describe, expect, it, vi } from 'vitest'; import { BarkBattleConfigEditor } from './BarkBattleConfigEditor'; describe('BarkBattleConfigEditor', () => { - it('allows creators to edit lightweight config and compile a Bark Battle draft', async () => { + it('allows creators to edit v1 descriptions and compile a Bark Battle draft', async () => { const onPreview = vi.fn(); render(); @@ -15,48 +15,92 @@ describe('BarkBattleConfigEditor', () => { expect(screen.getByText('轻配置')).toBeTruthy(); expect((screen.getByLabelText('作品标题') as HTMLInputElement).value).toBe('我的声浪竞技场'); expect((screen.getByLabelText('难度预设') as HTMLSelectElement).value).toBe('normal'); + expect(screen.queryByLabelText('资源 URL')).toBeNull(); + expect(screen.queryByLabelText('玩家图片 URL')).toBeNull(); + expect(screen.queryByLabelText('对手图片 URL')).toBeNull(); + expect(screen.queryByLabelText('UI背景 URL')).toBeNull(); + expect(screen.queryByLabelText('排行榜开关')).toBeNull(); + expect( + (screen.getByLabelText('拟声词') as HTMLTextAreaElement).value, + ).toContain('轰汪!'); await userEvent.clear(screen.getByLabelText('作品标题')); - await userEvent.type(screen.getByLabelText('作品标题'), '周末狗狗杯'); - await userEvent.selectOptions(screen.getByLabelText('主题背景'), 'neon-park'); - await userEvent.clear(screen.getByLabelText('玩家角色设定')); - await userEvent.type(screen.getByLabelText('玩家角色设定'), '主角'); - await userEvent.clear(screen.getByLabelText('对手角色设定')); - await userEvent.type(screen.getByLabelText('对手角色设定'), '对手'); + await userEvent.type(screen.getByLabelText('作品标题'), '狗狗冠军杯'); + await userEvent.clear(screen.getByLabelText('主题/场景描述')); + await userEvent.type(screen.getByLabelText('主题/场景描述'), '霓虹公园声浪擂台'); + await userEvent.clear(screen.getByLabelText('玩家形象描述')); + await userEvent.type(screen.getByLabelText('玩家形象描述'), '红围巾柴犬'); + await userEvent.clear(screen.getByLabelText('对手形象描述')); + await userEvent.type(screen.getByLabelText('对手形象描述'), '蓝头带哈士奇'); + await userEvent.clear(screen.getByLabelText('拟声词')); + await userEvent.type( + screen.getByLabelText('拟声词'), + '炸场!\n冲啊! / 破阵!、Boom!', + ); await userEvent.selectOptions(screen.getByLabelText('难度预设'), 'hard'); - await userEvent.type( - screen.getByLabelText('玩家形象'), - '/generated-bark-battle/player/image.png', - ); - await userEvent.type( - screen.getByLabelText('对手形象'), - 'https://example.test/opponent.png', - ); - await userEvent.type( - screen.getByLabelText('UI背景'), - '/generated-bark-battle/ui/background.png', - ); - await userEvent.type( - screen.getByLabelText('狗叫音效'), - '/generated-bark-battle/audio/bark.mp3', - ); await userEvent.click(screen.getByRole('button', { name: '生成草稿' })); expect(onPreview).toHaveBeenCalledWith({ - title: '周末狗狗杯', + title: '狗狗冠军杯', description: '', - themePreset: 'neon-park', - playerDogSkinPreset: '主角', - opponentDogSkinPreset: '对手', - playerCharacterImageSrc: '/generated-bark-battle/player/image.png', - opponentCharacterImageSrc: 'https://example.test/opponent.png', - uiBackgroundImageSrc: '/generated-bark-battle/ui/background.png', - barkSoundSrc: '/generated-bark-battle/audio/bark.mp3', + themeDescription: '霓虹公园声浪擂台', + playerImageDescription: '红围巾柴犬', + opponentImageDescription: '蓝头带哈士奇', + onomatopoeia: ['炸场!', '冲啊!', '破阵!', 'Boom!'], difficultyPreset: 'hard', - leaderboardEnabled: true, }); }); + it('uses a louder theme-aware default onomatopoeia pool without locking to dogs', async () => { + const onPreview = vi.fn(); + render(); + + const defaultWords = (screen.getByLabelText('拟声词') as HTMLTextAreaElement) + .value.split(/\n+/u) + .map((word) => word.trim()) + .filter(Boolean); + + expect(defaultWords.length).toBeGreaterThanOrEqual(10); + expect(defaultWords).toEqual( + expect.arrayContaining(['轰汪!', '炸场!', '破阵!', '燃起来!']), + ); + expect(defaultWords.some((word) => word.includes('喵'))).toBe(false); + expect(defaultWords.some((word) => word.includes('汪'))).toBe(true); + + await userEvent.clear(screen.getByLabelText('主题/场景描述')); + await userEvent.type( + screen.getByLabelText('主题/场景描述'), + '星舰机甲擂台,等离子音浪爆发', + ); + await userEvent.clear(screen.getByLabelText('玩家形象描述')); + await userEvent.type(screen.getByLabelText('玩家形象描述'), '星际猫骑士'); + await userEvent.clear(screen.getByLabelText('对手形象描述')); + await userEvent.type(screen.getByLabelText('对手形象描述'), '机器人拳手'); + + const updatedWords = (screen.getByLabelText('拟声词') as HTMLTextAreaElement) + .value.split(/\n+/u) + .map((word) => word.trim()) + .filter(Boolean); + expect(updatedWords).toEqual( + expect.arrayContaining(['能量爆裂!', '超频!', '电光轰鸣!']), + ); + expect(updatedWords.some((word) => word.includes('汪'))).toBe(false); + }); + + it('keeps creator-edited onomatopoeia when descriptions change', async () => { + const onPreview = vi.fn(); + render(); + + await userEvent.clear(screen.getByLabelText('拟声词')); + await userEvent.type(screen.getByLabelText('拟声词'), '轰!\n破阵!'); + await userEvent.clear(screen.getByLabelText('主题/场景描述')); + await userEvent.type(screen.getByLabelText('主题/场景描述'), '星舰机甲擂台'); + + expect((screen.getByLabelText('拟声词') as HTMLTextAreaElement).value).toBe( + '轰!\n破阵!', + ); + }); + it('requires a non-empty title before compiling a draft', async () => { const onPreview = vi.fn(); render(); @@ -72,7 +116,7 @@ describe('BarkBattleConfigEditor', () => { const onPreview = vi.fn(); render( { expect(screen.queryByRole('heading', { name: '汪汪声浪大作战' })).toBeNull(); expect(screen.queryByRole('button', { name: '返回' })).toBeNull(); expect(screen.getByLabelText('汪汪声浪轻配置编辑器')).toBeTruthy(); - expect(screen.getByText('发布失败')).toBeTruthy(); + expect(screen.getByText('外部错误')).toBeTruthy(); + }); + + it('keeps the mobile form in the parent scroll flow with a safe submit footer', () => { + const onPreview = vi.fn(); + render( + , + ); + + const editor = screen.getByLabelText('汪汪声浪轻配置编辑器'); + expect(editor.className).toContain('overflow-visible'); + expect(editor.className).toContain('lg:overflow-y-auto'); + expect(editor.className).not.toContain('overflow-y-auto overscroll-y-contain pr-0.5'); + + const themeLabel = screen.getByText('主题/场景描述'); + expect(themeLabel.className).toContain('bg-rose-50'); + + const submitFooter = screen + .getByRole('button', { name: '生成草稿' }) + .closest('div'); + expect(submitFooter?.className).toContain('shrink-0'); + expect(submitFooter?.className).toContain('safe-area-inset-bottom'); }); }); diff --git a/src/components/bark-battle-creation/BarkBattleConfigEditor.tsx b/src/components/bark-battle-creation/BarkBattleConfigEditor.tsx index edc5fa8b..87adbdb4 100644 --- a/src/components/bark-battle-creation/BarkBattleConfigEditor.tsx +++ b/src/components/bark-battle-creation/BarkBattleConfigEditor.tsx @@ -1,8 +1,9 @@ import { ArrowLeft, Loader2, Play } from 'lucide-react'; -import { useMemo, useState } from 'react'; +import { useEffect, useMemo, useState } from 'react'; import type { BarkBattleConfigEditorPayload } from '../../../packages/shared/src/contracts/barkBattle'; import type { BarkBattleDifficultyPreset } from '../../../packages/shared/src/contracts/barkBattle'; +import { buildBarkBattleDefaultOnomatopoeia } from '../../games/bark-battle/application/BarkBattleConfig'; import { BarkBattlePreviewCard } from './BarkBattlePreviewCard'; export type BarkBattleConfigEditorProps = { @@ -14,17 +15,26 @@ export type BarkBattleConfigEditorProps = { title?: string | null; }; -const THEME_OPTIONS = [ - { value: 'sunny-yard', label: '阳光院子' }, - { value: 'neon-park', label: '霓虹公园' }, - { value: 'moonlight-rooftop', label: '月光天台' }, -]; - const DIFFICULTY_OPTIONS: Array<{ value: BarkBattleDifficultyPreset; label: string }> = [ { value: 'easy', label: '轻松' }, { value: 'normal', label: '标准' }, { value: 'hard', label: '硬核' }, ]; +const FIELD_LABEL_CLASS = + 'mb-2 inline-flex rounded-full px-2 py-0.5 text-sm font-black text-[var(--platform-text-strong)]'; +const ACCENT_FIELD_LABEL_CLASS = + 'mb-2 inline-flex rounded-full border border-rose-200/70 bg-rose-50/88 px-2.5 py-1 text-sm font-black text-rose-700 shadow-sm'; +const DEFAULT_THEME_DESCRIPTION = '阳光草坪上的圆形声浪擂台'; +const DEFAULT_PLAYER_IMAGE_DESCRIPTION = '戴红色围巾的勇敢小狗'; +const DEFAULT_OPPONENT_IMAGE_DESCRIPTION = '戴蓝色头带的活力小狗'; + +function buildDefaultOnomatopoeiaText(params: { + themeDescription: string; + playerImageDescription: string; + opponentImageDescription: string; +}) { + return buildBarkBattleDefaultOnomatopoeia(params).join('\n'); +} export function BarkBattleConfigEditor({ isBusy = false, @@ -36,46 +46,72 @@ export function BarkBattleConfigEditor({ }: BarkBattleConfigEditorProps) { const [title, setTitle] = useState('我的声浪竞技场'); const [description, setDescription] = useState(''); - const [themePreset, setThemePreset] = useState('sunny-yard'); - const [playerDogSkinPreset, setPlayerDogSkinPreset] = useState('主角'); - const [opponentDogSkinPreset, setOpponentDogSkinPreset] = useState('对手'); - const [playerCharacterImageSrc, setPlayerCharacterImageSrc] = useState(''); - const [opponentCharacterImageSrc, setOpponentCharacterImageSrc] = useState(''); - const [uiBackgroundImageSrc, setUiBackgroundImageSrc] = useState(''); - const [barkSoundSrc, setBarkSoundSrc] = useState(''); + const [themeDescription, setThemeDescription] = useState( + DEFAULT_THEME_DESCRIPTION, + ); + const [playerImageDescription, setPlayerImageDescription] = useState( + DEFAULT_PLAYER_IMAGE_DESCRIPTION, + ); + const [opponentImageDescription, setOpponentImageDescription] = useState( + DEFAULT_OPPONENT_IMAGE_DESCRIPTION, + ); + const [isOnomatopoeiaCustomized, setIsOnomatopoeiaCustomized] = + useState(false); + const [onomatopoeiaText, setOnomatopoeiaText] = useState(() => + buildDefaultOnomatopoeiaText({ + themeDescription: DEFAULT_THEME_DESCRIPTION, + playerImageDescription: DEFAULT_PLAYER_IMAGE_DESCRIPTION, + opponentImageDescription: DEFAULT_OPPONENT_IMAGE_DESCRIPTION, + }), + ); const [difficultyPreset, setDifficultyPreset] = useState('normal'); const [localError, setLocalError] = useState(null); + useEffect(() => { + if (isOnomatopoeiaCustomized) { + return; + } + setOnomatopoeiaText( + buildDefaultOnomatopoeiaText({ + themeDescription, + playerImageDescription, + opponentImageDescription, + }), + ); + }, [ + isOnomatopoeiaCustomized, + themeDescription, + playerImageDescription, + opponentImageDescription, + ]); + + const onomatopoeia = useMemo( + () => + onomatopoeiaText + .split(/[\n,,、/|]+/u) + .map((word) => word.trim()) + .filter(Boolean) + .slice(0, 24), + [onomatopoeiaText], + ); + const payload = useMemo( () => ({ title: title.trim(), description: description.trim(), - themePreset, - playerDogSkinPreset, - opponentDogSkinPreset, - ...(playerCharacterImageSrc.trim() - ? { playerCharacterImageSrc: playerCharacterImageSrc.trim() } - : {}), - ...(opponentCharacterImageSrc.trim() - ? { opponentCharacterImageSrc: opponentCharacterImageSrc.trim() } - : {}), - ...(uiBackgroundImageSrc.trim() - ? { uiBackgroundImageSrc: uiBackgroundImageSrc.trim() } - : {}), - ...(barkSoundSrc.trim() ? { barkSoundSrc: barkSoundSrc.trim() } : {}), + themeDescription: themeDescription.trim(), + playerImageDescription: playerImageDescription.trim(), + opponentImageDescription: opponentImageDescription.trim(), + onomatopoeia, difficultyPreset, - leaderboardEnabled: true, }), [ title, description, - themePreset, - playerDogSkinPreset, - opponentDogSkinPreset, - playerCharacterImageSrc, - opponentCharacterImageSrc, - uiBackgroundImageSrc, - barkSoundSrc, + themeDescription, + playerImageDescription, + opponentImageDescription, + onomatopoeia, difficultyPreset, ], ); @@ -87,6 +123,14 @@ export function BarkBattleConfigEditor({ setLocalError('请先填写作品标题'); return; } + if (!payload.themeDescription) { + setLocalError('请先填写主题/场景描述'); + return; + } + if (!payload.playerImageDescription || !payload.opponentImageDescription) { + setLocalError('请先填写双方形象描述'); + return; + } setLocalError(null); void action(payload); }; @@ -94,7 +138,7 @@ export function BarkBattleConfigEditor({ return (

{showBackButton && onBack ? ( @@ -113,7 +157,7 @@ export function BarkBattleConfigEditor({ ) : null} -
+
{headingTitle ? (
@@ -128,13 +172,11 @@ export function BarkBattleConfigEditor({ ) : null}