fix: polish bark battle creation flow

This commit is contained in:
kdletters
2026-05-22 05:00:07 +08:00
parent 01da85a577
commit bf82f04b64
73 changed files with 9362 additions and 2663 deletions

View File

@@ -16,12 +16,20 @@
--- ---
## 2026-05-19 汪汪声浪创作先进入草稿结果页 ## 2026-05-20 汪汪声浪 v1 公开闭环计划
- 背景:汪汪声浪轻配置表单直接发布会缺少草稿编译、资源预览、手动上传、重新生成和发布前试玩环节创作者无法确认角色形象、UI 背景和狗叫音效替换效果 - 背景:Bark Battle v1 需要把创作、生成、结果、发布、详情和正式运行态收成一条闭环,避免把草稿试玩、公开广场和正式成绩混在一起
- 决策:`bark-battle` 入口继续保持创作 Tab 内嵌轻配置表单;提交后先调用 `/api/creation/bark-battle/drafts` 生成草稿并进入 `bark-battle-result`,草稿响应必须带回 SpacetimeDB 草稿行上的稳定 `workId``configVersion``rulesetVersion`。结果页负责资源预览、图片槽位重新生成、四类资源手动上传、发布前试玩和最终发布;发布必须复用草稿返回的同一个 `workId`,不得在 publish 阶段重新生成作品 ID。排行榜字段暂保留兼容但创作 UI 不展示排行榜开关 - 决策:`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``BarkBattleResultView``BarkBattlePreviewCard``PlatformEntryFlowShellImpl`、Bark Battle creation client、玩法链路文档和相关交互测试。 - 影响范围:`BarkBattleConfigEditor``BarkBattleGeneratingView``BarkBattleResultView``BarkBattleRuntimeShell``PlatformEntryFlowShellImpl``appPageRoutes`Bark Battle creation/runtime client、公开广场聚合与相关交互测试。
- 验证方式:创作 Tab 选择汪汪声浪后应看到轻配置表单点击生成草稿进入结果页结果页能看到玩家形象、对手形象、UI 背景和狗叫音效槽位,试玩在发布前可进入 runtime发布成功后再进入正式 runtime - 验证方式:提交表单后先进入生成页;生成页部分失败仍能落到结果页;结果页只出现单槽重试 / 重新生成 / 上传;发布后先到 `/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 并使用服务端 runtimeConfigprompt 单测应覆盖透明背景、正面和非狗描述不强注入狗;作品架测试应覆盖草稿与已发布卡片作者展示和封面兜底;拟声词测试应覆盖主题自动重算、自定义保持和随机展示。
- 关联文档:`docs/【玩法创作】平台入口与玩法链路-2026-05-15.md` - 关联文档:`docs/【玩法创作】平台入口与玩法链路-2026-05-15.md`
## 2026-05-18 Rust 手写模块入口统一不用 mod.rs ## 2026-05-18 Rust 手写模块入口统一不用 mod.rs
@@ -59,6 +67,7 @@
- 影响范围:`jenkins/Jenkinsfile.production-stdb-module-build` 及后续所有同类 Windows 构建流水线。 - 影响范围:`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。 - 验证方式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` - 关联文档:`docs/【开发运维】本地开发验证与生产运维-2026-05-15.md``.hermes/shared-memory/pitfalls.md`
## 2026-05-19 tracking outbox 改为 rotate 后异步 flush ## 2026-05-19 tracking outbox 改为 rotate 后异步 flush
- 背景:普通 route tracking 写入压力上来后,不能让 HTTP 请求线程等待 SpacetimeDB 批量入库。 - 背景:普通 route tracking 写入压力上来后,不能让 HTTP 请求线程等待 SpacetimeDB 批量入库。
@@ -273,6 +282,7 @@
- 影响范围:平台个人页、登录弹窗、法律 Markdown 渲染和前端认证交互测试。 - 影响范围:平台个人页、登录弹窗、法律 Markdown 渲染和前端认证交互测试。
- 验证方式:执行 `npm run test -- src/components/auth/AuthGate.test.tsx src/components/rpg-entry/RpgEntryHomeView.recharge.test.tsx`、触碰文件 ESLint、`npm run check:encoding` - 验证方式:执行 `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` - 关联文档:`docs/prd/PROFILE_LEGAL_INFO_AND_AUTH_AGREEMENT_PRD_2026-05-12.md`
## 2026-05-12 微信小程序待绑定手机号优先走原生手机号授权 ## 2026-05-12 微信小程序待绑定手机号优先走原生手机号授权
- 背景:微信小程序 `web-view` 壳登录后若返回 `pending_bind_phone`H5 仍会展示手输手机号和短信验证码绑定页,体验上多了一步。 - 背景:微信小程序 `web-view` 壳登录后若返回 `pending_bind_phone`H5 仍会展示手输手机号和短信验证码绑定页,体验上多了一步。
@@ -651,3 +661,15 @@
- 默认阈值:每批 500 条或 1 秒 flush 一次outbox 磁盘上限 256 MiB超过后丢弃低价值 route 事件并记录指标 / 日志。 - 默认阈值:每批 500 条或 1 秒 flush 一次outbox 磁盘上限 256 MiB超过后丢弃低价值 route 事件并记录指标 / 日志。
- 影响范围:`api-server` tracking 中间件、SpacetimeDB tracking procedure、部署数据目录、OTLP 指标和运维排障。 - 影响范围:`api-server` tracking 中间件、SpacetimeDB tracking procedure、部署数据目录、OTLP 指标和运维排障。
- 验证方式:数据库不可用时公开 route 请求不失败且 outbox 文件保留;恢复后批量写入成功并删除本地 sealed 文件;关键事件仍立即影响任务 / 统计。 - 验证方式:数据库不可用时公开 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 应读取结果页最终图片素材而不是初始草稿素材。

View File

@@ -46,6 +46,36 @@
- 验证:点击汪汪声浪后直接看到创作页内嵌表单,不再出现独立配置页;测试应覆盖内嵌表单与 runtime 返回路径。 - 验证:点击汪汪声浪后直接看到创作页内嵌表单,不再出现独立配置页;测试应覆盖内嵌表单与 runtime 返回路径。
- 关联:`src/components/platform-entry/PlatformEntryFlowShellImpl.tsx``src/components/bark-battle-creation/BarkBattleConfigEditor.tsx``src/components/rpg-entry/RpgEntryFlowShell.agent.interaction.test.tsx` - 关联:`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 ## 抓大鹅批量重新生成物品不要新增 itemId
- 现象:结果页批量重新生成物品后,试玩或正式运行态的物品类型和图片对应关系漂移,或者用户输入一个不存在名称后被当作新物品追加。 - 现象:结果页批量重新生成物品后,试玩或正式运行态的物品类型和图片对应关系漂移,或者用户输入一个不存在名称后被当作新物品追加。
@@ -1039,3 +1069,27 @@
- 处理:打开草稿时把持久化 `generationStatus=generating` 等同于生成中 notice恢复对应玩法生成进度页恢复计时使用作品摘要 `updatedAt` 推导 `startedAtMs` - 处理:打开草稿时把持久化 `generationStatus=generating` 等同于生成中 notice恢复对应玩法生成进度页恢复计时使用作品摘要 `updatedAt` 推导 `startedAtMs`
- 验证:`npm test -- src/components/rpg-entry/RpgEntryFlowShell.agent.interaction.test.tsx -t "persisted generating"` - 验证:`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` - 关联:`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`

View File

@@ -133,30 +133,38 @@
当前领域语言: 当前领域语言:
- 有效声浪触发:麦克风归一化响度在冷却结束后达到阈值的一次计分输入。 - 有效声浪触发:麦克风归一化响度在冷却结束后达到阈值的一次计分输入。
- 能量条:玩家与对手当前声浪优势的连续对抗刻度。 - 能量条:玩家与对手当前声浪优势的连续对抗刻度,推到玩家或对手一侧边界时本局立即结算
- 主题 / 竞技背景描述:配置字段为 `themeDescription`,用于生成竞技背景并表达整体场景,不再使用 `themePreset` 或狗狗皮肤预设。
- 玩家 / 对手形象描述:配置字段为 `playerImageDescription` / `opponentImageDescription`,对外统一称“形象描述”,不再称“角色设定”。
- 后端裁决结果:后端根据 start run 与 finish 派生指标校验后的正式单局结果。 - 后端裁决结果:后端根据 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` 的草稿结果。 - 草稿编译:`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 路径写回草稿配置。 - 手动上传:结果页通过平台资产直传 `/api/assets/direct-upload-tickets``/api/assets/objects/confirm` 写入私有资产,再把返回的历史 generated 路径写回草稿配置。
- 重新生成:玩家形象、对手形象和 UI 背景先复用现有图片生成链路;狗叫音效暂不假装自动生成,未接专用音频生成时走手动上传 - 发布:结果页确认后必须携带草稿返回的同一个 `workId` 和结果页最终 `publishedSnapshot` 调用 `POST /api/creation/bark-battle/works/publish`SpacetimeDB 发布态的 `config_json` 必须使用该最终快照works summary 若拿到 `publishedSnapshotJson` 也优先使用最终快照映射封面三图。发布成功后先进入统一作品详情页,再由详情页进入正式 runtime缺少 `workId` 的旧草稿状态需要重新生成草稿
- 试玩:在发布前使用草稿配置启动本地 runtime 预览,不写正式发布记录 - 作品架Bark Battle 草稿 / 已发布列表优先读取后端 `/works`,但创建、生成完成、保存或发布后的本地摘要必须在后端 read model 尚未回读到同 `workId` 前继续保留;创作中心作品架同时接入 pending shelf 兜底,避免 ready 且三图齐全的草稿在刷新窗口期从“我的草稿 / 已发布”中消失
- 发布:结果页确认后必须携带草稿返回的同一个 `workId` 调用 `POST /api/creation/bark-battle/works/publish`,发布成功后进入 runtime缺少 `workId` 的旧草稿状态需要重新生成草稿 - 试玩与正式 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` 写入基础统计派生指标;对局会在能量条推到任一侧边界时提前结算并弹出独立结算弹窗,运行态内固定提供返回按钮
支持的创作者可替换内容: 支持的创作者可替换内容:
- 基础信息:作品标题、简介、主题背景、玩家角色设定、对手角色设定和难度。 - 基础信息:作品标题、简介、主题 / 竞技背景描述(`themeDescription`)、玩家形象描述、对手形象描述和难度。
- 角色形象:可分别替换玩家与对手角色图片;未配置图片时继续使用狗狗预设兜底 - 生成素材:玩家形象、对手形象和竞技背景三个槽位可单槽重试、重新生成或上传;形象图保持正面和透明背景,不把非狗形象描述改写成狗
- UI 视觉:可替换运行态主背景图;未配置图片时继续使用主题背景兜底 - 拟声词:最多保留前 `24` 个有效词;默认池按狗、机甲 / 科技、幻想 / 骑士等主题补充高能短词,并叠加通用“炸场 / 破阵 / 声浪拉满”等基础词。局内只要有效声浪触发就随机快速展示,避免连续重复
- 狗叫音效:可替换局内触发叫声的音频资源;未配置音频时不强制播放自定义音效 - 运行态输入:正式 runtime 必须真实麦克风;草稿试玩允许 mock不写正式统计
这些替换槽位写入 Bark Battle 配置 JSON发布后由 runtime 读取;计分阈值、对局时长、反作弊校验和后端裁决仍由规则集与后端控制,不能通过前端替换项改变。排行榜相关后端字段暂保留兼容,但创作 UI 不再展示排行榜开关 这些创作字段写入 Bark Battle 配置 JSON发布后由 runtime 和基础统计链路读取;对局时长、反作弊校验和后端裁决仍由规则集与后端控制,不能通过前端替换项改变。当前声浪触发口径为前端默认阈值 `0.35`、有效触发冷却 `150ms`,后端 `BarkBattleRuleset``min_bark_gap_ms` 也保持 `150ms`,用于正式成绩校验的物理触发上限。历史排名相关后端字段暂保留兼容,但 v1 公开闭环不展示音频、皮肤预设或排名配置入口
## 方洞挑战 ## 方洞挑战

View File

@@ -1,15 +1,19 @@
import { describe, expect, test } from 'vitest'; import { describe, expect, test } from 'vitest';
import { import {
BARK_BATTLE_ASSET_SLOTS,
BARK_BATTLE_DIFFICULTY_PRESETS, BARK_BATTLE_DIFFICULTY_PRESETS,
type BarkBattleDraftConfig, type BarkBattleDraftConfig,
type BarkBattleDraftConfigUpdateRequest,
type BarkBattleFinishResponse, type BarkBattleFinishResponse,
type BarkBattleGeneratedImageAsset,
type BarkBattleImageAssetGenerateRequest,
type BarkBattlePersonalBestSummary, type BarkBattlePersonalBestSummary,
type BarkBattleWorkStats, type BarkBattleWorkStats,
} from './barkBattle'; } from './barkBattle';
describe('Bark Battle shared contracts', () => { 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 = { const draft: BarkBattleDraftConfig = {
draftId: 'draft-bark-1', draftId: 'draft-bark-1',
workId: 'work-bark-1', workId: 'work-bark-1',
@@ -17,15 +21,14 @@ describe('Bark Battle shared contracts', () => {
rulesetVersion: 'bark-battle-ruleset-v1', rulesetVersion: 'bark-battle-ruleset-v1',
title: '汪汪声浪挑战', title: '汪汪声浪挑战',
description: '轻配置草稿', description: '轻配置草稿',
themePreset: 'city-park', themeDescription: '傍晚城市公园里的声浪擂台',
playerDogSkinPreset: 'corgi', playerImageDescription: '戴红围巾的柯基主角',
opponentDogSkinPreset: 'husky', opponentImageDescription: '蓝色运动头带的哈士奇对手',
onomatopoeia: ['轰汪!', '嗷呜!', '咚咚!'],
playerCharacterImageSrc: '/generated-bark-battle/player/image.png', playerCharacterImageSrc: '/generated-bark-battle/player/image.png',
opponentCharacterImageSrc: 'https://example.test/opponent.png', opponentCharacterImageSrc: 'https://example.test/opponent.png',
uiBackgroundImageSrc: '/generated-bark-battle/ui/background.png', uiBackgroundImageSrc: '/generated-bark-battle/ui/background.png',
barkSoundSrc: '/generated-bark-battle/audio/bark.mp3',
difficultyPreset: 'normal', difficultyPreset: 'normal',
leaderboardEnabled: true,
updatedAt: '2026-05-13T03:00:00.000Z', updatedAt: '2026-05-13T03:00:00.000Z',
}; };
@@ -38,18 +41,100 @@ describe('Bark Battle shared contracts', () => {
'rulesetVersion', 'rulesetVersion',
'title', 'title',
'description', 'description',
'themePreset', 'themeDescription',
'playerDogSkinPreset', 'playerImageDescription',
'opponentDogSkinPreset', 'opponentImageDescription',
'onomatopoeia',
'playerCharacterImageSrc', 'playerCharacterImageSrc',
'opponentCharacterImageSrc', 'opponentCharacterImageSrc',
'uiBackgroundImageSrc', 'uiBackgroundImageSrc',
'barkSoundSrc',
'difficultyPreset', 'difficultyPreset',
'leaderboardEnabled',
'updatedAt', 'updatedAt',
]); ]);
expect(draft.playerCharacterImageSrc).toContain('/generated-bark-battle/'); 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', () => { test('finish accepted player_win fixture exposes backend adjudication result', () => {

View File

@@ -16,31 +16,65 @@ export type BarkBattleFinishStatus =
export type BarkBattlePlayTypeId = 'bark-battle'; 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 { export interface BarkBattleReplacementConfig {
playerCharacterImageSrc?: string; playerCharacterImageSrc?: string;
opponentCharacterImageSrc?: string; opponentCharacterImageSrc?: string;
uiBackgroundImageSrc?: string; uiBackgroundImageSrc?: string;
barkSoundSrc?: string;
} }
export type BarkBattleOnomatopoeia = string[];
export interface BarkBattleConfigEditorPayload extends BarkBattleReplacementConfig { export interface BarkBattleConfigEditorPayload extends BarkBattleReplacementConfig {
title: string; title: string;
description?: string; description?: string;
themePreset: string; themeDescription: string;
playerDogSkinPreset: string; playerImageDescription: string;
opponentDogSkinPreset: string; opponentImageDescription: string;
onomatopoeia?: BarkBattleOnomatopoeia;
difficultyPreset: BarkBattleDifficultyPreset; difficultyPreset: BarkBattleDifficultyPreset;
leaderboardEnabled: boolean;
} }
export interface BarkBattleDraftCreateRequest extends BarkBattleConfigEditorPayload {} export interface BarkBattleDraftCreateRequest extends BarkBattleConfigEditorPayload {}
export interface BarkBattleDraftConfigUpdateRequest
extends BarkBattleConfigEditorPayload {
draftId: string;
workId?: string | null;
configVersion?: number;
rulesetVersion?: string;
}
export interface BarkBattleWorkPublishRequest { export interface BarkBattleWorkPublishRequest {
draftId: string; draftId: string;
workId: string; workId: string;
publishedSnapshot?: BarkBattleConfigEditorPayload; 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 { export interface BarkBattleDraftConfig extends BarkBattleConfigEditorPayload {
draftId: string; draftId: string;
workId?: string; workId?: string;
@@ -57,19 +91,62 @@ export interface BarkBattlePublishedConfig {
playTypeId: BarkBattlePlayTypeId; playTypeId: BarkBattlePlayTypeId;
title: string; title: string;
description?: string; description?: string;
themePreset: string; themeDescription: string;
playerDogSkinPreset: string; playerImageDescription: string;
opponentDogSkinPreset: string; opponentImageDescription: string;
onomatopoeia?: BarkBattleOnomatopoeia;
playerCharacterImageSrc?: string; playerCharacterImageSrc?: string;
opponentCharacterImageSrc?: string; opponentCharacterImageSrc?: string;
uiBackgroundImageSrc?: string; uiBackgroundImageSrc?: string;
barkSoundSrc?: string;
difficultyPreset: BarkBattleDifficultyPreset; difficultyPreset: BarkBattleDifficultyPreset;
leaderboardEnabled: boolean;
updatedAt: string; updatedAt: string;
publishedAt: 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 { export interface BarkBattleRuntimeConfig {
workId: string; workId: string;
configVersion: number; configVersion: number;
@@ -81,14 +158,13 @@ export interface BarkBattleRuntimeConfig {
drawThreshold: number; drawThreshold: number;
minBarkGapMs: number; minBarkGapMs: number;
difficultyPreset: BarkBattleDifficultyPreset; difficultyPreset: BarkBattleDifficultyPreset;
themePreset: string; themeDescription: string;
playerDogSkinPreset: string; playerImageDescription: string;
opponentDogSkinPreset: string; opponentImageDescription: string;
onomatopoeia?: BarkBattleOnomatopoeia;
playerCharacterImageSrc?: string; playerCharacterImageSrc?: string;
opponentCharacterImageSrc?: string; opponentCharacterImageSrc?: string;
uiBackgroundImageSrc?: string; uiBackgroundImageSrc?: string;
barkSoundSrc?: string;
leaderboardEnabled: boolean;
updatedAt: string; updatedAt: string;
} }

Binary file not shown.

After

Width:  |  Height:  |  Size: 69 KiB

View File

@@ -1001,6 +1001,7 @@ mod tests {
"/generated-puzzle-assets/session-1/candidate/image.png", "/generated-puzzle-assets/session-1/candidate/image.png",
"/generated-custom-world-scenes/world-1/camp/scene.png", "/generated-custom-world-scenes/world-1/camp/scene.png",
"/generated-custom-world-covers/world-1/cover.webp", "/generated-custom-world-covers/world-1/cover.webp",
"/generated-bark-battle-assets/draft/player/image.webp",
"/generated-qwen-sprites/master/candidate-01.png", "/generated-qwen-sprites/master/candidate-01.png",
] { ] {
let response = app let response = app

File diff suppressed because it is too large Load Diff

View File

@@ -174,6 +174,25 @@ mod tests {
assert_eq!(resolve_creation_entry_route_id("/healthz"), None); 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] #[test]
fn test_creation_entry_config_response_keeps_baby_object_match_coming_soon() { fn test_creation_entry_config_response_keeps_baby_object_match_coming_soon() {
let config = test_creation_entry_config_response(); let config = test_creation_entry_config_response();

View File

@@ -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 config
.map(|config| Match3DConfigJson { .map(|config| Match3DConfigJson {
theme_text: config.theme_text.clone(), 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 { match current_turn {
0 => MATCH3D_QUESTION_THEME.to_string(), 0 => MATCH3D_QUESTION_THEME.to_string(),
1 => MATCH3D_QUESTION_CLEAR_COUNT.to_string(), 1 => MATCH3D_QUESTION_CLEAR_COUNT.to_string(),

View File

@@ -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 theme = config.theme_text.trim();
let normalized_theme = if theme.is_empty() { "抓大鹅" } else { theme }; let normalized_theme = if theme.is_empty() { "抓大鹅" } else { theme };
normalize_match3d_audio_prompt( normalize_match3d_audio_prompt(
@@ -1416,7 +1419,9 @@ fn resolve_match3d_material_cell_crop(
crop.to_crop_tuple() 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 mut image = image.to_rgba8();
let (width, height) = image.dimensions(); let (width, height) = image.dimensions();
remove_match3d_material_view_edge_matte(image.as_mut(), width as usize, height as usize); remove_match3d_material_view_edge_matte(image.as_mut(), width as usize, height as usize);

View File

@@ -134,12 +134,11 @@ pub(super) fn map_match3d_draft_response(
draft: Match3DResultDraftRecord, draft: Match3DResultDraftRecord,
) -> Match3DResultDraftResponse { ) -> Match3DResultDraftResponse {
// 中文注释session draft 自身也可能携带生成素材快照,不能只依赖 work detail 回读补齐 UI 背景和容器图。 // 中文注释session draft 自身也可能携带生成素材快照,不能只依赖 work detail 回读补齐 UI 背景和容器图。
let generated_item_assets = parse_match3d_generated_item_assets( let generated_item_assets =
draft.generated_item_assets_json.as_deref(), parse_match3d_generated_item_assets(draft.generated_item_assets_json.as_deref())
) .into_iter()
.into_iter() .map(Match3DGeneratedItemAsset::from)
.map(Match3DGeneratedItemAsset::from) .collect::<Vec<_>>();
.collect::<Vec<_>>();
let background_asset = find_match3d_generated_background_asset(&generated_item_assets); let background_asset = find_match3d_generated_background_asset(&generated_item_assets);
let mut response = Match3DResultDraftResponse { let mut response = Match3DResultDraftResponse {
profile_id: draft.profile_id, profile_id: draft.profile_id,

File diff suppressed because it is too large Load Diff

View File

@@ -587,7 +587,10 @@ async fn load_match3d_container_reference_image() -> Result<OpenAiReferenceImage
}) })
} }
pub(super) fn build_match3d_background_generation_prompt(config: &Match3DConfigJson, prompt: &str) -> String { pub(super) fn build_match3d_background_generation_prompt(
config: &Match3DConfigJson,
prompt: &str,
) -> String {
let style_clause = resolve_match3d_asset_style_prompt(config) let style_clause = resolve_match3d_asset_style_prompt(config)
.map(|style| format!("整体美术风格参考:{style}")) .map(|style| format!("整体美术风格参考:{style}"))
.unwrap_or_default(); .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) let style_clause = resolve_match3d_asset_style_prompt(config)
.map(|style| format!("整体美术风格参考:{style}")) .map(|style| format!("整体美术风格参考:{style}"))
.unwrap_or_default(); .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 state
.oss_client() .oss_client()
.ok_or_else(|| match3d_oss_config_error(&state.config)) .ok_or_else(|| match3d_oss_config_error(&state.config))

View File

@@ -7,7 +7,9 @@ use crate::{
auth::require_bearer_auth, auth::require_bearer_auth,
bark_battle::{ bark_battle::{
create_bark_battle_draft, finish_bark_battle_run, get_bark_battle_run, 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, state::AppState,
}; };
@@ -21,6 +23,20 @@ pub fn router(state: AppState) -> Router<AppState> {
require_bearer_auth, 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( .route(
"/api/creation/bark-battle/works/publish", "/api/creation/bark-battle/works/publish",
post(publish_bark_battle_work).route_layer(middleware::from_fn_with_state( post(publish_bark_battle_work).route_layer(middleware::from_fn_with_state(
@@ -28,6 +44,17 @@ pub fn router(state: AppState) -> Router<AppState> {
require_bearer_auth, 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( .route(
"/api/runtime/bark-battle/works/{work_id}/config", "/api/runtime/bark-battle/works/{work_id}/config",
get(get_bark_battle_runtime_config).route_layer(middleware::from_fn_with_state( get(get_bark_battle_runtime_config).route_layer(middleware::from_fn_with_state(

View File

@@ -199,11 +199,9 @@ fn cpu_usage_ratio_between_samples(
#[cfg(windows)] #[cfg(windows)]
fn collect_process_metrics() -> Result<ProcessMetricsSnapshot, String> { fn collect_process_metrics() -> Result<ProcessMetricsSnapshot, String> {
use windows_sys::Win32::{ use windows_sys::Win32::System::{
System::{ ProcessStatus::{GetProcessMemoryInfo, PROCESS_MEMORY_COUNTERS_EX},
ProcessStatus::{GetProcessMemoryInfo, PROCESS_MEMORY_COUNTERS_EX}, Threading::{GetCurrentProcess, GetCurrentProcessId, GetProcessHandleCount},
Threading::{GetCurrentProcess, GetCurrentProcessId, GetProcessHandleCount},
},
}; };
let handle = unsafe { GetCurrentProcess() }; let handle = unsafe { GetCurrentProcess() };
@@ -212,11 +210,7 @@ fn collect_process_metrics() -> Result<ProcessMetricsSnapshot, String> {
..Default::default() ..Default::default()
}; };
let ok = unsafe { let ok = unsafe {
GetProcessMemoryInfo( GetProcessMemoryInfo(handle, std::ptr::addr_of_mut!(counters).cast(), counters.cb)
handle,
std::ptr::addr_of_mut!(counters).cast(),
counters.cb,
)
}; };
if ok == 0 { if ok == 0 {
return Err("GetProcessMemoryInfo returned false".to_string()); return Err("GetProcessMemoryInfo returned false".to_string());
@@ -244,10 +238,7 @@ fn collect_process_metrics() -> Result<ProcessMetricsSnapshot, String> {
#[cfg(windows)] #[cfg(windows)]
fn windows_process_cpu_time_seconds(handle: windows_sys::Win32::Foundation::HANDLE) -> Option<f64> { fn windows_process_cpu_time_seconds(handle: windows_sys::Win32::Foundation::HANDLE) -> Option<f64> {
use windows_sys::Win32::{ use windows_sys::Win32::{Foundation::FILETIME, System::Threading::GetProcessTimes};
Foundation::FILETIME,
System::Threading::GetProcessTimes,
};
let mut creation_time = FILETIME::default(); let mut creation_time = FILETIME::default();
let mut exit_time = FILETIME::default(); let mut exit_time = FILETIME::default();
@@ -337,8 +328,8 @@ fn collect_process_metrics() -> Result<ProcessMetricsSnapshot, String> {
.ok_or_else(|| "missing VmSize/statm size field".to_string())?; .ok_or_else(|| "missing VmSize/statm size field".to_string())?;
let private_bytes = parse_status_kb(&status, "VmData:").map(|value| value * 1024); let private_bytes = parse_status_kb(&status, "VmData:").map(|value| value * 1024);
let cpu_time_seconds = linux_cpu_time_seconds(&stat)?; let cpu_time_seconds = linux_cpu_time_seconds(&stat)?;
let thread_count = parse_status_u64(&status, "Threads:") let thread_count =
.ok_or_else(|| "missing Threads field".to_string())?; parse_status_u64(&status, "Threads:").ok_or_else(|| "missing Threads field".to_string())?;
Ok(ProcessMetricsSnapshot { Ok(ProcessMetricsSnapshot {
rss_bytes, rss_bytes,
@@ -427,11 +418,7 @@ fn parse_status_u64(status: &str, key: &str) -> Option<u64> {
#[cfg(target_os = "linux")] #[cfg(target_os = "linux")]
fn parse_statm_pages(statm: &str, index: usize) -> Option<u64> { fn parse_statm_pages(statm: &str, index: usize) -> Option<u64> {
statm statm.split_whitespace().nth(index)?.parse::<u64>().ok()
.split_whitespace()
.nth(index)?
.parse::<u64>()
.ok()
} }
#[cfg(not(any(windows, target_os = "linux")))] #[cfg(not(any(windows, target_os = "linux")))]

View File

@@ -72,7 +72,7 @@ impl BarkBattleRuleset {
standard_duration_ms: 30_000, standard_duration_ms: 30_000,
min_duration_ms: 28_000, min_duration_ms: 28_000,
max_duration_ms: 35_000, max_duration_ms: 35_000,
min_bark_gap_ms: 250, min_bark_gap_ms: 150,
trigger_count_tolerance: 2, trigger_count_tolerance: 2,
min_volume: 0.0, min_volume: 0.0,
max_volume: 1.0, max_volume: 1.0,

View File

@@ -174,6 +174,7 @@ mod tests {
#[test] #[test]
fn flags_trigger_count_above_physical_limit_with_tolerance() { fn flags_trigger_count_above_physical_limit_with_tolerance() {
let ruleset = BarkBattleRuleset::v1(); let ruleset = BarkBattleRuleset::v1();
assert_eq!(ruleset.min_bark_gap_ms, 150);
let mut input = metrics(30_000); let mut input = metrics(30_000);
input.trigger_count = input.duration_ms / ruleset.min_bark_gap_ms input.trigger_count = input.duration_ms / ruleset.min_bark_gap_ms
+ u64::from(ruleset.trigger_count_tolerance) + u64::from(ruleset.trigger_count_tolerance)

View File

@@ -142,10 +142,10 @@ pub fn default_creation_entry_type_snapshots(
"bark-battle", "bark-battle",
"汪汪声浪", "汪汪声浪",
"声控对战挑战", "声控对战挑战",
"敬请期待", "可创建",
"/creation-type-references/creative-agent.webp", "/creation-type-references/bark-battle.webp",
true,
true, true,
false,
85, 85,
updated_at_micros, updated_at_micros,
), ),

View File

@@ -246,9 +246,13 @@ mod tests {
assert_eq!(bark_battle.title, "汪汪声浪"); assert_eq!(bark_battle.title, "汪汪声浪");
assert!(bark_battle.visible); assert!(bark_battle.visible);
assert!(!bark_battle.open); assert!(bark_battle.open);
assert_eq!(bark_battle.badge, "敬请期待"); assert_eq!(bark_battle.badge, "可创建");
assert_eq!(bark_battle.sort_order, 85); assert_eq!(bark_battle.sort_order, 85);
assert_eq!(
bark_battle.image_src,
"/creation-type-references/bark-battle.webp"
);
} }
#[test] #[test]
@@ -522,8 +526,9 @@ mod tests {
#[test] #[test]
fn runtime_profile_beijing_day_key_uses_business_day_boundary() { fn runtime_profile_beijing_day_key_uses_business_day_boundary() {
let before_beijing_midnight = 1_714_927_999_999_999; // 中文注释2024-05-06 00:00:00 Asia/Shanghai 前后 1 微秒。
let after_beijing_midnight = 1_714_928_000_000_000; let before_beijing_midnight = 1_714_924_799_999_999;
let after_beijing_midnight = 1_714_924_800_000_000;
assert_eq!( assert_eq!(
runtime_profile_beijing_day_key(before_beijing_midnight), runtime_profile_beijing_day_key(before_beijing_midnight),

View File

@@ -20,7 +20,7 @@ const OSS_V4_REQUEST: &str = "aliyun_v4_request";
const OSS_V4_SERVICE: &str = "oss"; const OSS_V4_SERVICE: &str = "oss";
const OSS_UNSIGNED_PAYLOAD: &str = "UNSIGNED-PAYLOAD"; 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-character-drafts",
"generated-characters", "generated-characters",
"generated-animations", "generated-animations",
@@ -30,6 +30,7 @@ pub const LEGACY_PUBLIC_PREFIXES: [&str; 10] = [
"generated-puzzle-assets", "generated-puzzle-assets",
"generated-custom-world-scenes", "generated-custom-world-scenes",
"generated-custom-world-covers", "generated-custom-world-covers",
"generated-bark-battle-assets",
"generated-qwen-sprites", "generated-qwen-sprites",
]; ];
@@ -51,6 +52,7 @@ pub enum LegacyAssetPrefix {
PuzzleAssets, PuzzleAssets,
CustomWorldScenes, CustomWorldScenes,
CustomWorldCovers, CustomWorldCovers,
BarkBattleAssets,
QwenSprites, QwenSprites,
} }
@@ -238,6 +240,7 @@ impl LegacyAssetPrefix {
"generated-puzzle-assets" => Some(Self::PuzzleAssets), "generated-puzzle-assets" => Some(Self::PuzzleAssets),
"generated-custom-world-scenes" => Some(Self::CustomWorldScenes), "generated-custom-world-scenes" => Some(Self::CustomWorldScenes),
"generated-custom-world-covers" => Some(Self::CustomWorldCovers), "generated-custom-world-covers" => Some(Self::CustomWorldCovers),
"generated-bark-battle-assets" => Some(Self::BarkBattleAssets),
"generated-qwen-sprites" => Some(Self::QwenSprites), "generated-qwen-sprites" => Some(Self::QwenSprites),
_ => None, _ => None,
} }
@@ -254,6 +257,7 @@ impl LegacyAssetPrefix {
Self::PuzzleAssets => "generated-puzzle-assets", Self::PuzzleAssets => "generated-puzzle-assets",
Self::CustomWorldScenes => "generated-custom-world-scenes", Self::CustomWorldScenes => "generated-custom-world-scenes",
Self::CustomWorldCovers => "generated-custom-world-covers", Self::CustomWorldCovers => "generated-custom-world-covers",
Self::BarkBattleAssets => "generated-bark-battle-assets",
Self::QwenSprites => "generated-qwen-sprites", 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-puzzle-assets"));
assert!(LEGACY_PUBLIC_PREFIXES.contains(&"generated-match3d-assets")); assert!(LEGACY_PUBLIC_PREFIXES.contains(&"generated-match3d-assets"));
assert!(LEGACY_PUBLIC_PREFIXES.contains(&"generated-bark-battle-assets"));
assert_eq!(LegacyAssetPrefix::parse("unknown"), None); assert_eq!(LegacyAssetPrefix::parse("unknown"), None);
} }

View File

@@ -30,6 +30,14 @@ pub enum BarkBattleFinishStatus {
Rejected, 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)] #[derive(Clone, Debug, Default, Serialize, Deserialize, PartialEq)]
#[serde(rename_all = "camelCase")] #[serde(rename_all = "camelCase")]
pub struct BarkBattleReplacementConfig { pub struct BarkBattleReplacementConfig {
@@ -39,8 +47,6 @@ pub struct BarkBattleReplacementConfig {
pub opponent_character_image_src: Option<String>, pub opponent_character_image_src: Option<String>,
#[serde(default, skip_serializing_if = "Option::is_none")] #[serde(default, skip_serializing_if = "Option::is_none")]
pub ui_background_image_src: Option<String>, pub ui_background_image_src: Option<String>,
#[serde(default, skip_serializing_if = "Option::is_none")]
pub bark_sound_src: Option<String>,
} }
#[derive(Clone, Debug, Serialize, Deserialize, PartialEq)] #[derive(Clone, Debug, Serialize, Deserialize, PartialEq)]
@@ -49,20 +55,19 @@ pub struct BarkBattleConfigEditorPayload {
pub title: String, pub title: String,
#[serde(default, skip_serializing_if = "Option::is_none")] #[serde(default, skip_serializing_if = "Option::is_none")]
pub description: Option<String>, pub description: Option<String>,
pub theme_preset: String, pub theme_description: String,
pub player_dog_skin_preset: String, pub player_image_description: String,
pub opponent_dog_skin_preset: String, pub opponent_image_description: String,
#[serde(default, skip_serializing_if = "Option::is_none")]
pub onomatopoeia: Option<Vec<String>>,
#[serde(default, skip_serializing_if = "Option::is_none")] #[serde(default, skip_serializing_if = "Option::is_none")]
pub player_character_image_src: Option<String>, pub player_character_image_src: Option<String>,
#[serde(default, skip_serializing_if = "Option::is_none")] #[serde(default, skip_serializing_if = "Option::is_none")]
pub opponent_character_image_src: Option<String>, pub opponent_character_image_src: Option<String>,
#[serde(default, skip_serializing_if = "Option::is_none")] #[serde(default, skip_serializing_if = "Option::is_none")]
pub ui_background_image_src: Option<String>, pub ui_background_image_src: Option<String>,
#[serde(default, skip_serializing_if = "Option::is_none")]
pub bark_sound_src: Option<String>,
#[serde(default)] #[serde(default)]
pub difficulty_preset: BarkBattleDifficultyPreset, pub difficulty_preset: BarkBattleDifficultyPreset,
pub leaderboard_enabled: bool,
} }
#[derive(Clone, Debug, Serialize, Deserialize, PartialEq)] #[derive(Clone, Debug, Serialize, Deserialize, PartialEq)]
@@ -71,20 +76,19 @@ pub struct BarkBattleDraftCreateRequest {
pub title: String, pub title: String,
#[serde(default, skip_serializing_if = "Option::is_none")] #[serde(default, skip_serializing_if = "Option::is_none")]
pub description: Option<String>, pub description: Option<String>,
pub theme_preset: String, pub theme_description: String,
pub player_dog_skin_preset: String, pub player_image_description: String,
pub opponent_dog_skin_preset: String, pub opponent_image_description: String,
#[serde(default, skip_serializing_if = "Option::is_none")]
pub onomatopoeia: Option<Vec<String>>,
#[serde(default, skip_serializing_if = "Option::is_none")] #[serde(default, skip_serializing_if = "Option::is_none")]
pub player_character_image_src: Option<String>, pub player_character_image_src: Option<String>,
#[serde(default, skip_serializing_if = "Option::is_none")] #[serde(default, skip_serializing_if = "Option::is_none")]
pub opponent_character_image_src: Option<String>, pub opponent_character_image_src: Option<String>,
#[serde(default, skip_serializing_if = "Option::is_none")] #[serde(default, skip_serializing_if = "Option::is_none")]
pub ui_background_image_src: Option<String>, pub ui_background_image_src: Option<String>,
#[serde(default, skip_serializing_if = "Option::is_none")]
pub bark_sound_src: Option<String>,
#[serde(default)] #[serde(default)]
pub difficulty_preset: BarkBattleDifficultyPreset, pub difficulty_preset: BarkBattleDifficultyPreset,
pub leaderboard_enabled: bool,
} }
impl From<BarkBattleDraftCreateRequest> for BarkBattleConfigEditorPayload { impl From<BarkBattleDraftCreateRequest> for BarkBattleConfigEditorPayload {
@@ -92,15 +96,59 @@ impl From<BarkBattleDraftCreateRequest> for BarkBattleConfigEditorPayload {
Self { Self {
title: value.title, title: value.title,
description: value.description, description: value.description,
theme_preset: value.theme_preset, theme_description: value.theme_description,
player_dog_skin_preset: value.player_dog_skin_preset, player_image_description: value.player_image_description,
opponent_dog_skin_preset: value.opponent_dog_skin_preset, 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<String>,
#[serde(default, skip_serializing_if = "Option::is_none")]
pub config_version: Option<u32>,
#[serde(default, skip_serializing_if = "Option::is_none")]
pub ruleset_version: Option<String>,
pub title: String,
#[serde(default, skip_serializing_if = "Option::is_none")]
pub description: Option<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<Vec<String>>,
#[serde(default, skip_serializing_if = "Option::is_none")]
pub player_character_image_src: Option<String>,
#[serde(default, skip_serializing_if = "Option::is_none")]
pub opponent_character_image_src: Option<String>,
#[serde(default, skip_serializing_if = "Option::is_none")]
pub ui_background_image_src: Option<String>,
#[serde(default)]
pub difficulty_preset: BarkBattleDifficultyPreset,
}
impl From<BarkBattleDraftConfigUpdateRequest> 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, player_character_image_src: value.player_character_image_src,
opponent_character_image_src: value.opponent_character_image_src, opponent_character_image_src: value.opponent_character_image_src,
ui_background_image_src: value.ui_background_image_src, ui_background_image_src: value.ui_background_image_src,
bark_sound_src: value.bark_sound_src,
difficulty_preset: value.difficulty_preset, difficulty_preset: value.difficulty_preset,
leaderboard_enabled: value.leaderboard_enabled,
} }
} }
} }
@@ -115,6 +163,30 @@ pub struct BarkBattleWorkPublishRequest {
pub published_snapshot: Option<BarkBattleConfigEditorPayload>, pub published_snapshot: Option<BarkBattleConfigEditorPayload>,
} }
#[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<String>,
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<String>,
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<String>,
}
#[derive(Clone, Debug, Serialize, Deserialize, PartialEq)] #[derive(Clone, Debug, Serialize, Deserialize, PartialEq)]
#[serde(rename_all = "camelCase")] #[serde(rename_all = "camelCase")]
pub struct BarkBattleDraftConfig { pub struct BarkBattleDraftConfig {
@@ -128,20 +200,19 @@ pub struct BarkBattleDraftConfig {
pub title: String, pub title: String,
#[serde(default, skip_serializing_if = "Option::is_none")] #[serde(default, skip_serializing_if = "Option::is_none")]
pub description: Option<String>, pub description: Option<String>,
pub theme_preset: String, pub theme_description: String,
pub player_dog_skin_preset: String, pub player_image_description: String,
pub opponent_dog_skin_preset: String, pub opponent_image_description: String,
#[serde(default, skip_serializing_if = "Option::is_none")]
pub onomatopoeia: Option<Vec<String>>,
#[serde(default, skip_serializing_if = "Option::is_none")] #[serde(default, skip_serializing_if = "Option::is_none")]
pub player_character_image_src: Option<String>, pub player_character_image_src: Option<String>,
#[serde(default, skip_serializing_if = "Option::is_none")] #[serde(default, skip_serializing_if = "Option::is_none")]
pub opponent_character_image_src: Option<String>, pub opponent_character_image_src: Option<String>,
#[serde(default, skip_serializing_if = "Option::is_none")] #[serde(default, skip_serializing_if = "Option::is_none")]
pub ui_background_image_src: Option<String>, pub ui_background_image_src: Option<String>,
#[serde(default, skip_serializing_if = "Option::is_none")]
pub bark_sound_src: Option<String>,
#[serde(default)] #[serde(default)]
pub difficulty_preset: BarkBattleDifficultyPreset, pub difficulty_preset: BarkBattleDifficultyPreset,
pub leaderboard_enabled: bool,
pub updated_at: String, pub updated_at: String,
} }
@@ -154,15 +225,14 @@ impl Default for BarkBattleDraftConfig {
ruleset_version: None, ruleset_version: None,
title: String::new(), title: String::new(),
description: None, description: None,
theme_preset: String::new(), theme_description: String::new(),
player_dog_skin_preset: String::new(), player_image_description: String::new(),
opponent_dog_skin_preset: String::new(), opponent_image_description: String::new(),
onomatopoeia: None,
player_character_image_src: None, player_character_image_src: None,
opponent_character_image_src: None, opponent_character_image_src: None,
ui_background_image_src: None, ui_background_image_src: None,
bark_sound_src: None,
difficulty_preset: BarkBattleDifficultyPreset::Normal, difficulty_preset: BarkBattleDifficultyPreset::Normal,
leaderboard_enabled: true,
updated_at: String::new(), updated_at: String::new(),
} }
} }
@@ -180,19 +250,18 @@ pub struct BarkBattlePublishedConfig {
pub title: String, pub title: String,
#[serde(default, skip_serializing_if = "Option::is_none")] #[serde(default, skip_serializing_if = "Option::is_none")]
pub description: Option<String>, pub description: Option<String>,
pub theme_preset: String, pub theme_description: String,
pub player_dog_skin_preset: String, pub player_image_description: String,
pub opponent_dog_skin_preset: String, pub opponent_image_description: String,
#[serde(default, skip_serializing_if = "Option::is_none")]
pub onomatopoeia: Option<Vec<String>>,
#[serde(default, skip_serializing_if = "Option::is_none")] #[serde(default, skip_serializing_if = "Option::is_none")]
pub player_character_image_src: Option<String>, pub player_character_image_src: Option<String>,
#[serde(default, skip_serializing_if = "Option::is_none")] #[serde(default, skip_serializing_if = "Option::is_none")]
pub opponent_character_image_src: Option<String>, pub opponent_character_image_src: Option<String>,
#[serde(default, skip_serializing_if = "Option::is_none")] #[serde(default, skip_serializing_if = "Option::is_none")]
pub ui_background_image_src: Option<String>, pub ui_background_image_src: Option<String>,
#[serde(default, skip_serializing_if = "Option::is_none")]
pub bark_sound_src: Option<String>,
pub difficulty_preset: BarkBattleDifficultyPreset, pub difficulty_preset: BarkBattleDifficultyPreset,
pub leaderboard_enabled: bool,
pub updated_at: String, pub updated_at: String,
pub published_at: String, pub published_at: String,
} }
@@ -210,21 +279,75 @@ pub struct BarkBattleRuntimeConfig {
pub draw_threshold: f32, pub draw_threshold: f32,
pub min_bark_gap_ms: u64, pub min_bark_gap_ms: u64,
pub difficulty_preset: BarkBattleDifficultyPreset, pub difficulty_preset: BarkBattleDifficultyPreset,
pub theme_preset: String, pub theme_description: String,
pub player_dog_skin_preset: String, pub player_image_description: String,
pub opponent_dog_skin_preset: String, pub opponent_image_description: String,
#[serde(default, skip_serializing_if = "Option::is_none")]
pub onomatopoeia: Option<Vec<String>>,
#[serde(default, skip_serializing_if = "Option::is_none")] #[serde(default, skip_serializing_if = "Option::is_none")]
pub player_character_image_src: Option<String>, pub player_character_image_src: Option<String>,
#[serde(default, skip_serializing_if = "Option::is_none")] #[serde(default, skip_serializing_if = "Option::is_none")]
pub opponent_character_image_src: Option<String>, pub opponent_character_image_src: Option<String>,
#[serde(default, skip_serializing_if = "Option::is_none")] #[serde(default, skip_serializing_if = "Option::is_none")]
pub ui_background_image_src: Option<String>, pub ui_background_image_src: Option<String>,
#[serde(default, skip_serializing_if = "Option::is_none")]
pub bark_sound_src: Option<String>,
pub leaderboard_enabled: bool,
pub updated_at: String, 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<String>,
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<Vec<String>>,
#[serde(default, skip_serializing_if = "Option::is_none")]
pub player_character_image_src: Option<String>,
#[serde(default, skip_serializing_if = "Option::is_none")]
pub opponent_character_image_src: Option<String>,
#[serde(default, skip_serializing_if = "Option::is_none")]
pub ui_background_image_src: Option<String>,
pub difficulty_preset: BarkBattleDifficultyPreset,
pub status: String,
#[serde(default, skip_serializing_if = "Option::is_none")]
pub generation_status: Option<String>,
pub publish_ready: bool,
pub play_count: u64,
#[serde(default, skip_serializing_if = "Option::is_none")]
pub finish_count: Option<u64>,
#[serde(default, skip_serializing_if = "Option::is_none")]
pub win_count: Option<u64>,
#[serde(default, skip_serializing_if = "Option::is_none")]
pub draw_count: Option<u64>,
#[serde(default, skip_serializing_if = "Option::is_none")]
pub loss_count: Option<u64>,
#[serde(default, skip_serializing_if = "Option::is_none")]
pub recent_play_count_7d: Option<u64>,
pub updated_at: String,
#[serde(default, skip_serializing_if = "Option::is_none")]
pub published_at: Option<String>,
}
#[derive(Clone, Debug, Default, Serialize, Deserialize, PartialEq)]
#[serde(rename_all = "camelCase")]
pub struct BarkBattleWorksResponse {
#[serde(default)]
pub items: Vec<BarkBattleWorkSummary>,
}
#[derive(Clone, Debug, Serialize, Deserialize, PartialEq)]
#[serde(rename_all = "camelCase")]
pub struct BarkBattleWorkDetailResponse {
pub item: BarkBattleWorkSummary,
}
#[derive(Clone, Debug, Serialize, Deserialize, PartialEq)] #[derive(Clone, Debug, Serialize, Deserialize, PartialEq)]
#[serde(rename_all = "camelCase")] #[serde(rename_all = "camelCase")]
pub struct BarkBattleRunStartRequest { pub struct BarkBattleRunStartRequest {
@@ -425,6 +548,115 @@ mod tests {
use super::*; use super::*;
use serde_json::json; 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] #[test]
fn draft_config_defaults_to_normal_difficulty() { fn draft_config_defaults_to_normal_difficulty() {
let config = BarkBattleDraftConfig::default(); let config = BarkBattleDraftConfig::default();
@@ -523,15 +755,14 @@ mod tests {
ruleset_version: Some("bark-battle-ruleset-v1".to_string()), ruleset_version: Some("bark-battle-ruleset-v1".to_string()),
title: "汪汪测试杯".to_string(), title: "汪汪测试杯".to_string(),
description: None, description: None,
theme_preset: "sunny-yard".to_string(), theme_description: "阳光草坪".to_string(),
player_dog_skin_preset: "主角".to_string(), player_image_description: "主角".to_string(),
opponent_dog_skin_preset: "对手".to_string(), opponent_image_description: "对手".to_string(),
onomatopoeia: None,
player_character_image_src: None, player_character_image_src: None,
opponent_character_image_src: None, opponent_character_image_src: None,
ui_background_image_src: None, ui_background_image_src: None,
bark_sound_src: None,
difficulty_preset: BarkBattleDifficultyPreset::Normal, difficulty_preset: BarkBattleDifficultyPreset::Normal,
leaderboard_enabled: true,
updated_at: "2026-05-14T10:00:00.000Z".to_string(), 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["draftId"], json!("bark-battle-draft-1"));
assert_eq!(payload["workId"], json!("bark-battle-work-1")); assert_eq!(payload["workId"], json!("bark-battle-work-1"));
assert_eq!(payload["configVersion"], json!(2)); 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!( assert_eq!(
payload["rulesetVersion"], payload["playerCharacterImageSrc"],
json!("bark-battle-ruleset-v1") 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] #[test]
@@ -551,15 +868,14 @@ mod tests {
let config = BarkBattleConfigEditorPayload { let config = BarkBattleConfigEditorPayload {
title: "周末狗狗杯".to_string(), title: "周末狗狗杯".to_string(),
description: Some("轻配置草稿".to_string()), description: Some("轻配置草稿".to_string()),
theme_preset: "neon-park".to_string(), theme_description: "霓虹公园".to_string(),
player_dog_skin_preset: "shiba".to_string(), player_image_description: "柴犬主角".to_string(),
opponent_dog_skin_preset: "husky".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()), player_character_image_src: Some("/generated-bark-battle/player.png".to_string()),
opponent_character_image_src: Some("https://example.test/opponent.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()), 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, difficulty_preset: BarkBattleDifficultyPreset::Hard,
leaderboard_enabled: true,
}; };
let payload = serde_json::to_value(config).expect("config should serialize"); let payload = serde_json::to_value(config).expect("config should serialize");
@@ -576,10 +892,7 @@ mod tests {
payload["uiBackgroundImageSrc"], payload["uiBackgroundImageSrc"],
json!("/generated-bark-battle/ui.png") json!("/generated-bark-battle/ui.png")
); );
assert_eq!( assert!(!payload.as_object().unwrap().contains_key("barkSoundSrc"));
payload["barkSoundSrc"],
json!("/generated-bark-battle/bark.mp3")
);
} }
#[test] #[test]

View File

@@ -1,4 +1,5 @@
use super::*; use super::*;
use std::collections::HashMap;
pub type BarkBattleDraftCreateRecordInput = BarkBattleDraftCreateInput; pub type BarkBattleDraftCreateRecordInput = BarkBattleDraftCreateInput;
pub type BarkBattleDraftConfigUpsertRecordInput = BarkBattleDraftConfigUpsertInput; pub type BarkBattleDraftConfigUpsertRecordInput = BarkBattleDraftConfigUpsertInput;
@@ -44,6 +45,32 @@ impl SpacetimeClient {
.await .await
} }
pub async fn get_bark_battle_draft_config(
&self,
draft_id: String,
owner_user_id: String,
) -> Result<BarkBattleDraftConfigRecord, SpacetimeClientError> {
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( pub async fn publish_bark_battle_work(
&self, &self,
input: BarkBattleWorkPublishRecordInput, input: BarkBattleWorkPublishRecordInput,
@@ -142,4 +169,83 @@ impl SpacetimeClient {
}) })
.await .await
} }
pub async fn list_bark_battle_works(
&self,
owner_user_id: String,
) -> Result<Vec<serde_json::Value>, SpacetimeClientError> {
self.read_after_connect("list_bark_battle_works", move |connection| {
let owner_user_id = owner_user_id.as_str();
let drafts: Vec<serde_json::Value> = 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<serde_json::Value> = 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<String, serde_json::Value> = 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<serde_json::Value> = 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<Vec<serde_json::Value>, 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::<Vec<_>>();
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
}
} }

View File

@@ -550,6 +550,7 @@ impl SpacetimeClient {
) -> Result<Vec<SubscriptionHandle>, SpacetimeClientError> { ) -> Result<Vec<SubscriptionHandle>, SpacetimeClientError> {
let mut subscriptions = Vec::new(); let mut subscriptions = Vec::new();
for query in [ for query in [
"SELECT * FROM bark_battle_gallery_view",
"SELECT * FROM puzzle_gallery_card_view", "SELECT * FROM puzzle_gallery_card_view",
"SELECT * FROM custom_world_gallery_entry", "SELECT * FROM custom_world_gallery_entry",
"SELECT * FROM match_3_d_gallery_view", "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 = 'square-hole'",
"SELECT * FROM public_work_play_daily_stat WHERE source_type = 'visual-novel'", "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 = 'big-fish'",
"SELECT * FROM public_work_play_daily_stat WHERE source_type = 'bark-battle'",
"SELECT * FROM creation_entry_config", "SELECT * FROM creation_entry_config",
"SELECT * FROM creation_entry_type_config", "SELECT * FROM creation_entry_type_config",
] { ] {

View File

@@ -112,8 +112,9 @@ pub(crate) use self::auth::{
map_auth_store_snapshot_import_procedure_result, map_auth_store_snapshot_procedure_result, map_auth_store_snapshot_import_procedure_result, map_auth_store_snapshot_procedure_result,
}; };
pub(crate) use self::bark_battle::{ pub(crate) use self::bark_battle::{
map_bark_battle_draft_config_procedure_result, map_bark_battle_run_procedure_result, map_bark_battle_draft_config_procedure_result, map_bark_battle_draft_config_row,
map_bark_battle_runtime_config_procedure_result, 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::{ pub(crate) use self::big_fish::{
map_big_fish_gallery_view_row, map_big_fish_run_procedure_result, map_big_fish_gallery_view_row, map_big_fish_run_procedure_result,

View File

@@ -36,6 +36,70 @@ pub(crate) fn map_bark_battle_run_procedure_result(
.map(bark_battle_run_to_value) .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 { fn bark_battle_draft_config_to_value(snapshot: BarkBattleDraftConfigSnapshot) -> serde_json::Value {
serde_json::json!({ serde_json::json!({
"draftId": snapshot.draft_id, "draftId": snapshot.draft_id,
@@ -44,7 +108,6 @@ fn bark_battle_draft_config_to_value(snapshot: BarkBattleDraftConfigSnapshot) ->
"configVersion": snapshot.config_version, "configVersion": snapshot.config_version,
"rulesetVersion": snapshot.ruleset_version, "rulesetVersion": snapshot.ruleset_version,
"difficultyPreset": snapshot.difficulty_preset, "difficultyPreset": snapshot.difficulty_preset,
"leaderboardEnabled": snapshot.leaderboard_enabled,
"configJson": snapshot.config_json, "configJson": snapshot.config_json,
"editorStateJson": snapshot.editor_state_json, "editorStateJson": snapshot.editor_state_json,
"createdAtMicros": snapshot.created_at_micros, "createdAtMicros": snapshot.created_at_micros,
@@ -62,7 +125,6 @@ fn bark_battle_runtime_config_to_value(
"configVersion": snapshot.config_version, "configVersion": snapshot.config_version,
"rulesetVersion": snapshot.ruleset_version, "rulesetVersion": snapshot.ruleset_version,
"difficultyPreset": snapshot.difficulty_preset, "difficultyPreset": snapshot.difficulty_preset,
"leaderboardEnabled": snapshot.leaderboard_enabled,
"configJson": snapshot.config_json, "configJson": snapshot.config_json,
"publishedSnapshotJson": snapshot.published_snapshot_json, "publishedSnapshotJson": snapshot.published_snapshot_json,
"publishedAtMicros": snapshot.published_at_micros, "publishedAtMicros": snapshot.published_at_micros,
@@ -78,7 +140,6 @@ fn bark_battle_run_to_value(snapshot: BarkBattleRunSnapshot) -> serde_json::Valu
"configVersion": snapshot.config_version, "configVersion": snapshot.config_version,
"rulesetVersion": snapshot.ruleset_version, "rulesetVersion": snapshot.ruleset_version,
"difficultyPreset": snapshot.difficulty_preset, "difficultyPreset": snapshot.difficulty_preset,
"leaderboardEnabled": snapshot.leaderboard_enabled,
"status": snapshot.status, "status": snapshot.status,
"clientStartedAtMicros": snapshot.client_started_at_micros, "clientStartedAtMicros": snapshot.client_started_at_micros,
"serverStartedAtMicros": snapshot.server_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, "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));
}
}

View File

@@ -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_table;
pub mod bark_battle_draft_config_upsert_input_type; pub mod bark_battle_draft_config_upsert_input_type;
pub mod bark_battle_draft_create_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_row_type;
pub mod bark_battle_leaderboard_entry_table; pub mod bark_battle_leaderboard_entry_table;
pub mod bark_battle_personal_best_projection_row_type; 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_table::*;
pub use bark_battle_draft_config_upsert_input_type::BarkBattleDraftConfigUpsertInput; pub use bark_battle_draft_config_upsert_input_type::BarkBattleDraftConfigUpsertInput;
pub use bark_battle_draft_create_input_type::BarkBattleDraftCreateInput; 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_row_type::BarkBattleLeaderboardEntryRow;
pub use bark_battle_leaderboard_entry_table::*; pub use bark_battle_leaderboard_entry_table::*;
pub use bark_battle_personal_best_projection_row_type::BarkBattlePersonalBestProjectionRow; pub use bark_battle_personal_best_projection_row_type::BarkBattlePersonalBestProjectionRow;
@@ -2143,6 +2147,7 @@ pub struct DbUpdate {
auth_store_projection_meta: __sdk::TableUpdate<AuthStoreProjectionMeta>, auth_store_projection_meta: __sdk::TableUpdate<AuthStoreProjectionMeta>,
auth_store_snapshot: __sdk::TableUpdate<AuthStoreSnapshot>, auth_store_snapshot: __sdk::TableUpdate<AuthStoreSnapshot>,
bark_battle_draft_config: __sdk::TableUpdate<BarkBattleDraftConfigRow>, bark_battle_draft_config: __sdk::TableUpdate<BarkBattleDraftConfigRow>,
bark_battle_gallery_view: __sdk::TableUpdate<BarkBattleGalleryViewRow>,
bark_battle_leaderboard_entry: __sdk::TableUpdate<BarkBattleLeaderboardEntryRow>, bark_battle_leaderboard_entry: __sdk::TableUpdate<BarkBattleLeaderboardEntryRow>,
bark_battle_personal_best_projection: __sdk::TableUpdate<BarkBattlePersonalBestProjectionRow>, bark_battle_personal_best_projection: __sdk::TableUpdate<BarkBattlePersonalBestProjectionRow>,
bark_battle_published_config: __sdk::TableUpdate<BarkBattlePublishedConfigRow>, bark_battle_published_config: __sdk::TableUpdate<BarkBattlePublishedConfigRow>,
@@ -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" => db_update.bark_battle_draft_config.append(
bark_battle_draft_config_table::parse_table_update(table_update)?, 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" => db_update.bark_battle_leaderboard_entry.append(
bark_battle_leaderboard_entry_table::parse_table_update(table_update)?, bark_battle_leaderboard_entry_table::parse_table_update(table_update)?,
), ),
@@ -3008,6 +3016,10 @@ impl __sdk::DbUpdate for DbUpdate {
&self.visual_novel_work_profile, &self.visual_novel_work_profile,
) )
.with_updates_by_pk(|row| &row.profile_id); .with_updates_by_pk(|row| &row.profile_id);
diff.bark_battle_gallery_view = cache.apply_diff_to_table::<BarkBattleGalleryViewRow>(
"bark_battle_gallery_view",
&self.bark_battle_gallery_view,
);
diff.big_fish_gallery_view = cache.apply_diff_to_table::<BigFishWorkSummarySnapshot>( diff.big_fish_gallery_view = cache.apply_diff_to_table::<BigFishWorkSummarySnapshot>(
"big_fish_gallery_view", "big_fish_gallery_view",
&self.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" => db_update
.bark_battle_draft_config .bark_battle_draft_config
.append(__sdk::parse_row_list_as_inserts(table_rows.rows)?), .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" => db_update
.bark_battle_leaderboard_entry .bark_battle_leaderboard_entry
.append(__sdk::parse_row_list_as_inserts(table_rows.rows)?), .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" => db_update
.bark_battle_draft_config .bark_battle_draft_config
.append(__sdk::parse_row_list_as_deletes(table_rows.rows)?), .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" => db_update
.bark_battle_leaderboard_entry .bark_battle_leaderboard_entry
.append(__sdk::parse_row_list_as_deletes(table_rows.rows)?), .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_projection_meta: __sdk::TableAppliedDiff<'r, AuthStoreProjectionMeta>,
auth_store_snapshot: __sdk::TableAppliedDiff<'r, AuthStoreSnapshot>, auth_store_snapshot: __sdk::TableAppliedDiff<'r, AuthStoreSnapshot>,
bark_battle_draft_config: __sdk::TableAppliedDiff<'r, BarkBattleDraftConfigRow>, 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_leaderboard_entry: __sdk::TableAppliedDiff<'r, BarkBattleLeaderboardEntryRow>,
bark_battle_personal_best_projection: bark_battle_personal_best_projection:
__sdk::TableAppliedDiff<'r, BarkBattlePersonalBestProjectionRow>, __sdk::TableAppliedDiff<'r, BarkBattlePersonalBestProjectionRow>,
@@ -3805,6 +3824,11 @@ impl<'r> __sdk::AppliedDiff<'r> for AppliedDiff<'r> {
&self.bark_battle_draft_config, &self.bark_battle_draft_config,
event, event,
); );
callbacks.invoke_table_row_callbacks::<BarkBattleGalleryViewRow>(
"bark_battle_gallery_view",
&self.bark_battle_gallery_view,
event,
);
callbacks.invoke_table_row_callbacks::<BarkBattleLeaderboardEntryRow>( callbacks.invoke_table_row_callbacks::<BarkBattleLeaderboardEntryRow>(
"bark_battle_leaderboard_entry", "bark_battle_leaderboard_entry",
&self.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_projection_meta_table::register_table(client_cache);
auth_store_snapshot_table::register_table(client_cache); auth_store_snapshot_table::register_table(client_cache);
bark_battle_draft_config_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_leaderboard_entry_table::register_table(client_cache);
bark_battle_personal_best_projection_table::register_table(client_cache); bark_battle_personal_best_projection_table::register_table(client_cache);
bark_battle_published_config_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_projection_meta",
"auth_store_snapshot", "auth_store_snapshot",
"bark_battle_draft_config", "bark_battle_draft_config",
"bark_battle_gallery_view",
"bark_battle_leaderboard_entry", "bark_battle_leaderboard_entry",
"bark_battle_personal_best_projection", "bark_battle_personal_best_projection",
"bark_battle_published_config", "bark_battle_published_config",

View File

@@ -13,7 +13,6 @@ pub struct BarkBattleDraftConfigSnapshot {
pub config_version: u64, pub config_version: u64,
pub ruleset_version: String, pub ruleset_version: String,
pub difficulty_preset: String, pub difficulty_preset: String,
pub leaderboard_enabled: bool,
pub config_json: String, pub config_json: String,
pub editor_state_json: String, pub editor_state_json: String,
pub created_at_micros: i64, pub created_at_micros: i64,

View File

@@ -13,7 +13,6 @@ pub struct BarkBattleDraftConfigUpsertInput {
pub config_version: u64, pub config_version: u64,
pub ruleset_version: String, pub ruleset_version: String,
pub difficulty_preset: String, pub difficulty_preset: String,
pub leaderboard_enabled: bool,
pub config_json: String, pub config_json: String,
pub updated_at_micros: i64, pub updated_at_micros: i64,
} }

View File

@@ -12,11 +12,10 @@ pub struct BarkBattleDraftCreateInput {
pub work_id: String, pub work_id: String,
pub title: Option<String>, pub title: Option<String>,
pub description: Option<String>, pub description: Option<String>,
pub theme_preset: String, pub theme_description: String,
pub player_dog_skin_preset: String, pub player_image_description: String,
pub opponent_dog_skin_preset: String, pub opponent_image_description: String,
pub difficulty_preset: Option<String>, pub difficulty_preset: Option<String>,
pub leaderboard_enabled: Option<bool>,
pub editor_state_json: Option<String>, pub editor_state_json: Option<String>,
pub created_at_micros: i64, pub created_at_micros: i64,
} }

View File

@@ -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<String>,
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<String>,
pub player_character_image_src: Option<String>,
pub opponent_character_image_src: Option<String>,
pub ui_background_image_src: Option<String>,
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<BarkBattleGalleryViewRow, String>,
pub owner_user_id: __sdk::__query_builder::Col<BarkBattleGalleryViewRow, String>,
pub source_draft_id: __sdk::__query_builder::Col<BarkBattleGalleryViewRow, Option<String>>,
pub config_version: __sdk::__query_builder::Col<BarkBattleGalleryViewRow, u64>,
pub ruleset_version: __sdk::__query_builder::Col<BarkBattleGalleryViewRow, String>,
pub difficulty_preset: __sdk::__query_builder::Col<BarkBattleGalleryViewRow, String>,
pub title: __sdk::__query_builder::Col<BarkBattleGalleryViewRow, String>,
pub description: __sdk::__query_builder::Col<BarkBattleGalleryViewRow, String>,
pub theme_description: __sdk::__query_builder::Col<BarkBattleGalleryViewRow, String>,
pub player_image_description: __sdk::__query_builder::Col<BarkBattleGalleryViewRow, String>,
pub opponent_image_description: __sdk::__query_builder::Col<BarkBattleGalleryViewRow, String>,
pub onomatopoeia: __sdk::__query_builder::Col<BarkBattleGalleryViewRow, Vec<String>>,
pub player_character_image_src:
__sdk::__query_builder::Col<BarkBattleGalleryViewRow, Option<String>>,
pub opponent_character_image_src:
__sdk::__query_builder::Col<BarkBattleGalleryViewRow, Option<String>>,
pub ui_background_image_src:
__sdk::__query_builder::Col<BarkBattleGalleryViewRow, Option<String>>,
pub play_count: __sdk::__query_builder::Col<BarkBattleGalleryViewRow, u64>,
pub finish_count: __sdk::__query_builder::Col<BarkBattleGalleryViewRow, u64>,
pub updated_at_micros: __sdk::__query_builder::Col<BarkBattleGalleryViewRow, i64>,
pub published_at_micros: __sdk::__query_builder::Col<BarkBattleGalleryViewRow, i64>,
}
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",
),
}
}
}

View File

@@ -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<BarkBattleGalleryViewRow>,
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::<BarkBattleGalleryViewRow>("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<Item = BarkBattleGalleryViewRow> + '_ {
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<super::RemoteModule>) {
let _table =
client_cache.get_or_make_table::<BarkBattleGalleryViewRow>("bark_battle_gallery_view");
}
#[doc(hidden)]
pub(super) fn parse_table_update(
raw_updates: __ws::v2::TableUpdate,
) -> __sdk::Result<__sdk::TableUpdate<BarkBattleGalleryViewRow>> {
__sdk::TableUpdate::parse_table_update(raw_updates).map_err(|e| {
__sdk::InternalError::failed_parse("TableUpdate<BarkBattleGalleryViewRow>", "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<BarkBattleGalleryViewRow>;
}
impl bark_battle_gallery_viewQueryTableAccess for __sdk::QueryTableAccessor {
fn bark_battle_gallery_view(&self) -> __sdk::__query_builder::Table<BarkBattleGalleryViewRow> {
__sdk::__query_builder::Table::new("bark_battle_gallery_view")
}
}

View File

@@ -13,7 +13,6 @@ pub struct BarkBattleRunSnapshot {
pub config_version: u64, pub config_version: u64,
pub ruleset_version: String, pub ruleset_version: String,
pub difficulty_preset: String, pub difficulty_preset: String,
pub leaderboard_enabled: bool,
pub status: String, pub status: String,
pub client_started_at_micros: i64, pub client_started_at_micros: i64,
pub server_started_at_micros: i64, pub server_started_at_micros: i64,

View File

@@ -13,7 +13,6 @@ pub struct BarkBattleRuntimeConfigSnapshot {
pub config_version: u64, pub config_version: u64,
pub ruleset_version: String, pub ruleset_version: String,
pub difficulty_preset: String, pub difficulty_preset: String,
pub leaderboard_enabled: bool,
pub config_json: String, pub config_json: String,
pub published_snapshot_json: String, pub published_snapshot_json: String,
pub published_at_micros: i64, pub published_at_micros: i64,

View File

@@ -81,7 +81,9 @@ fn spacetime_metrics() -> &'static SpacetimeMetrics {
read_duration_ms: meter read_duration_ms: meter
.f64_histogram("genarrative.spacetime.read.duration_ms") .f64_histogram("genarrative.spacetime.read.duration_ms")
.with_unit("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(), .build(),
} }
}) })

View File

@@ -1,6 +1,7 @@
use crate::*; use crate::*;
use serde::{Serialize, de::DeserializeOwned}; use serde::{Serialize, de::DeserializeOwned};
use sha2::{Digest, Sha256}; use sha2::{Digest, Sha256};
use spacetimedb::AnonymousViewContext;
pub(crate) mod tables; pub(crate) mod tables;
mod types; mod types;
@@ -8,6 +9,38 @@ mod types;
pub use tables::*; pub use tables::*;
pub use types::*; pub use types::*;
/// Bark Battle 公开广场列表投影。
///
/// HTTP gallery 订阅该 public view 后读取本地 cacheview 只从已发布配置和统计投影
/// 组装 v1 公开字段,避免每个公开列表请求重新调用 procedure 热路径。
#[spacetimedb::view(accessor = bark_battle_gallery_view, public)]
pub fn bark_battle_gallery_view(ctx: &AnonymousViewContext) -> Vec<BarkBattleGalleryViewRow> {
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::<Vec<_>>();
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] #[spacetimedb::procedure]
pub fn create_bark_battle_draft( pub fn create_bark_battle_draft(
ctx: &mut ProcedureContext, ctx: &mut ProcedureContext,
@@ -106,21 +139,23 @@ fn create_bark_battle_draft_tx(
let config = BarkBattleEditorConfigSnapshot { let config = BarkBattleEditorConfigSnapshot {
title: normalize_title(input.title.as_deref())?, title: normalize_title(input.title.as_deref())?,
description: normalize_optional_text(input.description.as_deref()), description: normalize_optional_text(input.description.as_deref()),
theme_preset: normalize_required_preset(&input.theme_preset, "theme_preset")?, theme_description: normalize_required_description(
player_dog_skin_preset: normalize_required_preset( &input.theme_description,
&input.player_dog_skin_preset, "theme_description",
"player_dog_skin_preset",
)?, )?,
opponent_dog_skin_preset: normalize_required_preset( player_image_description: normalize_required_description(
&input.opponent_dog_skin_preset, &input.player_image_description,
"opponent_dog_skin_preset", "player_image_description",
)?, )?,
opponent_image_description: normalize_required_description(
&input.opponent_image_description,
"opponent_image_description",
)?,
onomatopoeia: Vec::new(),
player_character_image_src: None, player_character_image_src: None,
opponent_character_image_src: None, opponent_character_image_src: None,
ui_background_image_src: None, ui_background_image_src: None,
bark_sound_src: None,
difficulty_preset: normalize_difficulty(input.difficulty_preset.as_deref())?, difficulty_preset: normalize_difficulty(input.difficulty_preset.as_deref())?,
leaderboard_enabled: input.leaderboard_enabled.unwrap_or(true),
}; };
let row = BarkBattleDraftConfigRow { let row = BarkBattleDraftConfigRow {
draft_id: input.draft_id.clone(), draft_id: input.draft_id.clone(),
@@ -129,7 +164,7 @@ fn create_bark_battle_draft_tx(
config_version: 1, config_version: 1,
ruleset_version: BARK_BATTLE_DEFAULT_RULESET_VERSION.to_string(), ruleset_version: BARK_BATTLE_DEFAULT_RULESET_VERSION.to_string(),
difficulty_preset: config.difficulty_preset.clone(), difficulty_preset: config.difficulty_preset.clone(),
leaderboard_enabled: config.leaderboard_enabled, leaderboard_enabled: true,
config_json: to_json_string(&config), config_json: to_json_string(&config),
editor_state_json: normalize_json_string( editor_state_json: normalize_json_string(
input.editor_state_json.as_deref(), 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")?; require_non_empty(&input.work_id, "bark_battle work_id")?;
let mut editor_config = parse_editor_config(&input.config_json)?; let mut editor_config = parse_editor_config(&input.config_json)?;
normalize_editor_config_snapshot(&mut editor_config)?; normalize_editor_config_snapshot(&mut editor_config)?;
if editor_config.difficulty_preset != input.difficulty_preset if editor_config.difficulty_preset != input.difficulty_preset {
|| editor_config.leaderboard_enabled != input.leaderboard_enabled return Err("bark_battle config_json 与 difficulty_preset 不匹配".to_string());
{
return Err("bark_battle config_json 与行字段不一致".to_string());
} }
let updated_at = Timestamp::from_micros_since_unix_epoch(input.updated_at_micros); let updated_at = Timestamp::from_micros_since_unix_epoch(input.updated_at_micros);
let existing = ctx 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 { if existing.owner_user_id != input.owner_user_id || existing.work_id != input.work_id {
return Err("bark_battle draft owner/work 不匹配".to_string()); 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; 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.ruleset_version = normalize_ruleset_version(&input.ruleset_version)?;
row.difficulty_preset = normalize_difficulty(Some(&input.difficulty_preset))?; row.difficulty_preset = normalize_difficulty(Some(&input.difficulty_preset))?;
row.leaderboard_enabled = input.leaderboard_enabled;
row.config_json = to_json_string(&editor_config); row.config_json = to_json_string(&editor_config);
row.updated_at = updated_at; row.updated_at = updated_at;
ctx.db 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 { if draft.owner_user_id != input.owner_user_id || draft.work_id != input.work_id {
return Err("bark_battle draft owner/work 不匹配".to_string()); 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 { let published = BarkBattlePublishedConfigRow {
work_id: draft.work_id.clone(), work_id: draft.work_id.clone(),
owner_user_id: draft.owner_user_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, config_version: draft.config_version,
ruleset_version: normalize_ruleset_version(&draft.ruleset_version)?, ruleset_version: normalize_ruleset_version(&draft.ruleset_version)?,
difficulty_preset: normalize_difficulty(Some(&draft.difficulty_preset))?, difficulty_preset: normalize_difficulty(Some(&draft.difficulty_preset))?,
leaderboard_enabled: draft.leaderboard_enabled, leaderboard_enabled: true,
config_json: draft.config_json.clone(), config_json: published_snapshot_json.clone(),
published_snapshot_json: match input.published_snapshot_json.as_deref() { published_snapshot_json,
Some(value) => normalize_json_string(Some(value), "published_snapshot_json")?,
None => draft.config_json.clone(),
},
created_at: published_at, created_at: published_at,
updated_at: published_at, updated_at: published_at,
published_at, published_at,
@@ -297,7 +341,7 @@ fn start_bark_battle_run_tx(
config_version: input.config_version, config_version: input.config_version,
ruleset_version: input.ruleset_version, ruleset_version: input.ruleset_version,
difficulty_preset: input.difficulty_preset, difficulty_preset: input.difficulty_preset,
leaderboard_enabled: published.leaderboard_enabled, leaderboard_enabled: true,
status: BARK_BATTLE_RUN_RUNNING.to_string(), status: BARK_BATTLE_RUN_RUNNING.to_string(),
client_started_at_micros: input.client_started_at_micros, client_started_at_micros: input.client_started_at_micros,
server_started_at: started_at, server_started_at: started_at,
@@ -483,7 +527,6 @@ fn draft_snapshot(row: &BarkBattleDraftConfigRow) -> BarkBattleDraftConfigSnapsh
config_version: row.config_version, config_version: row.config_version,
ruleset_version: row.ruleset_version.clone(), ruleset_version: row.ruleset_version.clone(),
difficulty_preset: row.difficulty_preset.clone(), difficulty_preset: row.difficulty_preset.clone(),
leaderboard_enabled: row.leaderboard_enabled,
config_json: row.config_json.clone(), config_json: row.config_json.clone(),
editor_state_json: row.editor_state_json.clone(), editor_state_json: row.editor_state_json.clone(),
created_at_micros: row.created_at.to_micros_since_unix_epoch(), 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, config_version: row.config_version,
ruleset_version: row.ruleset_version.clone(), ruleset_version: row.ruleset_version.clone(),
difficulty_preset: row.difficulty_preset.clone(), difficulty_preset: row.difficulty_preset.clone(),
leaderboard_enabled: row.leaderboard_enabled,
config_json: row.config_json.clone(), config_json: row.config_json.clone(),
published_snapshot_json: row.published_snapshot_json.clone(), published_snapshot_json: row.published_snapshot_json.clone(),
published_at_micros: row.published_at.to_micros_since_unix_epoch(), 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<BarkBattleGalleryViewRow, String> {
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 { fn hash_run_token(token: &str) -> String {
let digest = Sha256::digest(token.as_bytes()); let digest = Sha256::digest(token.as_bytes());
digest.iter().map(|byte| format!("{byte:02x}")).collect() digest.iter().map(|byte| format!("{byte:02x}")).collect()
@@ -530,11 +609,17 @@ fn normalize_editor_config_snapshot(
config: &mut BarkBattleEditorConfigSnapshot, config: &mut BarkBattleEditorConfigSnapshot,
) -> Result<(), String> { ) -> Result<(), String> {
config.title = normalize_title(Some(&config.title))?; config.title = normalize_title(Some(&config.title))?;
config.theme_preset = normalize_required_preset(&config.theme_preset, "theme_preset")?; config.theme_description =
config.player_dog_skin_preset = normalize_required_description(&config.theme_description, "theme_description")?;
normalize_required_preset(&config.player_dog_skin_preset, "player_dog_skin_preset")?; config.player_image_description = normalize_required_description(
config.opponent_dog_skin_preset = &config.player_image_description,
normalize_required_preset(&config.opponent_dog_skin_preset, "opponent_dog_skin_preset")?; "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 = normalize_optional_asset_source(
config.player_character_image_src.as_deref(), config.player_character_image_src.as_deref(),
"player_character_image_src", "player_character_image_src",
@@ -547,8 +632,6 @@ fn normalize_editor_config_snapshot(
config.ui_background_image_src.as_deref(), config.ui_background_image_src.as_deref(),
"ui_background_image_src", "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))?; config.difficulty_preset = normalize_difficulty(Some(&config.difficulty_preset))?;
Ok(()) Ok(())
} }
@@ -568,12 +651,24 @@ fn normalize_optional_text(value: Option<&str>) -> String {
value.unwrap_or_default().trim().chars().take(120).collect() value.unwrap_or_default().trim().chars().take(120).collect()
} }
fn normalize_required_preset(value: &str, field_name: &str) -> Result<String, String> { fn normalize_required_description(value: &str, field_name: &str) -> Result<String, String> {
let preset = value.trim(); let description = value.trim();
if preset.is_empty() { if description.is_empty() {
return Err(format!("bark_battle {field_name} 不能为空")); 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<String>) -> Vec<String> {
words
.into_iter()
.map(|word| word.trim().chars().take(12).collect::<String>())
.filter(|word| !word.is_empty())
.take(24)
.collect()
} }
fn normalize_optional_asset_source( fn normalize_optional_asset_source(
@@ -674,7 +769,6 @@ fn run_snapshot(row: &BarkBattleRuntimeRunRow) -> BarkBattleRunSnapshot {
config_version: row.config_version, config_version: row.config_version,
ruleset_version: row.ruleset_version.clone(), ruleset_version: row.ruleset_version.clone(),
difficulty_preset: row.difficulty_preset.clone(), difficulty_preset: row.difficulty_preset.clone(),
leaderboard_enabled: row.leaderboard_enabled,
status: row.status.clone(), status: row.status.clone(),
client_started_at_micros: row.client_started_at_micros, client_started_at_micros: row.client_started_at_micros,
server_started_at_micros: row.server_started_at.to_micros_since_unix_epoch(), server_started_at_micros: row.server_started_at.to_micros_since_unix_epoch(),
@@ -905,7 +999,6 @@ mod tests {
config_version: 1, config_version: 1,
ruleset_version: BARK_BATTLE_DEFAULT_RULESET_VERSION.to_string(), ruleset_version: BARK_BATTLE_DEFAULT_RULESET_VERSION.to_string(),
difficulty_preset: BARK_BATTLE_DIFFICULTY_NORMAL.to_string(), difficulty_preset: BARK_BATTLE_DIFFICULTY_NORMAL.to_string(),
leaderboard_enabled: true,
config_json: "{}".to_string(), config_json: "{}".to_string(),
updated_at_micros: 1_700_000, updated_at_micros: 1_700_000,
}; };
@@ -919,7 +1012,6 @@ mod tests {
config_version: input.config_version, config_version: input.config_version,
ruleset_version: input.ruleset_version.clone(), ruleset_version: input.ruleset_version.clone(),
difficulty_preset: input.difficulty_preset.clone(), difficulty_preset: input.difficulty_preset.clone(),
leaderboard_enabled: input.leaderboard_enabled,
config_json: input.config_json.clone(), config_json: input.config_json.clone(),
editor_state_json: "{}".to_string(), editor_state_json: "{}".to_string(),
created_at_micros: 1_700_000, created_at_micros: 1_700_000,
@@ -945,4 +1037,84 @@ mod tests {
assert!(normalize_title(Some(" 标题 ")).is_ok()); assert!(normalize_title(Some(" 标题 ")).is_ok());
assert!(normalize_title(Some(" ")).is_err()); 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()]);
}
} }

View File

@@ -24,11 +24,10 @@ pub struct BarkBattleDraftCreateInput {
pub work_id: String, pub work_id: String,
pub title: Option<String>, pub title: Option<String>,
pub description: Option<String>, pub description: Option<String>,
pub theme_preset: String, pub theme_description: String,
pub player_dog_skin_preset: String, pub player_image_description: String,
pub opponent_dog_skin_preset: String, pub opponent_image_description: String,
pub difficulty_preset: Option<String>, pub difficulty_preset: Option<String>,
pub leaderboard_enabled: Option<bool>,
pub editor_state_json: Option<String>, pub editor_state_json: Option<String>,
pub created_at_micros: i64, pub created_at_micros: i64,
} }
@@ -41,7 +40,6 @@ pub struct BarkBattleDraftConfigUpsertInput {
pub config_version: u64, pub config_version: u64,
pub ruleset_version: String, pub ruleset_version: String,
pub difficulty_preset: String, pub difficulty_preset: String,
pub leaderboard_enabled: bool,
pub config_json: String, pub config_json: String,
pub updated_at_micros: i64, pub updated_at_micros: i64,
} }
@@ -116,19 +114,18 @@ pub struct BarkBattleProcedureResult {
pub struct BarkBattleEditorConfigSnapshot { pub struct BarkBattleEditorConfigSnapshot {
pub title: String, pub title: String,
pub description: String, pub description: String,
pub theme_preset: String, pub theme_description: String,
pub player_dog_skin_preset: String, pub player_image_description: String,
pub opponent_dog_skin_preset: String, pub opponent_image_description: String,
#[serde(default, skip_serializing_if = "Vec::is_empty")]
pub onomatopoeia: Vec<String>,
#[serde(default, skip_serializing_if = "Option::is_none")] #[serde(default, skip_serializing_if = "Option::is_none")]
pub player_character_image_src: Option<String>, pub player_character_image_src: Option<String>,
#[serde(default, skip_serializing_if = "Option::is_none")] #[serde(default, skip_serializing_if = "Option::is_none")]
pub opponent_character_image_src: Option<String>, pub opponent_character_image_src: Option<String>,
#[serde(default, skip_serializing_if = "Option::is_none")] #[serde(default, skip_serializing_if = "Option::is_none")]
pub ui_background_image_src: Option<String>, pub ui_background_image_src: Option<String>,
#[serde(default, skip_serializing_if = "Option::is_none")]
pub bark_sound_src: Option<String>,
pub difficulty_preset: String, pub difficulty_preset: String,
pub leaderboard_enabled: bool,
} }
#[derive(Clone, Debug, PartialEq, Eq, Serialize, Deserialize, SpacetimeType)] #[derive(Clone, Debug, PartialEq, Eq, Serialize, Deserialize, SpacetimeType)]
@@ -140,7 +137,6 @@ pub struct BarkBattleDraftConfigSnapshot {
pub config_version: u64, pub config_version: u64,
pub ruleset_version: String, pub ruleset_version: String,
pub difficulty_preset: String, pub difficulty_preset: String,
pub leaderboard_enabled: bool,
pub config_json: String, pub config_json: String,
pub editor_state_json: String, pub editor_state_json: String,
pub created_at_micros: i64, pub created_at_micros: i64,
@@ -156,7 +152,6 @@ pub struct BarkBattleRuntimeConfigSnapshot {
pub config_version: u64, pub config_version: u64,
pub ruleset_version: String, pub ruleset_version: String,
pub difficulty_preset: String, pub difficulty_preset: String,
pub leaderboard_enabled: bool,
pub config_json: String, pub config_json: String,
pub published_snapshot_json: String, pub published_snapshot_json: String,
pub published_at_micros: i64, pub published_at_micros: i64,
@@ -172,7 +167,6 @@ pub struct BarkBattleRunSnapshot {
pub config_version: u64, pub config_version: u64,
pub ruleset_version: String, pub ruleset_version: String,
pub difficulty_preset: String, pub difficulty_preset: String,
pub leaderboard_enabled: bool,
pub status: String, pub status: String,
pub client_started_at_micros: i64, pub client_started_at_micros: i64,
pub server_started_at_micros: i64, pub server_started_at_micros: i64,
@@ -185,3 +179,31 @@ pub struct BarkBattleRunSnapshot {
pub leaderboard_score: Option<u64>, pub leaderboard_score: Option<u64>,
pub score_id: Option<String>, pub score_id: Option<String>,
} }
/// 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<String>,
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<String>,
pub player_character_image_src: Option<String>,
pub opponent_character_image_src: Option<String>,
pub ui_background_image_src: Option<String>,
pub play_count: u64,
pub finish_count: u64,
pub updated_at_micros: i64,
pub published_at_micros: i64,
}

View File

@@ -180,17 +180,7 @@ fn seed_creation_entry_config_if_missing(ctx: &ReducerContext) {
} }
migrate_visual_novel_entry_from_old_visible_default(ctx, now); migrate_visual_novel_entry_from_old_visible_default(ctx, now);
migrate_coming_soon_entry_from_old_open_default( migrate_bark_battle_entry_to_open_default(ctx, now);
ctx,
now,
ComingSoonEntryDefault {
id: "bark-battle",
title: "汪汪声浪",
subtitle: "声控对战挑战",
image_src: "/creation-type-references/creative-agent.webp",
sort_order: 85,
},
);
migrate_coming_soon_entry_from_old_open_default( migrate_coming_soon_entry_from_old_open_default(
ctx, ctx,
now, 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) { fn migrate_visual_novel_entry_from_old_visible_default(ctx: &ReducerContext, now: Timestamp) {
let id = "visual-novel".to_string(); let id = "visual-novel".to_string();
let Some(row) = ctx.db.creation_entry_type_config().id().find(&id) else { let Some(row) = ctx.db.creation_entry_type_config().id().find(&id) else {

View File

@@ -7,7 +7,7 @@ import { describe, expect, it, vi } from 'vitest';
import { BarkBattleConfigEditor } from './BarkBattleConfigEditor'; import { BarkBattleConfigEditor } from './BarkBattleConfigEditor';
describe('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(); const onPreview = vi.fn();
render(<BarkBattleConfigEditor isBusy={false} onPreview={onPreview} />); render(<BarkBattleConfigEditor isBusy={false} onPreview={onPreview} />);
@@ -15,48 +15,92 @@ describe('BarkBattleConfigEditor', () => {
expect(screen.getByText('轻配置')).toBeTruthy(); expect(screen.getByText('轻配置')).toBeTruthy();
expect((screen.getByLabelText('作品标题') as HTMLInputElement).value).toBe('我的声浪竞技场'); expect((screen.getByLabelText('作品标题') as HTMLInputElement).value).toBe('我的声浪竞技场');
expect((screen.getByLabelText('难度预设') as HTMLSelectElement).value).toBe('normal'); 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.clear(screen.getByLabelText('作品标题'));
await userEvent.type(screen.getByLabelText('作品标题'), '周末狗狗杯'); await userEvent.type(screen.getByLabelText('作品标题'), '狗狗冠军杯');
await userEvent.selectOptions(screen.getByLabelText('主题背景'), 'neon-park'); await userEvent.clear(screen.getByLabelText('主题/场景描述'));
await userEvent.clear(screen.getByLabelText('玩家角色设定')); await userEvent.type(screen.getByLabelText('主题/场景描述'), '霓虹公园声浪擂台');
await userEvent.type(screen.getByLabelText('玩家角色设定'), '主角'); await userEvent.clear(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('拟声词'),
'炸场!\n冲啊 / 破阵、Boom!',
);
await userEvent.selectOptions(screen.getByLabelText('难度预设'), 'hard'); 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: '生成草稿' })); await userEvent.click(screen.getByRole('button', { name: '生成草稿' }));
expect(onPreview).toHaveBeenCalledWith({ expect(onPreview).toHaveBeenCalledWith({
title: '周末狗狗杯', title: '狗狗冠军杯',
description: '', description: '',
themePreset: 'neon-park', themeDescription: '霓虹公园声浪擂台',
playerDogSkinPreset: '主角', playerImageDescription: '红围巾柴犬',
opponentDogSkinPreset: '对手', opponentImageDescription: '蓝头带哈士奇',
playerCharacterImageSrc: '/generated-bark-battle/player/image.png', onomatopoeia: ['炸场!', '冲啊!', '破阵!', 'Boom!'],
opponentCharacterImageSrc: 'https://example.test/opponent.png',
uiBackgroundImageSrc: '/generated-bark-battle/ui/background.png',
barkSoundSrc: '/generated-bark-battle/audio/bark.mp3',
difficultyPreset: 'hard', difficultyPreset: 'hard',
leaderboardEnabled: true,
}); });
}); });
it('uses a louder theme-aware default onomatopoeia pool without locking to dogs', async () => {
const onPreview = vi.fn();
render(<BarkBattleConfigEditor isBusy={false} onPreview={onPreview} />);
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(<BarkBattleConfigEditor isBusy={false} onPreview={onPreview} />);
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 () => { it('requires a non-empty title before compiling a draft', async () => {
const onPreview = vi.fn(); const onPreview = vi.fn();
render(<BarkBattleConfigEditor isBusy={false} onPreview={onPreview} />); render(<BarkBattleConfigEditor isBusy={false} onPreview={onPreview} />);
@@ -72,7 +116,7 @@ describe('BarkBattleConfigEditor', () => {
const onPreview = vi.fn(); const onPreview = vi.fn();
render( render(
<BarkBattleConfigEditor <BarkBattleConfigEditor
error="发布失败" error="外部错误"
isBusy={false} isBusy={false}
onPreview={onPreview} onPreview={onPreview}
showBackButton={false} showBackButton={false}
@@ -83,6 +127,32 @@ describe('BarkBattleConfigEditor', () => {
expect(screen.queryByRole('heading', { name: '汪汪声浪大作战' })).toBeNull(); expect(screen.queryByRole('heading', { name: '汪汪声浪大作战' })).toBeNull();
expect(screen.queryByRole('button', { name: '返回' })).toBeNull(); expect(screen.queryByRole('button', { name: '返回' })).toBeNull();
expect(screen.getByLabelText('汪汪声浪轻配置编辑器')).toBeTruthy(); 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(
<BarkBattleConfigEditor
isBusy={false}
onPreview={onPreview}
showBackButton={false}
title={null}
/>,
);
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');
}); });
}); });

View File

@@ -1,8 +1,9 @@
import { ArrowLeft, Loader2, Play } from 'lucide-react'; 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 { BarkBattleConfigEditorPayload } from '../../../packages/shared/src/contracts/barkBattle';
import type { BarkBattleDifficultyPreset } 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'; import { BarkBattlePreviewCard } from './BarkBattlePreviewCard';
export type BarkBattleConfigEditorProps = { export type BarkBattleConfigEditorProps = {
@@ -14,17 +15,26 @@ export type BarkBattleConfigEditorProps = {
title?: string | null; 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 }> = [ const DIFFICULTY_OPTIONS: Array<{ value: BarkBattleDifficultyPreset; label: string }> = [
{ value: 'easy', label: '轻松' }, { value: 'easy', label: '轻松' },
{ value: 'normal', label: '标准' }, { value: 'normal', label: '标准' },
{ value: 'hard', 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({ export function BarkBattleConfigEditor({
isBusy = false, isBusy = false,
@@ -36,46 +46,72 @@ export function BarkBattleConfigEditor({
}: BarkBattleConfigEditorProps) { }: BarkBattleConfigEditorProps) {
const [title, setTitle] = useState('我的声浪竞技场'); const [title, setTitle] = useState('我的声浪竞技场');
const [description, setDescription] = useState(''); const [description, setDescription] = useState('');
const [themePreset, setThemePreset] = useState('sunny-yard'); const [themeDescription, setThemeDescription] = useState(
const [playerDogSkinPreset, setPlayerDogSkinPreset] = useState('主角'); DEFAULT_THEME_DESCRIPTION,
const [opponentDogSkinPreset, setOpponentDogSkinPreset] = useState('对手'); );
const [playerCharacterImageSrc, setPlayerCharacterImageSrc] = useState(''); const [playerImageDescription, setPlayerImageDescription] = useState(
const [opponentCharacterImageSrc, setOpponentCharacterImageSrc] = useState(''); DEFAULT_PLAYER_IMAGE_DESCRIPTION,
const [uiBackgroundImageSrc, setUiBackgroundImageSrc] = useState(''); );
const [barkSoundSrc, setBarkSoundSrc] = useState(''); 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<BarkBattleDifficultyPreset>('normal'); const [difficultyPreset, setDifficultyPreset] = useState<BarkBattleDifficultyPreset>('normal');
const [localError, setLocalError] = useState<string | null>(null); const [localError, setLocalError] = useState<string | null>(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<BarkBattleConfigEditorPayload>( const payload = useMemo<BarkBattleConfigEditorPayload>(
() => ({ () => ({
title: title.trim(), title: title.trim(),
description: description.trim(), description: description.trim(),
themePreset, themeDescription: themeDescription.trim(),
playerDogSkinPreset, playerImageDescription: playerImageDescription.trim(),
opponentDogSkinPreset, opponentImageDescription: opponentImageDescription.trim(),
...(playerCharacterImageSrc.trim() onomatopoeia,
? { playerCharacterImageSrc: playerCharacterImageSrc.trim() }
: {}),
...(opponentCharacterImageSrc.trim()
? { opponentCharacterImageSrc: opponentCharacterImageSrc.trim() }
: {}),
...(uiBackgroundImageSrc.trim()
? { uiBackgroundImageSrc: uiBackgroundImageSrc.trim() }
: {}),
...(barkSoundSrc.trim() ? { barkSoundSrc: barkSoundSrc.trim() } : {}),
difficultyPreset, difficultyPreset,
leaderboardEnabled: true,
}), }),
[ [
title, title,
description, description,
themePreset, themeDescription,
playerDogSkinPreset, playerImageDescription,
opponentDogSkinPreset, opponentImageDescription,
playerCharacterImageSrc, onomatopoeia,
opponentCharacterImageSrc,
uiBackgroundImageSrc,
barkSoundSrc,
difficultyPreset, difficultyPreset,
], ],
); );
@@ -87,6 +123,14 @@ export function BarkBattleConfigEditor({
setLocalError('请先填写作品标题'); setLocalError('请先填写作品标题');
return; return;
} }
if (!payload.themeDescription) {
setLocalError('请先填写主题/场景描述');
return;
}
if (!payload.playerImageDescription || !payload.opponentImageDescription) {
setLocalError('请先填写双方形象描述');
return;
}
setLocalError(null); setLocalError(null);
void action(payload); void action(payload);
}; };
@@ -94,7 +138,7 @@ export function BarkBattleConfigEditor({
return ( return (
<section <section
className="platform-remap-surface mx-auto flex min-h-0 w-full max-w-5xl flex-1 flex-col overflow-y-auto overscroll-y-contain pr-0.5" className="platform-remap-surface mx-auto flex min-h-full w-full max-w-5xl flex-col overflow-visible lg:min-h-0 lg:flex-1 lg:overflow-y-auto lg:overscroll-y-contain lg:pr-0.5"
aria-label="汪汪声浪轻配置编辑器" aria-label="汪汪声浪轻配置编辑器"
> >
{showBackButton && onBack ? ( {showBackButton && onBack ? (
@@ -113,7 +157,7 @@ export function BarkBattleConfigEditor({
</div> </div>
) : null} ) : null}
<div className="flex min-h-0 flex-1 flex-col"> <div className="flex min-h-0 flex-col lg:flex-1">
{headingTitle ? ( {headingTitle ? (
<div className="mb-3 shrink-0 sm:mb-5"> <div className="mb-3 shrink-0 sm:mb-5">
<div className="flex flex-wrap items-center gap-2"> <div className="flex flex-wrap items-center gap-2">
@@ -128,13 +172,11 @@ export function BarkBattleConfigEditor({
) : null} ) : null}
<div <div
className={`grid flex-1 gap-3 lg:grid-cols-[minmax(0,1.12fr)_minmax(17rem,0.88fr)] ${isBusy ? 'opacity-55' : ''}`} className={`grid gap-3 lg:flex-1 lg:grid-cols-[minmax(0,1.12fr)_minmax(17rem,0.88fr)] ${isBusy ? 'opacity-55' : ''}`}
> >
<div className="flex flex-col gap-3 pr-0 lg:pr-1"> <div className="flex flex-col gap-3 pr-0 lg:pr-1">
<label className="block shrink-0"> <label className="block shrink-0">
<span className="mb-2 block text-sm font-black text-[var(--platform-text-strong)]"> <span className={FIELD_LABEL_CLASS}></span>
</span>
<input <input
value={title} value={title}
disabled={isBusy} disabled={isBusy}
@@ -146,9 +188,7 @@ export function BarkBattleConfigEditor({
</label> </label>
<label className="block shrink-0"> <label className="block shrink-0">
<span className="mb-2 block text-sm font-black text-[var(--platform-text-strong)]"> <span className={FIELD_LABEL_CLASS}></span>
</span>
<textarea <textarea
value={description} value={description}
disabled={isBusy} disabled={isBusy}
@@ -162,28 +202,7 @@ export function BarkBattleConfigEditor({
<div className="grid shrink-0 gap-2.5 sm:grid-cols-2"> <div className="grid shrink-0 gap-2.5 sm:grid-cols-2">
<label className="block"> <label className="block">
<span className="mb-2 block text-sm font-black text-[var(--platform-text-strong)]"> <span className={FIELD_LABEL_CLASS}></span>
</span>
<select
value={themePreset}
disabled={isBusy}
onChange={(event) => setThemePreset(event.target.value)}
className="h-11 w-full rounded-[1.05rem] border border-[var(--platform-subpanel-border)] bg-white/90 px-4 text-sm font-black text-[var(--platform-text-strong)] outline-none transition focus:border-rose-200 focus:bg-white focus:ring-2 focus:ring-rose-100"
aria-label="主题背景"
>
{THEME_OPTIONS.map((option) => (
<option key={option.value} value={option.value}>
{option.label}
</option>
))}
</select>
</label>
<label className="block">
<span className="mb-2 block text-sm font-black text-[var(--platform-text-strong)]">
</span>
<select <select
value={difficultyPreset} value={difficultyPreset}
disabled={isBusy} disabled={isBusy}
@@ -202,92 +221,64 @@ export function BarkBattleConfigEditor({
))} ))}
</select> </select>
</label> </label>
<label className="block">
<span className="mb-2 block text-sm font-black text-[var(--platform-text-strong)]">
</span>
<input
value={playerDogSkinPreset}
disabled={isBusy}
onChange={(event) => setPlayerDogSkinPreset(event.target.value)}
className="h-11 w-full rounded-[1.05rem] border border-[var(--platform-subpanel-border)] bg-white/90 px-4 text-sm font-black text-[var(--platform-text-strong)] outline-none transition focus:border-rose-200 focus:bg-white focus:ring-2 focus:ring-rose-100"
aria-label="玩家角色设定"
/>
</label>
<label className="block">
<span className="mb-2 block text-sm font-black text-[var(--platform-text-strong)]">
</span>
<input
value={opponentDogSkinPreset}
disabled={isBusy}
onChange={(event) => setOpponentDogSkinPreset(event.target.value)}
className="h-11 w-full rounded-[1.05rem] border border-[var(--platform-subpanel-border)] bg-white/90 px-4 text-sm font-black text-[var(--platform-text-strong)] outline-none transition focus:border-rose-200 focus:bg-white focus:ring-2 focus:ring-rose-100"
aria-label="对手角色设定"
/>
</label>
</div> </div>
<label className="block shrink-0">
<span className={ACCENT_FIELD_LABEL_CLASS}>
/
</span>
<textarea
value={themeDescription}
disabled={isBusy}
onChange={(event) => setThemeDescription(event.target.value)}
className="h-[5.5rem] min-h-[5.5rem] w-full resize-none rounded-[1.05rem] border border-[var(--platform-subpanel-border)] bg-white/90 px-4 py-3 text-base leading-6 text-[var(--platform-text-strong)] outline-none transition focus:border-rose-200 focus:bg-white focus:ring-2 focus:ring-rose-100"
maxLength={240}
placeholder=""
aria-label="主题/场景描述"
/>
</label>
<div className="grid shrink-0 gap-2.5 sm:grid-cols-2"> <div className="grid shrink-0 gap-2.5 sm:grid-cols-2">
<label className="block"> <label className="block">
<span className="mb-2 block text-sm font-black text-[var(--platform-text-strong)]"> <span className={FIELD_LABEL_CLASS}></span>
<textarea
</span> value={playerImageDescription}
<input
value={playerCharacterImageSrc}
disabled={isBusy} disabled={isBusy}
onChange={(event) => setPlayerCharacterImageSrc(event.target.value)} onChange={(event) => setPlayerImageDescription(event.target.value)}
className="h-11 w-full rounded-[1.05rem] border border-[var(--platform-subpanel-border)] bg-white/90 px-4 text-sm font-semibold text-[var(--platform-text-strong)] outline-none transition focus:border-rose-200 focus:bg-white focus:ring-2 focus:ring-rose-100" className="h-[5rem] min-h-[5rem] w-full resize-none rounded-[1.05rem] border border-[var(--platform-subpanel-border)] bg-white/90 px-4 py-3 text-sm font-semibold leading-6 text-[var(--platform-text-strong)] outline-none transition focus:border-rose-200 focus:bg-white focus:ring-2 focus:ring-rose-100"
placeholder="" maxLength={220}
aria-label="玩家形象" aria-label="玩家形象描述"
/> />
</label> </label>
<label className="block"> <label className="block">
<span className="mb-2 block text-sm font-black text-[var(--platform-text-strong)]"> <span className={FIELD_LABEL_CLASS}></span>
<textarea
</span> value={opponentImageDescription}
<input
value={opponentCharacterImageSrc}
disabled={isBusy} disabled={isBusy}
onChange={(event) => setOpponentCharacterImageSrc(event.target.value)} onChange={(event) => setOpponentImageDescription(event.target.value)}
className="h-11 w-full rounded-[1.05rem] border border-[var(--platform-subpanel-border)] bg-white/90 px-4 text-sm font-semibold text-[var(--platform-text-strong)] outline-none transition focus:border-rose-200 focus:bg-white focus:ring-2 focus:ring-rose-100" className="h-[5rem] min-h-[5rem] w-full resize-none rounded-[1.05rem] border border-[var(--platform-subpanel-border)] bg-white/90 px-4 py-3 text-sm font-semibold leading-6 text-[var(--platform-text-strong)] outline-none transition focus:border-rose-200 focus:bg-white focus:ring-2 focus:ring-rose-100"
placeholder="" maxLength={220}
aria-label="对手形象" aria-label="对手形象描述"
/>
</label>
<label className="block">
<span className="mb-2 block text-sm font-black text-[var(--platform-text-strong)]">
UI背景
</span>
<input
value={uiBackgroundImageSrc}
disabled={isBusy}
onChange={(event) => setUiBackgroundImageSrc(event.target.value)}
className="h-11 w-full rounded-[1.05rem] border border-[var(--platform-subpanel-border)] bg-white/90 px-4 text-sm font-semibold text-[var(--platform-text-strong)] outline-none transition focus:border-rose-200 focus:bg-white focus:ring-2 focus:ring-rose-100"
placeholder=""
aria-label="UI背景"
/>
</label>
<label className="block">
<span className="mb-2 block text-sm font-black text-[var(--platform-text-strong)]">
</span>
<input
value={barkSoundSrc}
disabled={isBusy}
onChange={(event) => setBarkSoundSrc(event.target.value)}
className="h-11 w-full rounded-[1.05rem] border border-[var(--platform-subpanel-border)] bg-white/90 px-4 text-sm font-semibold text-[var(--platform-text-strong)] outline-none transition focus:border-rose-200 focus:bg-white focus:ring-2 focus:ring-rose-100"
placeholder=""
aria-label="狗叫音效"
/> />
</label> </label>
</div> </div>
<label className="block shrink-0">
<span className={ACCENT_FIELD_LABEL_CLASS}></span>
<textarea
value={onomatopoeiaText}
disabled={isBusy}
onChange={(event) => {
setIsOnomatopoeiaCustomized(true);
setOnomatopoeiaText(event.target.value);
}}
className="h-[6.5rem] min-h-[6.5rem] w-full resize-none rounded-[1.05rem] border border-[var(--platform-subpanel-border)] bg-white/90 px-4 py-3 text-sm font-black leading-6 text-[var(--platform-text-strong)] outline-none transition focus:border-rose-200 focus:bg-white focus:ring-2 focus:ring-rose-100"
maxLength={260}
aria-label="拟声词"
/>
</label>
{visibleError ? ( {visibleError ? (
<div className="platform-banner platform-banner--danger shrink-0 rounded-2xl text-sm leading-6"> <div className="platform-banner platform-banner--danger shrink-0 rounded-2xl text-sm leading-6">
{visibleError} {visibleError}
@@ -299,7 +290,7 @@ export function BarkBattleConfigEditor({
</div> </div>
</div> </div>
<div className="mt-3 flex shrink-0 flex-wrap justify-center gap-2 pb-[max(0.25rem,env(safe-area-inset-bottom))] sm:mt-4"> <div className="mt-4 flex shrink-0 flex-wrap justify-center gap-2 pb-[calc(env(safe-area-inset-bottom,0px)+0.75rem)] sm:mt-4 lg:pb-[max(0.25rem,env(safe-area-inset-bottom))]">
<button <button
type="button" type="button"
disabled={isBusy} disabled={isBusy}

View File

@@ -0,0 +1,306 @@
/* @vitest-environment jsdom */
import { render, screen, waitFor } from '@testing-library/react';
import { describe, expect, it, vi } from 'vitest';
import {
type BarkBattleImageGenerationBatchResult,
generateAllBarkBattleImageAssets,
updateBarkBattleDraftConfig,
} from '../../services/bark-battle-creation';
import { BarkBattleGeneratingView } from './BarkBattleGeneratingView';
vi.mock('../../services/bark-battle-creation', () => ({
generateAllBarkBattleImageAssets: vi.fn(),
updateBarkBattleDraftConfig: vi.fn(),
}));
vi.mock('./BarkBattlePreviewCard', () => ({
BarkBattlePreviewCard: () => <div></div>,
}));
const draft = {
draftId: 'bark-battle-draft-1',
workId: 'BB-12345678',
title: '汪汪冠军杯',
description: '',
themeDescription: '霓虹公园擂台',
playerImageDescription: '红围巾柴犬',
opponentImageDescription: '蓝头带哈士奇',
difficultyPreset: 'normal' as const,
configVersion: 2,
rulesetVersion: 'bark-battle-ruleset-v1',
updatedAt: '2026-05-14T10:00:00.000Z',
};
describe('BarkBattleGeneratingView', () => {
it('renders all generation slots while parallel generation is still running', async () => {
const onComplete = vi.fn();
let resolveGeneration: (
result: BarkBattleImageGenerationBatchResult,
) => void = () => {};
vi.mocked(generateAllBarkBattleImageAssets).mockReturnValue(
new Promise<BarkBattleImageGenerationBatchResult>((resolve) => {
resolveGeneration = resolve;
}),
);
vi.mocked(updateBarkBattleDraftConfig).mockResolvedValue({
...draft,
configVersion: 3,
updatedAt: '2026-05-14T10:01:00.000Z',
});
render(
<BarkBattleGeneratingView
draft={draft}
onBack={() => {}}
onComplete={onComplete}
onError={() => {}}
/>,
);
expect(screen.getByText('玩家形象')).toBeTruthy();
expect(screen.getByText('对手形象')).toBeTruthy();
expect(screen.getByText('竞技背景')).toBeTruthy();
expect(onComplete).not.toHaveBeenCalled();
resolveGeneration({
assets: {
'player-character': {
imageSrc: '/generated-bark-battle/player.png',
assetId: 'asset-player',
model: 'gpt-image-2-all',
size: '1024*1024',
taskId: 'task-player',
prompt: 'player',
},
'opponent-character': {
imageSrc: '/generated-bark-battle/opponent.png',
assetId: 'asset-opponent',
model: 'gpt-image-2-all',
size: '1024*1024',
taskId: 'task-opponent',
prompt: 'opponent',
},
'ui-background': {
imageSrc: '/generated-bark-battle/background.png',
assetId: 'asset-background',
model: 'gpt-image-2-all',
size: '1024*1792',
taskId: 'task-background',
prompt: 'background',
},
},
failures: {},
});
await waitFor(() => expect(onComplete).toHaveBeenCalled());
});
it('persists generated image assets before entering result view', async () => {
const onComplete = vi.fn();
const onError = vi.fn();
vi.mocked(generateAllBarkBattleImageAssets).mockResolvedValue({
assets: {
'player-character': {
imageSrc: '/generated-bark-battle/player.png',
assetId: 'asset-player',
model: 'gpt-image-2-all',
size: '1024*1024',
taskId: 'task-player',
prompt: 'player',
},
'opponent-character': {
imageSrc: '/generated-bark-battle/opponent.png',
assetId: 'asset-opponent',
model: 'gpt-image-2-all',
size: '1024*1024',
taskId: 'task-opponent',
prompt: 'opponent',
},
'ui-background': {
imageSrc: '/generated-bark-battle/background.png',
assetId: 'asset-background',
model: 'gpt-image-2-all',
size: '1024*1792',
taskId: 'task-background',
prompt: 'background',
},
},
failures: {},
});
vi.mocked(updateBarkBattleDraftConfig).mockResolvedValue({
...draft,
configVersion: 3,
updatedAt: '2026-05-14T10:01:00.000Z',
});
render(
<BarkBattleGeneratingView
draft={draft}
onBack={() => {}}
onComplete={onComplete}
onError={onError}
/>,
);
await waitFor(() => {
expect(updateBarkBattleDraftConfig).toHaveBeenCalledWith(
expect.objectContaining({
draftId: 'bark-battle-draft-1',
workId: 'BB-12345678',
playerCharacterImageSrc: '/generated-bark-battle/player.png',
opponentCharacterImageSrc: '/generated-bark-battle/opponent.png',
uiBackgroundImageSrc: '/generated-bark-battle/background.png',
}),
);
});
expect(onComplete).toHaveBeenCalledWith(
expect.objectContaining({
configVersion: 3,
playerCharacterImageSrc: '/generated-bark-battle/player.png',
opponentCharacterImageSrc: '/generated-bark-battle/opponent.png',
uiBackgroundImageSrc: '/generated-bark-battle/background.png',
}),
false,
);
expect(onError).toHaveBeenCalledWith(null);
});
it('enters result view with partial failure when only part of the images are generated', async () => {
const onComplete = vi.fn();
vi.mocked(generateAllBarkBattleImageAssets).mockResolvedValue({
assets: {
'player-character': {
imageSrc: '/generated-bark-battle/player.png',
assetId: 'asset-player',
model: 'gpt-image-2-all',
size: '1024*1024',
taskId: 'task-player',
prompt: 'player',
},
},
failures: {
'opponent-character': '泥点不足,本次需要 1 泥点,当前 0 泥点。',
'ui-background': '场景图片生成失败:上游超时',
},
});
vi.mocked(updateBarkBattleDraftConfig).mockResolvedValue({
...draft,
playerCharacterImageSrc: '/generated-bark-battle/player.png',
configVersion: 3,
});
render(
<BarkBattleGeneratingView
draft={draft}
onBack={() => {}}
onComplete={onComplete}
onError={() => {}}
/>,
);
await waitFor(() => {
expect(onComplete).toHaveBeenCalledWith(
expect.objectContaining({
playerCharacterImageSrc: '/generated-bark-battle/player.png',
}),
true,
);
});
});
it('still enters result view when generated assets cannot be persisted', async () => {
const onComplete = vi.fn();
const onError = vi.fn();
vi.mocked(generateAllBarkBattleImageAssets).mockResolvedValue({
assets: {
'player-character': {
imageSrc: '/generated-bark-battle/player.png',
assetId: 'asset-player',
model: 'gpt-image-2-all',
size: '1024*1024',
taskId: 'task-player',
prompt: 'player',
},
'opponent-character': {
imageSrc: '/generated-bark-battle/opponent.png',
assetId: 'asset-opponent',
model: 'gpt-image-2-all',
size: '1024*1024',
taskId: 'task-opponent',
prompt: 'opponent',
},
'ui-background': {
imageSrc: '/generated-bark-battle/background.png',
assetId: 'asset-background',
model: 'gpt-image-2-all',
size: '1024*1792',
taskId: 'task-background',
prompt: 'background',
},
},
failures: {},
});
vi.mocked(updateBarkBattleDraftConfig).mockRejectedValue(
new Error('保存超时'),
);
render(
<BarkBattleGeneratingView
draft={draft}
onBack={() => {}}
onComplete={onComplete}
onError={onError}
/>,
);
await waitFor(() => {
expect(onComplete).toHaveBeenCalledWith(
expect.objectContaining({
playerCharacterImageSrc: '/generated-bark-battle/player.png',
opponentCharacterImageSrc: '/generated-bark-battle/opponent.png',
uiBackgroundImageSrc: '/generated-bark-battle/background.png',
}),
true,
);
});
expect(onError).toHaveBeenCalledWith('保存超时');
});
it('shows generation failures and enters result view when no image asset is generated', async () => {
const onComplete = vi.fn();
const onError = vi.fn();
vi.mocked(generateAllBarkBattleImageAssets).mockResolvedValue({
assets: {},
failures: {
'player-character': '泥点不足,本次需要 1 泥点,当前 0 泥点。',
'opponent-character': '泥点不足,本次需要 1 泥点,当前 0 泥点。',
'ui-background': '场景图片生成失败:上游超时',
},
});
vi.mocked(updateBarkBattleDraftConfig).mockResolvedValue(draft);
render(
<BarkBattleGeneratingView
draft={draft}
onBack={() => {}}
onComplete={onComplete}
onError={onError}
/>,
);
await waitFor(() => {
expect(onError).toHaveBeenCalledWith(
'泥点不足,本次需要 1 泥点,当前 0 泥点。',
);
expect(onComplete).toHaveBeenCalledWith(
expect.objectContaining({
draftId: draft.draftId,
workId: draft.workId,
title: draft.title,
}),
true,
);
});
});
});

View File

@@ -0,0 +1,357 @@
import { AlertCircle, ArrowLeft, CheckCircle2, Loader2, Sparkles } from 'lucide-react';
import { useEffect, useMemo, useRef, useState } from 'react';
import type { BarkBattleDraftConfig } from '../../../packages/shared/src/contracts/barkBattle';
import type {
BarkBattleAssetSlot,
BarkBattleGeneratedImageAssets,
BarkBattleImageGenerationBatchResult,
BarkBattleImageGenerationFailures,
} from '../../services/bark-battle-creation';
import {
generateAllBarkBattleImageAssets,
updateBarkBattleDraftConfig,
} from '../../services/bark-battle-creation';
import { BarkBattlePreviewCard } from './BarkBattlePreviewCard';
type BarkBattleGeneratingViewProps = {
draft: BarkBattleDraftConfig;
isBusy?: boolean;
error?: string | null;
onBack: () => void;
onComplete: (draft: BarkBattleDraftConfig, partialFailed: boolean) => void;
onError: (message: string | null) => void;
};
type BarkBattleGeneratingSlotStatus = 'generating' | 'ready' | 'failed';
const GENERATION_STEPS = [
{ slot: 'player-character', label: '玩家形象' },
{ slot: 'opponent-character', label: '对手形象' },
{ slot: 'ui-background', label: '竞技背景' },
] as const satisfies readonly {
slot: BarkBattleAssetSlot;
label: string;
}[];
const activeBarkBattleGenerationTasks = new Map<
string,
Promise<BarkBattleImageGenerationBatchResult>
>();
function applyGeneratedAssets(
draft: BarkBattleDraftConfig,
assets: BarkBattleGeneratedImageAssets,
): BarkBattleDraftConfig {
const nextDraft: BarkBattleDraftConfig = {
...draft,
updatedAt: new Date().toISOString(),
};
if (assets['player-character']?.imageSrc) {
nextDraft.playerCharacterImageSrc = assets['player-character'].imageSrc;
}
if (assets['opponent-character']?.imageSrc) {
nextDraft.opponentCharacterImageSrc = assets['opponent-character'].imageSrc;
}
if (assets['ui-background']?.imageSrc) {
nextDraft.uiBackgroundImageSrc = assets['ui-background'].imageSrc;
}
return nextDraft;
}
function hasSlotAsset(draft: BarkBattleDraftConfig, slot: BarkBattleAssetSlot) {
if (slot === 'player-character') {
return Boolean(draft.playerCharacterImageSrc?.trim());
}
if (slot === 'opponent-character') {
return Boolean(draft.opponentCharacterImageSrc?.trim());
}
return Boolean(draft.uiBackgroundImageSrc?.trim());
}
function mergeSlotAsset(
draft: BarkBattleDraftConfig,
slot: BarkBattleAssetSlot,
imageSrc: string,
): BarkBattleDraftConfig {
if (slot === 'player-character') {
return { ...draft, playerCharacterImageSrc: imageSrc };
}
if (slot === 'opponent-character') {
return { ...draft, opponentCharacterImageSrc: imageSrc };
}
return { ...draft, uiBackgroundImageSrc: imageSrc };
}
function isDraftPersistable(draft: BarkBattleDraftConfig) {
return Boolean(draft.draftId?.trim() && draft.workId?.trim());
}
function resolvePrimaryFailureMessage(
failures: BarkBattleImageGenerationFailures,
) {
for (const step of GENERATION_STEPS) {
const message = failures[step.slot]?.trim();
if (message) {
return message;
}
}
return null;
}
function buildDraftGenerationKey(draft: BarkBattleDraftConfig) {
return [
draft.draftId,
draft.playerCharacterImageSrc ?? '',
draft.opponentCharacterImageSrc ?? '',
draft.uiBackgroundImageSrc ?? '',
].join('|');
}
export function BarkBattleGeneratingView({
draft,
isBusy = false,
error = null,
onBack,
onComplete,
onError,
}: BarkBattleGeneratingViewProps) {
const startedDraftIdRef = useRef<string | null>(null);
const [slotFailures, setSlotFailures] =
useState<BarkBattleImageGenerationFailures>({});
const [previewDraft, setPreviewDraft] = useState(draft);
const [slotStatuses, setSlotStatuses] = useState<
Partial<Record<BarkBattleAssetSlot, BarkBattleGeneratingSlotStatus>>
>({});
const primaryFailureMessage = useMemo(
() => resolvePrimaryFailureMessage(slotFailures),
[slotFailures],
);
useEffect(() => {
setPreviewDraft(draft);
setSlotStatuses(
GENERATION_STEPS.reduce<
Partial<Record<BarkBattleAssetSlot, BarkBattleGeneratingSlotStatus>>
>((statuses, step) => {
statuses[step.slot] = hasSlotAsset(draft, step.slot)
? 'ready'
: 'generating';
return statuses;
}, {}),
);
}, [draft]);
useEffect(() => {
if (
!draft.draftId ||
(() => {
const draftGenerationKey = buildDraftGenerationKey(draft);
return startedDraftIdRef.current === draftGenerationKey;
})()
) {
return;
}
const startedDraftKey = buildDraftGenerationKey(draft);
startedDraftIdRef.current = startedDraftKey;
let cancelled = false;
const generationTask = generateAllBarkBattleImageAssets({
config: draft,
draftId: draft.draftId,
onSlotComplete: (slot, result) => {
if (cancelled || startedDraftIdRef.current !== startedDraftKey) {
return;
}
if (result.status === 'fulfilled') {
setPreviewDraft((currentDraft) =>
mergeSlotAsset(currentDraft, slot, result.asset.imageSrc),
);
setSlotStatuses((current) => ({ ...current, [slot]: 'ready' }));
setSlotFailures((current) => {
const next = { ...current };
delete next[slot];
return next;
});
return;
}
setSlotStatuses((current) => ({ ...current, [slot]: 'failed' }));
setSlotFailures((current) => ({ ...current, [slot]: result.message }));
},
});
activeBarkBattleGenerationTasks.set(startedDraftKey, generationTask);
onError(null);
setSlotFailures({});
setPreviewDraft(draft);
setSlotStatuses(
GENERATION_STEPS.reduce<
Partial<Record<BarkBattleAssetSlot, BarkBattleGeneratingSlotStatus>>
>((statuses, step) => {
statuses[step.slot] = hasSlotAsset(draft, step.slot)
? 'ready'
: 'generating';
return statuses;
}, {}),
);
void generationTask
.then(async ({ assets, failures }) => {
if (cancelled) {
return;
}
setSlotFailures(failures);
const primaryMessage = resolvePrimaryFailureMessage(failures);
if (primaryMessage) {
onError(primaryMessage);
}
const generatedDraft = applyGeneratedAssets(draft, assets);
const partialFailed = GENERATION_STEPS.some(
(step) => !hasSlotAsset(generatedDraft, step.slot),
);
if (!isDraftPersistable(generatedDraft)) {
onComplete(generatedDraft, partialFailed);
return;
}
try {
const persistedDraft = await updateBarkBattleDraftConfig({
draftId: generatedDraft.draftId,
workId: generatedDraft.workId,
configVersion: generatedDraft.configVersion,
rulesetVersion: generatedDraft.rulesetVersion,
title: generatedDraft.title,
description: generatedDraft.description,
themeDescription: generatedDraft.themeDescription,
playerImageDescription: generatedDraft.playerImageDescription,
opponentImageDescription: generatedDraft.opponentImageDescription,
onomatopoeia: generatedDraft.onomatopoeia,
playerCharacterImageSrc: generatedDraft.playerCharacterImageSrc,
opponentCharacterImageSrc: generatedDraft.opponentCharacterImageSrc,
uiBackgroundImageSrc: generatedDraft.uiBackgroundImageSrc,
difficultyPreset: generatedDraft.difficultyPreset,
});
const updatedDraft = applyGeneratedAssets(persistedDraft, assets);
if (!cancelled) {
onComplete(updatedDraft, partialFailed);
}
} catch (persistError) {
if (cancelled) {
return;
}
onError(
persistError instanceof Error
? persistError.message
: '汪汪声浪素材保存失败。',
);
onComplete(generatedDraft, true);
}
})
.catch((generationError) => {
if (cancelled) {
return;
}
onError(
generationError instanceof Error
? generationError.message
: '汪汪声浪素材生成失败。',
);
onComplete(draft, true);
})
.finally(() => {
if (activeBarkBattleGenerationTasks.get(startedDraftKey) === generationTask) {
activeBarkBattleGenerationTasks.delete(startedDraftKey);
}
});
return () => {
cancelled = true;
// 中文注释:离开生成页后不再全局复用同一 Promise避免悬挂生成任务导致再次进入时一直转圈。
if (activeBarkBattleGenerationTasks.get(startedDraftKey) === generationTask) {
activeBarkBattleGenerationTasks.delete(startedDraftKey);
}
if (startedDraftIdRef.current === startedDraftKey) {
startedDraftIdRef.current = null;
}
};
}, [draft, onComplete, onError]);
return (
<div className="platform-page-stage platform-remap-surface flex h-full min-h-0 flex-col overflow-hidden px-3 pb-3 pt-3 sm:px-4 sm:pt-4 xl:px-5 xl:pb-4 xl:pt-4">
<div className="mx-auto flex h-full min-h-0 w-full max-w-5xl flex-col">
<div className="mb-3 flex shrink-0 items-center justify-between gap-3">
<button
type="button"
onClick={onBack}
disabled={isBusy}
className={`platform-button platform-button--ghost min-h-0 px-3 py-1.5 text-[11px] ${isBusy ? 'opacity-45' : ''}`}
>
<ArrowLeft className="h-3.5 w-3.5" />
</button>
<span className="rounded-full border border-sky-200 bg-sky-50 px-3 py-1 text-[11px] font-black text-sky-700">
</span>
</div>
<section className="grid min-h-0 flex-1 gap-3 overflow-y-auto lg:grid-cols-[minmax(0,0.94fr)_minmax(18rem,0.86fr)]">
<div className="grid content-start gap-3">
<div className="rounded-[1.15rem] border border-[var(--platform-subpanel-border)] bg-white/72 p-4 shadow-[inset_0_1px_0_rgba(255,255,255,0.74)]">
<div className="flex items-center gap-2 text-sm font-black text-[var(--platform-text-soft)]">
<Sparkles className="h-4 w-4" />
</div>
<h1 className="m-0 mt-2 text-3xl font-black leading-tight tracking-normal text-[var(--platform-text-strong)] sm:text-5xl">
{draft.title || '未命名声浪竞技场'}
</h1>
</div>
<div className="grid gap-2">
{GENERATION_STEPS.map((step) => {
const status =
slotStatuses[step.slot] ??
(hasSlotAsset(previewDraft, step.slot) ? 'ready' : 'generating');
const ready = status === 'ready';
const failed =
status === 'failed' || Boolean(slotFailures[step.slot]);
const statusLabel = ready
? `${step.label}已生成`
: failed
? `${step.label}生成失败`
: `${step.label}生成中`;
return (
<div
key={step.slot}
className="flex items-center justify-between rounded-[1rem] border border-[var(--platform-subpanel-border)] bg-white/72 px-4 py-3"
aria-label={statusLabel}
>
<span className="text-sm font-black text-[var(--platform-text-strong)]">
{step.label}
</span>
{ready ? (
<CheckCircle2 className="h-4 w-4 text-emerald-600" />
) : failed ? (
<AlertCircle className="h-4 w-4 text-rose-500" />
) : (
<Loader2 className="h-4 w-4 animate-spin text-[var(--platform-text-soft)]" />
)}
</div>
);
})}
</div>
{error || primaryFailureMessage ? (
<div className="platform-banner platform-banner--danger rounded-2xl text-sm leading-6">
{error ?? primaryFailureMessage}
</div>
) : null}
</div>
<BarkBattlePreviewCard config={previewDraft} />
</section>
</div>
</div>
);
}
export default BarkBattleGeneratingView;

View File

@@ -5,12 +5,6 @@ type BarkBattlePreviewCardProps = {
config: BarkBattleConfigEditorPayload; config: BarkBattleConfigEditorPayload;
}; };
const THEME_LABELS: Record<string, string> = {
'sunny-yard': '阳光院子',
'neon-park': '霓虹公园',
'moonlight-rooftop': '月光天台',
};
const DIFFICULTY_LABELS = { const DIFFICULTY_LABELS = {
easy: '轻松', easy: '轻松',
normal: '标准', normal: '标准',
@@ -18,16 +12,15 @@ const DIFFICULTY_LABELS = {
}; };
export function BarkBattlePreviewCard({ config }: BarkBattlePreviewCardProps) { export function BarkBattlePreviewCard({ config }: BarkBattlePreviewCardProps) {
const hasCustomSound = Boolean(config.barkSoundSrc?.trim());
return ( return (
<aside <aside
className="platform-subpanel flex min-h-0 flex-col overflow-hidden rounded-[1.2rem] p-3 sm:p-4" className="platform-subpanel flex min-h-0 flex-col overflow-hidden rounded-[1.2rem] p-3 max-lg:p-2 sm:p-4"
aria-label="作品预览卡片" aria-label="作品预览卡片"
> >
<div className="flex min-h-0 flex-1 flex-col rounded-[1rem] border border-[var(--platform-subpanel-border)] bg-white/76 p-3 shadow-[inset_0_1px_0_rgba(255,255,255,0.78)] sm:p-4"> <div className="flex min-h-0 flex-1 flex-col rounded-[1rem] border border-[var(--platform-subpanel-border)] bg-white/76 p-2.5 shadow-[inset_0_1px_0_rgba(255,255,255,0.78)] sm:p-4">
<div <div
className="relative mb-4 grid min-h-[8.5rem] grid-cols-[1fr_auto_1fr] items-center gap-3 overflow-hidden rounded-[1rem] bg-[linear-gradient(135deg,rgba(255,255,255,0.96),rgba(255,236,241,0.9)_46%,rgba(224,247,250,0.82))] px-4 text-center text-3xl shadow-[inset_0_1px_0_rgba(255,255,255,0.8)] sm:min-h-[10rem]" className="relative mb-2.5 grid min-h-[5.75rem] grid-cols-[1fr_auto_1fr] items-center gap-2 overflow-hidden rounded-[1rem] bg-[linear-gradient(135deg,rgba(255,255,255,0.96),rgba(255,236,241,0.9)_46%,rgba(224,247,250,0.82))] px-3 text-center text-2xl shadow-[inset_0_1px_0_rgba(255,255,255,0.8)] sm:mb-4 sm:min-h-[10rem] sm:gap-3 sm:px-4 sm:text-3xl"
data-testid="bark-battle-preview-stage"
aria-hidden="true" aria-hidden="true"
> >
{config.uiBackgroundImageSrc ? ( {config.uiBackgroundImageSrc ? (
@@ -42,13 +35,13 @@ export function BarkBattlePreviewCard({ config }: BarkBattlePreviewCardProps) {
<ResolvedAssetImage <ResolvedAssetImage
src={config.playerCharacterImageSrc} src={config.playerCharacterImageSrc}
alt="" alt=""
className="h-20 w-20 object-contain drop-shadow-xl sm:h-24 sm:w-24" className="h-14 w-14 object-contain drop-shadow-xl sm:h-24 sm:w-24"
/> />
) : ( ) : (
<span className="text-5xl sm:text-6xl">🐕</span> <span className="text-4xl sm:text-6xl">🐕</span>
)} )}
</span> </span>
<span className="relative rounded-full bg-white/70 px-3 py-1 text-base font-black text-[var(--platform-text-strong)]"> <span className="relative rounded-full bg-white/70 px-2.5 py-0.5 text-xs font-black text-[var(--platform-text-strong)] sm:px-3 sm:py-1 sm:text-base">
VS VS
</span> </span>
<span className="relative grid place-items-center"> <span className="relative grid place-items-center">
@@ -56,48 +49,44 @@ export function BarkBattlePreviewCard({ config }: BarkBattlePreviewCardProps) {
<ResolvedAssetImage <ResolvedAssetImage
src={config.opponentCharacterImageSrc} src={config.opponentCharacterImageSrc}
alt="" alt=""
className="h-20 w-20 object-contain drop-shadow-xl sm:h-24 sm:w-24" className="h-14 w-14 object-contain drop-shadow-xl sm:h-24 sm:w-24"
/> />
) : ( ) : (
<span className="text-5xl sm:text-6xl">🐶</span> <span className="text-4xl sm:text-6xl">🐶</span>
)} )}
</span> </span>
</div> </div>
<h2 className="text-lg font-black leading-tight text-[var(--platform-text-strong)]"> <h2 className="text-base font-black leading-tight text-[var(--platform-text-strong)] sm:text-lg">
{config.title || '未命名声浪竞技场'} {config.title || '未命名声浪竞技场'}
</h2> </h2>
<p className="mt-2 min-h-[2.625rem] text-sm font-semibold leading-6 text-[var(--platform-text-muted)]"> <p className="mt-1.5 min-h-0 text-xs font-semibold leading-5 text-[var(--platform-text-muted)] sm:mt-2 sm:min-h-[2.625rem] sm:text-sm sm:leading-6">
{config.description || '30 秒声浪拔河,喊出你的能量优势。'} {config.description || '30 秒声浪拔河,喊出你的能量优势。'}
</p> </p>
<dl className="mt-4 grid gap-2 text-sm"> <dl className="mt-2.5 grid gap-1.5 text-xs sm:mt-4 sm:gap-2 sm:text-sm">
<div className="flex justify-between gap-3 rounded-[0.85rem] bg-white/74 px-3 py-2"> <div className="flex justify-between gap-2 rounded-[0.85rem] bg-white/74 px-2.5 py-1.5 sm:gap-3 sm:px-3 sm:py-2">
<dt className="text-[var(--platform-text-muted)]"></dt> <dt className="text-[var(--platform-text-muted)]"></dt>
<dd className="font-black text-[var(--platform-text-strong)]"> <dd className="font-black text-[var(--platform-text-strong)]">
{THEME_LABELS[config.themePreset] ?? config.themePreset} {config.themeDescription || '声浪擂台'}
</dd> </dd>
</div> </div>
<div className="flex justify-between gap-3 rounded-[0.85rem] bg-white/74 px-3 py-2"> <div className="flex justify-between gap-2 rounded-[0.85rem] bg-white/74 px-2.5 py-1.5 sm:gap-3 sm:px-3 sm:py-2">
<dt className="text-[var(--platform-text-muted)]"></dt> <dt className="text-[var(--platform-text-muted)]"></dt>
<dd className="font-black text-[var(--platform-text-strong)]"> <dd className="font-black text-[var(--platform-text-strong)]">
{config.playerDogSkinPreset || '主角'} {config.playerImageDescription || '玩家'}
{' vs '} {' vs '}
{config.opponentDogSkinPreset || '对手'} {config.opponentImageDescription || '对手'}
</dd> </dd>
</div> </div>
<div className="flex justify-between gap-3 rounded-[0.85rem] bg-white/74 px-3 py-2"> <div className="flex justify-between gap-2 rounded-[0.85rem] bg-white/74 px-2.5 py-1.5 sm:gap-3 sm:px-3 sm:py-2">
<dt className="text-[var(--platform-text-muted)]"></dt> <dt className="text-[var(--platform-text-muted)]"></dt>
<dd className="font-black text-[var(--platform-text-strong)]"> <dd className="font-black text-[var(--platform-text-strong)]">
{DIFFICULTY_LABELS[config.difficultyPreset]} {DIFFICULTY_LABELS[config.difficultyPreset]}
</dd> </dd>
</div> </div>
<div className="flex justify-between gap-3 rounded-[0.85rem] bg-white/74 px-3 py-2"> <div className="flex justify-between gap-2 rounded-[0.85rem] bg-white/74 px-2.5 py-1.5 sm:gap-3 sm:px-3 sm:py-2">
<dt className="text-[var(--platform-text-muted)]"></dt> <dt className="text-[var(--platform-text-muted)]"></dt>
<dd className="font-black text-[var(--platform-text-strong)]"> <dd className="font-black text-[var(--platform-text-strong)]">
{[ {config.onomatopoeia?.slice(0, 3).join(' / ') || '炸场!'}
config.playerCharacterImageSrc || config.opponentCharacterImageSrc ? '形象' : '',
config.uiBackgroundImageSrc ? 'UI' : '',
hasCustomSound ? '狗叫' : '',
].filter(Boolean).join(' / ') || '预设'}
</dd> </dd>
</div> </div>
</dl> </dl>

View File

@@ -4,7 +4,10 @@ import { render, screen, waitFor, within } from '@testing-library/react';
import userEvent from '@testing-library/user-event'; import userEvent from '@testing-library/user-event';
import { describe, expect, it, vi } from 'vitest'; import { describe, expect, it, vi } from 'vitest';
import { uploadBarkBattleAsset } from '../../services/bark-battle-creation'; import {
regenerateBarkBattleImageAsset,
uploadBarkBattleAsset,
} from '../../services/bark-battle-creation';
import { BarkBattleResultView } from './BarkBattleResultView'; import { BarkBattleResultView } from './BarkBattleResultView';
vi.mock('../../services/bark-battle-creation', () => ({ vi.mock('../../services/bark-battle-creation', () => ({
@@ -26,13 +29,12 @@ vi.mock('../ResolvedAssetImage', () => ({
const draft = { const draft = {
draftId: 'bark-battle-draft-1', draftId: 'bark-battle-draft-1',
workId: 'bark-battle-work-1', workId: 'bark-battle-work-1',
title: '汪汪测试杯', title: '汪汪冠军杯',
description: '', description: '',
themePreset: 'sunny-yard', themeDescription: '霓虹公园擂台',
playerDogSkinPreset: '主角', playerImageDescription: '红围巾柴犬',
opponentDogSkinPreset: '对手', opponentImageDescription: '蓝头带哈士奇',
difficultyPreset: 'normal' as const, difficultyPreset: 'normal' as const,
leaderboardEnabled: true,
configVersion: 1, configVersion: 1,
rulesetVersion: 'bark-battle-ruleset-v1', rulesetVersion: 'bark-battle-ruleset-v1',
updatedAt: '2026-05-14T10:00:00.000Z', updatedAt: '2026-05-14T10:00:00.000Z',
@@ -54,7 +56,7 @@ describe('BarkBattleResultView', () => {
/>, />,
); );
expect(screen.getByText('草稿编译')).toBeTruthy(); expect(screen.getByText('霓虹公园擂台')).toBeTruthy();
await user.click(screen.getByRole('button', { name: '试玩' })); await user.click(screen.getByRole('button', { name: '试玩' }));
expect(onStartTestRun).toHaveBeenCalledWith(draft); expect(onStartTestRun).toHaveBeenCalledWith(draft);
expect(onPublish).not.toHaveBeenCalled(); expect(onPublish).not.toHaveBeenCalled();
@@ -63,7 +65,27 @@ describe('BarkBattleResultView', () => {
expect(onPublish).toHaveBeenCalledWith(draft); expect(onPublish).toHaveBeenCalledWith(draft);
}); });
it('uploads replacement assets into the selected slot', async () => { it('uses compact mobile-first result layout classes', () => {
render(
<BarkBattleResultView
draft={draft}
onBack={() => {}}
onDraftChange={() => {}}
onStartTestRun={() => {}}
onPublish={() => {}}
/>,
);
expect(screen.getByRole('heading', { name: '汪汪冠军杯', level: 1 }).className).toContain(
'text-2xl',
);
expect(screen.getByLabelText('作品预览卡片').className).toContain('max-lg:p-2');
expect(screen.getByTestId('bark-battle-preview-stage').className).toContain(
'min-h-[5.75rem]',
);
});
it('uploads replacement image assets into the selected slot', async () => {
const user = userEvent.setup(); const user = userEvent.setup();
const onDraftChange = vi.fn(); const onDraftChange = vi.fn();
vi.mocked(uploadBarkBattleAsset).mockResolvedValue({ vi.mocked(uploadBarkBattleAsset).mockResolvedValue({
@@ -83,7 +105,9 @@ describe('BarkBattleResultView', () => {
/>, />,
); );
const playerSlot = screen.getByText('玩家形象').closest('article'); const playerSlot = screen
.getByRole('heading', { name: '玩家形象' })
.closest('article');
expect(playerSlot).toBeTruthy(); expect(playerSlot).toBeTruthy();
const fileInput = within(playerSlot as HTMLElement).getByLabelText( const fileInput = within(playerSlot as HTMLElement).getByLabelText(
'上传玩家形象文件', '上传玩家形象文件',
@@ -107,4 +131,82 @@ describe('BarkBattleResultView', () => {
}), }),
); );
}); });
it('does not render the raw object key or asset path in the slot summary', () => {
render(
<BarkBattleResultView
draft={{
...draft,
playerCharacterImageSrc: 'generated-bark-battle-assets/player-character/very-long-object-key.png',
}}
onBack={() => {}}
onDraftChange={() => {}}
onStartTestRun={() => {}}
onPublish={() => {}}
/>,
);
const playerSlot = screen.getByRole('heading', { name: '玩家形象' }).closest('article');
expect(playerSlot).toBeTruthy();
expect(within(playerSlot as HTMLElement).getByText('已替换')).toBeTruthy();
expect(
within(playerSlot as HTMLElement).queryByText(
'generated-bark-battle-assets/player-character/very-long-object-key.png',
),
).toBeNull();
expect(within(playerSlot as HTMLElement).queryByText(/objectKey|object key/i)).toBeNull();
});
it('keeps result assets to three image slots with per-slot regeneration only', async () => {
const user = userEvent.setup();
const onDraftChange = vi.fn();
vi.mocked(regenerateBarkBattleImageAsset).mockResolvedValue({
imageSrc: '/generated-bark-battle-assets/player.png',
assetId: 'asset-player',
model: 'gpt-image-2-all',
size: '1024*1024',
taskId: 'task-player',
prompt: 'player',
});
render(
<BarkBattleResultView
draft={draft}
onBack={() => {}}
onDraftChange={onDraftChange}
onStartTestRun={() => {}}
onPublish={() => {}}
/>,
);
expect(screen.getByRole('heading', { name: '玩家形象' })).toBeTruthy();
expect(screen.getByRole('heading', { name: '对手形象' })).toBeTruthy();
expect(screen.getByRole('heading', { name: 'UI背景' })).toBeTruthy();
expect(screen.queryByText('狗叫音效')).toBeNull();
expect(screen.queryByRole('button', { name: '一次生成' })).toBeNull();
const playerSlot = screen
.getByRole('heading', { name: '玩家形象' })
.closest('article');
expect(playerSlot).toBeTruthy();
await user.click(
within(playerSlot as HTMLElement).getByRole('button', { name: '重新生成' }),
);
await waitFor(() => {
expect(regenerateBarkBattleImageAsset).toHaveBeenCalledWith({
slot: 'player-character',
config: expect.objectContaining({
title: '汪汪冠军杯',
themeDescription: '霓虹公园擂台',
}),
draftId: 'bark-battle-draft-1',
});
});
expect(onDraftChange).toHaveBeenCalledWith(
expect.objectContaining({
playerCharacterImageSrc: '/generated-bark-battle-assets/player.png',
}),
);
});
}); });

View File

@@ -6,7 +6,6 @@ import {
Play, Play,
RefreshCw, RefreshCw,
Upload, Upload,
Volume2,
} from 'lucide-react'; } from 'lucide-react';
import { type ChangeEvent, type ReactNode, useMemo, useRef, useState } from 'react'; import { type ChangeEvent, type ReactNode, useMemo, useRef, useState } from 'react';
@@ -31,22 +30,20 @@ type BarkBattleResultViewProps = {
onPublish: (draft: BarkBattleDraftConfig) => void; onPublish: (draft: BarkBattleDraftConfig) => void;
}; };
type BarkBattleImageSlot = Exclude<BarkBattleAssetSlot, 'bark-sound'>;
const SLOT_LABELS = { const SLOT_LABELS = {
'player-character': '玩家形象', 'player-character': '玩家形象',
'opponent-character': '对手形象', 'opponent-character': '对手形象',
'ui-background': 'UI背景', 'ui-background': 'UI背景',
'bark-sound': '狗叫音效',
} satisfies Record<BarkBattleAssetSlot, string>; } satisfies Record<BarkBattleAssetSlot, string>;
function mapDraftToConfig(draft: BarkBattleDraftConfig): BarkBattleConfigEditorPayload { function mapDraftToConfig(draft: BarkBattleDraftConfig): BarkBattleConfigEditorPayload {
return { return {
title: draft.title, title: draft.title,
description: draft.description, description: draft.description,
themePreset: draft.themePreset, themeDescription: draft.themeDescription,
playerDogSkinPreset: draft.playerDogSkinPreset, playerImageDescription: draft.playerImageDescription,
opponentDogSkinPreset: draft.opponentDogSkinPreset, opponentImageDescription: draft.opponentImageDescription,
onomatopoeia: draft.onomatopoeia,
...(draft.playerCharacterImageSrc ...(draft.playerCharacterImageSrc
? { playerCharacterImageSrc: draft.playerCharacterImageSrc } ? { playerCharacterImageSrc: draft.playerCharacterImageSrc }
: {}), : {}),
@@ -56,9 +53,7 @@ function mapDraftToConfig(draft: BarkBattleDraftConfig): BarkBattleConfigEditorP
...(draft.uiBackgroundImageSrc ...(draft.uiBackgroundImageSrc
? { uiBackgroundImageSrc: draft.uiBackgroundImageSrc } ? { uiBackgroundImageSrc: draft.uiBackgroundImageSrc }
: {}), : {}),
...(draft.barkSoundSrc ? { barkSoundSrc: draft.barkSoundSrc } : {}),
difficultyPreset: draft.difficultyPreset, difficultyPreset: draft.difficultyPreset,
leaderboardEnabled: draft.leaderboardEnabled,
}; };
} }
@@ -77,7 +72,7 @@ function applyAssetToDraft(
if (slot === 'ui-background') { if (slot === 'ui-background') {
return { ...draft, uiBackgroundImageSrc: assetSrc, updatedAt }; return { ...draft, uiBackgroundImageSrc: assetSrc, updatedAt };
} }
return { ...draft, barkSoundSrc: assetSrc, updatedAt }; return { ...draft, updatedAt };
} }
function getSlotAssetSrc(draft: BarkBattleDraftConfig, slot: BarkBattleAssetSlot) { function getSlotAssetSrc(draft: BarkBattleDraftConfig, slot: BarkBattleAssetSlot) {
@@ -90,7 +85,7 @@ function getSlotAssetSrc(draft: BarkBattleDraftConfig, slot: BarkBattleAssetSlot
if (slot === 'ui-background') { if (slot === 'ui-background') {
return draft.uiBackgroundImageSrc ?? ''; return draft.uiBackgroundImageSrc ?? '';
} }
return draft.barkSoundSrc ?? ''; return '';
} }
function ResultActionButton({ function ResultActionButton({
@@ -111,7 +106,7 @@ function ResultActionButton({
onClick={onClick} onClick={onClick}
className={`platform-button ${ className={`platform-button ${
tone === 'primary' ? 'platform-button--primary' : 'platform-button--secondary' tone === 'primary' ? 'platform-button--primary' : 'platform-button--secondary'
} min-h-11 justify-center disabled:cursor-not-allowed disabled:opacity-55`} } min-h-10 justify-center text-sm disabled:cursor-not-allowed disabled:opacity-55 sm:min-h-11`}
> >
{children} {children}
</button> </button>
@@ -135,7 +130,7 @@ function BarkBattleAssetSlotControl({
const [isUploading, setIsUploading] = useState(false); const [isUploading, setIsUploading] = useState(false);
const [isRegenerating, setIsRegenerating] = useState(false); const [isRegenerating, setIsRegenerating] = useState(false);
const assetSrc = getSlotAssetSrc(draft, slot); const assetSrc = getSlotAssetSrc(draft, slot);
const isImageSlot = slot !== 'bark-sound'; const assetStatus = assetSrc ? '已替换' : '未替换';
const handleUpload = async (event: ChangeEvent<HTMLInputElement>) => { const handleUpload = async (event: ChangeEvent<HTMLInputElement>) => {
const file = event.currentTarget.files?.[0] ?? null; const file = event.currentTarget.files?.[0] ?? null;
@@ -152,7 +147,8 @@ function BarkBattleAssetSlotControl({
file, file,
draftId: draft.draftId, draftId: draft.draftId,
}); });
onChange(applyAssetToDraft(draft, slot, asset.assetSrc)); const nextDraft = applyAssetToDraft(draft, slot, asset.assetSrc);
onChange(nextDraft);
} catch (error) { } catch (error) {
onError(error instanceof Error ? error.message : '上传素材失败。'); onError(error instanceof Error ? error.message : '上传素材失败。');
} finally { } finally {
@@ -161,20 +157,16 @@ function BarkBattleAssetSlotControl({
}; };
const handleRegenerate = async () => { const handleRegenerate = async () => {
if (!isImageSlot) {
onError('狗叫音效暂未接入自动生成,请先手动上传音频。');
return;
}
setIsRegenerating(true); setIsRegenerating(true);
onError(null); onError(null);
try { try {
const result = await regenerateBarkBattleImageAsset({ const result = await regenerateBarkBattleImageAsset({
slot: slot as BarkBattleImageSlot, slot,
config: mapDraftToConfig(draft), config: mapDraftToConfig(draft),
draftId: draft.draftId, draftId: draft.draftId,
}); });
onChange(applyAssetToDraft(draft, slot, result.imageSrc)); const nextDraft = applyAssetToDraft(draft, slot, result.imageSrc);
onChange(nextDraft);
} catch (error) { } catch (error) {
onError(error instanceof Error ? error.message : '重新生成素材失败。'); onError(error instanceof Error ? error.message : '重新生成素材失败。');
} finally { } finally {
@@ -185,29 +177,27 @@ function BarkBattleAssetSlotControl({
const isSlotBusy = isUploading || isRegenerating; const isSlotBusy = isUploading || isRegenerating;
return ( return (
<article className="rounded-[1rem] border border-[var(--platform-subpanel-border)] bg-white/72 p-3 shadow-[inset_0_1px_0_rgba(255,255,255,0.74)]"> <article className="rounded-[1rem] border border-[var(--platform-subpanel-border)] bg-white/72 p-2.5 shadow-[inset_0_1px_0_rgba(255,255,255,0.74)] sm:p-3">
<div className="flex items-center justify-between gap-3"> <div className="flex items-center justify-between gap-2 sm:gap-3">
<div className="min-w-0"> <div className="min-w-0">
<h3 className="m-0 text-sm font-black text-[var(--platform-text-strong)]"> <h3 className="m-0 text-xs font-black text-[var(--platform-text-strong)] sm:text-sm">
{SLOT_LABELS[slot]} {SLOT_LABELS[slot]}
</h3> </h3>
<div className="mt-1 truncate text-xs font-semibold text-[var(--platform-text-soft)]"> <div className="mt-0.5 truncate text-[11px] font-semibold text-[var(--platform-text-soft)] sm:mt-1 sm:text-xs">
{assetSrc || '未替换'} {assetStatus}
</div> </div>
</div> </div>
{isSlotBusy ? ( {isSlotBusy ? (
<Loader2 className="h-4 w-4 shrink-0 animate-spin text-[var(--platform-text-soft)]" /> <Loader2 className="h-4 w-4 shrink-0 animate-spin text-[var(--platform-text-soft)]" />
) : isImageSlot ? (
<ImagePlus className="h-4 w-4 shrink-0 text-[var(--platform-text-soft)]" />
) : ( ) : (
<Volume2 className="h-4 w-4 shrink-0 text-[var(--platform-text-soft)]" /> <ImagePlus className="h-4 w-4 shrink-0 text-[var(--platform-text-soft)]" />
)} )}
</div> </div>
<div className="mt-3 grid grid-cols-2 gap-2"> <div className="mt-2 grid grid-cols-2 gap-1.5 sm:mt-3 sm:gap-2">
<input <input
ref={fileInputRef} ref={fileInputRef}
type="file" type="file"
accept={isImageSlot ? 'image/png,image/jpeg,image/webp' : 'audio/mpeg,audio/wav,audio/ogg,audio/webm'} accept="image/png,image/jpeg,image/webp"
className="hidden" className="hidden"
aria-label={`上传${SLOT_LABELS[slot]}文件`} aria-label={`上传${SLOT_LABELS[slot]}文件`}
onChange={handleUpload} onChange={handleUpload}
@@ -216,7 +206,7 @@ function BarkBattleAssetSlotControl({
type="button" type="button"
disabled={disabled || isSlotBusy} disabled={disabled || isSlotBusy}
onClick={() => fileInputRef.current?.click()} onClick={() => fileInputRef.current?.click()}
className="platform-button platform-button--secondary min-h-9 justify-center rounded-full px-3 py-1.5 text-xs disabled:cursor-not-allowed disabled:opacity-55" className="platform-button platform-button--secondary min-h-8 justify-center rounded-full px-2.5 py-1 text-[11px] disabled:cursor-not-allowed disabled:opacity-55 sm:min-h-9 sm:px-3 sm:py-1.5 sm:text-xs"
> >
<Upload className="h-3.5 w-3.5" /> <Upload className="h-3.5 w-3.5" />
@@ -225,7 +215,7 @@ function BarkBattleAssetSlotControl({
type="button" type="button"
disabled={disabled || isSlotBusy} disabled={disabled || isSlotBusy}
onClick={handleRegenerate} onClick={handleRegenerate}
className="platform-button platform-button--secondary min-h-9 justify-center rounded-full px-3 py-1.5 text-xs disabled:cursor-not-allowed disabled:opacity-55" className="platform-button platform-button--secondary min-h-8 justify-center rounded-full px-2.5 py-1 text-[11px] disabled:cursor-not-allowed disabled:opacity-55 sm:min-h-9 sm:px-3 sm:py-1.5 sm:text-xs"
> >
<RefreshCw className="h-3.5 w-3.5" /> <RefreshCw className="h-3.5 w-3.5" />
@@ -247,33 +237,34 @@ export function BarkBattleResultView({
const [localError, setLocalError] = useState<string | null>(null); const [localError, setLocalError] = useState<string | null>(null);
const previewConfig = useMemo(() => mapDraftToConfig(draft), [draft]); const previewConfig = useMemo(() => mapDraftToConfig(draft), [draft]);
const visibleError = localError ?? error; const visibleError = localError ?? error;
const isActionBusy = isBusy;
return ( return (
<div className="platform-page-stage platform-remap-surface flex h-full min-h-0 flex-col overflow-hidden px-3 pb-3 pt-3 sm:px-4 sm:pt-4 xl:px-5 xl:pb-4 xl:pt-4"> <div className="platform-page-stage platform-remap-surface flex h-full min-h-0 flex-col overflow-hidden px-2 pb-2 pt-2 sm:px-4 sm:pt-4 xl:px-5 xl:pb-4 xl:pt-4">
<div className="mx-auto flex h-full min-h-0 w-full max-w-5xl flex-col"> <div className="mx-auto flex h-full min-h-0 w-full max-w-4xl flex-col">
<div className="mb-3 flex shrink-0 items-center justify-between gap-3"> <div className="mb-2 flex shrink-0 items-center justify-between gap-2 sm:mb-3 sm:gap-3">
<button <button
type="button" type="button"
onClick={onBack} onClick={onBack}
disabled={isBusy} disabled={isActionBusy}
className={`platform-button platform-button--ghost min-h-0 px-3 py-1.5 text-[11px] ${isBusy ? 'opacity-45' : ''}`} className={`platform-button platform-button--ghost min-h-0 px-3 py-1.5 text-[11px] ${isActionBusy ? 'opacity-45' : ''}`}
> >
<ArrowLeft className="h-3.5 w-3.5" /> <ArrowLeft className="h-3.5 w-3.5" />
</button> </button>
<span className="rounded-full border border-emerald-200 bg-emerald-50 px-3 py-1 text-[11px] font-black text-emerald-700"> <span className="rounded-full border border-emerald-200 bg-emerald-50 px-2.5 py-0.5 text-[11px] font-black text-emerald-700 sm:px-3 sm:py-1">
稿 稿
</span> </span>
</div> </div>
<div className="min-h-0 flex-1 overflow-y-auto pr-0.5"> <div className="min-h-0 flex-1 overflow-y-auto pr-0.5">
<section className="grid gap-3 lg:grid-cols-[minmax(0,0.94fr)_minmax(18rem,0.86fr)]"> <section className="grid gap-2.5 lg:grid-cols-[minmax(0,0.94fr)_minmax(18rem,0.86fr)] lg:gap-3">
<div className="grid gap-3"> <div className="grid gap-2.5 lg:gap-3">
<div className="rounded-[1.15rem] border border-[var(--platform-subpanel-border)] bg-white/68 p-4 shadow-[inset_0_1px_0_rgba(255,255,255,0.74)]"> <div className="rounded-[1.15rem] border border-[var(--platform-subpanel-border)] bg-white/68 p-3 shadow-[inset_0_1px_0_rgba(255,255,255,0.74)] sm:p-4">
<div className="text-sm font-black text-[var(--platform-text-soft)]"> <div className="text-xs font-black text-[var(--platform-text-soft)] sm:text-sm">
稿 稿
</div> </div>
<h1 className="m-0 mt-2 text-3xl font-black leading-tight tracking-normal text-[var(--platform-text-strong)] sm:text-5xl"> <h1 className="m-0 mt-1 text-2xl font-black leading-tight tracking-normal text-[var(--platform-text-strong)] sm:mt-2 sm:text-4xl lg:text-5xl">
{draft.title || '未命名声浪竞技场'} {draft.title || '未命名声浪竞技场'}
</h1> </h1>
</div> </div>
@@ -283,14 +274,13 @@ export function BarkBattleResultView({
'player-character', 'player-character',
'opponent-character', 'opponent-character',
'ui-background', 'ui-background',
'bark-sound',
] as const ] as const
).map((slot) => ( ).map((slot) => (
<BarkBattleAssetSlotControl <BarkBattleAssetSlotControl
key={slot} key={slot}
draft={draft} draft={draft}
slot={slot} slot={slot}
disabled={isBusy} disabled={isActionBusy}
onChange={(nextDraft) => { onChange={(nextDraft) => {
setLocalError(null); setLocalError(null);
onDraftChange(nextDraft); onDraftChange(nextDraft);
@@ -312,7 +302,7 @@ export function BarkBattleResultView({
<div className="mt-3 grid shrink-0 gap-2 pb-[max(0.25rem,env(safe-area-inset-bottom))] sm:grid-cols-2"> <div className="mt-3 grid shrink-0 gap-2 pb-[max(0.25rem,env(safe-area-inset-bottom))] sm:grid-cols-2">
<ResultActionButton <ResultActionButton
disabled={isBusy} disabled={isActionBusy}
onClick={() => onStartTestRun(draft)} onClick={() => onStartTestRun(draft)}
> >
<Play className="h-4 w-4" /> <Play className="h-4 w-4" />
@@ -320,7 +310,7 @@ export function BarkBattleResultView({
</ResultActionButton> </ResultActionButton>
<ResultActionButton <ResultActionButton
tone="primary" tone="primary"
disabled={isBusy} disabled={isActionBusy}
onClick={() => onPublish(draft)} onClick={() => onPublish(draft)}
> >
{isBusy ? ( {isBusy ? (

View File

@@ -4,6 +4,7 @@ import { fireEvent, render, screen } from '@testing-library/react';
import userEvent from '@testing-library/user-event'; import userEvent from '@testing-library/user-event';
import { afterEach, expect, test, vi } from 'vitest'; import { afterEach, expect, test, vi } from 'vitest';
import type { BarkBattleWorkSummary } from '../../../packages/shared/src/contracts/barkBattle';
import type { CustomWorldWorkSummary } from '../../../packages/shared/src/contracts/customWorldAgent'; import type { CustomWorldWorkSummary } from '../../../packages/shared/src/contracts/customWorldAgent';
import type { BabyObjectMatchDraft } from '../../../packages/shared/src/contracts/edutainmentBabyObject'; import type { BabyObjectMatchDraft } from '../../../packages/shared/src/contracts/edutainmentBabyObject';
import type { SquareHoleWorkSummary } from '../../../packages/shared/src/contracts/squareHoleWorks'; import type { SquareHoleWorkSummary } from '../../../packages/shared/src/contracts/squareHoleWorks';
@@ -226,6 +227,42 @@ const babyObjectMatchDraftItem: BabyObjectMatchDraft = {
publishedAt: null, publishedAt: null,
}; };
const barkBattleDraftItem: BarkBattleWorkSummary = {
workId: 'bark-battle-work-draft-visible',
draftId: 'bark-battle-draft-visible',
ownerUserId: 'user-1',
authorDisplayName: '声浪作者',
title: '竖屏声浪草稿',
summary: '生成完成后也必须留在我的草稿里。',
themeDescription: '霓虹竖屏擂台',
playerImageDescription: '红围巾选手',
opponentImageDescription: '蓝头带对手',
onomatopoeia: ['炸场', '破阵'],
playerCharacterImageSrc: '/bark/player.png',
opponentCharacterImageSrc: '/bark/opponent.png',
uiBackgroundImageSrc: '/bark/background.png',
difficultyPreset: 'normal',
status: 'draft',
generationStatus: 'ready',
publishReady: true,
playCount: 0,
updatedAt: '2026-05-21T10:00:00.000Z',
publishedAt: null,
};
const barkBattlePublishedItem: BarkBattleWorkSummary = {
...barkBattleDraftItem,
workId: 'bark-battle-work-published-visible',
draftId: 'bark-battle-draft-published-visible',
title: '竖屏声浪已发布',
summary: '发布完成后必须留在已发布作品里。',
authorDisplayName: '发布作者',
status: 'published',
playCount: 9,
updatedAt: '2026-05-21T10:10:00.000Z',
publishedAt: '2026-05-21T10:10:00.000Z',
};
test('creation hub reflects updated draft title summary and counts after rerender', async () => { test('creation hub reflects updated draft title summary and counts after rerender', async () => {
const user = userEvent.setup(); const user = userEvent.setup();
const onCreateType = vi.fn(); const onCreateType = vi.fn();
@@ -592,6 +629,47 @@ test('creation hub shows delete action for baby object match drafts', async () =
expect(onOpenBabyObjectMatchDetail).not.toHaveBeenCalled(); expect(onOpenBabyObjectMatchDetail).not.toHaveBeenCalled();
}); });
test('creation hub works-only tab filters bark battle draft and published works', async () => {
const user = userEvent.setup();
const onOpenBarkBattleDetail = vi.fn();
render(
<CustomWorldCreationHub
mode="works-only"
items={[]}
barkBattleItems={[barkBattleDraftItem, barkBattlePublishedItem]}
loading={false}
error={null}
onRetry={() => {}}
onCreateType={noopCreateType}
onOpenDraft={() => {}}
onEnterPublished={() => {}}
onOpenBarkBattleDetail={onOpenBarkBattleDetail}
entryConfig={testEntryConfig}
creationTypes={testCreationTypes}
/>,
);
expect(screen.getByRole('button', { name: '全部 2' })).toBeTruthy();
expect(screen.getByRole('button', { name: '草稿 1' })).toBeTruthy();
expect(screen.getByRole('button', { name: '已发布 1' })).toBeTruthy();
expect(screen.getByText('竖屏声浪草稿')).toBeTruthy();
expect(screen.getByText('竖屏声浪已发布')).toBeTruthy();
await user.click(screen.getByRole('button', { name: '草稿 1' }));
expect(screen.getByText('竖屏声浪草稿')).toBeTruthy();
expect(screen.queryByText('竖屏声浪已发布')).toBeNull();
await user.click(screen.getByRole('button', { name: '已发布 1' }));
expect(screen.queryByText('竖屏声浪草稿')).toBeNull();
expect(screen.getByText('竖屏声浪已发布')).toBeTruthy();
await user.click(
screen.getByRole('button', { name: //u }),
);
expect(onOpenBarkBattleDetail).toHaveBeenCalledWith(barkBattlePublishedItem);
});
test('creation hub published work delete action is revealed without opening card', async () => { test('creation hub published work delete action is revealed without opening card', async () => {
const user = userEvent.setup(); const user = userEvent.setup();
const onDeletePuzzle = vi.fn(); const onDeletePuzzle = vi.fn();

View File

@@ -1,6 +1,7 @@
import { useEffect, useMemo, useState } from 'react'; import { useEffect, useMemo, useState } from 'react';
import type { BigFishWorkSummary } from '../../../packages/shared/src/contracts/bigFishWorkSummary'; import type { BigFishWorkSummary } from '../../../packages/shared/src/contracts/bigFishWorkSummary';
import type { BarkBattleWorkSummary } from '../../../packages/shared/src/contracts/barkBattle';
import type { CustomWorldWorkSummary } from '../../../packages/shared/src/contracts/customWorldAgent'; import type { CustomWorldWorkSummary } from '../../../packages/shared/src/contracts/customWorldAgent';
import type { BabyObjectMatchDraft } from '../../../packages/shared/src/contracts/edutainmentBabyObject'; import type { BabyObjectMatchDraft } from '../../../packages/shared/src/contracts/edutainmentBabyObject';
import type { Match3DWorkSummary } from '../../../packages/shared/src/contracts/match3dWorks'; import type { Match3DWorkSummary } from '../../../packages/shared/src/contracts/match3dWorks';
@@ -68,6 +69,9 @@ type CustomWorldCreationHubProps = {
babyObjectMatchItems?: BabyObjectMatchDraft[]; babyObjectMatchItems?: BabyObjectMatchDraft[];
onOpenBabyObjectMatchDetail?: ((item: BabyObjectMatchDraft) => void) | null; onOpenBabyObjectMatchDetail?: ((item: BabyObjectMatchDraft) => void) | null;
onDeleteBabyObjectMatch?: ((item: BabyObjectMatchDraft) => void) | null; onDeleteBabyObjectMatch?: ((item: BabyObjectMatchDraft) => void) | null;
barkBattleItems?: BarkBattleWorkSummary[];
onOpenBarkBattleDetail?: ((item: BarkBattleWorkSummary) => void) | null;
onDeleteBarkBattle?: ((item: BarkBattleWorkSummary) => void) | null;
visualNovelItems?: VisualNovelWorkSummary[]; visualNovelItems?: VisualNovelWorkSummary[];
onOpenVisualNovelDetail?: ((item: VisualNovelWorkSummary) => void) | null; onOpenVisualNovelDetail?: ((item: VisualNovelWorkSummary) => void) | null;
onDeleteVisualNovel?: ((item: VisualNovelWorkSummary) => void) | null; onDeleteVisualNovel?: ((item: VisualNovelWorkSummary) => void) | null;
@@ -173,6 +177,9 @@ export function CustomWorldCreationHub({
babyObjectMatchItems = [], babyObjectMatchItems = [],
onOpenBabyObjectMatchDetail = null, onOpenBabyObjectMatchDetail = null,
onDeleteBabyObjectMatch = null, onDeleteBabyObjectMatch = null,
barkBattleItems = [],
onOpenBarkBattleDetail = null,
onDeleteBarkBattle = null,
visualNovelItems = [], visualNovelItems = [],
onOpenVisualNovelDetail = null, onOpenVisualNovelDetail = null,
onDeleteVisualNovel = null, onDeleteVisualNovel = null,
@@ -196,6 +203,7 @@ export function CustomWorldCreationHub({
squareHoleItems: isSquareHoleCreationVisible ? squareHoleItems : [], squareHoleItems: isSquareHoleCreationVisible ? squareHoleItems : [],
puzzleItems, puzzleItems,
babyObjectMatchItems, babyObjectMatchItems,
barkBattleItems,
visualNovelItems, visualNovelItems,
canDeleteRpg: Boolean(onDeletePublished), canDeleteRpg: Boolean(onDeletePublished),
canDeleteBigFish: Boolean(onDeleteBigFish), canDeleteBigFish: Boolean(onDeleteBigFish),
@@ -204,6 +212,7 @@ export function CustomWorldCreationHub({
isSquareHoleCreationVisible && Boolean(onDeleteSquareHole), isSquareHoleCreationVisible && Boolean(onDeleteSquareHole),
canDeletePuzzle: Boolean(onDeletePuzzle), canDeletePuzzle: Boolean(onDeletePuzzle),
canDeleteBabyObjectMatch: Boolean(onDeleteBabyObjectMatch), canDeleteBabyObjectMatch: Boolean(onDeleteBabyObjectMatch),
canDeleteBarkBattle: Boolean(onDeleteBarkBattle),
canDeleteVisualNovel: Boolean(onDeleteVisualNovel), canDeleteVisualNovel: Boolean(onDeleteVisualNovel),
onOpenRpgDraft: onOpenDraft, onOpenRpgDraft: onOpenDraft,
onEnterRpgPublished: onEnterPublished, onEnterRpgPublished: onEnterPublished,
@@ -219,6 +228,8 @@ export function CustomWorldCreationHub({
onClaimPuzzlePointIncentive: onClaimPuzzlePointIncentive ?? undefined, onClaimPuzzlePointIncentive: onClaimPuzzlePointIncentive ?? undefined,
onOpenBabyObjectMatchDetail: onOpenBabyObjectMatchDetail ?? undefined, onOpenBabyObjectMatchDetail: onOpenBabyObjectMatchDetail ?? undefined,
onDeleteBabyObjectMatch: onDeleteBabyObjectMatch ?? undefined, onDeleteBabyObjectMatch: onDeleteBabyObjectMatch ?? undefined,
onOpenBarkBattleDetail: onOpenBarkBattleDetail ?? undefined,
onDeleteBarkBattle: onDeleteBarkBattle ?? undefined,
onOpenVisualNovelDetail: onOpenVisualNovelDetail ?? undefined, onOpenVisualNovelDetail: onOpenVisualNovelDetail ?? undefined,
onDeleteVisualNovel: onDeleteVisualNovel ?? undefined, onDeleteVisualNovel: onDeleteVisualNovel ?? undefined,
getItemState: getWorkState, getItemState: getWorkState,
@@ -227,6 +238,7 @@ export function CustomWorldCreationHub({
bigFishItems, bigFishItems,
isSquareHoleCreationVisible, isSquareHoleCreationVisible,
babyObjectMatchItems, babyObjectMatchItems,
barkBattleItems,
items, items,
match3dItems, match3dItems,
onDeleteBigFish, onDeleteBigFish,
@@ -235,12 +247,14 @@ export function CustomWorldCreationHub({
onDeletePublished, onDeletePublished,
onDeletePuzzle, onDeletePuzzle,
onDeleteBabyObjectMatch, onDeleteBabyObjectMatch,
onDeleteBarkBattle,
onDeleteVisualNovel, onDeleteVisualNovel,
onClaimPuzzlePointIncentive, onClaimPuzzlePointIncentive,
onOpenBigFishDetail, onOpenBigFishDetail,
onOpenDraft, onOpenDraft,
onOpenMatch3DDetail, onOpenMatch3DDetail,
onOpenBabyObjectMatchDetail, onOpenBabyObjectMatchDetail,
onOpenBarkBattleDetail,
onOpenPuzzleDetail, onOpenPuzzleDetail,
onOpenSquareHoleDetail, onOpenSquareHoleDetail,
onOpenVisualNovelDetail, onOpenVisualNovelDetail,
@@ -284,6 +298,9 @@ export function CustomWorldCreationHub({
case 'visual-novel': case 'visual-novel':
onOpenVisualNovelDetail?.(item.source.item); onOpenVisualNovelDetail?.(item.source.item);
return; return;
case 'bark-battle':
onOpenBarkBattleDetail?.(item.source.item);
return;
case 'big-fish': case 'big-fish':
onOpenBigFishDetail?.(item.source.item); onOpenBigFishDetail?.(item.source.item);
return; return;

View File

@@ -61,6 +61,7 @@ const CREATION_WORK_KIND_FALLBACK_COVER: Record<CreationWorkShelfKind, string> =
'square-hole': '/creation-type-references/square-hole.webp', 'square-hole': '/creation-type-references/square-hole.webp',
puzzle: '/creation-type-references/puzzle.webp', puzzle: '/creation-type-references/puzzle.webp',
'baby-object-match': '/creation-type-references/creative-agent.webp', 'baby-object-match': '/creation-type-references/creative-agent.webp',
'bark-battle': '/creation-type-references/bark-battle.webp',
'visual-novel': '/creation-type-references/visual-novel.webp', 'visual-novel': '/creation-type-references/visual-novel.webp',
}; };
@@ -727,6 +728,8 @@ export function CustomWorldWorkCard({
{item.summary} {item.summary}
</div> </div>
<div className="creation-work-card__author">{item.authorDisplayName}</div>
{isPublished ? ( {isPublished ? (
<div className="creation-work-card__published-info"> <div className="creation-work-card__published-info">
{item.pointIncentive ? ( {item.pointIncentive ? (

View File

@@ -1,10 +1,16 @@
import { createElement } from 'react';
import { renderToStaticMarkup } from 'react-dom/server';
import { expect, test, vi } from 'vitest'; import { expect, test, vi } from 'vitest';
import type { BabyObjectMatchDraft } from '../../../packages/shared/src/contracts/edutainmentBabyObject'; import type { BabyObjectMatchDraft } from '../../../packages/shared/src/contracts/edutainmentBabyObject';
import { import {
buildCreationWorkShelfItems, buildCreationWorkShelfItems,
getCreationWorkShelfItemTime, getCreationWorkShelfItemTime,
hasBarkBattleRequiredImages,
isPersistedBarkBattleDraftGenerating,
type CreationWorkShelfItem,
} from './creationWorkShelf'; } from './creationWorkShelf';
import { CustomWorldWorkCard } from './CustomWorldWorkCard';
test('buildCreationWorkShelfItems maps visual novel items with VN public code', () => { test('buildCreationWorkShelfItems maps visual novel items with VN public code', () => {
const items = buildCreationWorkShelfItems({ const items = buildCreationWorkShelfItems({
@@ -50,6 +56,253 @@ test('buildCreationWorkShelfItems maps visual novel items with VN public code',
expect(items[1]?.publicWorkCode).toBeNull(); expect(items[1]?.publicWorkCode).toBeNull();
}); });
test('buildCreationWorkShelfItems keeps published bark battle over duplicate draft', () => {
const items = buildCreationWorkShelfItems({
rpgItems: [],
bigFishItems: [],
puzzleItems: [],
barkBattleItems: [
{
workId: 'bark-battle-work-1',
draftId: 'bark-battle-draft-1',
ownerUserId: 'user-1',
authorDisplayName: '草稿作者',
title: '汪汪测试杯',
summary: '',
themeDescription: '阳光草坪声浪竞技场',
playerImageDescription: '戴红色围巾的柯基选手',
opponentImageDescription: '蓝色护目镜哈士奇对手',
playerCharacterImageSrc: '/generated-bark-battle/player.png',
opponentCharacterImageSrc: '/generated-bark-battle/opponent.png',
uiBackgroundImageSrc: '/generated-bark-battle/background.png',
difficultyPreset: 'normal',
status: 'draft',
generationStatus: 'ready',
publishReady: true,
playCount: 0,
updatedAt: '2026-05-14T10:01:00.000Z',
publishedAt: null,
},
{
workId: 'bark-battle-work-1',
draftId: 'bark-battle-draft-1',
ownerUserId: 'user-1',
authorDisplayName: '测试玩家',
title: '汪汪测试杯',
summary: '',
themeDescription: '阳光草坪声浪竞技场',
playerImageDescription: '戴红色围巾的柯基选手',
opponentImageDescription: '蓝色护目镜哈士奇对手',
playerCharacterImageSrc: '/generated-bark-battle/player.png',
opponentCharacterImageSrc: '/generated-bark-battle/opponent.png',
uiBackgroundImageSrc: '/generated-bark-battle/background.png',
difficultyPreset: 'normal',
status: 'published',
generationStatus: 'ready',
publishReady: true,
playCount: 0,
updatedAt: '2026-05-14T10:02:00.000Z',
publishedAt: '2026-05-14T10:02:00.000Z',
},
],
});
expect(items).toHaveLength(1);
expect(items[0]?.kind).toBe('bark-battle');
expect(items[0]?.status).toBe('published');
expect(items[0]?.publicWorkCode).toBe('BB-TLEWORK1');
expect(items[0]?.authorDisplayName).toBe('测试玩家');
});
test('buildCreationWorkShelfItems keeps separate bark battle draft and published works visible', () => {
const items = buildCreationWorkShelfItems({
rpgItems: [],
bigFishItems: [],
puzzleItems: [],
barkBattleItems: [
{
workId: 'BB-DRAFT001',
draftId: 'bark-battle-draft-visible',
ownerUserId: 'user-1',
authorDisplayName: '草稿作者',
title: '草稿声浪赛',
summary: '',
themeDescription: '草地声浪挑战',
playerImageDescription: '柯基选手',
opponentImageDescription: '哈士奇对手',
playerCharacterImageSrc: '/draft-player.png',
opponentCharacterImageSrc: '/draft-opponent.png',
uiBackgroundImageSrc: '/draft-background.png',
difficultyPreset: 'easy',
status: 'draft',
generationStatus: 'ready',
publishReady: false,
playCount: 0,
updatedAt: '2026-05-20T00:00:00.000Z',
publishedAt: null,
},
{
workId: 'BB-PUB00001',
draftId: 'bark-battle-draft-published',
ownerUserId: 'user-1',
authorDisplayName: '发布作者',
title: '已发布声浪赛',
summary: '',
themeDescription: '霓虹声浪挑战',
playerImageDescription: '柴犬选手',
opponentImageDescription: '机器人对手',
playerCharacterImageSrc: '/published-player.png',
opponentCharacterImageSrc: '/published-opponent.png',
uiBackgroundImageSrc: '/published-background.png',
difficultyPreset: 'normal',
status: 'published',
generationStatus: 'ready',
publishReady: true,
playCount: 3,
updatedAt: '2026-05-21T00:00:00.000Z',
publishedAt: '2026-05-21T00:00:00.000Z',
},
],
});
expect(items).toHaveLength(2);
expect(items.find((item) => item.status === 'draft')?.id).toBe('BB-DRAFT001');
expect(items.find((item) => item.status === 'published')?.id).toBe(
'BB-PUB00001',
);
expect(items.find((item) => item.status === 'published')?.publicWorkCode).toBe(
'BB-PUB00001',
);
});
test('buildCreationWorkShelfItems gives bark battle draft cover from character or reference fallback', () => {
const items = buildCreationWorkShelfItems({
rpgItems: [],
bigFishItems: [],
puzzleItems: [],
barkBattleItems: [
{
workId: 'BB-COVER001',
draftId: 'bark-battle-draft-cover',
ownerUserId: 'user-1',
authorDisplayName: '草稿作者',
title: '角色封面声浪赛',
summary: '',
themeDescription: '草地声浪挑战',
playerImageDescription: '柯基选手',
opponentImageDescription: '哈士奇对手',
playerCharacterImageSrc: '/draft-player-cover.png',
opponentCharacterImageSrc: '/draft-opponent-cover.png',
uiBackgroundImageSrc: null,
difficultyPreset: 'easy',
status: 'draft',
generationStatus: 'partial_failed',
publishReady: false,
playCount: 0,
updatedAt: '2026-05-20T00:00:00.000Z',
publishedAt: null,
},
{
workId: 'BB-COVER002',
draftId: 'bark-battle-draft-cover-fallback',
ownerUserId: 'user-1',
authorDisplayName: '草稿作者',
title: '默认封面声浪赛',
summary: '',
themeDescription: '夜市声浪挑战',
playerImageDescription: '柴犬选手',
opponentImageDescription: '机器人对手',
playerCharacterImageSrc: null,
opponentCharacterImageSrc: null,
uiBackgroundImageSrc: null,
difficultyPreset: 'normal',
status: 'draft',
generationStatus: 'pending_assets',
publishReady: false,
playCount: 0,
updatedAt: '2026-05-19T00:00:00.000Z',
publishedAt: null,
},
],
});
expect(items.find((item) => item.id === 'BB-COVER001')?.coverImageSrc).toBe(
'/draft-player-cover.png',
);
expect(items.find((item) => item.id === 'BB-COVER001')?.coverCharacterImageSrcs).toEqual([
'/draft-player-cover.png',
'/draft-opponent-cover.png',
]);
expect(items.find((item) => item.id === 'BB-COVER002')?.coverImageSrc).toBe(
'/creation-type-references/bark-battle.webp',
);
});
test('buildCreationWorkShelfItems keeps bark battle draft author display name', () => {
const items = buildCreationWorkShelfItems({
rpgItems: [],
bigFishItems: [],
puzzleItems: [],
barkBattleItems: [
{
workId: 'bark-battle-work-draft-author',
draftId: 'bark-battle-draft-author',
ownerUserId: 'user-1',
authorDisplayName: '草稿作者',
title: '草稿声浪赛',
summary: '',
themeDescription: '草地声浪挑战',
playerImageDescription: '柯基选手',
opponentImageDescription: '哈士奇对手',
playerCharacterImageSrc: '/player.png',
opponentCharacterImageSrc: '/opponent.png',
uiBackgroundImageSrc: '/background.png',
difficultyPreset: 'easy',
status: 'draft',
generationStatus: 'ready',
publishReady: false,
playCount: 0,
updatedAt: '2026-05-20T00:00:00.000Z',
publishedAt: null,
},
],
});
expect(items[0]?.kind).toBe('bark-battle');
expect(items[0]?.status).toBe('draft');
expect(items[0]?.authorDisplayName).toBe('草稿作者');
});
test('buildCreationWorkShelfItems falls back unknown authors to player label', () => {
const items = buildCreationWorkShelfItems({
rpgItems: [],
bigFishItems: [],
puzzleItems: [],
match3dItems: [
{
workId: 'match3d-work-author-fallback',
profileId: 'match3d-profile-author-fallback',
ownerUserId: 'user-1',
gameName: '水果抓大鹅',
themeText: '水果',
summary: '把水果从透明罐里抓出来。',
tags: [],
coverImageSrc: null,
clearCount: 0,
difficulty: 1,
publicationStatus: 'published',
playCount: 0,
updatedAt: '2026-05-20T00:00:00.000Z',
publishedAt: '2026-05-20T00:00:00.000Z',
publishReady: true,
},
],
});
expect(items[0]?.kind).toBe('match3d');
expect(items[0]?.authorDisplayName).toBe('玩家');
});
test('buildCreationWorkShelfItems attaches open and delete actions through shelf adapters', () => { test('buildCreationWorkShelfItems attaches open and delete actions through shelf adapters', () => {
const onOpenPuzzleDetail = vi.fn(); const onOpenPuzzleDetail = vi.fn();
const onDeletePuzzle = vi.fn(); const onDeletePuzzle = vi.fn();
@@ -672,6 +925,159 @@ test('buildCreationWorkShelfItems uses match3d transparent container reference a
); );
}); });
test('buildCreationWorkShelfItems maps bark battle works with scene role cover and BB code', () => {
const onOpenBarkBattleDetail = vi.fn();
const items = buildCreationWorkShelfItems({
rpgItems: [],
bigFishItems: [],
puzzleItems: [],
barkBattleItems: [
{
workId: 'bark-battle-work-12345678',
draftId: 'bark-battle-draft-1',
ownerUserId: 'user-1',
authorDisplayName: '玩家',
title: '公园声浪赛',
summary: '柯基和哈士奇比拼声浪。',
themeDescription: '傍晚公园擂台',
playerImageDescription: '红围巾柯基',
opponentImageDescription: '蓝头带哈士奇',
playerCharacterImageSrc: '/generated-bark-battle/player.png',
opponentCharacterImageSrc: '/generated-bark-battle/opponent.png',
uiBackgroundImageSrc: '/generated-bark-battle/background.png',
difficultyPreset: 'normal',
status: 'published',
generationStatus: 'ready',
publishReady: true,
playCount: 6,
updatedAt: '2026-05-20T00:00:00.000Z',
publishedAt: '2026-05-20T00:00:00.000Z',
},
],
onOpenBarkBattleDetail,
});
const item = items[0];
item?.actions.open();
expect(item?.kind).toBe('bark-battle');
expect(item?.publicWorkCode).toBe('BB-12345678');
expect(item?.sharePath).toContain('/works/detail?work=BB-12345678');
expect(item?.coverImageSrc).toBe('/generated-bark-battle/background.png');
expect(item?.coverRenderMode).toBe('scene_with_roles');
expect(item?.coverCharacterImageSrcs).toEqual([
'/generated-bark-battle/player.png',
'/generated-bark-battle/opponent.png',
]);
expect(onOpenBarkBattleDetail).toHaveBeenCalledWith(
expect.objectContaining({ workId: 'bark-battle-work-12345678' }),
);
});
test('bark battle draft generating state follows pending assets or missing three images', () => {
const draft = {
workId: 'bark-battle-work-draft',
draftId: 'bark-battle-draft-1',
ownerUserId: 'user-1',
authorDisplayName: '玩家',
title: '草稿声浪赛',
summary: '',
themeDescription: '草地',
playerImageDescription: '柯基',
opponentImageDescription: '哈士奇',
playerCharacterImageSrc: '/player.png',
opponentCharacterImageSrc: null,
uiBackgroundImageSrc: '/background.png',
difficultyPreset: 'easy' as const,
status: 'draft' as const,
generationStatus: 'pending_assets',
publishReady: false,
playCount: 0,
updatedAt: '2026-05-20T00:00:00.000Z',
publishedAt: null,
};
expect(hasBarkBattleRequiredImages(draft)).toBe(false);
expect(isPersistedBarkBattleDraftGenerating(draft)).toBe(true);
expect(
isPersistedBarkBattleDraftGenerating({
...draft,
opponentCharacterImageSrc: '/opponent.png',
generationStatus: 'ready',
}),
).toBe(false);
});
test('CustomWorldWorkCard renders author for draft and published works', () => {
const buildItem = (
status: CreationWorkShelfItem['status'],
authorDisplayName: string,
): CreationWorkShelfItem => ({
id: `card-${status}`,
kind: 'bark-battle',
status,
authorDisplayName,
title: status === 'draft' ? '草稿声浪赛' : '发布声浪赛',
summary: '一场轻快的汪汪声浪对决。',
updatedAt: '2026-05-20T00:00:00.000Z',
coverImageSrc: null,
coverRenderMode: 'image',
coverCharacterImageSrcs: [],
publicWorkCode: null,
sharePath: null,
openActionLabel: status === 'draft' ? '继续创作' : '查看详情',
canDelete: false,
canShare: false,
badges: [
{ id: 'status', label: status === 'draft' ? '草稿' : '已发布', tone: 'neutral' },
{ id: 'type', label: '汪汪', tone: 'neutral' },
],
metrics: [],
actions: { open: () => {} },
source: {
kind: 'bark-battle',
item: {
workId: `bark-battle-${status}`,
draftId: `draft-${status}`,
ownerUserId: 'user-1',
authorDisplayName,
title: status === 'draft' ? '草稿声浪赛' : '发布声浪赛',
summary: '一场轻快的汪汪声浪对决。',
themeDescription: '公园舞台',
playerImageDescription: '柯基选手',
opponentImageDescription: '哈士奇对手',
playerCharacterImageSrc: null,
opponentCharacterImageSrc: null,
uiBackgroundImageSrc: null,
difficultyPreset: 'normal',
status,
generationStatus: 'ready',
publishReady: status === 'published',
playCount: 0,
updatedAt: '2026-05-20T00:00:00.000Z',
publishedAt: status === 'published' ? '2026-05-20T00:00:00.000Z' : null,
},
},
});
const draftHtml = renderToStaticMarkup(
createElement(CustomWorldWorkCard, {
item: buildItem('draft', '草稿作者'),
onOpen: () => {},
}),
);
const publishedHtml = renderToStaticMarkup(
createElement(CustomWorldWorkCard, {
item: buildItem('published', '发布作者'),
onOpen: () => {},
}),
);
expect(draftHtml).toContain('作者:草稿作者');
expect(publishedHtml).toContain('作者:发布作者');
});
test('getCreationWorkShelfItemTime parses backend seconds.microsZ values', () => { test('getCreationWorkShelfItemTime parses backend seconds.microsZ values', () => {
expect(getCreationWorkShelfItemTime('1778457601.234567Z')).toBe( expect(getCreationWorkShelfItemTime('1778457601.234567Z')).toBe(
1778457601234.567, 1778457601234.567,

View File

@@ -1,3 +1,4 @@
import type { BarkBattleWorkSummary } from '../../../packages/shared/src/contracts/barkBattle';
import type { BigFishWorkSummary } from '../../../packages/shared/src/contracts/bigFishWorkSummary'; import type { BigFishWorkSummary } from '../../../packages/shared/src/contracts/bigFishWorkSummary';
import type { CustomWorldWorkSummary } from '../../../packages/shared/src/contracts/customWorldAgent'; import type { CustomWorldWorkSummary } from '../../../packages/shared/src/contracts/customWorldAgent';
import type { BabyObjectMatchDraft } from '../../../packages/shared/src/contracts/edutainmentBabyObject'; import type { BabyObjectMatchDraft } from '../../../packages/shared/src/contracts/edutainmentBabyObject';
@@ -9,6 +10,7 @@ import type { VisualNovelWorkSummary } from '../../../packages/shared/src/contra
import { buildPublicWorkStagePath } from '../../routing/appPageRoutes'; import { buildPublicWorkStagePath } from '../../routing/appPageRoutes';
import { import {
buildBabyObjectMatchPublicWorkCode, buildBabyObjectMatchPublicWorkCode,
buildBarkBattlePublicWorkCode,
buildBigFishPublicWorkCode, buildBigFishPublicWorkCode,
buildMatch3DPublicWorkCode, buildMatch3DPublicWorkCode,
buildPuzzlePublicWorkCode, buildPuzzlePublicWorkCode,
@@ -19,6 +21,9 @@ import type { CustomWorldProfile } from '../../types';
const MATCH3D_CONTAINER_REFERENCE_COVER_SRC = const MATCH3D_CONTAINER_REFERENCE_COVER_SRC =
'/match3d-background-references/pot-fused-reference.png'; '/match3d-background-references/pot-fused-reference.png';
const BARK_BATTLE_REFERENCE_COVER_SRC =
'/creation-type-references/bark-battle.webp';
const DEFAULT_CREATION_WORK_AUTHOR = '玩家';
export type CreationWorkShelfKind = export type CreationWorkShelfKind =
| 'rpg' | 'rpg'
@@ -27,6 +32,7 @@ export type CreationWorkShelfKind =
| 'square-hole' | 'square-hole'
| 'puzzle' | 'puzzle'
| 'baby-object-match' | 'baby-object-match'
| 'bark-battle'
| 'visual-novel'; | 'visual-novel';
export type CreationWorkShelfStatus = 'draft' | 'published'; export type CreationWorkShelfStatus = 'draft' | 'published';
@@ -84,6 +90,10 @@ export type CreationWorkShelfSource =
kind: 'visual-novel'; kind: 'visual-novel';
item: VisualNovelWorkSummary; item: VisualNovelWorkSummary;
} }
| {
kind: 'bark-battle';
item: BarkBattleWorkSummary;
}
| { | {
kind: 'baby-object-match'; kind: 'baby-object-match';
item: BabyObjectMatchDraft; item: BabyObjectMatchDraft;
@@ -103,6 +113,7 @@ export type CreationWorkShelfItem = {
hasUnreadUpdate?: boolean; hasUnreadUpdate?: boolean;
title: string; title: string;
summary: string; summary: string;
authorDisplayName: string;
updatedAt: string; updatedAt: string;
coverImageSrc: string | null; coverImageSrc: string | null;
coverRenderMode: 'image' | 'scene_with_roles'; coverRenderMode: 'image' | 'scene_with_roles';
@@ -127,6 +138,7 @@ export function buildCreationWorkShelfItems(params: {
squareHoleItems?: SquareHoleWorkSummary[]; squareHoleItems?: SquareHoleWorkSummary[];
puzzleItems: PuzzleWorkSummary[]; puzzleItems: PuzzleWorkSummary[];
babyObjectMatchItems?: BabyObjectMatchDraft[]; babyObjectMatchItems?: BabyObjectMatchDraft[];
barkBattleItems?: BarkBattleWorkSummary[];
visualNovelItems?: VisualNovelWorkSummary[]; visualNovelItems?: VisualNovelWorkSummary[];
canDeleteRpg?: boolean; canDeleteRpg?: boolean;
canDeleteBigFish?: boolean; canDeleteBigFish?: boolean;
@@ -134,6 +146,7 @@ export function buildCreationWorkShelfItems(params: {
canDeleteSquareHole?: boolean; canDeleteSquareHole?: boolean;
canDeletePuzzle?: boolean; canDeletePuzzle?: boolean;
canDeleteBabyObjectMatch?: boolean; canDeleteBabyObjectMatch?: boolean;
canDeleteBarkBattle?: boolean;
canDeleteVisualNovel?: boolean; canDeleteVisualNovel?: boolean;
onOpenRpgDraft?: (item: CustomWorldWorkSummary) => void; onOpenRpgDraft?: (item: CustomWorldWorkSummary) => void;
onEnterRpgPublished?: (profileId: string) => void; onEnterRpgPublished?: (profileId: string) => void;
@@ -149,6 +162,8 @@ export function buildCreationWorkShelfItems(params: {
onClaimPuzzlePointIncentive?: (item: PuzzleWorkSummary) => void; onClaimPuzzlePointIncentive?: (item: PuzzleWorkSummary) => void;
onOpenBabyObjectMatchDetail?: (item: BabyObjectMatchDraft) => void; onOpenBabyObjectMatchDetail?: (item: BabyObjectMatchDraft) => void;
onDeleteBabyObjectMatch?: (item: BabyObjectMatchDraft) => void; onDeleteBabyObjectMatch?: (item: BabyObjectMatchDraft) => void;
onOpenBarkBattleDetail?: (item: BarkBattleWorkSummary) => void;
onDeleteBarkBattle?: (item: BarkBattleWorkSummary) => void;
onOpenVisualNovelDetail?: (item: VisualNovelWorkSummary) => void; onOpenVisualNovelDetail?: (item: VisualNovelWorkSummary) => void;
onDeleteVisualNovel?: (item: VisualNovelWorkSummary) => void; onDeleteVisualNovel?: (item: VisualNovelWorkSummary) => void;
getItemState?: ( getItemState?: (
@@ -163,6 +178,7 @@ export function buildCreationWorkShelfItems(params: {
squareHoleItems = [], squareHoleItems = [],
puzzleItems, puzzleItems,
babyObjectMatchItems = [], babyObjectMatchItems = [],
barkBattleItems = [],
visualNovelItems = [], visualNovelItems = [],
canDeleteRpg = false, canDeleteRpg = false,
canDeleteBigFish = false, canDeleteBigFish = false,
@@ -170,6 +186,7 @@ export function buildCreationWorkShelfItems(params: {
canDeleteSquareHole = false, canDeleteSquareHole = false,
canDeletePuzzle = false, canDeletePuzzle = false,
canDeleteBabyObjectMatch = false, canDeleteBabyObjectMatch = false,
canDeleteBarkBattle = false,
canDeleteVisualNovel = false, canDeleteVisualNovel = false,
onOpenRpgDraft, onOpenRpgDraft,
onEnterRpgPublished, onEnterRpgPublished,
@@ -185,6 +202,8 @@ export function buildCreationWorkShelfItems(params: {
onClaimPuzzlePointIncentive, onClaimPuzzlePointIncentive,
onOpenBabyObjectMatchDetail, onOpenBabyObjectMatchDetail,
onDeleteBabyObjectMatch, onDeleteBabyObjectMatch,
onOpenBarkBattleDetail,
onDeleteBarkBattle,
onOpenVisualNovelDetail, onOpenVisualNovelDetail,
onDeleteVisualNovel, onDeleteVisualNovel,
getItemState, getItemState,
@@ -229,6 +248,12 @@ export function buildCreationWorkShelfItems(params: {
onDelete: onDeleteBabyObjectMatch, onDelete: onDeleteBabyObjectMatch,
}), }),
), ),
...mergeBarkBattleShelfSourceItems(barkBattleItems).map((item) =>
mapBarkBattleWorkToShelfItem(item, canDeleteBarkBattle, {
onOpen: onOpenBarkBattleDetail,
onDelete: onDeleteBarkBattle,
}),
),
...visualNovelItems.map((item) => ...visualNovelItems.map((item) =>
mapVisualNovelWorkToShelfItem(item, canDeleteVisualNovel, { mapVisualNovelWorkToShelfItem(item, canDeleteVisualNovel, {
onOpen: onOpenVisualNovelDetail, onOpen: onOpenVisualNovelDetail,
@@ -259,6 +284,28 @@ export function buildCreationWorkShelfItems(params: {
); );
} }
function mergeBarkBattleShelfSourceItems(
items: readonly BarkBattleWorkSummary[],
): BarkBattleWorkSummary[] {
const byWorkId = new Map<string, BarkBattleWorkSummary>();
for (const item of items) {
const current = byWorkId.get(item.workId);
if (!current) {
byWorkId.set(item.workId, item);
continue;
}
if (current.status !== 'published' && item.status === 'published') {
byWorkId.set(item.workId, { ...current, ...item });
continue;
}
if (current.status === item.status) {
byWorkId.set(item.workId, { ...current, ...item });
}
}
return Array.from(byWorkId.values());
}
type RpgWorkShelfAdapter = { type RpgWorkShelfAdapter = {
onOpenDraft?: (item: CustomWorldWorkSummary) => void; onOpenDraft?: (item: CustomWorldWorkSummary) => void;
onEnterPublished?: (profileId: string) => void; onEnterPublished?: (profileId: string) => void;
@@ -303,6 +350,7 @@ function mapRpgWorkToShelfItem(
status: item.status, status: item.status,
title: item.title, title: item.title,
summary: item.summary, summary: item.summary,
authorDisplayName: resolveAuthorDisplayName(item, libraryEntry),
updatedAt: item.updatedAt, updatedAt: item.updatedAt,
coverImageSrc: item.coverImageSrc ?? null, coverImageSrc: item.coverImageSrc ?? null,
coverRenderMode: item.coverRenderMode ?? 'image', coverRenderMode: item.coverRenderMode ?? 'image',
@@ -342,6 +390,7 @@ function mapBigFishWorkToShelfItem(
status: item.status, status: item.status,
title: item.title, title: item.title,
summary: item.summary, summary: item.summary,
authorDisplayName: resolveAuthorDisplayName(item),
updatedAt: item.updatedAt, updatedAt: item.updatedAt,
coverImageSrc: item.coverImageSrc ?? null, coverImageSrc: item.coverImageSrc ?? null,
coverRenderMode: 'image', coverRenderMode: 'image',
@@ -386,6 +435,7 @@ function mapMatch3DWorkToShelfItem(
status, status,
title: item.gameName, title: item.gameName,
summary: item.summary, summary: item.summary,
authorDisplayName: resolveAuthorDisplayName(item),
updatedAt: item.updatedAt, updatedAt: item.updatedAt,
coverImageSrc, coverImageSrc,
coverRenderMode: 'image', coverRenderMode: 'image',
@@ -434,6 +484,7 @@ function mapPuzzleWorkToShelfItem(
item.workDescription?.trim() || item.workDescription?.trim() ||
item.summary.trim() || item.summary.trim() ||
(status === 'draft' ? '未填写作品描述' : ''), (status === 'draft' ? '未填写作品描述' : ''),
authorDisplayName: resolveAuthorDisplayName(item),
updatedAt: item.updatedAt, updatedAt: item.updatedAt,
coverImageSrc, coverImageSrc,
coverRenderMode: 'image', coverRenderMode: 'image',
@@ -500,6 +551,7 @@ function mapBabyObjectMatchDraftToShelfItem(
summary: summary:
item.workDescription.trim() || item.workDescription.trim() ||
`${item.itemNames[0]}${item.itemNames[1]}识物分类`, `${item.itemNames[0]}${item.itemNames[1]}识物分类`,
authorDisplayName: resolveAuthorDisplayName(item),
updatedAt: item.updatedAt, updatedAt: item.updatedAt,
coverImageSrc, coverImageSrc,
coverRenderMode: 'image', coverRenderMode: 'image',
@@ -549,6 +601,7 @@ function mapVisualNovelWorkToShelfItem(
status, status,
title, title,
summary, summary,
authorDisplayName: resolveAuthorDisplayName(item),
updatedAt: item.updatedAt, updatedAt: item.updatedAt,
coverImageSrc: item.coverImageSrc ?? null, coverImageSrc: item.coverImageSrc ?? null,
coverRenderMode: 'image', coverRenderMode: 'image',
@@ -578,6 +631,72 @@ function mapVisualNovelWorkToShelfItem(
}; };
} }
function mapBarkBattleWorkToShelfItem(
item: BarkBattleWorkSummary,
canDelete: boolean,
adapter: WorkShelfAdapter<BarkBattleWorkSummary>,
): CreationWorkShelfItem {
const status = item.status;
const publicWorkCode =
status === 'published' ? buildBarkBattlePublicWorkCode(item.workId) : null;
const playerCharacterImageSrc = normalizeCoverImageSrc(
item.playerCharacterImageSrc,
);
const opponentCharacterImageSrc = normalizeCoverImageSrc(
item.opponentCharacterImageSrc,
);
const coverImageSrc =
normalizeCoverImageSrc(item.uiBackgroundImageSrc) ??
playerCharacterImageSrc ??
opponentCharacterImageSrc ??
BARK_BATTLE_REFERENCE_COVER_SRC;
const coverCharacterImageSrcs = [
playerCharacterImageSrc,
opponentCharacterImageSrc,
].filter((imageSrc): imageSrc is string => Boolean(imageSrc));
const canRenderSceneWithRoles =
Boolean(normalizeCoverImageSrc(item.uiBackgroundImageSrc)) &&
coverCharacterImageSrcs.length >= 2;
return {
id: item.workId,
kind: 'bark-battle',
status,
title: item.title.trim() || '汪汪声浪大作战',
summary:
item.summary.trim() ||
item.themeDescription.trim() ||
(status === 'draft' ? '未填写作品描述' : ''),
authorDisplayName: resolveAuthorDisplayName(item),
updatedAt: item.updatedAt,
coverImageSrc,
coverRenderMode: canRenderSceneWithRoles ? 'scene_with_roles' : 'image',
coverCharacterImageSrcs,
publicWorkCode,
sharePath:
publicWorkCode && status === 'published'
? buildPublicWorkStagePath('work-detail', publicWorkCode)
: null,
openActionLabel: status === 'published' ? '查看详情' : '继续创作',
canDelete,
canShare: status === 'published' && Boolean(publicWorkCode),
badges: [
buildStatusBadge(status),
{ id: 'type', label: '汪汪', tone: 'neutral' },
],
metrics:
status === 'published'
? buildPublishedMetrics({
playCount: item.playCount,
remixCount: 0,
likeCount: 0,
})
: [],
actions: buildWorkShelfActions(item, adapter),
source: { kind: 'bark-battle', item },
};
}
function mapSquareHoleWorkToShelfItem( function mapSquareHoleWorkToShelfItem(
item: SquareHoleWorkSummary, item: SquareHoleWorkSummary,
canDelete: boolean, canDelete: boolean,
@@ -596,6 +715,7 @@ function mapSquareHoleWorkToShelfItem(
status, status,
title: item.gameName, title: item.gameName,
summary: item.summary, summary: item.summary,
authorDisplayName: resolveAuthorDisplayName(item),
updatedAt: item.updatedAt, updatedAt: item.updatedAt,
coverImageSrc, coverImageSrc,
coverRenderMode: 'image', coverRenderMode: 'image',
@@ -625,6 +745,26 @@ function mapSquareHoleWorkToShelfItem(
}; };
} }
function resolveAuthorDisplayName(
...sources: Array<unknown>
) {
for (const source of sources) {
const authorDisplayName =
source &&
typeof source === 'object' &&
'authorDisplayName' in source &&
typeof source.authorDisplayName === 'string'
? source.authorDisplayName.trim()
: '';
if (authorDisplayName) {
return authorDisplayName;
}
}
return DEFAULT_CREATION_WORK_AUTHOR;
}
function normalizeCoverImageSrc(value?: string | null) { function normalizeCoverImageSrc(value?: string | null) {
return value?.trim() || null; return value?.trim() || null;
} }
@@ -816,11 +956,34 @@ function isPersistedCreationWorkGenerating(item: CreationWorkShelfItem) {
return item.source.item.generationStatus === 'generating'; return item.source.item.generationStatus === 'generating';
case 'puzzle': case 'puzzle':
return isPersistedPuzzleDraftGenerating(item.source.item); return isPersistedPuzzleDraftGenerating(item.source.item);
case 'bark-battle':
return isPersistedBarkBattleDraftGenerating(item.source.item);
default: default:
return false; return false;
} }
} }
export function isPersistedBarkBattleDraftGenerating(
item: BarkBattleWorkSummary,
) {
if (item.status === 'published') {
return false;
}
return (
item.generationStatus === 'pending_assets' ||
!hasBarkBattleRequiredImages(item)
);
}
export function hasBarkBattleRequiredImages(item: BarkBattleWorkSummary) {
return Boolean(
normalizeCoverImageSrc(item.playerCharacterImageSrc) &&
normalizeCoverImageSrc(item.opponentCharacterImageSrc) &&
normalizeCoverImageSrc(item.uiBackgroundImageSrc),
);
}
export function isPersistedPuzzleDraftGenerating(item: PuzzleWorkSummary) { export function isPersistedPuzzleDraftGenerating(item: PuzzleWorkSummary) {
if (item.generationStatus !== 'generating') { if (item.generationStatus !== 'generating') {
return false; return false;

File diff suppressed because it is too large Load Diff

View File

@@ -22,6 +22,7 @@ import {
formatPlatformWorkDisplayName, formatPlatformWorkDisplayName,
formatPlatformWorkDisplayTags, formatPlatformWorkDisplayTags,
formatPlatformWorldTime, formatPlatformWorldTime,
isBarkBattleGalleryEntry,
isEdutainmentGalleryEntry, isEdutainmentGalleryEntry,
type PlatformPublicGalleryCard, type PlatformPublicGalleryCard,
resolvePlatformPublicWorkCode, resolvePlatformPublicWorkCode,
@@ -67,6 +68,9 @@ function getSourceLabel(entry: PlatformPublicGalleryCard) {
if ('sourceType' in entry && entry.sourceType === 'visual-novel') { if ('sourceType' in entry && entry.sourceType === 'visual-novel') {
return '视觉小说'; return '视觉小说';
} }
if (isBarkBattleGalleryEntry(entry)) {
return '汪汪声浪';
}
if (isEdutainmentGalleryEntry(entry)) { if (isEdutainmentGalleryEntry(entry)) {
return entry.templateName; return entry.templateName;
} }

View File

@@ -0,0 +1,108 @@
import { expect, test } from 'vitest';
import type { BarkBattleWorkSummary } from '../../../packages/shared/src/contracts/barkBattle';
import {
mergeBarkBattleWorksByWorkId,
mergeBarkBattleWorkSummary,
shouldPreserveLocalBarkBattleWorkOnRefresh,
} from './barkBattleWorkCache';
function buildBarkBattleWork(
overrides: Partial<BarkBattleWorkSummary> = {},
): BarkBattleWorkSummary {
return {
workId: 'BB-cache-race-12345678',
draftId: 'bark-battle-draft-1',
ownerUserId: 'user-1',
authorDisplayName: '测试玩家',
title: '汪汪测试杯',
summary: '测试声浪赛',
themeDescription: '阳光草坪声浪竞技场',
playerImageDescription: '戴红色围巾的柯基选手',
opponentImageDescription: '蓝色护目镜哈士奇对手',
playerCharacterImageSrc: '/generated-bark-battle/player.png',
opponentCharacterImageSrc: '/generated-bark-battle/opponent.png',
uiBackgroundImageSrc: '/generated-bark-battle/background.png',
difficultyPreset: 'normal',
status: 'draft',
generationStatus: 'ready',
publishReady: true,
playCount: 0,
updatedAt: '2026-05-21T10:00:00.000Z',
publishedAt: null,
...overrides,
};
}
test('preserves local published bark battle when refresh only returns same work draft', () => {
const published = buildBarkBattleWork({
status: 'published',
playCount: 3,
updatedAt: '2026-05-21T10:02:00.000Z',
publishedAt: '2026-05-21T10:02:00.000Z',
});
const refreshedDraft = buildBarkBattleWork({
status: 'draft',
playCount: 0,
updatedAt: '2026-05-21T10:01:00.000Z',
publishedAt: null,
});
expect(shouldPreserveLocalBarkBattleWorkOnRefresh(published, [refreshedDraft])).toBe(
true,
);
const [merged] = mergeBarkBattleWorksByWorkId([refreshedDraft, published]);
expect(merged?.status).toBe('published');
expect(merged?.publishedAt).toBe('2026-05-21T10:02:00.000Z');
expect(merged?.playCount).toBe(3);
});
test('does not let later draft cache updates downgrade an existing published bark battle', () => {
const published = buildBarkBattleWork({
status: 'published',
playCount: 4,
updatedAt: '2026-05-21T10:03:00.000Z',
publishedAt: '2026-05-21T10:03:00.000Z',
});
const staleDraft = buildBarkBattleWork({
title: '旧草稿标题',
status: 'draft',
playCount: 0,
updatedAt: '2026-05-21T10:01:00.000Z',
publishedAt: null,
});
const merged = mergeBarkBattleWorkSummary(published, staleDraft);
expect(merged.status).toBe('published');
expect(merged.title).toBe('汪汪测试杯');
expect(merged.playCount).toBe(4);
expect(merged.publishedAt).toBe('2026-05-21T10:03:00.000Z');
});
test('preserves local ready bark battle draft when refresh has not returned it yet', () => {
const readyDraft = buildBarkBattleWork({
status: 'draft',
generationStatus: 'ready',
publishReady: true,
playerCharacterImageSrc: '/generated-bark-battle/player-ready.png',
opponentCharacterImageSrc: '/generated-bark-battle/opponent-ready.png',
uiBackgroundImageSrc: '/generated-bark-battle/background-ready.png',
});
expect(shouldPreserveLocalBarkBattleWorkOnRefresh(readyDraft, [])).toBe(true);
const merged = mergeBarkBattleWorksByWorkId([
...[],
...(shouldPreserveLocalBarkBattleWorkOnRefresh(readyDraft, [])
? [readyDraft]
: []),
]);
expect(merged).toHaveLength(1);
expect(merged[0]?.workId).toBe('BB-cache-race-12345678');
expect(merged[0]?.generationStatus).toBe('ready');
});

View File

@@ -0,0 +1,112 @@
import type { PublicUserSummary } from '../../../packages/shared/src/contracts/auth';
import type {
BarkBattleDraftConfig,
BarkBattleGenerationStatus as SharedBarkBattleGenerationStatus,
BarkBattleWorkSummary,
} from '../../../packages/shared/src/contracts/barkBattle';
export type BarkBattleGenerationStatus = SharedBarkBattleGenerationStatus;
export function mergeBarkBattleWorkSummary(
current: BarkBattleWorkSummary,
updated: BarkBattleWorkSummary,
): BarkBattleWorkSummary {
if (current.workId !== updated.workId) {
return current;
}
if (current.status === 'published' && updated.status !== 'published') {
return {
...updated,
...current,
playCount: current.playCount ?? updated.playCount,
recentPlayCount7d: current.recentPlayCount7d ?? updated.recentPlayCount7d,
updatedAt: current.updatedAt || updated.updatedAt,
publishedAt: current.publishedAt ?? updated.publishedAt,
};
}
return { ...current, ...updated };
}
export function hasBarkBattleSummaryRequiredImages(item: BarkBattleWorkSummary) {
return Boolean(
item.playerCharacterImageSrc?.trim() &&
item.opponentCharacterImageSrc?.trim() &&
item.uiBackgroundImageSrc?.trim(),
);
}
export function shouldPreserveLocalBarkBattleWorkOnRefresh(
item: BarkBattleWorkSummary,
refreshed: readonly BarkBattleWorkSummary[],
) {
if (item.status === 'published') {
return !refreshed.some(
(entry) => entry.workId === item.workId && entry.status === 'published',
);
}
if (refreshed.some((entry) => entry.workId === item.workId)) {
return false;
}
// 中文注释Bark Battle 创建/生成完成/保存后会先把本地摘要塞进作品架,
// 后端 /works 读模型可能短暂落后;只要刷新结果还没有同 workId就保留本地草稿
// 避免 ready 且三图齐全的草稿在刷新窗口期从“我的草稿”里消失。
return true;
}
export function buildBarkBattleWorkSummaryFromDraft(
draft: BarkBattleDraftConfig,
user: PublicUserSummary | null | undefined,
generationStatus: BarkBattleGenerationStatus = 'pending_assets',
): BarkBattleWorkSummary {
const workId = draft.workId?.trim() || draft.draftId;
return {
workId,
draftId: draft.draftId,
ownerUserId: user?.id ?? '',
authorDisplayName: user?.displayName ?? '创作者',
title: draft.title,
summary: draft.description ?? '',
themeDescription: draft.themeDescription,
playerImageDescription: draft.playerImageDescription,
opponentImageDescription: draft.opponentImageDescription,
onomatopoeia: draft.onomatopoeia,
playerCharacterImageSrc: draft.playerCharacterImageSrc ?? null,
opponentCharacterImageSrc: draft.opponentCharacterImageSrc ?? null,
uiBackgroundImageSrc: draft.uiBackgroundImageSrc ?? null,
difficultyPreset: draft.difficultyPreset,
status: 'draft',
generationStatus,
publishReady: Boolean(
draft.playerCharacterImageSrc?.trim() &&
draft.opponentCharacterImageSrc?.trim() &&
draft.uiBackgroundImageSrc?.trim(),
),
playCount: 0,
updatedAt: draft.updatedAt,
publishedAt: null,
};
}
export function mergeBarkBattleWorksByWorkId(
items: readonly BarkBattleWorkSummary[],
): BarkBattleWorkSummary[] {
const byWorkId = new Map<string, BarkBattleWorkSummary>();
for (const item of items) {
const current = byWorkId.get(item.workId);
if (!current) {
byWorkId.set(item.workId, item);
continue;
}
if (current.status !== 'published' && item.status === 'published') {
byWorkId.set(item.workId, { ...current, ...item });
continue;
}
if (current.status === item.status || current.status === 'published') {
byWorkId.set(item.workId, mergeBarkBattleWorkSummary(current, item));
}
}
return Array.from(byWorkId.values());
}

View File

@@ -31,6 +31,7 @@ export type SelectionStage =
| 'square-hole-generating' | 'square-hole-generating'
| 'square-hole-result' | 'square-hole-result'
| 'square-hole-runtime' | 'square-hole-runtime'
| 'bark-battle-generating'
| 'bark-battle-result' | 'bark-battle-result'
| 'bark-battle-runtime' | 'bark-battle-runtime'
| 'creative-agent-workspace' | 'creative-agent-workspace'

View File

@@ -5,6 +5,7 @@ import userEvent from '@testing-library/user-event';
import { useState } from 'react'; import { useState } from 'react';
import { afterEach, beforeEach, expect, test, vi } from 'vitest'; import { afterEach, beforeEach, expect, test, vi } from 'vitest';
import type { BarkBattleWorkSummary } from '../../../packages/shared/src/contracts/barkBattle';
import type { BigFishWorkSummary } from '../../../packages/shared/src/contracts/bigFishWorkSummary'; import type { BigFishWorkSummary } from '../../../packages/shared/src/contracts/bigFishWorkSummary';
import type { CreativeAgentSessionSnapshot } from '../../../packages/shared/src/contracts/creativeAgent'; import type { CreativeAgentSessionSnapshot } from '../../../packages/shared/src/contracts/creativeAgent';
import type { import type {
@@ -43,7 +44,11 @@ import { ApiClientError } from '../../services/apiClient';
import type { AuthUser } from '../../services/authService'; import type { AuthUser } from '../../services/authService';
import { import {
createBarkBattleDraft, createBarkBattleDraft,
generateAllBarkBattleImageAssets,
listBarkBattleGallery,
listBarkBattleWorks,
publishBarkBattleWork, publishBarkBattleWork,
updateBarkBattleDraftConfig,
} from '../../services/bark-battle-creation'; } from '../../services/bark-battle-creation';
import { import {
createBigFishCreationSession, createBigFishCreationSession,
@@ -475,8 +480,12 @@ vi.mock('../../services/big-fish-runtime', () => ({
vi.mock('../../services/bark-battle-creation', () => ({ vi.mock('../../services/bark-battle-creation', () => ({
createBarkBattleDraft: vi.fn(), createBarkBattleDraft: vi.fn(),
generateAllBarkBattleImageAssets: vi.fn(),
listBarkBattleGallery: vi.fn(),
listBarkBattleWorks: vi.fn(),
publishBarkBattleWork: vi.fn(), publishBarkBattleWork: vi.fn(),
regenerateBarkBattleImageAsset: vi.fn(), regenerateBarkBattleImageAsset: vi.fn(),
updateBarkBattleDraftConfig: vi.fn(),
uploadBarkBattleAsset: vi.fn(), uploadBarkBattleAsset: vi.fn(),
})); }));
@@ -1000,11 +1009,10 @@ vi.mock('../bark-battle-creation/BarkBattleConfigEditor', () => ({
onPreview: (payload: { onPreview: (payload: {
title: string; title: string;
description: string; description: string;
themePreset: string; themeDescription: string;
playerDogSkinPreset: string; playerImageDescription: string;
opponentDogSkinPreset: string; opponentImageDescription: string;
difficultyPreset: 'normal'; difficultyPreset: 'normal';
leaderboardEnabled: boolean;
}) => void; }) => void;
}) => ( }) => (
<div className="bark-battle-config-editor-mock"> <div className="bark-battle-config-editor-mock">
@@ -1026,11 +1034,10 @@ vi.mock('../bark-battle-creation/BarkBattleConfigEditor', () => ({
onPreview({ onPreview({
title: '汪汪测试杯', title: '汪汪测试杯',
description: '', description: '',
themePreset: 'sunny-yard', themeDescription: '阳光草坪声浪竞技场',
playerDogSkinPreset: 'corgi', playerImageDescription: '戴红色围巾的柯基选手',
opponentDogSkinPreset: 'husky', opponentImageDescription: '蓝色护目镜哈士奇对手',
difficultyPreset: 'normal', difficultyPreset: 'normal',
leaderboardEnabled: true,
}); });
}} }}
> >
@@ -1122,14 +1129,27 @@ vi.mock('../../games/bark-battle/ui/BarkBattleRuntimeShell', () => ({
BarkBattleRuntimeShell: ({ BarkBattleRuntimeShell: ({
title, title,
workId, workId,
runtimeMode,
publishedConfig,
onExit, onExit,
}: { }: {
title?: string; title?: string;
workId?: string; workId?: string;
runtimeMode?: string;
publishedConfig?: { workId?: string; playerCharacterImageSrc?: string | null } | null;
onExit?: () => void; onExit?: () => void;
}) => ( }) => (
<div className="bark-battle-runtime-shell-mock"> <div className="bark-battle-runtime-shell-mock">
<div>{title ?? '未命名'} / {workId ?? 'missing-work'}</div> <div>{title ?? '未命名'} / {workId ?? 'missing-work'}</div>
<div data-testid="bark-battle-runtime-mode">
{runtimeMode ?? 'missing-mode'}
</div>
<div data-testid="bark-battle-runtime-work-id">
{publishedConfig?.workId ?? 'missing-config-work'}
</div>
<div data-testid="bark-battle-runtime-player-src">
{publishedConfig?.playerCharacterImageSrc ?? 'missing-player-src'}
</div>
<button type="button" onClick={onExit}> <button type="button" onClick={onExit}>
</button> </button>
@@ -1311,6 +1331,34 @@ function buildMockBabyObjectMatchDraft(
}; };
} }
function buildMockBarkBattleWork(
overrides: Partial<BarkBattleWorkSummary> = {},
): BarkBattleWorkSummary {
return {
workId: 'BB-C661A45F',
draftId: 'bark-battle-draft-public-1',
ownerUserId: 'user-1',
authorDisplayName: '测试玩家',
title: '汪汪公开杯',
summary: '',
themeDescription: '霓虹城市公园里的声浪擂台',
playerImageDescription: '戴红围巾的柴犬主角',
opponentImageDescription: '戴蓝色头带的哈士奇对手',
playerCharacterImageSrc: '/generated-bark-battle/player.png',
opponentCharacterImageSrc: '/generated-bark-battle/opponent.png',
uiBackgroundImageSrc: '/generated-bark-battle/background.png',
difficultyPreset: 'normal',
status: 'published',
generationStatus: 'ready',
publishReady: true,
playCount: 0,
finishCount: 0,
updatedAt: '2026-05-14T10:00:00.000Z',
publishedAt: '2026-05-14T10:00:00.000Z',
...overrides,
};
}
function buildMockSquareHoleAgentSession( function buildMockSquareHoleAgentSession(
overrides: Partial< overrides: Partial<
Parameters<typeof buildMockSquareHoleAgentSessionImpl>[0] Parameters<typeof buildMockSquareHoleAgentSessionImpl>[0]
@@ -2837,15 +2885,61 @@ beforeEach(() => {
workId: 'bark-battle-work-1', workId: 'bark-battle-work-1',
title: '汪汪测试杯', title: '汪汪测试杯',
description: '', description: '',
themePreset: 'sunny-yard', themeDescription: '阳光草坪声浪竞技场',
playerDogSkinPreset: 'corgi', playerImageDescription: '戴红色围巾的柯基选手',
opponentDogSkinPreset: 'husky', opponentImageDescription: '蓝色护目镜哈士奇对手',
difficultyPreset: 'normal', difficultyPreset: 'normal',
leaderboardEnabled: true,
configVersion: 1, configVersion: 1,
rulesetVersion: 'bark-battle-ruleset-v1', rulesetVersion: 'bark-battle-ruleset-v1',
updatedAt: '2026-05-14T10:00:00.000Z', updatedAt: '2026-05-14T10:00:00.000Z',
}); });
vi.mocked(generateAllBarkBattleImageAssets).mockResolvedValue({
assets: {
'player-character': {
imageSrc: '/generated-bark-battle/player.png',
assetId: 'asset-player',
model: 'gpt-image-2-all',
size: '1024*1024',
taskId: 'task-player',
prompt: 'player',
},
'opponent-character': {
imageSrc: '/generated-bark-battle/opponent.png',
assetId: 'asset-opponent',
model: 'gpt-image-2-all',
size: '1024*1024',
taskId: 'task-opponent',
prompt: 'opponent',
},
'ui-background': {
imageSrc: '/generated-bark-battle/background.png',
assetId: 'asset-background',
model: 'gpt-image-2-all',
size: '1024*1792',
taskId: 'task-background',
prompt: 'background',
},
},
failures: {},
});
vi.mocked(updateBarkBattleDraftConfig).mockImplementation(async (payload) => ({
draftId: payload.draftId,
workId: payload.workId ?? 'bark-battle-work-1',
title: payload.title,
description: payload.description,
themeDescription: payload.themeDescription,
playerImageDescription: payload.playerImageDescription,
opponentImageDescription: payload.opponentImageDescription,
playerCharacterImageSrc: payload.playerCharacterImageSrc,
opponentCharacterImageSrc: payload.opponentCharacterImageSrc,
uiBackgroundImageSrc: payload.uiBackgroundImageSrc,
difficultyPreset: payload.difficultyPreset,
configVersion: (payload.configVersion ?? 1) + 1,
rulesetVersion: payload.rulesetVersion ?? 'bark-battle-ruleset-v1',
updatedAt: '2026-05-14T10:01:00.000Z',
}));
vi.mocked(listBarkBattleWorks).mockResolvedValue({ items: [] });
vi.mocked(listBarkBattleGallery).mockResolvedValue({ items: [] });
vi.mocked(publishBarkBattleWork).mockResolvedValue({ vi.mocked(publishBarkBattleWork).mockResolvedValue({
workId: 'bark-battle-work-1', workId: 'bark-battle-work-1',
draftId: 'bark-battle-draft-1', draftId: 'bark-battle-draft-1',
@@ -2854,11 +2948,10 @@ beforeEach(() => {
playTypeId: 'bark-battle', playTypeId: 'bark-battle',
title: '汪汪测试杯', title: '汪汪测试杯',
description: '', description: '',
themePreset: 'sunny-yard', themeDescription: '阳光草坪声浪竞技场',
playerDogSkinPreset: 'corgi', playerImageDescription: '戴红色围巾的柯基选手',
opponentDogSkinPreset: 'husky', opponentImageDescription: '蓝色护目镜哈士奇对手',
difficultyPreset: 'normal', difficultyPreset: 'normal',
leaderboardEnabled: true,
updatedAt: '2026-05-14T10:00:00.000Z', updatedAt: '2026-05-14T10:00:00.000Z',
publishedAt: '2026-05-14T10:00:00.000Z', publishedAt: '2026-05-14T10:00:00.000Z',
}); });
@@ -3233,6 +3326,9 @@ test('create tab shows template tabs and embeds puzzle form by default', async (
await openCreateTemplateHub(user); await openCreateTemplateHub(user);
expect(screen.getByRole('tablist', { name: '选择模板' })).toBeTruthy(); expect(screen.getByRole('tablist', { name: '选择模板' })).toBeTruthy();
expect(screen.getByRole('tablist', { name: '选择模板' }).className).toContain(
'scroll-px-3',
);
expect( expect(
screen.getByRole('tab', { name: '拼图' }).getAttribute('aria-selected'), screen.getByRole('tab', { name: '拼图' }).getAttribute('aria-selected'),
).toBe('true'); ).toBe('true');
@@ -3309,7 +3405,7 @@ test('create tab switches bark battle into the embedded config form', async () =
expect(publishBarkBattleWork).not.toHaveBeenCalled(); expect(publishBarkBattleWork).not.toHaveBeenCalled();
}); });
test('bark battle draft result can test before publish and return to the embedded form', async () => { test('bark battle draft result can test before publish and publish to work detail', async () => {
const user = userEvent.setup(); const user = userEvent.setup();
render(<TestWrapper withAuth />); render(<TestWrapper withAuth />);
@@ -3321,11 +3417,21 @@ test('bark battle draft result can test before publish and return to the embedde
expect(createBarkBattleDraft).toHaveBeenCalledWith({ expect(createBarkBattleDraft).toHaveBeenCalledWith({
title: '汪汪测试杯', title: '汪汪测试杯',
description: '', description: '',
themePreset: 'sunny-yard', themeDescription: '阳光草坪声浪竞技场',
playerDogSkinPreset: 'corgi', playerImageDescription: '戴红色围巾的柯基选手',
opponentDogSkinPreset: 'husky', opponentImageDescription: '蓝色护目镜哈士奇对手',
difficultyPreset: 'normal', difficultyPreset: 'normal',
leaderboardEnabled: true, });
await waitFor(() => {
expect(updateBarkBattleDraftConfig).toHaveBeenCalledWith(
expect.objectContaining({
draftId: 'bark-battle-draft-1',
workId: 'bark-battle-work-1',
playerCharacterImageSrc: '/generated-bark-battle/player.png',
opponentCharacterImageSrc: '/generated-bark-battle/opponent.png',
uiBackgroundImageSrc: '/generated-bark-battle/background.png',
}),
);
}); });
expect(await screen.findByText(/汪汪声浪结果页:汪汪测试杯/u)).toBeTruthy(); expect(await screen.findByText(/汪汪声浪结果页:汪汪测试杯/u)).toBeTruthy();
expect(await screen.findByText('作品IDbark-battle-work-1')).toBeTruthy(); expect(await screen.findByText('作品IDbark-battle-work-1')).toBeTruthy();
@@ -3345,17 +3451,154 @@ test('bark battle draft result can test before publish and return to the embedde
workId: 'bark-battle-work-1', workId: 'bark-battle-work-1',
publishedSnapshot: expect.objectContaining({ publishedSnapshot: expect.objectContaining({
title: '汪汪测试杯', title: '汪汪测试杯',
leaderboardEnabled: true, themeDescription: '阳光草坪声浪竞技场',
playerImageDescription: '戴红色围巾的柯基选手',
opponentImageDescription: '蓝色护目镜哈士奇对手',
}), }),
}); });
expect(await screen.findByText(/汪汪声浪运行态:汪汪测试杯/u)).toBeTruthy(); await waitFor(() => {
expect(window.location.pathname).toBe('/works/detail');
expect(window.location.search).toBe('?work=BB-TLEWORK1');
});
expect(await screen.findByText('分享给朋友')).toBeTruthy();
expect(screen.getByText(/作品号BB-TLEWORK1/u)).toBeTruthy();
expect(screen.queryByText(/汪汪声浪运行态:汪汪测试杯/u)).toBeNull();
});
await user.click(screen.getByRole('button', { name: '返回配置' })); test('direct bark battle runtime public code opens published runtime', async () => {
const publicWork = buildMockBarkBattleWork();
vi.mocked(listBarkBattleGallery).mockResolvedValueOnce({
items: [publicWork],
});
window.history.replaceState(
null,
'',
'/runtime/bark-battle?work=BB-C661A45F',
);
render(<TestWrapper withAuth />);
expect(await screen.findByText(/汪汪声浪运行态:汪汪公开杯/u)).toBeTruthy();
expect(screen.getByTestId('bark-battle-runtime-mode').textContent).toBe(
'published',
);
expect(screen.getByTestId('bark-battle-runtime-work-id').textContent).toBe(
'BB-C661A45F',
);
expect(screen.getByTestId('bark-battle-runtime-player-src').textContent).toBe(
'/generated-bark-battle/player.png',
);
expect(screen.queryByText('分享给朋友')).toBeNull();
});
test('bark battle form checks mud points before creating image assets', async () => {
const user = userEvent.setup();
vi.mocked(getProfileDashboard).mockResolvedValue({
walletBalance: 2,
totalPlayTimeMs: 0,
playedWorldCount: 0,
updatedAt: '2026-05-14T10:00:00.000Z',
});
render(<TestWrapper withAuth />);
await openCreateTemplateHub(user);
await user.click(screen.getByRole('tab', { name: '汪汪声浪' }));
await user.click(await screen.findByRole('button', { name: '生成草稿' }));
expect(await screen.findByText('汪汪声浪配置表单')).toBeTruthy();
expect( expect(
screen.getByRole('tab', { name: '汪汪声浪' }).getAttribute('aria-selected'), await screen.findByText('泥点不足,本次需要 3 泥点,当前 2 泥点。'),
).toBe('true'); ).toBeTruthy();
expect(createBarkBattleDraft).not.toHaveBeenCalled();
expect(generateAllBarkBattleImageAssets).not.toHaveBeenCalled();
});
test('bark battle draft is visible in draft shelf while image assets are generating', async () => {
const user = userEvent.setup();
vi.mocked(generateAllBarkBattleImageAssets).mockImplementation(
() =>
new Promise<Awaited<ReturnType<typeof generateAllBarkBattleImageAssets>>>(
() => undefined,
),
);
render(<TestWrapper withAuth />);
await openCreateTemplateHub(user);
await user.click(screen.getByRole('tab', { name: '汪汪声浪' }));
await user.click(await screen.findByRole('button', { name: '生成草稿' }));
expect(await screen.findByText('自动生成素材')).toBeTruthy();
await user.click(screen.getByRole('button', { name: '返回编辑' }));
await openDraftHub(user);
const panel = getPlatformTabPanel('saves');
expect(
await within(panel).findByRole('button', {
name: /继续创作《汪汪测试杯》/u,
}),
).toBeTruthy();
await expectDraftHubGeneratingBadgeCountAtLeast(1);
expect(listBarkBattleWorks).toHaveBeenCalled();
});
test('published bark battle stays visible when refresh temporarily returns only the duplicate draft', async () => {
const user = userEvent.setup();
vi.mocked(listBarkBattleWorks).mockResolvedValueOnce({
items: [
{
workId: 'bark-battle-work-1',
draftId: 'bark-battle-draft-1',
ownerUserId: 'user-1',
authorDisplayName: '测试玩家',
title: '汪汪测试杯',
summary: '',
themeDescription: '阳光草坪声浪竞技场',
playerImageDescription: '戴红色围巾的柯基选手',
opponentImageDescription: '蓝色护目镜哈士奇对手',
playerCharacterImageSrc: '/generated-bark-battle/player.png',
opponentCharacterImageSrc: '/generated-bark-battle/opponent.png',
uiBackgroundImageSrc: '/generated-bark-battle/background.png',
difficultyPreset: 'normal',
status: 'draft',
generationStatus: 'ready',
publishReady: true,
playCount: 0,
updatedAt: '2026-05-14T10:01:00.000Z',
publishedAt: null,
},
],
});
render(<TestWrapper withAuth />);
await openCreateTemplateHub(user);
await user.click(screen.getByRole('tab', { name: '汪汪声浪' }));
await user.click(await screen.findByRole('button', { name: '生成草稿' }));
await waitFor(() => {
expect(updateBarkBattleDraftConfig).toHaveBeenCalledWith(
expect.objectContaining({
draftId: 'bark-battle-draft-1',
workId: 'bark-battle-work-1',
}),
);
});
expect(await screen.findByText(/汪汪声浪结果页:汪汪测试杯/u)).toBeTruthy();
await user.click(screen.getByRole('button', { name: '发布' }));
await waitFor(() => {
expect(window.location.pathname).toBe('/works/detail');
});
await user.click(await screen.findByRole('button', { name: '返回' }));
await openDraftHub(user);
const panel = getPlatformTabPanel('saves');
await user.click(within(panel).getByRole('button', { name: /已发布/u }));
expect(await within(panel).findByText('汪汪测试杯')).toBeTruthy();
expect(within(panel).getByRole('button', { name: /查看详情《汪汪测试杯》/u })).toBeTruthy();
}); });
test('running match3d form generation can return to draft tab and reopen progress', async () => { test('running match3d form generation can return to draft tab and reopen progress', async () => {
@@ -4590,7 +4833,7 @@ test('completed match3d draft notice first opens trial then reopens result', asy
).toHaveProperty('textContent', '1'); ).toHaveProperty('textContent', '1');
await user.click(screen.getByRole('button', { name: '返回' })); await user.click(screen.getByRole('button', { name: '返回' }));
expect(await screen.findByText('抓大鹅结果页')).toBeTruthy(); expect(await screen.findByText('抓大鹅结果页')).toBeTruthy();
await user.click(screen.getByRole('button', { name: '返回' })); await user.click(await screen.findByRole('button', { name: '返回' }));
await openDraftHub(user); await openDraftHub(user);
expect(screen.queryByLabelText('新生成完成')).toBeNull(); expect(screen.queryByLabelText('新生成完成')).toBeNull();
@@ -4638,7 +4881,7 @@ test('completed baby object match draft viewed immediately does not keep unread
expect(screen.queryByText('宝贝识物结果页')).toBeNull(); expect(screen.queryByText('宝贝识物结果页')).toBeNull();
}); });
expect(await screen.findByLabelText('物品 A')).toBeTruthy(); expect(await screen.findByLabelText('物品 A')).toBeTruthy();
await user.click(screen.getByRole('button', { name: '返回' })); await user.click(await screen.findByRole('button', { name: '返回' }));
await openDraftHub(user); await openDraftHub(user);
expect( expect(

View File

@@ -118,6 +118,7 @@ import {
findPublicWorkForHistoryEntry, findPublicWorkForHistoryEntry,
isEdutainmentEntryEnabled, isEdutainmentEntryEnabled,
} from '../platform-entry/platformEdutainmentVisibility'; } from '../platform-entry/platformEdutainmentVisibility';
import { CustomWorldCoverArtwork } from '../CustomWorldCoverArtwork';
import { ResolvedAssetImage } from '../ResolvedAssetImage'; import { ResolvedAssetImage } from '../ResolvedAssetImage';
import { RpgEntryBrandLogo } from './RpgEntryBrandLogo'; import { RpgEntryBrandLogo } from './RpgEntryBrandLogo';
import { import {
@@ -126,6 +127,7 @@ import {
formatPlatformWorkDisplayName, formatPlatformWorkDisplayName,
formatPlatformWorkDisplayTag, formatPlatformWorkDisplayTag,
formatPlatformWorldTime, formatPlatformWorldTime,
isBarkBattleGalleryEntry,
isBigFishGalleryEntry, isBigFishGalleryEntry,
isEdutainmentGalleryEntry, isEdutainmentGalleryEntry,
isMatch3DGalleryEntry, isMatch3DGalleryEntry,
@@ -263,6 +265,7 @@ type PlatformCategoryKindFilter =
| 'match3d' | 'match3d'
| 'square-hole' | 'square-hole'
| 'visual-novel' | 'visual-novel'
| 'bark-battle'
| 'big-fish' | 'big-fish'
| 'custom-world'; | 'custom-world';
type PlatformCategorySortMode = 'composite' | 'latest' | 'play' | 'like'; type PlatformCategorySortMode = 'composite' | 'latest' | 'play' | 'like';
@@ -302,6 +305,7 @@ const PLATFORM_CATEGORY_KIND_FILTERS: Array<{
{ id: 'match3d', label: '抓鹅' }, { id: 'match3d', label: '抓鹅' },
{ id: 'square-hole', label: '方洞' }, { id: 'square-hole', label: '方洞' },
{ id: 'visual-novel', label: '视觉' }, { id: 'visual-novel', label: '视觉' },
{ id: 'bark-battle', label: '汪汪' },
{ id: 'big-fish', label: '大鱼' }, { id: 'big-fish', label: '大鱼' },
{ id: 'custom-world', label: 'RPG' }, { id: 'custom-world', label: 'RPG' },
]; ];
@@ -415,6 +419,43 @@ function ResolvedAssetBackdrop({
); );
} }
function PlatformWorkCoverArtwork({
entry,
imageSrc,
fallbackSrc,
alt,
className,
}: {
entry: PlatformPublicGalleryCard;
imageSrc?: string | null;
fallbackSrc?: string | null;
alt: string;
className: string;
}) {
if (isBarkBattleGalleryEntry(entry)) {
return (
<CustomWorldCoverArtwork
imageSrc={imageSrc}
fallbackImageSrc={fallbackSrc}
title={entry.worldName}
fallbackLabel="封面"
renderMode={entry.coverRenderMode}
characterImageSrcs={entry.coverCharacterImageSrcs}
className={className}
/>
);
}
return (
<ResolvedAssetBackdrop
src={imageSrc}
fallbackSrc={fallbackSrc}
alt={alt}
className={className}
/>
);
}
function SectionHeader({ title, detail }: { title: string; detail: string }) { function SectionHeader({ title, detail }: { title: string; detail: string }) {
return ( return (
<div className="mb-3"> <div className="mb-3">
@@ -609,8 +650,9 @@ function WorldCard({
> >
<div className="platform-public-work-card__cover relative aspect-video overflow-hidden"> <div className="platform-public-work-card__cover relative aspect-video overflow-hidden">
{coverImage ? ( {coverImage ? (
<ResolvedAssetBackdrop <PlatformWorkCoverArtwork
src={coverImage} entry={entry}
imageSrc={coverImage}
fallbackSrc={fallbackAssetCoverImage} fallbackSrc={fallbackAssetCoverImage}
alt={entry.worldName} alt={entry.worldName}
className="absolute inset-0 h-full w-full object-cover" className="absolute inset-0 h-full w-full object-cover"
@@ -713,8 +755,9 @@ function RecommendCoverOnlyCard({
className="platform-recommend-cover-only" className="platform-recommend-cover-only"
> >
{coverImage ? ( {coverImage ? (
<ResolvedAssetBackdrop <PlatformWorkCoverArtwork
src={coverImage} entry={entry}
imageSrc={coverImage}
fallbackSrc={fallbackCoverImage} fallbackSrc={fallbackCoverImage}
alt={entry.worldName} alt={entry.worldName}
className="absolute inset-0 h-full w-full object-cover" className="absolute inset-0 h-full w-full object-cover"
@@ -873,8 +916,9 @@ function RecommendRuntimePreviewCard({
data-preview-position={position} data-preview-position={position}
> >
{coverImage ? ( {coverImage ? (
<ResolvedAssetBackdrop <PlatformWorkCoverArtwork
src={coverImage} entry={entry}
imageSrc={coverImage}
alt="" alt=""
className="absolute inset-0 h-full w-full object-cover" className="absolute inset-0 h-full w-full object-cover"
/> />
@@ -1258,8 +1302,9 @@ function DesktopTrendingItem({
> >
<div className="relative h-[5.5rem] w-[4.3rem] shrink-0 overflow-hidden rounded-[1rem] border border-[var(--platform-subpanel-border)] bg-[rgba(255,255,255,0.66)]"> <div className="relative h-[5.5rem] w-[4.3rem] shrink-0 overflow-hidden rounded-[1rem] border border-[var(--platform-subpanel-border)] bg-[rgba(255,255,255,0.66)]">
{coverImage ? ( {coverImage ? (
<ResolvedAssetBackdrop <PlatformWorkCoverArtwork
src={coverImage} entry={entry}
imageSrc={coverImage}
alt={entry.worldName} alt={entry.worldName}
className="h-full w-full object-cover" className="h-full w-full object-cover"
/> />
@@ -1339,8 +1384,9 @@ function PlatformRankingItem({
<div className="platform-ranking-item__rank">{rank}</div> <div className="platform-ranking-item__rank">{rank}</div>
<div className="platform-ranking-item__cover"> <div className="platform-ranking-item__cover">
{coverImage ? ( {coverImage ? (
<ResolvedAssetBackdrop <PlatformWorkCoverArtwork
src={coverImage} entry={entry}
imageSrc={coverImage}
alt={entry.worldName} alt={entry.worldName}
className="h-full w-full object-cover" className="h-full w-full object-cover"
/> />
@@ -1406,8 +1452,9 @@ function PlatformCategoryGameItem({
> >
<div className="platform-category-game-item__cover"> <div className="platform-category-game-item__cover">
{coverImage ? ( {coverImage ? (
<ResolvedAssetBackdrop <PlatformWorkCoverArtwork
src={coverImage} entry={entry}
imageSrc={coverImage}
alt={entry.worldName} alt={entry.worldName}
className="h-full w-full object-cover" className="h-full w-full object-cover"
/> />
@@ -1732,9 +1779,11 @@ function buildPublicGalleryCardKey(entry: PlatformPublicGalleryCard) {
? 'square-hole' ? 'square-hole'
: isVisualNovelGalleryEntry(entry) : isVisualNovelGalleryEntry(entry)
? 'visual-novel' ? 'visual-novel'
: isEdutainmentGalleryEntry(entry) : isBarkBattleGalleryEntry(entry)
? `edutainment:${entry.templateId}` ? 'bark-battle'
: 'rpg'; : isEdutainmentGalleryEntry(entry)
? `edutainment:${entry.templateId}`
: 'rpg';
return `${kind}:${entry.ownerUserId}:${entry.profileId}`; return `${kind}:${entry.ownerUserId}:${entry.profileId}`;
} }
@@ -1846,9 +1895,11 @@ function describePublicGalleryCardKind(entry: PlatformPublicGalleryCard) {
? '方洞' ? '方洞'
: isVisualNovelGalleryEntry(entry) : isVisualNovelGalleryEntry(entry)
? '视觉' ? '视觉'
: isEdutainmentGalleryEntry(entry) : isBarkBattleGalleryEntry(entry)
? entry.templateName ? '汪汪'
: describePlatformThemeLabel(entry.themeMode); : isEdutainmentGalleryEntry(entry)
? entry.templateName
: describePlatformThemeLabel(entry.themeMode);
return formatPlatformWorkDisplayTag(kind); return formatPlatformWorkDisplayTag(kind);
} }
@@ -2016,6 +2067,10 @@ function getPlatformCategoryKindFilter(entry: PlatformPublicGalleryCard) {
return 'visual-novel'; return 'visual-novel';
} }
if (isBarkBattleGalleryEntry(entry)) {
return 'bark-battle';
}
if (isBigFishGalleryEntry(entry)) { if (isBigFishGalleryEntry(entry)) {
return 'big-fish'; return 'big-fish';
} }
@@ -5949,12 +6004,21 @@ export function RpgEntryHomeView({
className={`${HERO_SURFACE_CLASS} relative block overflow-hidden px-7 py-6 text-left`} className={`${HERO_SURFACE_CLASS} relative block overflow-hidden px-7 py-6 text-left`}
> >
{desktopHeroCover ? ( {desktopHeroCover ? (
<ResolvedAssetBackdrop desktopHeroEntry ? (
src={desktopHeroCover} <PlatformWorkCoverArtwork
alt="" entry={desktopHeroEntry}
aria-hidden="true" imageSrc={desktopHeroCover}
className="absolute inset-0 h-full w-full object-cover opacity-34" alt=""
/> className="absolute inset-0 h-full w-full object-cover opacity-34"
/>
) : (
<ResolvedAssetBackdrop
src={desktopHeroCover}
alt=""
aria-hidden="true"
className="absolute inset-0 h-full w-full object-cover opacity-34"
/>
)
) : null} ) : null}
<div className="absolute inset-0 bg-[var(--platform-hero-overlay-strong)]" /> <div className="absolute inset-0 bg-[var(--platform-hero-overlay-strong)]" />
<div className="relative z-10 flex min-h-[24rem] flex-col justify-between"> <div className="relative z-10 flex min-h-[24rem] flex-col justify-between">
@@ -5998,10 +6062,10 @@ export function RpgEntryHomeView({
> >
<div className="relative aspect-[1.35/1] overflow-hidden"> <div className="relative aspect-[1.35/1] overflow-hidden">
{coverImage ? ( {coverImage ? (
<ResolvedAssetBackdrop <PlatformWorkCoverArtwork
src={coverImage} entry={entry}
imageSrc={coverImage}
alt="" alt=""
aria-hidden="true"
className="h-full w-full object-cover" className="h-full w-full object-cover"
/> />
) : null} ) : null}

View File

@@ -8,9 +8,11 @@ import {
formatPlatformWorkDisplayName, formatPlatformWorkDisplayName,
formatPlatformWorkDisplayTags, formatPlatformWorkDisplayTags,
formatPlatformWorldTime, formatPlatformWorldTime,
isBarkBattleGalleryEntry,
isEdutainmentGalleryEntry, isEdutainmentGalleryEntry,
isVisualNovelGalleryEntry, isVisualNovelGalleryEntry,
mapBabyObjectMatchDraftToPlatformGalleryCard, mapBabyObjectMatchDraftToPlatformGalleryCard,
mapBarkBattleWorkToPlatformGalleryCard,
mapVisualNovelWorkToPlatformGalleryCard, mapVisualNovelWorkToPlatformGalleryCard,
type PlatformEdutainmentGalleryCard, type PlatformEdutainmentGalleryCard,
type PlatformPuzzleGalleryCard, type PlatformPuzzleGalleryCard,
@@ -235,3 +237,98 @@ test('maps baby object match draft to edutainment public card', () => {
expect(card.coverImageSrc).toBe('/apple.png'); expect(card.coverImageSrc).toBe('/apple.png');
expect(card.themeTags[0]).toBe('寓教于乐'); expect(card.themeTags[0]).toBe('寓教于乐');
}); });
test('maps bark battle work to BB public card with scene roles cover', () => {
const card = mapBarkBattleWorkToPlatformGalleryCard({
workId: 'bark-battle-work-abcdef12',
draftId: 'bark-battle-draft-1',
ownerUserId: 'user-1',
authorDisplayName: '玩家',
title: '公园声浪赛',
summary: '柯基和哈士奇比拼声浪。',
themeDescription: '傍晚公园擂台',
playerImageDescription: '红围巾柯基',
opponentImageDescription: '蓝头带哈士奇',
playerCharacterImageSrc: '/generated-bark-battle/player.png',
opponentCharacterImageSrc: '/generated-bark-battle/opponent.png',
uiBackgroundImageSrc: '/generated-bark-battle/background.png',
difficultyPreset: 'hard',
status: 'published',
generationStatus: 'ready',
publishReady: true,
playCount: 9,
recentPlayCount7d: 4,
updatedAt: '2026-05-20T00:00:00.000Z',
publishedAt: '2026-05-20T00:00:00.000Z',
});
expect(isBarkBattleGalleryEntry(card)).toBe(true);
expect(card.publicWorkCode).toBe('BB-ABCDEF12');
expect(resolvePlatformPublicWorkCode(card)).toBe('BB-ABCDEF12');
expect(card.coverImageSrc).toBe('/generated-bark-battle/background.png');
expect(card.coverRenderMode).toBe('scene_with_roles');
expect(card.coverCharacterImageSrcs).toEqual([
'/generated-bark-battle/player.png',
'/generated-bark-battle/opponent.png',
]);
expect(buildPlatformWorldDisplayTags(card, 3)).toEqual([
'汪汪声浪',
'高能',
'傍晚公园',
]);
});
test('maps bark battle public card cover from character or reference fallback', () => {
const characterCoverCard = mapBarkBattleWorkToPlatformGalleryCard({
workId: 'BB-COVER001',
draftId: 'bark-battle-draft-cover',
ownerUserId: 'user-1',
authorDisplayName: '玩家',
title: '角色封面赛',
summary: '',
themeDescription: '草地声浪挑战',
playerImageDescription: '柯基选手',
opponentImageDescription: '哈士奇对手',
playerCharacterImageSrc: '/bark/player-cover.png',
opponentCharacterImageSrc: '/bark/opponent-cover.png',
uiBackgroundImageSrc: null,
difficultyPreset: 'normal',
status: 'published',
generationStatus: 'ready',
publishReady: true,
playCount: 1,
updatedAt: '2026-05-20T00:00:00.000Z',
publishedAt: '2026-05-20T00:00:00.000Z',
});
const fallbackCoverCard = mapBarkBattleWorkToPlatformGalleryCard({
workId: 'BB-COVER002',
draftId: 'bark-battle-draft-cover-fallback',
ownerUserId: 'user-1',
authorDisplayName: '玩家',
title: '默认封面赛',
summary: '',
themeDescription: '夜市声浪挑战',
playerImageDescription: '柴犬选手',
opponentImageDescription: '机器人对手',
playerCharacterImageSrc: null,
opponentCharacterImageSrc: null,
uiBackgroundImageSrc: null,
difficultyPreset: 'easy',
status: 'published',
generationStatus: 'ready',
publishReady: true,
playCount: 1,
updatedAt: '2026-05-20T00:00:00.000Z',
publishedAt: '2026-05-20T00:00:00.000Z',
});
expect(characterCoverCard.coverImageSrc).toBe('/bark/player-cover.png');
expect(characterCoverCard.coverCharacterImageSrcs).toEqual([
'/bark/player-cover.png',
'/bark/opponent-cover.png',
]);
expect(fallbackCoverCard.coverImageSrc).toBe(
'/creation-type-references/bark-battle.webp',
);
expect(fallbackCoverCard.publicWorkCode).toBe('BB-COVER002');
});

View File

@@ -1,3 +1,4 @@
import type { BarkBattleWorkSummary } from '../../../packages/shared/src/contracts/barkBattle';
import type { BigFishWorkSummary } from '../../../packages/shared/src/contracts/bigFishWorkSummary'; import type { BigFishWorkSummary } from '../../../packages/shared/src/contracts/bigFishWorkSummary';
import type { BabyObjectMatchDraft } from '../../../packages/shared/src/contracts/edutainmentBabyObject'; import type { BabyObjectMatchDraft } from '../../../packages/shared/src/contracts/edutainmentBabyObject';
import { BABY_OBJECT_MATCH_EDUTAINMENT_TAG } from '../../../packages/shared/src/contracts/edutainmentBabyObject'; import { BABY_OBJECT_MATCH_EDUTAINMENT_TAG } from '../../../packages/shared/src/contracts/edutainmentBabyObject';
@@ -22,6 +23,7 @@ import { buildCustomWorldPlayableCharacters } from '../../data/characterPresets'
import { resolveCustomWorldCampSceneImage } from '../../data/customWorldVisuals'; import { resolveCustomWorldCampSceneImage } from '../../data/customWorldVisuals';
import { import {
buildBabyObjectMatchPublicWorkCode, buildBabyObjectMatchPublicWorkCode,
buildBarkBattlePublicWorkCode,
buildBigFishPublicWorkCode, buildBigFishPublicWorkCode,
buildMatch3DPublicWorkCode, buildMatch3DPublicWorkCode,
buildPuzzlePublicWorkCode, buildPuzzlePublicWorkCode,
@@ -43,6 +45,7 @@ export type PlatformWorldCardLike =
| PlatformSquareHoleGalleryCard | PlatformSquareHoleGalleryCard
| PlatformPuzzleGalleryCard | PlatformPuzzleGalleryCard
| PlatformVisualNovelGalleryCard | PlatformVisualNovelGalleryCard
| PlatformBarkBattleGalleryCard
| PlatformEdutainmentGalleryCard; | PlatformEdutainmentGalleryCard;
export type PlatformPuzzleGalleryCard = { export type PlatformPuzzleGalleryCard = {
@@ -196,6 +199,34 @@ export type PlatformEdutainmentGalleryCard = {
updatedAt: string; updatedAt: string;
}; };
export type PlatformBarkBattleGalleryCard = {
sourceType: 'bark-battle';
workId: string;
profileId: string;
sourceSessionId?: string | null;
publicWorkCode: string;
ownerUserId: string;
authorPublicUserCode: string | null;
authorDisplayName: string;
worldName: string;
subtitle: string;
summaryText: string;
coverImageSrc: string | null;
coverRenderMode: 'image' | 'scene_with_roles';
coverCharacterImageSrcs: string[];
themeTags: string[];
themeMode: CustomWorldGalleryCard['themeMode'];
playableNpcCount: number;
landmarkCount: number;
playCount?: number;
remixCount?: number;
likeCount?: number;
recentPlayCount7d?: number;
visibility: 'published';
publishedAt: string | null;
updatedAt: string;
};
export type PlatformPublicGalleryCard = export type PlatformPublicGalleryCard =
| CustomWorldGalleryCard | CustomWorldGalleryCard
| PlatformBigFishGalleryCard | PlatformBigFishGalleryCard
@@ -203,6 +234,7 @@ export type PlatformPublicGalleryCard =
| PlatformSquareHoleGalleryCard | PlatformSquareHoleGalleryCard
| PlatformPuzzleGalleryCard | PlatformPuzzleGalleryCard
| PlatformVisualNovelGalleryCard | PlatformVisualNovelGalleryCard
| PlatformBarkBattleGalleryCard
| PlatformEdutainmentGalleryCard; | PlatformEdutainmentGalleryCard;
export function isLibraryWorldEntry( export function isLibraryWorldEntry(
@@ -247,6 +279,12 @@ export function isEdutainmentGalleryEntry(
return 'sourceType' in entry && entry.sourceType === 'edutainment'; return 'sourceType' in entry && entry.sourceType === 'edutainment';
} }
export function isBarkBattleGalleryEntry(
entry: PlatformWorldCardLike,
): entry is PlatformBarkBattleGalleryCard {
return 'sourceType' in entry && entry.sourceType === 'bark-battle';
}
export function mapPuzzleWorkToPlatformGalleryCard( export function mapPuzzleWorkToPlatformGalleryCard(
work: PuzzleWorkSummary, work: PuzzleWorkSummary,
): PlatformPuzzleGalleryCard { ): PlatformPuzzleGalleryCard {
@@ -422,6 +460,64 @@ export function mapBabyObjectMatchDraftToPlatformGalleryCard(
}; };
} }
export function mapBarkBattleWorkToPlatformGalleryCard(
work: BarkBattleWorkSummary,
): PlatformBarkBattleGalleryCard {
const playerCharacterImageSrc = normalizePlatformOptionalImageSrc(
work.playerCharacterImageSrc,
);
const opponentCharacterImageSrc = normalizePlatformOptionalImageSrc(
work.opponentCharacterImageSrc,
);
const backgroundImageSrc = normalizePlatformOptionalImageSrc(
work.uiBackgroundImageSrc,
);
const coverImageSrc =
backgroundImageSrc ??
playerCharacterImageSrc ??
opponentCharacterImageSrc ??
'/creation-type-references/bark-battle.webp';
const coverCharacterImageSrcs = [
playerCharacterImageSrc,
opponentCharacterImageSrc,
].filter((imageSrc): imageSrc is string => Boolean(imageSrc));
const canRenderSceneWithRoles =
Boolean(backgroundImageSrc) && coverCharacterImageSrcs.length >= 2;
return {
sourceType: 'bark-battle',
workId: work.workId,
profileId: work.workId,
sourceSessionId: work.draftId ?? null,
publicWorkCode: buildBarkBattlePublicWorkCode(work.workId),
ownerUserId: work.ownerUserId,
authorPublicUserCode: null,
authorDisplayName: work.authorDisplayName,
worldName: work.title.trim() || '汪汪声浪大作战',
subtitle: `汪汪声浪 · ${describeBarkBattleDifficultyLabel(
work.difficultyPreset,
)}`,
summaryText:
work.summary.trim() ||
work.themeDescription.trim() ||
'用声音能量挑战对手。',
coverImageSrc,
coverRenderMode: canRenderSceneWithRoles ? 'scene_with_roles' : 'image',
coverCharacterImageSrcs,
themeTags: buildBarkBattleThemeTags(work),
themeMode: 'martial',
playableNpcCount: 0,
landmarkCount: 0,
playCount: work.playCount ?? 0,
remixCount: 0,
likeCount: 0,
recentPlayCount7d: work.recentPlayCount7d ?? 0,
visibility: 'published',
publishedAt: work.publishedAt ?? null,
updatedAt: work.updatedAt,
};
}
export function resolvePlatformWorldStats(entry: PlatformWorldCardLike) { export function resolvePlatformWorldStats(entry: PlatformWorldCardLike) {
return { return {
playCount: 'playCount' in entry ? (entry.playCount ?? 0) : 0, playCount: 'playCount' in entry ? (entry.playCount ?? 0) : 0,
@@ -473,6 +569,10 @@ export function resolvePlatformWorldFallbackCoverImage(
return '/creation-type-references/creative-agent.webp'; return '/creation-type-references/creative-agent.webp';
} }
if (isBarkBattleGalleryEntry(entry)) {
return '/creation-type-references/bark-battle.webp';
}
return '/creation-type-references/rpg.webp'; return '/creation-type-references/rpg.webp';
} }
@@ -634,6 +734,12 @@ export function buildPlatformWorldTags(entry: PlatformWorldCardLike) {
: [entry.templateName]; : [entry.templateName];
} }
if (isBarkBattleGalleryEntry(entry)) {
return entry.themeTags.length > 0
? entry.themeTags.slice(0, 3)
: ['汪汪声浪'];
}
if (!isLibraryWorldEntry(entry)) { if (!isLibraryWorldEntry(entry)) {
return [ return [
describePlatformThemeLabel(entry.themeMode), describePlatformThemeLabel(entry.themeMode),
@@ -724,6 +830,10 @@ export function resolvePlatformPublicWorkCode(
return entry.publicWorkCode; return entry.publicWorkCode;
} }
if (isBarkBattleGalleryEntry(entry)) {
return entry.publicWorkCode;
}
return entry.publicWorkCode; return entry.publicWorkCode;
} }
@@ -745,3 +855,31 @@ export function describePlatformThemeLabel(
return '回响'; return '回响';
} }
} }
function normalizePlatformOptionalImageSrc(value?: string | null) {
return value?.trim() || null;
}
function describeBarkBattleDifficultyLabel(
difficulty: BarkBattleWorkSummary['difficultyPreset'],
) {
switch (difficulty) {
case 'easy':
return '轻松';
case 'hard':
return '高能';
default:
return '普通';
}
}
function buildBarkBattleThemeTags(work: BarkBattleWorkSummary) {
return [
'汪汪声浪',
describeBarkBattleDifficultyLabel(work.difficultyPreset),
work.themeDescription,
]
.map((tag) => tag.trim())
.filter(Boolean)
.slice(0, 3);
}

View File

@@ -15,11 +15,67 @@ export const DEFAULT_BARK_BATTLE_CONFIG: BarkBattleConfig = {
roundDurationMs: 30_000, roundDurationMs: 30_000,
countdownMs: 3_000, countdownMs: 3_000,
drawThreshold: 12, drawThreshold: 12,
barkThreshold: 0.5, barkThreshold: 0.35,
minBarkGapMs: 300, minBarkGapMs: 150,
minBarkDurationMs: 90, minBarkDurationMs: 90,
maxBarkDurationMs: 900, maxBarkDurationMs: 900,
balanceFactor: 32, balanceFactor: 32,
calibrationMaxWaitMs: 4_000, calibrationMaxWaitMs: 4_000,
opponentBasePower: 0.22, opponentBasePower: 0.22,
}; };
const BASE_ONOMATOPOEIA = [
'轰!',
'炸场!',
'冲啊!',
'破阵!',
'爆发!',
'燃起来!',
'顶上去!',
'压过去!',
'震翻全场!',
'声浪拉满!',
] as const;
const DOG_ONOMATOPOEIA = ['轰汪!', '汪爆!', '嗷呜!'] as const;
const TECH_ONOMATOPOEIA = ['能量爆裂!', '超频!', '电光轰鸣!'] as const;
const FANTASY_ONOMATOPOEIA = ['龙吼!', '雷鸣!', '战鼓!'] as const;
type BarkBattleOnomatopoeiaSeed = {
themeDescription?: string;
playerImageDescription?: string;
opponentImageDescription?: string;
};
function pushUnique(target: string[], words: readonly string[]) {
for (const word of words) {
if (!target.includes(word)) {
target.push(word);
}
}
}
export function buildBarkBattleDefaultOnomatopoeia(
seed: BarkBattleOnomatopoeiaSeed = {},
) {
const joined = [
seed.themeDescription,
seed.playerImageDescription,
seed.opponentImageDescription,
]
.join(' ')
.toLowerCase();
const words: string[] = [];
if (/||||||shiba|husky|corgi|dog/u.test(joined)) {
pushUnique(words, DOG_ONOMATOPOEIA);
}
if (/||||||||laser|robot|mecha|cyber/u.test(joined)) {
pushUnique(words, TECH_ONOMATOPOEIA);
}
if (/||||||dragon|knight|magic/u.test(joined)) {
pushUnique(words, FANTASY_ONOMATOPOEIA);
}
pushUnique(words, BASE_ONOMATOPOEIA);
return words.slice(0, 16);
}

View File

@@ -26,6 +26,11 @@ export class BarkBattleController {
this.restart(); this.restart();
} }
updateConfigForActiveRound(config: BarkBattleConfig) {
this.config = config;
this.detector = this.createDetector();
}
finishNow() { finishNow() {
if (this.session.snapshot.phase !== 'playing') { if (this.session.snapshot.phase !== 'playing') {
this.session = this.session.startMockRound(); this.session = this.session.startMockRound();

View File

@@ -72,4 +72,22 @@ describe('BarkBattleController', () => {
expect(controller.getSnapshot().player.barkCount).toBe(2); expect(controller.getSnapshot().player.barkCount).toBe(2);
}); });
it('默认阈值和冷却降低后,真实输入能快速连续触发声浪', () => {
const controller = new BarkBattleController({
...DEFAULT_BARK_BATTLE_CONFIG,
countdownMs: 0,
});
controller.startWithMockInput();
controller.submitInputSample(0.36, 0);
controller.submitInputSample(0.38, 150);
controller.submitInputSample(0.1, 170);
controller.submitInputSample(0.39, 300);
controller.submitInputSample(0.1, 320);
expect(DEFAULT_BARK_BATTLE_CONFIG.barkThreshold).toBeLessThanOrEqual(0.35);
expect(DEFAULT_BARK_BATTLE_CONFIG.minBarkGapMs).toBeLessThanOrEqual(150);
expect(controller.getSnapshot().player.barkCount).toBe(3);
});
}); });

View File

@@ -68,23 +68,11 @@ export class BarkBattleSession {
lastEvents: [], lastEvents: [],
}; };
if (remainingMs > 0) { if (remainingMs > 0 && !hasEnergyReachedEdge(energy)) {
return new BarkBattleSession(this.config, nextSnapshot); return new BarkBattleSession(this.config, nextSnapshot);
} }
const result = buildBarkBattleResult({ return this.finishWithSnapshot(nextSnapshot);
energy,
drawThreshold: this.config.drawThreshold,
playerBarkCount: nextSnapshot.player.barkCount,
opponentBarkCount: nextSnapshot.opponent.barkCount,
});
return new BarkBattleSession(this.config, {
...nextSnapshot,
phase: 'finished',
uiState: 'finished',
winner: result.winner,
result,
});
} }
applyPlayerBark(event: BarkBattleEvent) { applyPlayerBark(event: BarkBattleEvent) {
@@ -93,15 +81,22 @@ export class BarkBattleSession {
} }
const playerPower = Math.min(1, Math.max(this.snapshot.player.power, event.peakVolume)); const playerPower = Math.min(1, Math.max(this.snapshot.player.power, event.peakVolume));
return new BarkBattleSession(this.config, { const energy = clampEnergy(this.snapshot.energy + event.peakVolume * 12);
const nextSnapshot: BarkBattleSnapshot = {
...this.snapshot, ...this.snapshot,
energy: clampEnergy(this.snapshot.energy + event.peakVolume * 12), energy,
player: { player: {
barkCount: this.snapshot.player.barkCount + 1, barkCount: this.snapshot.player.barkCount + 1,
power: playerPower, power: playerPower,
}, },
lastEvents: [event], lastEvents: [event],
}); };
if (hasEnergyReachedEdge(energy)) {
return this.finishWithSnapshot(nextSnapshot);
}
return new BarkBattleSession(this.config, nextSnapshot);
} }
failMicrophone(reason: BarkBattleSnapshot['errorReason']) { failMicrophone(reason: BarkBattleSnapshot['errorReason']) {
@@ -121,6 +116,26 @@ export class BarkBattleSession {
lastEvents, lastEvents,
}); });
} }
private finishWithSnapshot(snapshot: BarkBattleSnapshot) {
const result = buildBarkBattleResult({
energy: snapshot.energy,
drawThreshold: this.config.drawThreshold,
playerBarkCount: snapshot.player.barkCount,
opponentBarkCount: snapshot.opponent.barkCount,
});
return new BarkBattleSession(this.config, {
...snapshot,
phase: 'finished',
uiState: 'finished',
winner: result.winner,
result,
});
}
}
function hasEnergyReachedEdge(energy: number) {
return Math.abs(energy) >= 100;
} }
const MICROPHONE_STATUS_KEYS = { const MICROPHONE_STATUS_KEYS = {

View File

@@ -2,7 +2,7 @@ import { describe, expect, it } from 'vitest';
import { DEFAULT_BARK_BATTLE_CONFIG } from '../../application/BarkBattleConfig'; import { DEFAULT_BARK_BATTLE_CONFIG } from '../../application/BarkBattleConfig';
import { decideBarkBattleWinner } from '../BarkBattleScoring'; import { decideBarkBattleWinner } from '../BarkBattleScoring';
import { createBarkBattleSession } from '../BarkBattleSession'; import { BarkBattleSession, createBarkBattleSession } from '../BarkBattleSession';
describe('BarkBattleSession', () => { describe('BarkBattleSession', () => {
it('能从校准完成进入倒计时、playing 并在归零后结算', () => { it('能从校准完成进入倒计时、playing 并在归零后结算', () => {
@@ -38,6 +38,50 @@ describe('BarkBattleSession', () => {
expect(session.snapshot.player.barkCount).toBe(before.player.barkCount); expect(session.snapshot.player.barkCount).toBe(before.player.barkCount);
expect(session.snapshot.energy).toBe(before.energy); expect(session.snapshot.energy).toBe(before.energy);
}); });
it('顶部能量条被玩家推到边界时立刻结算', () => {
const config = {
...DEFAULT_BARK_BATTLE_CONFIG,
roundDurationMs: 10_000,
countdownMs: 0,
balanceFactor: 200,
opponentBasePower: 0,
};
let session = createBarkBattleSession(config).startMockRound();
session = new BarkBattleSession(config, {
...session.snapshot,
energy: 89,
});
session = session.applyPlayerBark({
atMs: 0,
peakVolume: 1,
durationMs: 120,
side: 'player',
});
expect(session.snapshot.phase).toBe('finished');
expect(session.snapshot.remainingMs).toBe(10_000);
expect(session.snapshot.energy).toBe(100);
expect(session.snapshot.result?.winner).toBe('player');
});
it('顶部能量条被对手推到边界时立刻结算', () => {
let session = createBarkBattleSession({
...DEFAULT_BARK_BATTLE_CONFIG,
roundDurationMs: 10_000,
countdownMs: 0,
balanceFactor: 200,
opponentBasePower: 1,
}).startMockRound();
session = session.tick(500);
expect(session.snapshot.phase).toBe('finished');
expect(session.snapshot.remainingMs).toBe(9_500);
expect(session.snapshot.energy).toBe(-100);
expect(session.snapshot.result?.winner).toBe('opponent');
});
}); });
describe('decideBarkBattleWinner', () => { describe('decideBarkBattleWinner', () => {

View File

@@ -11,6 +11,22 @@
overflow: hidden; overflow: hidden;
} }
.bark-battle-runtime__back-button {
position: fixed;
top: max(12px, env(safe-area-inset-top));
left: 12px;
z-index: 9;
border: 1px solid rgba(255, 247, 237, 0.46);
border-radius: 999px;
padding: 10px 14px;
color: #fff7ed;
background: rgba(15, 23, 42, 0.58);
font-size: 14px;
font-weight: 900;
box-shadow: 0 14px 34px rgba(15, 23, 42, 0.28);
backdrop-filter: blur(14px);
}
.bark-battle-hud__background-image { .bark-battle-hud__background-image {
position: absolute; position: absolute;
inset: 0; inset: 0;
@@ -116,6 +132,23 @@
background: rgba(255, 255, 255, 0.16); background: rgba(255, 255, 255, 0.16);
} }
.bark-battle-countdown {
position: absolute;
inset: 0;
z-index: 2;
display: grid;
place-items: center;
pointer-events: none;
font-size: clamp(82px, 30vw, 168px);
font-weight: 1000;
line-height: 1;
color: #fff7ed;
text-shadow:
0 10px 32px rgba(15, 23, 42, 0.66),
0 0 36px rgba(250, 204, 21, 0.56);
animation: barkBattleCountdownPulse 920ms ease-out infinite;
}
.bark-battle-controls, .bark-battle-controls,
.bark-battle-result__stats { .bark-battle-result__stats {
position: relative; position: relative;
@@ -140,6 +173,20 @@
background: linear-gradient(135deg, #facc15, #fb7185); background: linear-gradient(135deg, #facc15, #fb7185);
} }
.bark-battle-runtime-alert {
position: relative;
z-index: 1;
margin: 0 auto 10px;
width: min(92vw, 420px);
border-radius: 999px;
padding: 10px 14px;
text-align: center;
font-weight: 800;
color: #fff7ed;
background: rgba(127, 29, 29, 0.78);
box-shadow: 0 14px 32px rgba(15, 23, 42, 0.28);
}
.bark-battle-status-card, .bark-battle-status-card,
.bark-battle-result { .bark-battle-result {
margin: auto; margin: auto;
@@ -153,6 +200,38 @@
backdrop-filter: blur(18px); backdrop-filter: blur(18px);
} }
.bark-battle-result-modal {
position: fixed;
inset: 0;
z-index: 10;
display: grid;
place-items: center;
padding: max(20px, env(safe-area-inset-top)) 16px max(20px, env(safe-area-inset-bottom));
background: rgba(15, 23, 42, 0.58);
backdrop-filter: blur(10px);
}
.bark-battle-result--modal {
margin: 0;
}
.bark-battle-result__actions {
display: flex;
justify-content: center;
gap: 10px;
flex-wrap: wrap;
margin-top: 18px;
}
.bark-battle-result__actions button {
border: 0;
border-radius: 999px;
padding: 12px 18px;
color: #1f1147;
background: #fff7ed;
font-weight: 900;
}
.bark-battle-result__stats span { .bark-battle-result__stats span {
min-width: 84px; min-width: 84px;
display: grid; display: grid;
@@ -302,3 +381,10 @@
42% { opacity: 1; } 42% { opacity: 1; }
to { transform: translateY(-80px) scale(1.14); opacity: 0; } to { transform: translateY(-80px) scale(1.14); opacity: 0; }
} }
@keyframes barkBattleCountdownPulse {
from { transform: scale(0.84); opacity: 0; }
24% { opacity: 1; }
78% { transform: scale(1.06); opacity: 1; }
to { transform: scale(1.18); opacity: 0; }
}

View File

@@ -11,6 +11,10 @@ type BarkBattleHudProps = {
onMockBark?: () => void; onMockBark?: () => void;
onMockQuiet?: () => void; onMockQuiet?: () => void;
onRestart?: () => void; onRestart?: () => void;
enableMockControls?: boolean;
runtimeError?: string | null;
playerBurstText?: string;
opponentBurstText?: string;
playerCharacterImageSrc?: string | null; playerCharacterImageSrc?: string | null;
opponentCharacterImageSrc?: string | null; opponentCharacterImageSrc?: string | null;
uiBackgroundImageSrc?: string | null; uiBackgroundImageSrc?: string | null;
@@ -36,6 +40,10 @@ export function BarkBattleHud({
onMockBark, onMockBark,
onMockQuiet, onMockQuiet,
onRestart, onRestart,
enableMockControls = true,
runtimeError = null,
playerBurstText = '汪',
opponentBurstText = '反击',
playerCharacterImageSrc, playerCharacterImageSrc,
opponentCharacterImageSrc, opponentCharacterImageSrc,
uiBackgroundImageSrc, uiBackgroundImageSrc,
@@ -43,6 +51,8 @@ export function BarkBattleHud({
const playerWidth = `${Math.round(((snapshot.energy + 100) / 200) * 100)}%`; const playerWidth = `${Math.round(((snapshot.energy + 100) / 200) * 100)}%`;
const opponentWidth = `${Math.round(((100 - snapshot.energy) / 200) * 100)}%`; const opponentWidth = `${Math.round(((100 - snapshot.energy) / 200) * 100)}%`;
const isUnavailable = snapshot.phase === 'unavailable'; const isUnavailable = snapshot.phase === 'unavailable';
const isCountingDown = snapshot.phase === 'countdown';
const countdownSeconds = Math.ceil(snapshot.countdownMs / 1000);
return ( return (
<section className="bark-battle-hud" aria-label="汪汪声浪大作战"> <section className="bark-battle-hud" aria-label="汪汪声浪大作战">
@@ -80,8 +90,10 @@ export function BarkBattleHud({
</div> </div>
) : ( ) : (
<div className="bark-battle-arena" aria-label="竖屏声浪竞技场"> <div className="bark-battle-arena" aria-label="竖屏声浪竞技场">
<div key={`opponent-${opponentPulseKey}`} className="bark-battle-dog bark-battle-dog--opponent" aria-label="对手狗狗面向屏幕"> <div key={`opponent-${opponentPulseKey}`} className="bark-battle-dog bark-battle-dog--opponent" aria-label="对手声浪角色面向屏幕">
<span className="bark-battle-dog__burst" aria-hidden="true"></span> <span className="bark-battle-dog__burst" aria-hidden="true">
{opponentBurstText}
</span>
<span className="bark-battle-dog__body"> <span className="bark-battle-dog__body">
{opponentCharacterImageSrc ? ( {opponentCharacterImageSrc ? (
<ResolvedAssetImage <ResolvedAssetImage
@@ -96,8 +108,10 @@ export function BarkBattleHud({
<span className="bark-battle-dog__label"> · {snapshot.opponent.barkCount}</span> <span className="bark-battle-dog__label"> · {snapshot.opponent.barkCount}</span>
</div> </div>
<div className="bark-battle-vs">VS</div> <div className="bark-battle-vs">VS</div>
<div key={`player-${playerPulseKey}`} className="bark-battle-dog bark-battle-dog--player" aria-label="玩家狗狗背对屏幕"> <div key={`player-${playerPulseKey}`} className="bark-battle-dog bark-battle-dog--player" aria-label="玩家声浪角色背对屏幕">
<span className="bark-battle-dog__burst" aria-hidden="true"></span> <span className="bark-battle-dog__burst" aria-hidden="true">
{playerBurstText}
</span>
<span className="bark-battle-dog__body"> <span className="bark-battle-dog__body">
{playerCharacterImageSrc ? ( {playerCharacterImageSrc ? (
<ResolvedAssetImage <ResolvedAssetImage
@@ -114,15 +128,32 @@ export function BarkBattleHud({
</div> </div>
)} )}
{isCountingDown ? (
<div
className="bark-battle-countdown"
aria-label={`倒计时 ${countdownSeconds}`}
>
{countdownSeconds}
</div>
) : null}
{runtimeError ? (
<div className="bark-battle-runtime-alert" role="alert">
{runtimeError}
</div>
) : null}
<footer className="bark-battle-controls"> <footer className="bark-battle-controls">
{snapshot.phase === 'permission' ? ( {snapshot.phase === 'permission' ? (
<button className="bark-battle-primary-button" type="button" onClick={onStartMicrophone}> <button className="bark-battle-primary-button" type="button" onClick={onStartMicrophone}>
</button> </button>
) : null} ) : null}
<button type="button" onPointerDown={onMockBark} onPointerUp={onMockQuiet} onClick={onMockBark}> {enableMockControls ? (
<button type="button" onPointerDown={onMockBark} onPointerUp={onMockQuiet} onClick={onMockBark}>
</button>
</button>
) : null}
{snapshot.phase === 'finished' ? ( {snapshot.phase === 'finished' ? (
<button type="button" onClick={onRestart}></button> <button type="button" onClick={onRestart}></button>
) : null} ) : null}

View File

@@ -1,9 +1,19 @@
import { useCallback, useEffect, useMemo, useRef, useState } from 'react'; import { useCallback, useEffect, useMemo, useRef, useState } from 'react';
import type { BarkBattlePublishedConfig } from '../../../../packages/shared/src/contracts/barkBattle'; import type {
import { useResolvedAssetReadUrl } from '../../../hooks/useResolvedAssetReadUrl'; BarkBattleDerivedMetrics,
BarkBattlePublishedConfig,
BarkBattleRuntimeConfig,
BarkBattleRunStartResponse,
BarkBattleServerResult,
} from '../../../../packages/shared/src/contracts/barkBattle';
import {
finishBarkBattleRun,
startBarkBattleRun,
} from '../../../services/bark-battle-runtime';
import { import {
type BarkBattleConfig, type BarkBattleConfig,
buildBarkBattleDefaultOnomatopoeia,
DEFAULT_BARK_BATTLE_CONFIG, DEFAULT_BARK_BATTLE_CONFIG,
} from '../application/BarkBattleConfig'; } from '../application/BarkBattleConfig';
import { BarkBattleController } from '../application/BarkBattleController'; import { BarkBattleController } from '../application/BarkBattleController';
@@ -13,12 +23,14 @@ import {
startBrowserMicrophoneSampler, startBrowserMicrophoneSampler,
} from '../infrastructure/BrowserMicrophoneInput'; } from '../infrastructure/BrowserMicrophoneInput';
import { BarkBattleHud } from './BarkBattleHud'; import { BarkBattleHud } from './BarkBattleHud';
import { BarkBattleResultPanel } from './BarkBattleResultPanel';
type BarkBattleRuntimeMode = 'draft' | 'published';
type BarkBattleRuntimeShellProps = { type BarkBattleRuntimeShellProps = {
title?: string; title?: string;
workId?: string; workId?: string;
publishedConfig?: BarkBattlePublishedConfig | null; publishedConfig?: BarkBattlePublishedConfig | null;
runtimeMode?: BarkBattleRuntimeMode;
onExit?: () => void; onExit?: () => void;
}; };
@@ -27,6 +39,66 @@ type DebugEvent = {
text: string; text: string;
}; };
type BarkBattleActiveRun = Pick<
BarkBattleRunStartResponse,
| 'runId'
| 'runToken'
| 'workId'
| 'configVersion'
| 'rulesetVersion'
| 'difficultyPreset'
| 'serverStartedAt'
>;
type BarkBattleMetricAccumulator = {
sampleCount: number;
volumeSum: number;
maxVolume: number;
comboMax: number;
currentCombo: number;
};
const BARK_BATTLE_CLIENT_RUNTIME_VERSION = 'bark-battle-web-v1';
function createMetricAccumulator(): BarkBattleMetricAccumulator {
return {
sampleCount: 0,
volumeSum: 0,
maxVolume: 0,
comboMax: 0,
currentCombo: 0,
};
}
function normalizeMetricVolume(volume: number) {
if (!Number.isFinite(volume)) {
return 0;
}
return Math.max(0, Math.min(1, volume));
}
function resolveClientResult(
winner: 'player' | 'opponent' | 'draw' | null,
): BarkBattleServerResult {
if (winner === 'player') {
return 'player_win';
}
if (winner === 'opponent') {
return 'opponent_win';
}
return 'draw';
}
function resolveResultTitle(winner: 'player' | 'opponent' | 'draw' | null) {
if (winner === 'player') {
return '汪力压制成功';
}
if (winner === 'opponent') {
return '对手声浪更强';
}
return '势均力敌';
}
const DEBUG_CONFIG_FIELDS: Array<{ const DEBUG_CONFIG_FIELDS: Array<{
key: keyof Pick< key: keyof Pick<
BarkBattleConfig, BarkBattleConfig,
@@ -43,7 +115,13 @@ const DEBUG_CONFIG_FIELDS: Array<{
max: number; max: number;
step: number; step: number;
}> = [ }> = [
{ key: 'roundDurationMs', label: '局长(ms)', min: 1000, max: 60000, step: 1000 }, {
key: 'roundDurationMs',
label: '局长(ms)',
min: 1000,
max: 60000,
step: 1000,
},
{ key: 'countdownMs', label: '倒计时(ms)', min: 0, max: 5000, step: 500 }, { key: 'countdownMs', label: '倒计时(ms)', min: 0, max: 5000, step: 500 },
{ key: 'drawThreshold', label: '平局阈值', min: 0, max: 40, step: 1 }, { key: 'drawThreshold', label: '平局阈值', min: 0, max: 40, step: 1 },
{ key: 'barkThreshold', label: '叫声阈值', min: 0.1, max: 1, step: 0.05 }, { key: 'barkThreshold', label: '叫声阈值', min: 0.1, max: 1, step: 0.05 },
@@ -64,8 +142,13 @@ const MICROPHONE_FAILURE_REASONS = new Set<MicrophoneFailureReason>([
'unknown', 'unknown',
]); ]);
function isMicrophoneFailureReason(reason: unknown): reason is MicrophoneFailureReason { function isMicrophoneFailureReason(
return typeof reason === 'string' && MICROPHONE_FAILURE_REASONS.has(reason as MicrophoneFailureReason); reason: unknown,
): reason is MicrophoneFailureReason {
return (
typeof reason === 'string' &&
MICROPHONE_FAILURE_REASONS.has(reason as MicrophoneFailureReason)
);
} }
function buildRuntimeConfigFromPublishedConfig( function buildRuntimeConfigFromPublishedConfig(
@@ -79,9 +162,9 @@ function buildRuntimeConfigFromPublishedConfig(
BarkBattlePublishedConfig['difficultyPreset'], BarkBattlePublishedConfig['difficultyPreset'],
Partial<BarkBattleConfig> Partial<BarkBattleConfig>
> = { > = {
easy: { barkThreshold: 0.42, opponentBasePower: 0.16, drawThreshold: 10 }, easy: { opponentBasePower: 0.16 },
normal: { barkThreshold: 0.5, opponentBasePower: 0.22, drawThreshold: 12 }, normal: { opponentBasePower: 0.22 },
hard: { barkThreshold: 0.58, opponentBasePower: 0.3, drawThreshold: 14 }, hard: { opponentBasePower: 0.3 },
}; };
return { return {
@@ -90,10 +173,99 @@ function buildRuntimeConfigFromPublishedConfig(
}; };
} }
function buildRuntimeConfigFromServerConfig(
runtimeConfig: BarkBattleRuntimeConfig,
): BarkBattleConfig {
const baseConfig = buildRuntimeConfigFromPublishedConfig({
workId: runtimeConfig.workId,
draftId: null,
configVersion: runtimeConfig.configVersion,
rulesetVersion: runtimeConfig.rulesetVersion,
playTypeId: runtimeConfig.playTypeId,
title: '',
description: '',
themeDescription: runtimeConfig.themeDescription,
playerImageDescription: runtimeConfig.playerImageDescription,
opponentImageDescription: runtimeConfig.opponentImageDescription,
onomatopoeia: runtimeConfig.onomatopoeia,
playerCharacterImageSrc: runtimeConfig.playerCharacterImageSrc,
opponentCharacterImageSrc: runtimeConfig.opponentCharacterImageSrc,
uiBackgroundImageSrc: runtimeConfig.uiBackgroundImageSrc,
difficultyPreset: runtimeConfig.difficultyPreset,
updatedAt: runtimeConfig.updatedAt,
publishedAt: runtimeConfig.updatedAt,
});
return {
...baseConfig,
roundDurationMs: runtimeConfig.durationMs,
drawThreshold: runtimeConfig.drawThreshold,
minBarkGapMs: runtimeConfig.minBarkGapMs,
};
}
function normalizeOnomatopoeiaPool(
publishedConfig?: BarkBattlePublishedConfig | null,
) {
const custom = publishedConfig?.onomatopoeia
?.map((word) => word.trim())
.filter(Boolean)
.slice(0, 24);
if (custom?.length) {
return custom;
}
return buildBarkBattleDefaultOnomatopoeia({
themeDescription: publishedConfig?.themeDescription,
playerImageDescription: publishedConfig?.playerImageDescription,
opponentImageDescription: publishedConfig?.opponentImageDescription,
});
}
function buildPublishedConfigFromServerRuntimeConfig(
current: BarkBattlePublishedConfig,
runtimeConfig: BarkBattleRuntimeConfig,
): BarkBattlePublishedConfig {
return {
...current,
workId: runtimeConfig.workId,
configVersion: runtimeConfig.configVersion,
rulesetVersion: runtimeConfig.rulesetVersion,
playTypeId: runtimeConfig.playTypeId,
themeDescription: runtimeConfig.themeDescription,
playerImageDescription: runtimeConfig.playerImageDescription,
opponentImageDescription: runtimeConfig.opponentImageDescription,
onomatopoeia: runtimeConfig.onomatopoeia,
playerCharacterImageSrc: runtimeConfig.playerCharacterImageSrc,
opponentCharacterImageSrc: runtimeConfig.opponentCharacterImageSrc,
uiBackgroundImageSrc: runtimeConfig.uiBackgroundImageSrc,
difficultyPreset: runtimeConfig.difficultyPreset,
updatedAt: runtimeConfig.updatedAt,
};
}
function pickRandomOnomatopoeia(
pool: readonly string[],
previous: string,
) {
if (!pool.length) {
return '炸场!';
}
if (pool.length === 1) {
return pool[0] ?? '炸场!';
}
const candidates = pool.filter((word) => word !== previous);
const activePool = candidates.length ? candidates : pool;
const index = Math.min(
activePool.length - 1,
Math.floor(Math.random() * activePool.length),
);
return activePool[index] ?? activePool[0] ?? '炸场!';
}
export function BarkBattleRuntimeShell({ export function BarkBattleRuntimeShell({
title = '汪汪声浪大作战', title = '汪汪声浪大作战',
workId, workId,
publishedConfig, publishedConfig,
runtimeMode = 'draft',
onExit, onExit,
}: BarkBattleRuntimeShellProps) { }: BarkBattleRuntimeShellProps) {
const initialConfig = useMemo( const initialConfig = useMemo(
@@ -101,6 +273,7 @@ export function BarkBattleRuntimeShell({
[publishedConfig], [publishedConfig],
); );
const [config, setConfig] = useState(initialConfig); const [config, setConfig] = useState(initialConfig);
const runtimeConfigRef = useRef(initialConfig);
const controllerRef = useRef<BarkBattleController | null>(null); const controllerRef = useRef<BarkBattleController | null>(null);
if (!controllerRef.current) { if (!controllerRef.current) {
controllerRef.current = new BarkBattleController(config); controllerRef.current = new BarkBattleController(config);
@@ -108,31 +281,52 @@ export function BarkBattleRuntimeShell({
const controller = controllerRef.current; const controller = controllerRef.current;
const [snapshot, setSnapshot] = useState(() => controller.getSnapshot()); const [snapshot, setSnapshot] = useState(() => controller.getSnapshot());
const [particleText, setParticleText] = useState(''); const [particleText, setParticleText] = useState('');
const [inputMode, setInputMode] = useState<'mock' | 'microphone'>('mock'); const replacementConfig = publishedConfig ?? null;
const [activePublishedConfig, setActivePublishedConfig] = useState(
replacementConfig,
);
const onomatopoeiaPool = useMemo(
() => normalizeOnomatopoeiaPool(activePublishedConfig),
[activePublishedConfig],
);
const [playerBurstText, setPlayerBurstText] = useState(
() => onomatopoeiaPool[0] ?? '炸场!',
);
const isPublishedRuntime =
runtimeMode === 'published' && Boolean(replacementConfig?.workId);
const [inputMode, setInputMode] = useState<'mock' | 'microphone'>(
isPublishedRuntime ? 'microphone' : 'mock',
);
useEffect(() => {
setInputMode(isPublishedRuntime ? 'microphone' : 'mock');
}, [isPublishedRuntime]);
const [liveInputVolume, setLiveInputVolume] = useState(0); const [liveInputVolume, setLiveInputVolume] = useState(0);
const [isDebugExpanded, setIsDebugExpanded] = useState(false); const [isDebugExpanded, setIsDebugExpanded] = useState(false);
const [playerPulseKey, setPlayerPulseKey] = useState(0); const [playerPulseKey, setPlayerPulseKey] = useState(0);
const [opponentPulseKey, setOpponentPulseKey] = useState(0); const [opponentPulseKey, setOpponentPulseKey] = useState(0);
const [debugEvents, setDebugEvents] = useState<DebugEvent[]>([]); const [debugEvents, setDebugEvents] = useState<DebugEvent[]>([]);
const barkAudioRef = useRef<HTMLAudioElement | null>(null); const [runtimeError, setRuntimeError] = useState<string | null>(null);
const heldRef = useRef(false); const heldRef = useRef(false);
const lastPlayerBarkCountRef = useRef(0); const lastPlayerBarkCountRef = useRef(0);
const lastOpponentPowerRef = useRef(0); const lastOpponentPowerRef = useRef(0);
const debugEventIdRef = useRef(0); const debugEventIdRef = useRef(0);
const microphoneSamplerRef = useRef<BrowserMicrophoneSampler | null>(null); const microphoneSamplerRef = useRef<BrowserMicrophoneSampler | null>(null);
const replacementConfig = publishedConfig ?? null; const activeRunRef = useRef<BarkBattleActiveRun | null>(null);
const { resolvedUrl: resolvedBarkSoundSrc } = useResolvedAssetReadUrl( const pendingRunStartRef = useRef<Promise<boolean> | null>(null);
replacementConfig?.barkSoundSrc ?? null, const runStartedAtRef = useRef<string | null>(null);
const submittedRunIdsRef = useRef<Set<string>>(new Set());
const autoStartMicrophoneAttemptedRef = useRef(false);
const metricAccumulatorRef = useRef<BarkBattleMetricAccumulator>(
createMetricAccumulator(),
); );
const lastOnomatopoeiaRef = useRef('');
// 中文注释:正式公开 runtime 面向玩家只保留真实麦克风入口mock 与调参面板只服务草稿试玩。
const shouldShowDebugPanel = !isPublishedRuntime;
const playBarkSound = useCallback(() => { useEffect(() => {
const audio = barkAudioRef.current; lastOnomatopoeiaRef.current = '';
if (!audio || !resolvedBarkSoundSrc) { setPlayerBurstText(onomatopoeiaPool[0] ?? '炸场!');
return; }, [onomatopoeiaPool]);
}
audio.currentTime = 0;
void audio.play().catch(() => {});
}, [resolvedBarkSoundSrc]);
const appendDebugEvent = useCallback((text: string) => { const appendDebugEvent = useCallback((text: string) => {
debugEventIdRef.current += 1; debugEventIdRef.current += 1;
@@ -140,72 +334,296 @@ export function BarkBattleRuntimeShell({
setDebugEvents((current) => [event, ...current].slice(0, 5)); setDebugEvents((current) => [event, ...current].slice(0, 5));
}, []); }, []);
const flashOnomatopoeia = useCallback(() => {
const nextWord = pickRandomOnomatopoeia(
onomatopoeiaPool,
lastOnomatopoeiaRef.current,
);
lastOnomatopoeiaRef.current = nextWord;
setPlayerBurstText(nextWord);
setParticleText(nextWord);
window.setTimeout(() => setParticleText(''), 520);
}, [onomatopoeiaPool]);
const resetRuntimeRunState = useCallback(() => {
activeRunRef.current = null;
pendingRunStartRef.current = null;
runStartedAtRef.current = null;
submittedRunIdsRef.current = new Set();
metricAccumulatorRef.current = createMetricAccumulator();
setRuntimeError(null);
}, []);
const recordRuntimeSample = useCallback((volume: number) => {
const normalized = normalizeMetricVolume(volume);
const metrics = metricAccumulatorRef.current;
metrics.sampleCount += 1;
metrics.volumeSum += normalized;
metrics.maxVolume = Math.max(metrics.maxVolume, normalized);
}, []);
const recordRuntimeTrigger = useCallback((volume: number) => {
const normalized = normalizeMetricVolume(volume);
const metrics = metricAccumulatorRef.current;
metrics.currentCombo += 1;
metrics.comboMax = Math.max(metrics.comboMax, metrics.currentCombo);
metrics.maxVolume = Math.max(metrics.maxVolume, normalized);
}, []);
const buildDerivedMetrics = useCallback((): BarkBattleDerivedMetrics => {
const metrics = metricAccumulatorRef.current;
const nextSnapshot = controller.getSnapshot();
return {
triggerCount: nextSnapshot.player.barkCount,
maxVolume: Number(metrics.maxVolume.toFixed(3)),
averageVolume: Number(
(metrics.sampleCount
? metrics.volumeSum / metrics.sampleCount
: 0
).toFixed(3),
),
finalEnergy: Number(nextSnapshot.energy.toFixed(2)),
comboMax: metrics.comboMax,
};
}, [controller]);
const submitFinishedRunIfNeeded = useCallback(
(nextSnapshot = controller.getSnapshot()) => {
if (!isPublishedRuntime || nextSnapshot.phase !== 'finished') {
return;
}
const activeRun = activeRunRef.current;
if (!activeRun || submittedRunIdsRef.current.has(activeRun.runId)) {
return;
}
submittedRunIdsRef.current.add(activeRun.runId);
const finishedAt = new Date().toISOString();
const startedAt =
runStartedAtRef.current ?? activeRun.serverStartedAt ?? finishedAt;
const durationMs = Math.max(
0,
runtimeConfigRef.current.roundDurationMs - nextSnapshot.remainingMs,
);
void finishBarkBattleRun(activeRun.runId, {
runId: activeRun.runId,
runToken: activeRun.runToken,
workId: activeRun.workId,
configVersion: activeRun.configVersion,
rulesetVersion: activeRun.rulesetVersion,
difficultyPreset: activeRun.difficultyPreset,
clientStartedAt: startedAt,
clientFinishedAt: finishedAt,
durationMs,
derivedMetrics: buildDerivedMetrics(),
clientResult: resolveClientResult(nextSnapshot.winner),
clientRuntimeVersion: BARK_BATTLE_CLIENT_RUNTIME_VERSION,
})
.then(() => {
appendDebugEvent('正式成绩已提交');
})
.catch((error) => {
setRuntimeError(
error instanceof Error ? error.message : '提交正式成绩失败',
);
appendDebugEvent('正式成绩提交失败');
});
},
[
appendDebugEvent,
buildDerivedMetrics,
controller,
isPublishedRuntime,
],
);
const startFormalRunIfNeeded = useCallback(async (): Promise<boolean> => {
if (!isPublishedRuntime || !replacementConfig?.workId) {
return true;
}
if (activeRunRef.current) {
return true;
}
if (!pendingRunStartRef.current) {
pendingRunStartRef.current = (async () => {
try {
setRuntimeError(null);
const started = await startBarkBattleRun(replacementConfig.workId, {
// 中文注释:公开广场摘要可能滞后于已发布运行配置;正式开局以服务端当前发布快照为准。
sourceRoute:
typeof window === 'undefined'
? 'bark-battle-runtime'
: window.location.pathname,
clientRuntimeVersion: BARK_BATTLE_CLIENT_RUNTIME_VERSION,
});
const serverRuntimeConfig = buildRuntimeConfigFromServerConfig(
started.runtimeConfig,
);
// 中文注释:公开卡片可能只带摘要;正式开局后用服务端 runtimeConfig 刷新拟声词和素材。
setActivePublishedConfig((current) =>
buildPublishedConfigFromServerRuntimeConfig(
current ?? replacementConfig,
started.runtimeConfig,
),
);
runtimeConfigRef.current = serverRuntimeConfig;
controller.updateConfigForActiveRound(serverRuntimeConfig);
activeRunRef.current = {
runId: started.runId,
runToken: started.runToken,
workId: started.workId,
configVersion: started.configVersion,
rulesetVersion: started.rulesetVersion,
difficultyPreset: started.difficultyPreset,
serverStartedAt: started.serverStartedAt,
};
runStartedAtRef.current = new Date().toISOString();
appendDebugEvent(`正式对局已登记:${started.runId}`);
return true;
} catch (error) {
const message =
error instanceof Error ? error.message : '启动正式对局失败';
setRuntimeError(message);
appendDebugEvent(message);
return false;
} finally {
pendingRunStartRef.current = null;
}
})();
}
return pendingRunStartRef.current ?? Promise.resolve(true);
}, [appendDebugEvent, controller, isPublishedRuntime, replacementConfig]);
const syncSnapshot = useCallback(() => { const syncSnapshot = useCallback(() => {
const nextSnapshot = controller.getSnapshot(); const nextSnapshot = controller.getSnapshot();
if (nextSnapshot.player.barkCount > lastPlayerBarkCountRef.current) { if (nextSnapshot.player.barkCount > lastPlayerBarkCountRef.current) {
setPlayerPulseKey((current) => current + 1); setPlayerPulseKey((current) => current + 1);
playBarkSound(); recordRuntimeTrigger(nextSnapshot.player.power);
appendDebugEvent(`玩家叫声触发 #${nextSnapshot.player.barkCount} · 能量 ${Math.round(nextSnapshot.energy)}`); flashOnomatopoeia();
appendDebugEvent(
`玩家叫声触发 #${nextSnapshot.player.barkCount} · 能量 ${Math.round(nextSnapshot.energy)}`,
);
} }
if (nextSnapshot.phase === 'playing' && Math.abs(nextSnapshot.opponent.power - lastOpponentPowerRef.current) >= 0.08) { if (
nextSnapshot.phase === 'playing' &&
Math.abs(nextSnapshot.opponent.power - lastOpponentPowerRef.current) >=
0.08
) {
setOpponentPulseKey((current) => current + 1); setOpponentPulseKey((current) => current + 1);
playBarkSound(); appendDebugEvent(
appendDebugEvent(`对手反击强度 ${(nextSnapshot.opponent.power * 100).toFixed(0)}%`); `对手反击强度 ${(nextSnapshot.opponent.power * 100).toFixed(0)}%`,
);
} }
lastPlayerBarkCountRef.current = nextSnapshot.player.barkCount; lastPlayerBarkCountRef.current = nextSnapshot.player.barkCount;
lastOpponentPowerRef.current = nextSnapshot.opponent.power; lastOpponentPowerRef.current = nextSnapshot.opponent.power;
setSnapshot(nextSnapshot); setSnapshot(nextSnapshot);
}, [appendDebugEvent, controller, playBarkSound]); submitFinishedRunIfNeeded(nextSnapshot);
}, [
appendDebugEvent,
controller,
flashOnomatopoeia,
recordRuntimeTrigger,
submitFinishedRunIfNeeded,
]);
const stopMicrophone = useCallback(() => { const stopMicrophone = useCallback(() => {
microphoneSamplerRef.current?.stop(); microphoneSamplerRef.current?.stop();
microphoneSamplerRef.current = null; microphoneSamplerRef.current = null;
}, []); }, []);
// 中文注释:领域层沿用 startMockRound 表示“进入对局倒计时”,正式/草稿输入差异由外层 sampler 控制。
const startRuntimeRound = useCallback(() => {
controller.startWithMockInput();
}, [controller]);
useEffect(() => { useEffect(() => {
setConfig(initialConfig); setConfig(initialConfig);
runtimeConfigRef.current = initialConfig;
controller.updateConfig(initialConfig); controller.updateConfig(initialConfig);
syncSnapshot(); setActivePublishedConfig(replacementConfig);
}, [controller, initialConfig, syncSnapshot]); }, [controller, initialConfig, replacementConfig]);
const startMicrophone = useCallback(async () => { const startMicrophone = useCallback(async () => {
stopMicrophone(); stopMicrophone();
let shouldAcceptMicrophoneSamples = false;
try { try {
controller.startWithMockInput();
const sampler = await startBrowserMicrophoneSampler((volume, atMs) => { const sampler = await startBrowserMicrophoneSampler((volume, atMs) => {
if (!shouldAcceptMicrophoneSamples) {
return;
}
setLiveInputVolume(volume); setLiveInputVolume(volume);
recordRuntimeSample(volume);
if (volume >= config.barkThreshold) { if (volume >= config.barkThreshold) {
appendDebugEvent(`麦克风输入 ${(volume * 100).toFixed(0)}%`); appendDebugEvent(`麦克风输入 ${(volume * 100).toFixed(0)}%`);
} }
controller.submitInputSample(volume, atMs); controller.submitInputSample(
volume,
controller.getSampleClockMs() + atMs,
);
}); });
if (!(await startFormalRunIfNeeded())) {
sampler.stop();
return;
}
startRuntimeRound();
microphoneSamplerRef.current = sampler; microphoneSamplerRef.current = sampler;
setInputMode('microphone'); setInputMode('microphone');
shouldAcceptMicrophoneSamples = true;
appendDebugEvent('真实麦克风已开启'); appendDebugEvent('真实麦克风已开启');
syncSnapshot(); syncSnapshot();
} catch (error) { } catch (error) {
const reason = error && typeof error === 'object' && 'reason' in error ? error.reason : 'unknown'; const reason =
const failureReason = isMicrophoneFailureReason(reason) ? reason : 'unknown'; error && typeof error === 'object' && 'reason' in error
? error.reason
: 'unknown';
const failureReason = isMicrophoneFailureReason(reason)
? reason
: 'unknown';
controller.failMicrophone(failureReason); controller.failMicrophone(failureReason);
appendDebugEvent(`麦克风不可用:${failureReason}`); appendDebugEvent(`麦克风不可用:${failureReason}`);
syncSnapshot(); syncSnapshot();
} }
}, [appendDebugEvent, config.barkThreshold, controller, stopMicrophone, syncSnapshot]); }, [
appendDebugEvent,
config.barkThreshold,
controller,
recordRuntimeSample,
startFormalRunIfNeeded,
startRuntimeRound,
stopMicrophone,
syncSnapshot,
]);
useEffect(() => {
if (
!isPublishedRuntime ||
snapshot.phase !== 'permission' ||
autoStartMicrophoneAttemptedRef.current
) {
return;
}
// 中文注释:公开作品从详情页“启动”进入运行态后立即申请麦克风,授权成功后直接进入倒计时。
autoStartMicrophoneAttemptedRef.current = true;
void startMicrophone();
}, [isPublishedRuntime, snapshot.phase, startMicrophone]);
useEffect(() => stopMicrophone, [stopMicrophone]); useEffect(() => stopMicrophone, [stopMicrophone]);
useEffect(() => { useEffect(() => {
runtimeConfigRef.current = config;
controller.updateConfig(config); controller.updateConfig(config);
syncSnapshot(); setSnapshot(controller.getSnapshot());
}, [config, controller, syncSnapshot]); }, [config, controller]);
useEffect(() => { useEffect(() => {
const timer = window.setInterval(() => { const timer = window.setInterval(() => {
controller.tick(100); controller.tick(100);
if (inputMode === 'mock') { if (inputMode === 'mock' && !isPublishedRuntime) {
if (heldRef.current) { if (heldRef.current) {
recordRuntimeSample(0.88);
controller.submitMockSample(0.88); controller.submitMockSample(0.88);
} else { } else {
recordRuntimeSample(0.12);
controller.submitMockSample(0.12); controller.submitMockSample(0.12);
setLiveInputVolume(0); setLiveInputVolume(0);
} }
@@ -213,31 +631,52 @@ export function BarkBattleRuntimeShell({
syncSnapshot(); syncSnapshot();
}, 100); }, 100);
return () => window.clearInterval(timer); return () => window.clearInterval(timer);
}, [controller, inputMode, syncSnapshot]); }, [
controller,
inputMode,
isPublishedRuntime,
recordRuntimeSample,
syncSnapshot,
]);
const restart = () => { const restart = () => {
heldRef.current = false; heldRef.current = false;
stopMicrophone(); stopMicrophone();
setInputMode('mock'); setInputMode(isPublishedRuntime ? 'microphone' : 'mock');
setLiveInputVolume(0); setLiveInputVolume(0);
controller.restart(); controller.restart();
setParticleText(''); setParticleText('');
setPlayerBurstText(onomatopoeiaPool[0] ?? '炸场!');
setDebugEvents([]); setDebugEvents([]);
resetRuntimeRunState();
autoStartMicrophoneAttemptedRef.current = false;
lastPlayerBarkCountRef.current = 0; lastPlayerBarkCountRef.current = 0;
lastOpponentPowerRef.current = 0; lastOpponentPowerRef.current = 0;
syncSnapshot(); syncSnapshot();
}; };
const startMock = () => { const startMock = async () => {
if (isPublishedRuntime) {
const message = '正式对局需要使用真实麦克风';
setRuntimeError(message);
appendDebugEvent(message);
return;
}
stopMicrophone(); stopMicrophone();
setInputMode('mock'); setInputMode('mock');
setLiveInputVolume(0); setLiveInputVolume(0);
controller.startWithMockInput(); startRuntimeRound();
appendDebugEvent('开始 mock 对局(不会请求浏览器麦克风权限)'); appendDebugEvent('开始 mock 对局(不会请求浏览器麦克风权限)');
syncSnapshot(); syncSnapshot();
}; };
const finishNow = () => { const finishNow = () => {
if (isPublishedRuntime && !activeRunRef.current) {
const message = '正式对局需要使用真实麦克风';
setRuntimeError(message);
appendDebugEvent(message);
return;
}
heldRef.current = false; heldRef.current = false;
stopMicrophone(); stopMicrophone();
controller.finishNow(); controller.finishNow();
@@ -245,89 +684,179 @@ export function BarkBattleRuntimeShell({
syncSnapshot(); syncSnapshot();
}; };
const bark = () => { const bark = async () => {
if (isPublishedRuntime) {
const message = '正式对局需要使用真实麦克风';
setRuntimeError(message);
appendDebugEvent(message);
return;
}
recordRuntimeSample(0.9);
controller.forcePlayerBark(0.9); controller.forcePlayerBark(0.9);
syncSnapshot(); syncSnapshot();
setParticleText('汪!'); };
window.setTimeout(() => setParticleText(''), 680);
const exitRuntime = () => {
heldRef.current = false;
stopMicrophone();
onExit?.();
}; };
return ( return (
<main className="bark-battle-runtime" aria-label={title}> <main className="bark-battle-runtime" aria-label={title}>
{resolvedBarkSoundSrc ? ( {onExit ? (
<audio ref={barkAudioRef} src={resolvedBarkSoundSrc} preload="auto" /> <button
className="bark-battle-runtime__back-button"
type="button"
onClick={exitRuntime}
>
</button>
) : null} ) : null}
<BarkBattleHud <BarkBattleHud
snapshot={snapshot} snapshot={snapshot}
playerPulseKey={playerPulseKey} playerPulseKey={playerPulseKey}
opponentPulseKey={opponentPulseKey} opponentPulseKey={opponentPulseKey}
playerCharacterImageSrc={replacementConfig?.playerCharacterImageSrc} playerCharacterImageSrc={activePublishedConfig?.playerCharacterImageSrc}
opponentCharacterImageSrc={replacementConfig?.opponentCharacterImageSrc} opponentCharacterImageSrc={activePublishedConfig?.opponentCharacterImageSrc}
uiBackgroundImageSrc={replacementConfig?.uiBackgroundImageSrc} uiBackgroundImageSrc={activePublishedConfig?.uiBackgroundImageSrc}
onStartMicrophone={startMicrophone} onStartMicrophone={startMicrophone}
onMockBark={bark} onMockBark={bark}
onMockQuiet={() => { onMockQuiet={() => {
heldRef.current = false; heldRef.current = false;
}} }}
onRestart={restart} onRestart={restart}
enableMockControls={!isPublishedRuntime}
runtimeError={shouldShowDebugPanel ? null : runtimeError}
playerBurstText={playerBurstText}
opponentBurstText="反击"
/> />
<aside className={`bark-battle-debug-panel${isDebugExpanded ? ' bark-battle-debug-panel--expanded' : ''}`} aria-label="调试面板"> {shouldShowDebugPanel ? (
<header> <aside
<strong></strong> className={`bark-battle-debug-panel${isDebugExpanded ? ' bark-battle-debug-panel--expanded' : ''}`}
<button aria-label="调试面板"
type="button" >
className="bark-battle-debug-panel__toggle" <header>
aria-expanded={isDebugExpanded} <strong></strong>
onClick={() => setIsDebugExpanded((current) => !current)} <button
type="button"
className="bark-battle-debug-panel__toggle"
aria-expanded={isDebugExpanded}
onClick={() => setIsDebugExpanded((current) => !current)}
>
{isDebugExpanded ? '收起' : '展开'}
</button>
<span>{snapshot.phase}</span>
</header>
<div className="bark-battle-debug-panel__body">
<div className="bark-battle-debug-panel__controls">
<button type="button" onClick={startMock}>
</button>
<button type="button" onClick={finishNow}>
</button>
<button type="button" onClick={restart}>
</button>
{onExit ? (
<button type="button" onClick={onExit}>
</button>
) : null}
</div>
{workId ? (
<p className="bark-battle-debug-panel__work-id">{workId}</p>
) : null}
{runtimeError ? (
<p className="bark-battle-debug-panel__work-id" role="alert">
{runtimeError}
</p>
) : null}
<div className="bark-battle-debug-metrics" aria-label="触发反馈">
<span className="bark-battle-debug-metrics__wide">
{inputMode === 'microphone' ? '真实麦克风' : 'Mock 输入'}
</span>
<span>{(liveInputVolume * 100).toFixed(0)}%</span>
<span>{controller.getSampleClockMs()}ms</span>
<span>{snapshot.player.barkCount}</span>
<span>{(snapshot.player.power * 100).toFixed(0)}%</span>
<span>{(snapshot.opponent.power * 100).toFixed(0)}%</span>
<span>{Math.round(snapshot.energy)}</span>
</div>
<ol className="bark-battle-debug-events" aria-label="触发日志">
{debugEvents.length ? (
debugEvents.map((event) => <li key={event.id}>{event.text}</li>)
) : (
<li></li>
)}
</ol>
{DEBUG_CONFIG_FIELDS.map((field) => (
<label key={field.key}>
<span>{field.label}</span>
<input
aria-label={field.label}
type="range"
min={field.min}
max={field.max}
step={field.step}
value={config[field.key]}
onChange={(event) => {
const value = Number(event.currentTarget.value);
setConfig((current) => ({ ...current, [field.key]: value }));
}}
/>
<output>{config[field.key]}</output>
</label>
))}
</div>
</aside>
) : null}
{particleText ? (
<div className="bark-battle-particles">{particleText}</div>
) : null}
{snapshot.result ? (
<div className="bark-battle-result-modal" role="presentation">
<section
className="bark-battle-result bark-battle-result--modal"
role="dialog"
aria-modal="true"
aria-label="对战结算"
> >
{isDebugExpanded ? '收起' : '展开'} <p className="bark-battle-result__eyebrow"></p>
</button> <h2>{resolveResultTitle(snapshot.result.winner)}</h2>
<span>{snapshot.phase}</span> <div className="bark-battle-result__stats">
</header> <span>
<div className="bark-battle-debug-panel__body"> <strong>{snapshot.result.playerBarkCount}</strong>
<div className="bark-battle-debug-panel__controls">
<button type="button" onClick={startMock}></button> </span>
<button type="button" onClick={finishNow}></button> <span>
<button type="button" onClick={restart}></button> <strong>{snapshot.result.opponentBarkCount}</strong>
{onExit ? <button type="button" onClick={onExit}></button> : null}
</div> </span>
{workId ? ( <span>
<p className="bark-battle-debug-panel__work-id">{workId}</p> <strong>{snapshot.result.score}</strong>
) : null}
<div className="bark-battle-debug-metrics" aria-label="触发反馈"> </span>
<span className="bark-battle-debug-metrics__wide">{inputMode === 'microphone' ? '真实麦克风' : 'Mock 输入'}</span> </div>
<span>{(liveInputVolume * 100).toFixed(0)}%</span> <div className="bark-battle-result__actions">
<span>{controller.getSampleClockMs()}ms</span> {onExit ? (
<span>{snapshot.player.barkCount}</span> <button type="button" onClick={exitRuntime}>
<span>{(snapshot.player.power * 100).toFixed(0)}%</span>
<span>{(snapshot.opponent.power * 100).toFixed(0)}%</span> </button>
<span>{Math.round(snapshot.energy)}</span> ) : null}
</div> <button
<ol className="bark-battle-debug-events" aria-label="触发日志"> className="bark-battle-primary-button"
{debugEvents.length ? debugEvents.map((event) => <li key={event.id}>{event.text}</li>) : <li></li>} type="button"
</ol> onClick={restart}
{DEBUG_CONFIG_FIELDS.map((field) => ( >
<label key={field.key}>
<span>{field.label}</span> </button>
<input </div>
aria-label={field.label} </section>
type="range"
min={field.min}
max={field.max}
step={field.step}
value={config[field.key]}
onChange={(event) => {
const value = Number(event.currentTarget.value);
setConfig((current) => ({ ...current, [field.key]: value }));
}}
/>
<output>{config[field.key]}</output>
</label>
))}
</div> </div>
</aside> ) : null}
{particleText ? <div className="bark-battle-particles">{particleText}</div> : null}
{snapshot.result ? <BarkBattleResultPanel result={snapshot.result} onRestart={restart} /> : null}
</main> </main>
); );
} }

View File

@@ -1,19 +1,27 @@
/* @vitest-environment jsdom */ /* @vitest-environment jsdom */
import { render, screen, within } from '@testing-library/react'; import { act, render, screen, waitFor, within } from '@testing-library/react';
import userEvent from '@testing-library/user-event'; import userEvent from '@testing-library/user-event';
import { describe, expect, it, vi } from 'vitest'; import { afterEach, describe, expect, it, vi } from 'vitest';
import type {
BarkBattlePublishedConfig,
BarkBattleRunStartResponse,
} from '../../../../../packages/shared/src/contracts/barkBattle';
import { BarkBattleRuntimeShell } from '../BarkBattleRuntimeShell'; import { BarkBattleRuntimeShell } from '../BarkBattleRuntimeShell';
vi.mock('../../../../hooks/useResolvedAssetReadUrl', () => ({ const runtimeClientMock = vi.hoisted(() => ({
useResolvedAssetReadUrl: (source: string | null | undefined) => ({ startBarkBattleRun: vi.fn(),
resolvedUrl: source ?? '', finishBarkBattleRun: vi.fn(),
isResolving: false,
shouldResolve: false,
}),
})); }));
const microphoneInputMock = vi.hoisted(() => ({
startBrowserMicrophoneSampler: vi.fn(),
}));
vi.mock('../../../../services/bark-battle-runtime', () => runtimeClientMock);
vi.mock('../../infrastructure/BrowserMicrophoneInput', () => microphoneInputMock);
vi.mock('../../../../components/ResolvedAssetImage', () => ({ vi.mock('../../../../components/ResolvedAssetImage', () => ({
ResolvedAssetImage: ({ ResolvedAssetImage: ({
src, src,
@@ -25,35 +33,192 @@ vi.mock('../../../../components/ResolvedAssetImage', () => ({
}) => <img src={src ?? ''} alt={alt ?? ''} {...props} />, }) => <img src={src ?? ''} alt={alt ?? ''} {...props} />,
})); }));
function createPublishedConfig(
overrides: Partial<BarkBattlePublishedConfig> = {},
): BarkBattlePublishedConfig {
return {
workId: 'work-bark-1',
draftId: 'draft-bark-1',
configVersion: 2,
rulesetVersion: 'bark-battle-ruleset-v1',
playTypeId: 'bark-battle',
title: '周末狗狗杯',
description: '公开汪汪声浪作品',
themeDescription: '霓虹城市公园里的声浪擂台',
playerImageDescription: '戴红围巾的柴犬主角',
opponentImageDescription: '戴蓝色头带的哈士奇对手',
playerCharacterImageSrc: '/generated-bark-battle/player.png',
opponentCharacterImageSrc: '/generated-bark-battle/opponent.png',
uiBackgroundImageSrc: '/generated-bark-battle/ui.png',
difficultyPreset: 'hard',
updatedAt: '2026-05-13T03:00:00.000Z',
publishedAt: '2026-05-13T03:00:00.000Z',
...overrides,
};
}
function createRunStartResponse(
overrides: Partial<BarkBattleRunStartResponse> = {},
): BarkBattleRunStartResponse {
const publishedConfig = createPublishedConfig();
return {
runId: 'run-bark-1',
runToken: 'token-bark-1',
workId: publishedConfig.workId,
configVersion: publishedConfig.configVersion,
rulesetVersion: publishedConfig.rulesetVersion,
difficultyPreset: publishedConfig.difficultyPreset,
runtimeConfig: {
workId: publishedConfig.workId,
configVersion: publishedConfig.configVersion,
rulesetVersion: publishedConfig.rulesetVersion,
playTypeId: 'bark-battle',
durationMs: 30000,
energyMin: 0,
energyMax: 100,
drawThreshold: 12,
minBarkGapMs: 150,
difficultyPreset: publishedConfig.difficultyPreset,
themeDescription: publishedConfig.themeDescription,
playerImageDescription: publishedConfig.playerImageDescription,
opponentImageDescription: publishedConfig.opponentImageDescription,
playerCharacterImageSrc: publishedConfig.playerCharacterImageSrc,
opponentCharacterImageSrc: publishedConfig.opponentCharacterImageSrc,
uiBackgroundImageSrc: publishedConfig.uiBackgroundImageSrc,
updatedAt: publishedConfig.updatedAt,
},
serverStartedAt: '2026-05-13T03:00:00.000Z',
expiresAt: '2026-05-13T03:10:00.000Z',
...overrides,
};
}
describe('BarkBattleRuntimeShell 调试面板', () => { describe('BarkBattleRuntimeShell 调试面板', () => {
it('从发布配置加载自定义狗叫音效资源', () => { afterEach(() => {
runtimeClientMock.startBarkBattleRun.mockReset();
runtimeClientMock.finishBarkBattleRun.mockReset();
microphoneInputMock.startBrowserMicrophoneSampler.mockReset();
vi.restoreAllMocks();
vi.useRealTimers();
});
it('发布配置只渲染视觉素材', async () => {
microphoneInputMock.startBrowserMicrophoneSampler.mockResolvedValueOnce({
stop: vi.fn(),
});
runtimeClientMock.startBarkBattleRun.mockResolvedValueOnce(
createRunStartResponse(),
);
render( render(
<BarkBattleRuntimeShell <BarkBattleRuntimeShell
publishedConfig={{ runtimeMode="published"
workId: 'work-bark-1', publishedConfig={createPublishedConfig()}
draftId: 'draft-bark-1',
configVersion: 2,
rulesetVersion: 'bark-battle-ruleset-v1',
playTypeId: 'bark-battle',
title: '周末狗狗杯',
themePreset: 'neon-park',
playerDogSkinPreset: 'shiba',
opponentDogSkinPreset: 'husky',
playerCharacterImageSrc: '/generated-bark-battle/player.png',
opponentCharacterImageSrc: '/generated-bark-battle/opponent.png',
uiBackgroundImageSrc: '/generated-bark-battle/ui.png',
barkSoundSrc: '/generated-bark-battle/bark.mp3',
difficultyPreset: 'hard',
leaderboardEnabled: true,
updatedAt: '2026-05-13T03:00:00.000Z',
publishedAt: '2026-05-13T03:00:00.000Z',
}}
/>, />,
); );
expect(document.querySelector('audio[src="/generated-bark-battle/bark.mp3"]')).toBeTruthy(); expect(document.querySelector('audio')).toBeNull();
expect(document.querySelector('img[src="/generated-bark-battle/player.png"]')).toBeTruthy(); expect(
expect(document.querySelector('img[src="/generated-bark-battle/ui.png"]')).toBeTruthy(); document.querySelector('img[src="/generated-bark-battle/player.png"]'),
).toBeTruthy();
expect(
document.querySelector('img[src="/generated-bark-battle/ui.png"]'),
).toBeTruthy();
expect(screen.queryByLabelText('调试面板')).toBeNull();
expect(screen.queryByRole('button', { name: '模拟叫声' })).toBeNull();
await waitFor(() => {
expect(
microphoneInputMock.startBrowserMicrophoneSampler,
).toHaveBeenCalledTimes(1);
});
});
it('草稿调试参数中难度只覆盖对手基础力,不改阈值和平局线', async () => {
render(
<BarkBattleRuntimeShell
runtimeMode="draft"
publishedConfig={createPublishedConfig({ difficultyPreset: 'hard' })}
/>,
);
const debugPanel = screen.getByLabelText('调试面板');
await userEvent.click(
within(debugPanel).getByRole('button', { name: '展开' }),
);
expect((screen.getByLabelText('叫声阈值') as HTMLInputElement).value).toBe(
'0.35',
);
expect((screen.getByLabelText('平局阈值') as HTMLInputElement).value).toBe(
'12',
);
expect(
(screen.getByLabelText('叫声间隔(ms)') as HTMLInputElement).value,
).toBe('150');
expect(
(screen.getByLabelText('对手基础力') as HTMLInputElement).value,
).toBe('0.3');
expect(runtimeClientMock.startBarkBattleRun).not.toHaveBeenCalled();
});
it('发布配置使用自定义拟声词池并在连续触发时随机展示', async () => {
vi
.spyOn(Math, 'random')
.mockReturnValueOnce(0.1)
.mockReturnValueOnce(0.7);
const onomatopoeia = ['炸场!', '冲啊!', '破阵!'];
render(
<BarkBattleRuntimeShell
runtimeMode="draft"
publishedConfig={createPublishedConfig({ onomatopoeia })}
/>,
);
const debugPanel = screen.getByLabelText('调试面板');
await userEvent.click(
within(debugPanel).getByRole('button', { name: '展开' }),
);
await userEvent.click(screen.getByRole('button', { name: '开始' }));
await userEvent.click(screen.getByRole('button', { name: '模拟叫声' }));
const firstBurst = screen.getByLabelText('玩家声浪角色背对屏幕')
.textContent;
expect(onomatopoeia.some((word) => firstBurst?.includes(word))).toBe(true);
await userEvent.click(screen.getByRole('button', { name: '模拟叫声' }));
const nextBurst = screen.getByLabelText('玩家声浪角色背对屏幕')
.textContent;
expect(onomatopoeia.some((word) => nextBurst?.includes(word))).toBe(true);
expect(screen.queryByText('汪!')).toBeNull();
});
it('没有自定义拟声词时根据主题使用更燥的默认拟声词池', async () => {
vi.spyOn(Math, 'random').mockReturnValue(0.2);
render(
<BarkBattleRuntimeShell
runtimeMode="draft"
publishedConfig={createPublishedConfig({
themeDescription: '星舰机甲擂台,等离子音浪爆发',
playerImageDescription: '星际猫骑士',
opponentImageDescription: '机器人拳手',
})}
/>,
);
const debugPanel = screen.getByLabelText('调试面板');
await userEvent.click(
within(debugPanel).getByRole('button', { name: '展开' }),
);
await userEvent.click(screen.getByRole('button', { name: '开始' }));
await userEvent.click(screen.getByRole('button', { name: '模拟叫声' }));
const burstText = screen.getByLabelText('玩家声浪角色背对屏幕')
.textContent;
expect(
['能量爆裂!', '超频!', '电光轰鸣!', '雷鸣!'].some((word) =>
burstText?.includes(word),
),
).toBe(true);
expect(screen.queryByText('汪!')).toBeNull();
}); });
it('提供开始、结束、重置流程控制按钮和参数滑杆', async () => { it('提供开始、结束、重置流程控制按钮和参数滑杆', async () => {
@@ -61,10 +226,16 @@ describe('BarkBattleRuntimeShell 调试面板', () => {
const debugPanel = screen.getByLabelText('调试面板'); const debugPanel = screen.getByLabelText('调试面板');
expect(debugPanel).toBeTruthy(); expect(debugPanel).toBeTruthy();
expect(within(debugPanel).getByRole('button', { name: '展开' })).toBeTruthy(); expect(
within(debugPanel).getByRole('button', { name: '展开' }),
).toBeTruthy();
await userEvent.click(within(debugPanel).getByRole('button', { name: '展开' })); await userEvent.click(
expect(within(debugPanel).getByRole('button', { name: '收起' })).toBeTruthy(); within(debugPanel).getByRole('button', { name: '展开' }),
);
expect(
within(debugPanel).getByRole('button', { name: '收起' }),
).toBeTruthy();
expect(screen.getByRole('button', { name: '开始' })).toBeTruthy(); expect(screen.getByRole('button', { name: '开始' })).toBeTruthy();
expect(screen.getByRole('button', { name: '结束' })).toBeTruthy(); expect(screen.getByRole('button', { name: '结束' })).toBeTruthy();
expect(screen.getByRole('button', { name: '重置' })).toBeTruthy(); expect(screen.getByRole('button', { name: '重置' })).toBeTruthy();
@@ -83,18 +254,321 @@ describe('BarkBattleRuntimeShell 调试面板', () => {
}); });
it('真实声控入口在不支持麦克风时展示失败原因mock 开始不请求权限', async () => { it('真实声控入口在不支持麦克风时展示失败原因mock 开始不请求权限', async () => {
microphoneInputMock.startBrowserMicrophoneSampler.mockRejectedValueOnce(
Object.assign(new Error('unsupported'), { reason: 'unsupported' }),
);
render(<BarkBattleRuntimeShell />); render(<BarkBattleRuntimeShell />);
const debugPanel = screen.getByLabelText('调试面板'); const debugPanel = screen.getByLabelText('调试面板');
await userEvent.click(within(debugPanel).getByRole('button', { name: '展开' })); await userEvent.click(
within(debugPanel).getByRole('button', { name: '展开' }),
);
await userEvent.click(screen.getByRole('button', { name: '开始声控' })); await userEvent.click(screen.getByRole('button', { name: '开始声控' }));
expect(screen.getByText('当前浏览器不支持麦克风输入')).toBeTruthy(); expect(screen.getByText('当前浏览器不支持麦克风输入')).toBeTruthy();
expect(screen.getAllByText(/unsupported/u).length).toBeGreaterThan(0); expect(
screen.getAllByText(/unsupported/u).length,
).toBeGreaterThan(0);
await userEvent.click(screen.getByRole('button', { name: '开始' })); await userEvent.click(screen.getByRole('button', { name: '开始' }));
expect(screen.getAllByText(/ mock /u).length).toBeGreaterThan(0); expect(
screen.getAllByText(/ mock /u).length,
).toBeGreaterThan(0);
expect(screen.getByText(/Mock /u)).toBeTruthy(); expect(screen.getByText(/Mock /u)).toBeTruthy();
}); });
it('草稿试玩不会登记正式对局', async () => {
render(<BarkBattleRuntimeShell />);
const debugPanel = screen.getByLabelText('调试面板');
await userEvent.click(
within(debugPanel).getByRole('button', { name: '展开' }),
);
await userEvent.click(screen.getByRole('button', { name: '开始' }));
expect(runtimeClientMock.startBarkBattleRun).not.toHaveBeenCalled();
expect(runtimeClientMock.finishBarkBattleRun).not.toHaveBeenCalled();
});
it('发布态不渲染 mock 控制和调试面板,并自动申请麦克风权限', async () => {
microphoneInputMock.startBrowserMicrophoneSampler.mockResolvedValueOnce({
stop: vi.fn(),
});
runtimeClientMock.startBarkBattleRun.mockResolvedValueOnce(
createRunStartResponse(),
);
render(
<BarkBattleRuntimeShell
runtimeMode="published"
publishedConfig={createPublishedConfig()}
/>,
);
expect(screen.queryByLabelText('调试面板')).toBeNull();
expect(screen.queryByRole('button', { name: '开始' })).toBeNull();
expect(screen.queryByRole('button', { name: '结束' })).toBeNull();
expect(screen.queryByRole('button', { name: '模拟叫声' })).toBeNull();
await waitFor(() => {
expect(
microphoneInputMock.startBrowserMicrophoneSampler,
).toHaveBeenCalledTimes(1);
});
await waitFor(() => {
expect(runtimeClientMock.startBarkBattleRun).toHaveBeenCalled();
});
expect(
runtimeClientMock.startBarkBattleRun.mock.calls[0]?.[0],
).toBe('work-bark-1');
expect(createRunStartResponse().runtimeConfig.minBarkGapMs).toBe(150);
expect(runtimeClientMock.finishBarkBattleRun).not.toHaveBeenCalled();
});
it('发布态进入运行态后展示可点击的返回按钮', async () => {
const handleExit = vi.fn();
microphoneInputMock.startBrowserMicrophoneSampler.mockResolvedValueOnce({
stop: vi.fn(),
});
runtimeClientMock.startBarkBattleRun.mockResolvedValueOnce(
createRunStartResponse(),
);
render(
<BarkBattleRuntimeShell
runtimeMode="published"
publishedConfig={createPublishedConfig()}
onExit={handleExit}
/>,
);
const backButton = screen.getByRole('button', { name: '返回' });
await userEvent.click(backButton);
expect(handleExit).toHaveBeenCalledTimes(1);
});
it('结束后弹出独立结果弹窗,并提供返回和再来一局', async () => {
const handleExit = vi.fn();
render(<BarkBattleRuntimeShell onExit={handleExit} />);
const debugPanel = screen.getByLabelText('调试面板');
await userEvent.click(
within(debugPanel).getByRole('button', { name: '展开' }),
);
await userEvent.click(screen.getByRole('button', { name: '开始' }));
await userEvent.click(screen.getByRole('button', { name: '模拟叫声' }));
await userEvent.click(screen.getByRole('button', { name: '结束' }));
const resultDialog = screen.getByRole('dialog', { name: '对战结算' });
expect(resultDialog.getAttribute('aria-modal')).toBe('true');
expect(resultDialog.closest('.bark-battle-hud')).toBeNull();
expect(within(resultDialog).getByText('本局结束')).toBeTruthy();
expect(within(resultDialog).getByText('玩家叫声')).toBeTruthy();
expect(within(resultDialog).getByText('对手压制')).toBeTruthy();
expect(within(resultDialog).getByText('声浪分')).toBeTruthy();
expect(
within(resultDialog).getByRole('button', { name: '再来一局' }),
).toBeTruthy();
await userEvent.click(
within(resultDialog).getByRole('button', { name: '返回' }),
);
expect(handleExit).toHaveBeenCalledTimes(1);
});
it('发布态麦克风失败不会登记正式对局', async () => {
microphoneInputMock.startBrowserMicrophoneSampler.mockRejectedValueOnce(
Object.assign(new Error('permission-denied'), {
reason: 'permission-denied',
}),
);
render(
<BarkBattleRuntimeShell
runtimeMode="published"
publishedConfig={createPublishedConfig()}
/>,
);
await waitFor(() => {
expect(screen.getByText('麦克风授权被拒绝')).toBeTruthy();
});
expect(screen.queryByLabelText('调试面板')).toBeNull();
expect(runtimeClientMock.startBarkBattleRun).not.toHaveBeenCalled();
expect(runtimeClientMock.finishBarkBattleRun).not.toHaveBeenCalled();
});
it('发布态启动后会直接申请麦克风权限,授权成功后登记 start run 并进入倒计时', async () => {
microphoneInputMock.startBrowserMicrophoneSampler.mockResolvedValueOnce({
stop: vi.fn(),
});
runtimeClientMock.startBarkBattleRun.mockResolvedValueOnce(
createRunStartResponse(),
);
runtimeClientMock.finishBarkBattleRun.mockReturnValueOnce(
new Promise(() => {}),
);
render(
<BarkBattleRuntimeShell
runtimeMode="published"
publishedConfig={createPublishedConfig()}
/>,
);
await waitFor(() => {
expect(runtimeClientMock.startBarkBattleRun).toHaveBeenCalledWith(
'work-bark-1',
expect.objectContaining({
sourceRoute: expect.any(String),
clientRuntimeVersion: expect.any(String),
}),
);
});
expect(screen.getByLabelText(//u)).toBeTruthy();
expect(screen.queryByRole('button', { name: '开始声控' })).toBeNull();
expect(screen.queryByLabelText('调试面板')).toBeNull();
expect(screen.queryByRole('button', { name: '模拟叫声' })).toBeNull();
expect(runtimeClientMock.finishBarkBattleRun).not.toHaveBeenCalled();
});
it('发布态正式对局使用 start run 返回的服务端 runtimeConfig', async () => {
microphoneInputMock.startBrowserMicrophoneSampler.mockResolvedValueOnce({
stop: vi.fn(),
});
const started = createRunStartResponse();
runtimeClientMock.startBarkBattleRun.mockResolvedValueOnce({
...started,
runtimeConfig: {
...started.runtimeConfig,
durationMs: 1000,
drawThreshold: 3,
minBarkGapMs: 150,
},
});
runtimeClientMock.finishBarkBattleRun.mockReturnValueOnce(
new Promise(() => {}),
);
vi.useFakeTimers();
render(
<BarkBattleRuntimeShell
runtimeMode="published"
publishedConfig={createPublishedConfig()}
/>,
);
await act(async () => {
await Promise.resolve();
await Promise.resolve();
});
expect(runtimeClientMock.startBarkBattleRun).toHaveBeenCalled();
act(() => {
vi.advanceTimersByTime(5000);
});
expect(runtimeClientMock.finishBarkBattleRun).toHaveBeenCalledWith(
'run-bark-1',
expect.objectContaining({
durationMs: 1000,
}),
);
});
it('发布态正式对局使用服务端 runtimeConfig 刷新自定义拟声词和素材', async () => {
microphoneInputMock.startBrowserMicrophoneSampler.mockImplementationOnce(
async (onSample: (volume: number, atMs: number) => void) => {
onSample(0.9, 0);
return { stop: vi.fn() };
},
);
const started = createRunStartResponse();
runtimeClientMock.startBarkBattleRun.mockResolvedValueOnce({
...started,
runtimeConfig: {
...started.runtimeConfig,
onomatopoeia: ['喵能爆裂!'],
playerCharacterImageSrc: '/server/player.png',
opponentCharacterImageSrc: '/server/opponent.png',
uiBackgroundImageSrc: '/server/background.png',
},
});
runtimeClientMock.finishBarkBattleRun.mockReturnValueOnce(
new Promise(() => {}),
);
vi.useFakeTimers();
render(
<BarkBattleRuntimeShell
runtimeMode="published"
publishedConfig={createPublishedConfig({ onomatopoeia: undefined })}
/>,
);
await act(async () => {
await Promise.resolve();
await Promise.resolve();
});
expect(runtimeClientMock.startBarkBattleRun).toHaveBeenCalled();
act(() => {
vi.advanceTimersByTime(3100);
});
expect(
document.querySelector('img[src="/server/player.png"]'),
).toBeTruthy();
expect(screen.getByText('喵能爆裂!')).toBeTruthy();
});
it('发布态真实麦克风对局结算后提交派生指标', async () => {
microphoneInputMock.startBrowserMicrophoneSampler.mockResolvedValueOnce({
stop: vi.fn(),
});
runtimeClientMock.startBarkBattleRun.mockResolvedValueOnce(
createRunStartResponse(),
);
runtimeClientMock.finishBarkBattleRun.mockReturnValueOnce(
new Promise(() => {}),
);
vi.useFakeTimers();
render(
<BarkBattleRuntimeShell
runtimeMode="published"
publishedConfig={createPublishedConfig()}
/>,
);
await act(async () => {
await Promise.resolve();
});
expect(runtimeClientMock.startBarkBattleRun).toHaveBeenCalled();
act(() => {
vi.advanceTimersByTime(34_000);
});
expect(runtimeClientMock.finishBarkBattleRun).toHaveBeenCalledWith(
'run-bark-1',
expect.objectContaining({
runId: 'run-bark-1',
runToken: 'token-bark-1',
workId: 'work-bark-1',
configVersion: 2,
rulesetVersion: 'bark-battle-ruleset-v1',
difficultyPreset: 'hard',
derivedMetrics: expect.objectContaining({
triggerCount: expect.any(Number),
finalEnergy: expect.any(Number),
}),
clientResult: expect.any(String),
}),
);
act(() => {
vi.clearAllTimers();
});
vi.useRealTimers();
});
}); });

View File

@@ -21,6 +21,9 @@ const STAGE_ROUTE_ENTRIES = [
['square-hole-agent-workspace', '/creation/square-hole/agent'], ['square-hole-agent-workspace', '/creation/square-hole/agent'],
['square-hole-result', '/creation/square-hole/result'], ['square-hole-result', '/creation/square-hole/result'],
['square-hole-runtime', '/runtime/square-hole'], ['square-hole-runtime', '/runtime/square-hole'],
['bark-battle-generating', '/creation/bark-battle/generating'],
['bark-battle-result', '/creation/bark-battle/result'],
['bark-battle-runtime', '/runtime/bark-battle'],
['creative-agent-workspace', '/creation/creative-agent'], ['creative-agent-workspace', '/creation/creative-agent'],
['visual-novel-agent-workspace', '/creation/visual-novel/agent'], ['visual-novel-agent-workspace', '/creation/visual-novel/agent'],
['visual-novel-result', '/creation/visual-novel/result'], ['visual-novel-result', '/creation/visual-novel/result'],

View File

@@ -1,6 +1,12 @@
import { afterEach, describe, expect, it, vi } from 'vitest'; import { afterEach, describe, expect, it, vi } from 'vitest';
import { createBarkBattleDraft, publishBarkBattleWork } from './barkBattleCreationClient'; import {
createBarkBattleDraft,
generateAllBarkBattleImageAssets,
publishBarkBattleWork,
regenerateBarkBattleImageAsset,
updateBarkBattleDraftConfig,
} from './barkBattleCreationClient';
const requestJsonMock = vi.hoisted(() => vi.fn()); const requestJsonMock = vi.hoisted(() => vi.fn());
@@ -13,21 +19,17 @@ describe('barkBattleCreationClient', () => {
requestJsonMock.mockReset(); requestJsonMock.mockReset();
}); });
it('creates a lightweight draft through creation API', async () => { it('creates a v1 lightweight draft through creation API', async () => {
requestJsonMock.mockResolvedValueOnce({ draftId: 'draft-1' }); requestJsonMock.mockResolvedValueOnce({ draftId: 'draft-1' });
await createBarkBattleDraft({ await createBarkBattleDraft({
title: '周末狗狗杯', title: '汪汪冠军杯',
description: '', description: '',
themePreset: 'neon-park', themeDescription: 'neon park at night',
playerDogSkinPreset: 'shiba', playerImageDescription: 'shiba with red scarf',
opponentDogSkinPreset: 'husky', opponentImageDescription: 'husky with silver goggles',
playerCharacterImageSrc: '/generated-bark-battle/player.png', onomatopoeia: ['轰汪!', '炸场!', '冲啊!'],
opponentCharacterImageSrc: 'https://example.test/opponent.png',
uiBackgroundImageSrc: '/generated-bark-battle/ui.png',
barkSoundSrc: '/generated-bark-battle/bark.mp3',
difficultyPreset: 'hard', difficultyPreset: 'hard',
leaderboardEnabled: true,
}); });
expect(requestJsonMock).toHaveBeenCalledWith( expect(requestJsonMock).toHaveBeenCalledWith(
@@ -36,21 +38,19 @@ describe('barkBattleCreationClient', () => {
method: 'POST', method: 'POST',
headers: { 'Content-Type': 'application/json' }, headers: { 'Content-Type': 'application/json' },
body: JSON.stringify({ body: JSON.stringify({
title: '周末狗狗杯', title: '汪汪冠军杯',
description: '', description: '',
themePreset: 'neon-park', themeDescription: 'neon park at night',
playerDogSkinPreset: 'shiba', playerImageDescription: 'shiba with red scarf',
opponentDogSkinPreset: 'husky', opponentImageDescription: 'husky with silver goggles',
playerCharacterImageSrc: '/generated-bark-battle/player.png', onomatopoeia: ['轰汪!', '炸场!', '冲啊!'],
opponentCharacterImageSrc: 'https://example.test/opponent.png',
uiBackgroundImageSrc: '/generated-bark-battle/ui.png',
barkSoundSrc: '/generated-bark-battle/bark.mp3',
difficultyPreset: 'hard', difficultyPreset: 'hard',
leaderboardEnabled: true,
}), }),
}), }),
'创建汪汪声浪大作战草稿失败', '创建汪汪声浪大作战草稿失败',
expect.objectContaining({ retry: expect.objectContaining({ retryUnsafeMethods: true }) }), expect.objectContaining({
retry: expect.objectContaining({ retryUnsafeMethods: true }),
}),
); );
}); });
@@ -67,7 +67,146 @@ describe('barkBattleCreationClient', () => {
body: JSON.stringify({ draftId: 'draft-1', workId: 'work-1' }), body: JSON.stringify({ draftId: 'draft-1', workId: 'work-1' }),
}), }),
'发布汪汪声浪大作战作品失败', '发布汪汪声浪大作战作品失败',
expect.objectContaining({ retry: expect.objectContaining({ retryUnsafeMethods: true }) }), expect.objectContaining({
retry: expect.objectContaining({ retryUnsafeMethods: true }),
}),
); );
}); });
it('persists generated image slots into an existing draft config', async () => {
requestJsonMock.mockResolvedValueOnce({ draftId: 'draft-1' });
await updateBarkBattleDraftConfig({
draftId: 'draft-1',
workId: 'BB-12345678',
configVersion: 2,
rulesetVersion: 'bark-battle-ruleset-v1',
title: '汪汪冠军杯',
description: '',
themeDescription: 'neon park at night',
playerImageDescription: 'shiba with red scarf',
opponentImageDescription: 'husky with silver goggles',
onomatopoeia: ['轰汪!', '炸场!', '冲啊!'],
playerCharacterImageSrc: '/generated-bark-battle/player.png',
opponentCharacterImageSrc: '/generated-bark-battle/opponent.png',
uiBackgroundImageSrc: '/generated-bark-battle/background.png',
difficultyPreset: 'hard',
});
expect(requestJsonMock).toHaveBeenCalledWith(
'/api/creation/bark-battle/drafts/draft-1/config',
expect.objectContaining({
method: 'POST',
headers: { 'Content-Type': 'application/json' },
body: JSON.stringify({
draftId: 'draft-1',
workId: 'BB-12345678',
configVersion: 2,
rulesetVersion: 'bark-battle-ruleset-v1',
title: '汪汪冠军杯',
description: '',
themeDescription: 'neon park at night',
playerImageDescription: 'shiba with red scarf',
opponentImageDescription: 'husky with silver goggles',
onomatopoeia: ['轰汪!', '炸场!', '冲啊!'],
playerCharacterImageSrc: '/generated-bark-battle/player.png',
opponentCharacterImageSrc: '/generated-bark-battle/opponent.png',
uiBackgroundImageSrc: '/generated-bark-battle/background.png',
difficultyPreset: 'hard',
}),
}),
'保存汪汪声浪草稿素材失败',
expect.objectContaining({
retry: expect.objectContaining({ retryUnsafeMethods: true }),
timeoutMs: 30_000,
}),
);
});
it('generates an individual image slot from v1 description fields', async () => {
requestJsonMock.mockResolvedValueOnce({
imageSrc: '/generated-bark-battle/player.png',
assetId: 'asset-player',
model: 'gpt-image-2-all',
size: '1024*1024',
taskId: 'task-player',
prompt: 'player',
});
await regenerateBarkBattleImageAsset({
slot: 'player-character',
draftId: 'draft-1',
config: {
title: '汪汪冠军杯',
description: '',
themeDescription: '霓虹公园擂台',
playerImageDescription: '红围巾柴犬',
opponentImageDescription: '蓝头带哈士奇',
onomatopoeia: ['轰汪!', '炸场!', '冲啊!'],
difficultyPreset: 'hard',
},
});
expect(requestJsonMock).toHaveBeenCalledTimes(1);
expect(requestJsonMock).toHaveBeenCalledWith(
'/api/creation/bark-battle/images/generate',
expect.objectContaining({
method: 'POST',
headers: { 'Content-Type': 'application/json' },
body: JSON.stringify({
slot: 'player-character',
draftId: 'draft-1',
config: {
title: '汪汪冠军杯',
description: '',
themeDescription: '霓虹公园擂台',
playerImageDescription: '红围巾柴犬',
opponentImageDescription: '蓝头带哈士奇',
onomatopoeia: ['轰汪!', '炸场!', '冲啊!'],
difficultyPreset: 'hard',
},
}),
}),
'生成汪汪声浪素材失败',
expect.objectContaining({
retry: expect.objectContaining({ retryUnsafeMethods: true }),
timeoutMs: 180_000,
}),
);
});
it('reports failed image slots while keeping generated image assets', async () => {
requestJsonMock
.mockResolvedValueOnce({
imageSrc: '/generated-bark-battle/player.png',
assetId: 'asset-player',
model: 'gpt-image-2-all',
size: '1024*1024',
taskId: 'task-player',
prompt: 'player',
})
.mockRejectedValueOnce(new Error('泥点不足,本次需要 1 泥点,当前 0 泥点。'))
.mockRejectedValueOnce(new Error('场景图片生成失败:上游超时'));
const result = await generateAllBarkBattleImageAssets({
draftId: 'draft-1',
config: {
title: '汪汪冠军杯',
description: '',
themeDescription: '霓虹公园擂台',
playerImageDescription: '红围巾柴犬',
opponentImageDescription: '蓝头带哈士奇',
onomatopoeia: ['轰汪!', '炸场!', '冲啊!'],
difficultyPreset: 'hard',
},
});
expect(result.assets['player-character']?.imageSrc).toBe(
'/generated-bark-battle/player.png',
);
expect(result.failures).toEqual({
'opponent-character': '泥点不足,本次需要 1 泥点,当前 0 泥点。',
'ui-background': '场景图片生成失败:上游超时',
});
});
}); });

View File

@@ -1,21 +1,28 @@
import type { import type {
BarkBattleAssetSlot,
BarkBattleConfigEditorPayload, BarkBattleConfigEditorPayload,
BarkBattleDraftConfig, BarkBattleDraftConfig,
BarkBattleDraftConfigUpdateRequest,
BarkBattleDraftCreateRequest, BarkBattleDraftCreateRequest,
BarkBattleGeneratedImageAsset,
BarkBattleImageAssetGenerateRequest,
BarkBattlePublishedConfig, BarkBattlePublishedConfig,
BarkBattleWorkPublishRequest, BarkBattleWorkPublishRequest,
BarkBattleWorksResponse,
} from '../../../packages/shared/src/contracts/barkBattle'; } from '../../../packages/shared/src/contracts/barkBattle';
import type { CustomWorldSceneImageResult } from '../aiTypes';
import { import {
type ApiRequestOptions, type ApiRequestOptions,
type ApiRetryOptions, type ApiRetryOptions,
requestJson, requestJson,
} from '../apiClient'; } from '../apiClient';
import { generateRpgWorldSceneImage } from '../rpg-creation/rpgCreationAssetClient';
export type { BarkBattleAssetSlot } from '../../../packages/shared/src/contracts/barkBattle';
const BARK_BATTLE_CREATION_API_BASE = '/api/creation/bark-battle'; const BARK_BATTLE_CREATION_API_BASE = '/api/creation/bark-battle';
const BARK_BATTLE_RUNTIME_API_BASE = '/api/runtime/bark-battle';
const BARK_BATTLE_ASSET_UPLOAD_MAX_IMAGE_BYTES = 10 * 1024 * 1024; const BARK_BATTLE_ASSET_UPLOAD_MAX_IMAGE_BYTES = 10 * 1024 * 1024;
const BARK_BATTLE_ASSET_UPLOAD_MAX_AUDIO_BYTES = 20 * 1024 * 1024; const BARK_BATTLE_GENERATION_SLOT_TIMEOUT_MS = 180_000;
const BARK_BATTLE_DRAFT_CONFIG_SAVE_TIMEOUT_MS = 30_000;
const BARK_BATTLE_CREATION_WRITE_RETRY: ApiRetryOptions = { const BARK_BATTLE_CREATION_WRITE_RETRY: ApiRetryOptions = {
maxRetries: 1, maxRetries: 1,
@@ -32,12 +39,6 @@ export type BarkBattleCreationRequestOptions = Pick<
| 'clearAuthOnUnauthorized' | 'clearAuthOnUnauthorized'
>; >;
export type BarkBattleAssetSlot =
| 'player-character'
| 'opponent-character'
| 'ui-background'
| 'bark-sound';
export type BarkBattleUploadedAsset = { export type BarkBattleUploadedAsset = {
assetObjectId: string; assetObjectId: string;
assetKind: string; assetKind: string;
@@ -45,6 +46,23 @@ export type BarkBattleUploadedAsset = {
assetSrc: string; assetSrc: string;
}; };
export type BarkBattleGeneratedImageAssets = Partial<
Record<BarkBattleAssetSlot, BarkBattleGeneratedImageAsset>
>;
export type BarkBattleImageGenerationFailures = Partial<
Record<BarkBattleAssetSlot, string>
>;
export type BarkBattleSlotGenerationResult =
| { status: 'fulfilled'; asset: BarkBattleGeneratedImageAsset }
| { status: 'rejected'; message: string };
export type BarkBattleImageGenerationBatchResult = {
assets: BarkBattleGeneratedImageAssets;
failures: BarkBattleImageGenerationFailures;
};
type DirectUploadTicketResponse = { type DirectUploadTicketResponse = {
upload: { upload: {
bucket: string; bucket: string;
@@ -82,16 +100,10 @@ const SLOT_UPLOAD_CONFIG = {
legacyPrefix: 'generated-bark-battle-assets', legacyPrefix: 'generated-bark-battle-assets',
maxSizeBytes: BARK_BATTLE_ASSET_UPLOAD_MAX_IMAGE_BYTES, maxSizeBytes: BARK_BATTLE_ASSET_UPLOAD_MAX_IMAGE_BYTES,
}, },
'bark-sound': {
acceptKind: 'audio',
assetKind: 'bark_battle_bark_sound',
legacyPrefix: 'generated-bark-battle-assets',
maxSizeBytes: BARK_BATTLE_ASSET_UPLOAD_MAX_AUDIO_BYTES,
},
} satisfies Record< } satisfies Record<
BarkBattleAssetSlot, BarkBattleAssetSlot,
{ {
acceptKind: 'image' | 'audio'; acceptKind: 'image';
assetKind: string; assetKind: string;
legacyPrefix: string; legacyPrefix: string;
maxSizeBytes: number; maxSizeBytes: number;
@@ -101,11 +113,7 @@ const SLOT_UPLOAD_CONFIG = {
const MIME_BY_EXTENSION: Record<string, string> = { const MIME_BY_EXTENSION: Record<string, string> = {
jpeg: 'image/jpeg', jpeg: 'image/jpeg',
jpg: 'image/jpeg', jpg: 'image/jpeg',
mp3: 'audio/mpeg',
ogg: 'audio/ogg',
png: 'image/png', png: 'image/png',
wav: 'audio/wav',
webm: 'audio/webm',
webp: 'image/webp', webp: 'image/webp',
}; };
@@ -129,9 +137,6 @@ function validateBarkBattleUploadFile(slot: BarkBattleAssetSlot, file: File) {
if (config.acceptKind === 'image' && !contentType.startsWith('image/')) { if (config.acceptKind === 'image' && !contentType.startsWith('image/')) {
throw new Error('请选择图片素材。'); throw new Error('请选择图片素材。');
} }
if (config.acceptKind === 'audio' && !contentType.startsWith('audio/')) {
throw new Error('请选择音频素材。');
}
return contentType; return contentType;
} }
@@ -174,27 +179,29 @@ async function postDirectUploadFile(
} }
} }
function buildBarkBattleImagePrompt( function withBarkBattleGenerationTimeout<T>(
slot: Exclude<BarkBattleAssetSlot, 'bark-sound'>, promise: Promise<T>,
payload: BarkBattleConfigEditorPayload, slot: BarkBattleAssetSlot,
) { ): Promise<T> {
const slotPrompt = { let timeoutId: ReturnType<typeof setTimeout> | null = null;
'player-character': `玩家角色形象:${payload.playerDogSkinPreset}`, const timeout = new Promise<never>((_, reject) => {
'opponent-character': `对手角色形象:${payload.opponentDogSkinPreset}`, timeoutId = setTimeout(() => {
'ui-background': `游戏 UI 背景:${payload.themePreset}`, reject(new Error(`${slot} 生成超时`));
} satisfies Record<Exclude<BarkBattleAssetSlot, 'bark-sound'>, string>; }, BARK_BATTLE_GENERATION_SLOT_TIMEOUT_MS);
});
return [ return Promise.race([promise, timeout]).finally(() => {
`汪汪声浪大作战《${payload.title}`, if (timeoutId) {
payload.description ?? '', clearTimeout(timeoutId);
slotPrompt[slot], }
slot === 'ui-background' });
? '竖屏移动端游戏背景,无文字,无按钮,无角色遮挡' }
: '游戏角色立绘,完整主体,透明感背景,无文字,无 UI',
] function resolveBarkBattleGenerationFailureMessage(error: unknown) {
.map((part) => part.trim()) if (error instanceof Error && error.message.trim()) {
.filter(Boolean) return error.message.trim();
.join(''); }
return '汪汪声浪素材生成失败。';
} }
export function createBarkBattleDraft( export function createBarkBattleDraft(
@@ -219,6 +226,31 @@ export function createBarkBattleDraft(
); );
} }
export function updateBarkBattleDraftConfig(
payload: BarkBattleDraftConfigUpdateRequest,
options: BarkBattleCreationRequestOptions = {},
) {
return requestJson<BarkBattleDraftConfig>(
`${BARK_BATTLE_CREATION_API_BASE}/drafts/${encodeURIComponent(
payload.draftId,
)}/config`,
{
method: 'POST',
headers: { 'Content-Type': 'application/json' },
body: JSON.stringify(payload),
},
'保存汪汪声浪草稿素材失败',
{
retry: BARK_BATTLE_CREATION_WRITE_RETRY,
timeoutMs: BARK_BATTLE_DRAFT_CONFIG_SAVE_TIMEOUT_MS,
authImpact: options.authImpact,
skipRefresh: options.skipRefresh,
notifyAuthStateChange: options.notifyAuthStateChange,
clearAuthOnUnauthorized: options.clearAuthOnUnauthorized,
},
);
}
export function publishBarkBattleWork( export function publishBarkBattleWork(
payload: BarkBattleWorkPublishRequest, payload: BarkBattleWorkPublishRequest,
options: BarkBattleCreationRequestOptions = {}, options: BarkBattleCreationRequestOptions = {},
@@ -241,6 +273,34 @@ export function publishBarkBattleWork(
); );
} }
export function listBarkBattleWorks(
options: BarkBattleCreationRequestOptions = {},
) {
return requestJson<BarkBattleWorksResponse>(
`${BARK_BATTLE_RUNTIME_API_BASE}/works`,
{ method: 'GET' },
'读取汪汪声浪作品架失败',
{
retry: BARK_BATTLE_CREATION_WRITE_RETRY,
authImpact: options.authImpact,
skipRefresh: options.skipRefresh,
notifyAuthStateChange: options.notifyAuthStateChange,
clearAuthOnUnauthorized: options.clearAuthOnUnauthorized,
},
);
}
export function listBarkBattleGallery() {
return requestJson<BarkBattleWorksResponse>(
`${BARK_BATTLE_RUNTIME_API_BASE}/gallery`,
{ method: 'GET' },
'读取汪汪声浪公开广场失败',
{
retry: BARK_BATTLE_CREATION_WRITE_RETRY,
},
);
}
export async function uploadBarkBattleAsset(payload: { export async function uploadBarkBattleAsset(payload: {
slot: BarkBattleAssetSlot; slot: BarkBattleAssetSlot;
file: File; file: File;
@@ -302,45 +362,90 @@ export async function uploadBarkBattleAsset(payload: {
} }
export function regenerateBarkBattleImageAsset(payload: { export function regenerateBarkBattleImageAsset(payload: {
slot: Exclude<BarkBattleAssetSlot, 'bark-sound'>; slot: BarkBattleAssetSlot;
config: BarkBattleConfigEditorPayload; config: BarkBattleConfigEditorPayload;
draftId?: string | null; draftId?: string | null;
}): Promise<CustomWorldSceneImageResult> { }): Promise<BarkBattleGeneratedImageAsset> {
return generateRpgWorldSceneImage({ const request: BarkBattleImageAssetGenerateRequest = {
profile: { slot: payload.slot,
id: payload.draftId?.trim() || 'bark-battle-draft', draftId: payload.draftId ?? null,
name: payload.config.title.trim() || '汪汪声浪大作战', config: payload.config,
subtitle: '汪汪声浪', };
summary: payload.config.description?.trim() || payload.config.themePreset, return requestJson<BarkBattleGeneratedImageAsset>(
tone: payload.config.themePreset, `${BARK_BATTLE_CREATION_API_BASE}/images/generate`,
playerGoal: '用声浪压过对手', {
settingText: [ method: 'POST',
payload.config.themePreset, headers: { 'Content-Type': 'application/json' },
payload.config.playerDogSkinPreset, body: JSON.stringify(request),
payload.config.opponentDogSkinPreset,
]
.map((part) => part.trim())
.filter(Boolean)
.join('\n'),
}, },
landmark: { '生成汪汪声浪素材失败',
id: payload.slot, {
name: retry: BARK_BATTLE_CREATION_WRITE_RETRY,
payload.slot === 'ui-background' timeoutMs: BARK_BATTLE_GENERATION_SLOT_TIMEOUT_MS,
? '声浪竞技 UI 背景'
: payload.slot === 'player-character'
? payload.config.playerDogSkinPreset || '玩家角色'
: payload.config.opponentDogSkinPreset || '对手角色',
description: buildBarkBattleImagePrompt(payload.slot, payload.config),
}, },
userPrompt: buildBarkBattleImagePrompt(payload.slot, payload.config), );
size: payload.slot === 'ui-background' ? '1024*1792' : '1024*1024', }
export async function generateAllBarkBattleImageAssets(payload: {
config: BarkBattleConfigEditorPayload;
draftId?: string | null;
onSlotComplete?: (
slot: BarkBattleAssetSlot,
result: BarkBattleSlotGenerationResult,
) => void;
}): Promise<BarkBattleImageGenerationBatchResult> {
const slots = [
'player-character',
'opponent-character',
'ui-background',
] as const;
const results = await Promise.allSettled(
slots.map(async (slot) => [
slot,
await withBarkBattleGenerationTimeout(
regenerateBarkBattleImageAsset({
slot,
config: payload.config,
draftId: payload.draftId,
}),
slot,
)
.then((asset) => {
payload.onSlotComplete?.(slot, { status: 'fulfilled', asset });
return asset;
})
.catch((error) => {
const message = resolveBarkBattleGenerationFailureMessage(error);
payload.onSlotComplete?.(slot, { status: 'rejected', message });
throw new Error(message);
}),
] as const),
);
const assets: BarkBattleGeneratedImageAssets = {};
const failures: BarkBattleImageGenerationFailures = {};
results.forEach((result, index) => {
const slot = slots[index];
if (!slot) {
return;
}
if (result.status === 'fulfilled') {
assets[slot] = result.value[1];
return;
}
failures[slot] = resolveBarkBattleGenerationFailureMessage(result.reason);
}); });
return { assets, failures };
} }
export const barkBattleCreationClient = { export const barkBattleCreationClient = {
createDraft: createBarkBattleDraft, createDraft: createBarkBattleDraft,
generateAllImageAssets: generateAllBarkBattleImageAssets,
listGallery: listBarkBattleGallery,
listWorks: listBarkBattleWorks,
regenerateImageAsset: regenerateBarkBattleImageAsset, regenerateImageAsset: regenerateBarkBattleImageAsset,
publish: publishBarkBattleWork, publish: publishBarkBattleWork,
updateDraftConfig: updateBarkBattleDraftConfig,
uploadAsset: uploadBarkBattleAsset, uploadAsset: uploadBarkBattleAsset,
}; };

View File

@@ -2,9 +2,16 @@ export {
type BarkBattleAssetSlot, type BarkBattleAssetSlot,
barkBattleCreationClient, barkBattleCreationClient,
type BarkBattleCreationRequestOptions, type BarkBattleCreationRequestOptions,
type BarkBattleGeneratedImageAssets,
type BarkBattleImageGenerationBatchResult,
type BarkBattleImageGenerationFailures,
type BarkBattleUploadedAsset, type BarkBattleUploadedAsset,
createBarkBattleDraft, createBarkBattleDraft,
generateAllBarkBattleImageAssets,
listBarkBattleGallery,
listBarkBattleWorks,
publishBarkBattleWork, publishBarkBattleWork,
regenerateBarkBattleImageAsset, regenerateBarkBattleImageAsset,
updateBarkBattleDraftConfig,
uploadBarkBattleAsset, uploadBarkBattleAsset,
} from './barkBattleCreationClient'; } from './barkBattleCreationClient';

View File

@@ -1,4 +1,4 @@
export function normalizePublicCodeText(value: string) { export function normalizePublicCodeText(value: string) {
return value return value
.trim() .trim()
.replace(/[^a-zA-Z0-9]/gu, '') .replace(/[^a-zA-Z0-9]/gu, '')
@@ -53,6 +53,20 @@ export function buildBabyObjectMatchPublicWorkCode(profileId: string) {
return `BO-${suffix}`; return `BO-${suffix}`;
} }
function normalizeBarkBattlePublicWorkCodeSuffix(workId: string) {
const normalized = normalizePublicCodeText(workId);
const withoutPrefix = normalized.startsWith('BB')
? normalized.slice(2)
: normalized;
const fallback = withoutPrefix || normalized || '00000000';
return fallback.slice(-8).padStart(8, '0');
}
export function buildBarkBattlePublicWorkCode(workId: string) {
return `BB-${normalizeBarkBattlePublicWorkCodeSuffix(workId)}`;
}
export function isSamePuzzlePublicWorkCode(keyword: string, profileId: string) { export function isSamePuzzlePublicWorkCode(keyword: string, profileId: string) {
const normalizedKeyword = normalizePublicCodeText(keyword); const normalizedKeyword = normalizePublicCodeText(keyword);
@@ -124,3 +138,14 @@ export function isSameBabyObjectMatchPublicWorkCode(
normalizedKeyword === normalizePublicCodeText(profileId) normalizedKeyword === normalizePublicCodeText(profileId)
); );
} }
export function isSameBarkBattlePublicWorkCode(keyword: string, workId: string) {
const normalizedKeyword = normalizePublicCodeText(keyword);
return (
normalizedKeyword ===
normalizePublicCodeText(buildBarkBattlePublicWorkCode(workId)) ||
normalizedKeyword === normalizePublicCodeText(workId) ||
normalizedKeyword === normalizeBarkBattlePublicWorkCodeSuffix(workId)
);
}