fix: polish bark battle creation flow
This commit is contained in:
@@ -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 并使用服务端 runtimeConfig;prompt 单测应覆盖透明背景、正面和非狗描述不强注入狗;作品架测试应覆盖草稿与已发布卡片作者展示和封面兜底;拟声词测试应覆盖主题自动重算、自定义保持和随机展示。
|
||||||
- 关联文档:`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 应读取结果页最终图片素材而不是初始草稿素材。
|
||||||
|
|||||||
@@ -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`。
|
||||||
|
|||||||
@@ -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 公开闭环不展示音频、皮肤预设或排名配置入口。
|
||||||
|
|
||||||
## 方洞挑战
|
## 方洞挑战
|
||||||
|
|
||||||
|
|||||||
@@ -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', () => {
|
||||||
|
|||||||
@@ -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;
|
||||||
}
|
}
|
||||||
|
|
||||||
|
|||||||
BIN
public/creation-type-references/bark-battle.webp
Normal file
BIN
public/creation-type-references/bark-battle.webp
Normal file
Binary file not shown.
|
After Width: | Height: | Size: 69 KiB |
@@ -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
@@ -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();
|
||||||
|
|||||||
@@ -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(),
|
||||||
|
|||||||
@@ -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);
|
||||||
|
|||||||
@@ -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
@@ -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))
|
||||||
|
|||||||
@@ -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(
|
||||||
|
|||||||
@@ -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")))]
|
||||||
|
|||||||
@@ -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,
|
||||||
|
|||||||
@@ -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)
|
||||||
|
|||||||
@@ -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,
|
||||||
),
|
),
|
||||||
|
|||||||
@@ -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),
|
||||||
|
|||||||
@@ -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);
|
||||||
}
|
}
|
||||||
|
|
||||||
|
|||||||
@@ -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]
|
||||||
|
|||||||
@@ -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
|
||||||
|
}
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -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",
|
||||||
] {
|
] {
|
||||||
|
|||||||
@@ -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,
|
||||||
|
|||||||
@@ -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));
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|||||||
@@ -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",
|
||||||
|
|||||||
@@ -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,
|
||||||
|
|||||||
@@ -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,
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -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,
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -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",
|
||||||
|
),
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
@@ -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")
|
||||||
|
}
|
||||||
|
}
|
||||||
@@ -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,
|
||||||
|
|||||||
@@ -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,
|
||||||
|
|||||||
@@ -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(),
|
||||||
}
|
}
|
||||||
})
|
})
|
||||||
|
|||||||
@@ -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 后读取本地 cache;view 只从已发布配置和统计投影
|
||||||
|
/// 组装 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()]);
|
||||||
|
}
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -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,
|
||||||
|
}
|
||||||
|
|||||||
@@ -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 {
|
||||||
|
|||||||
@@ -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');
|
||||||
});
|
});
|
||||||
});
|
});
|
||||||
|
|||||||
@@ -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}
|
||||||
|
|||||||
@@ -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,
|
||||||
|
);
|
||||||
|
});
|
||||||
|
});
|
||||||
|
});
|
||||||
357
src/components/bark-battle-creation/BarkBattleGeneratingView.tsx
Normal file
357
src/components/bark-battle-creation/BarkBattleGeneratingView.tsx
Normal 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;
|
||||||
|
|
||||||
@@ -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>
|
||||||
|
|||||||
@@ -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',
|
||||||
|
}),
|
||||||
|
);
|
||||||
|
});
|
||||||
});
|
});
|
||||||
|
|||||||
@@ -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 ? (
|
||||||
|
|||||||
@@ -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();
|
||||||
|
|||||||
@@ -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;
|
||||||
|
|||||||
@@ -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 ? (
|
||||||
|
|||||||
@@ -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,
|
||||||
|
|||||||
@@ -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
@@ -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;
|
||||||
}
|
}
|
||||||
|
|||||||
108
src/components/platform-entry/barkBattleWorkCache.test.ts
Normal file
108
src/components/platform-entry/barkBattleWorkCache.test.ts
Normal 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');
|
||||||
|
});
|
||||||
|
|
||||||
112
src/components/platform-entry/barkBattleWorkCache.ts
Normal file
112
src/components/platform-entry/barkBattleWorkCache.ts
Normal 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());
|
||||||
|
}
|
||||||
@@ -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'
|
||||||
|
|||||||
@@ -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('作品ID:bark-battle-work-1')).toBeTruthy();
|
expect(await screen.findByText('作品ID:bark-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(
|
||||||
|
|||||||
@@ -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}
|
||||||
|
|||||||
@@ -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');
|
||||||
|
});
|
||||||
|
|||||||
@@ -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);
|
||||||
|
}
|
||||||
|
|||||||
@@ -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);
|
||||||
|
}
|
||||||
|
|||||||
@@ -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();
|
||||||
|
|||||||
@@ -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);
|
||||||
|
});
|
||||||
});
|
});
|
||||||
|
|||||||
@@ -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 = {
|
||||||
|
|||||||
@@ -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', () => {
|
||||||
|
|||||||
@@ -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; }
|
||||||
|
}
|
||||||
|
|||||||
@@ -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}
|
||||||
|
|||||||
@@ -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>
|
||||||
);
|
);
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -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();
|
||||||
|
});
|
||||||
});
|
});
|
||||||
|
|||||||
@@ -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'],
|
||||||
|
|||||||
@@ -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': '场景图片生成失败:上游超时',
|
||||||
|
});
|
||||||
|
});
|
||||||
});
|
});
|
||||||
|
|||||||
@@ -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,
|
||||||
};
|
};
|
||||||
|
|||||||
@@ -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';
|
||||||
|
|||||||
@@ -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)
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|||||||
Reference in New Issue
Block a user