codex/bark-battle #30

Merged
kdletters merged 5 commits from codex/bark-battle into master 2026-05-22 05:14:10 +08:00
71 changed files with 9085 additions and 979 deletions

View File

@@ -1,6 +1,10 @@
--- ---
name: genarrative-play-type-integration name: genarrative-play-type-integration
description: 在 Genarrative 新增、开放或重构玩法创作工具时,按平台级强约束 SOP 接入入口配置、表单/图片输入创作工作台、单图资产槽位、系列素材图集生成、独立契约、后端 DDD、结果页、运行态、作品架、广场与验证用于避免复制既有玩法、默认对话式 Agent、页面内手写图片输入或复用玩法专属素材模型 description: 在 Genarrative 新增或补齐一个创作入口/玩法类型时,按入口配置、前端分流、契约、后端接口、工作台、独立生成页、结果页、发布、统一作品详情、正式 runtime、公开 read model、基础统计与作品架/广场的顺序接入
license: MIT
metadata:
author: Hermes Agent
version: "1.0"
--- ---
# Genarrative 新增玩法创作工具平台 SOP # Genarrative 新增玩法创作工具平台 SOP
@@ -44,7 +48,46 @@ description: 在 Genarrative 新增、开放或重构玩法创作工具时,按
### 1. 文档和领域词先行 ### 1. 文档和领域词先行
先读: - 创作入口
- 工作台
- 草稿生成
- 独立生成页(如果存在自动资产生成)
- 结果页
- 发布
- 统一作品详情页
- 试玩 runtime
- 公开作品卡 / 作品架 / 广场 / 分享
- 正式 runtime 统计
## 公开闭环决策点
新增玩法如果要作为公开作品交付,先按这些决策点对齐,不要直接套某个玩法的具体字段或 UI
```text
创作入口 / 工作台
-> 草稿保存 / 编译
-> 独立生成页(自动素材,可选但推荐)
-> 结果页(确认、单槽重试、上传、发布)
-> 统一作品详情页 /works/detail?work=<公开作品码>
-> 正式 runtime
-> 基础统计 / 公开 read model
-> 作品架 / 发现流 / 分类推荐 / 今日卡片
```
必须先做这些决策:
1. **公开作品身份**:是否需要公开作品码;前缀、解析入口、分享 URL 和统一作品详情页如何映射。
2. **编辑契约边界**:哪些字段是 v1 公开编辑语义;旧字段是兼容、迁移、只读展示,还是明确不兼容。
3. **生成阶段归属**:是否有自动素材生成;生成动作放在工作台、独立 `*-generating` 页,还是结果页手动触发。
4. **失败承接策略**:全部失败、部分失败、单槽失败分别进入哪个页面;错误态由生成页还是结果页承接。
5. **结果页能力边界**:结果页只做确认 / 单槽重试 / 重新生成 / 上传,还是还允许批量生成、规则编辑或资源配置。
6. **发布后去向**:发布成功后默认进入统一作品详情页;只有明确需要时才新增专属详情页。
7. **公开卡片资产来源**:封面是复用已有素材合成、使用首图、还是新增独立封面资产。
8. **公开读取路径**:广场 / 发现流读取 SpacetimeDB view 或 public read modelapi-server 是否需要订阅缓存,避免每请求 procedure 热路径。
9. **runtime 模式差异**`draft``published` 的输入能力、mock/debug 开关、鉴权、开始条件是否不同。
10. **正式统计口径**:哪些 runtime 事件写正式统计草稿试玩、mock、debug 是否必须排除。
11. **规则参数归属**:哪些配置是创作者可编辑;哪些阈值、时长、冷却、计分、反作弊、裁决规则必须留在后端规则集。
12. **旧数据策略**:旧草稿、旧发布配置、旧分享码是迁移、降级展示、重新生成,还是明确不兼容。
- `AGENTS.md` - `AGENTS.md`
- `.hermes/shared-memory/` - `.hermes/shared-memory/`

View File

@@ -16,41 +16,21 @@
--- ---
## 2026-05-21 外部 API 失败必须 OTLP 上报并落库 ## 2026-05-20 汪汪声浪 v1 公开闭环计划
- 背景:图片生成等外部供应商调用失败时,仅返回 502/504 或普通日志无法支持后续按 provider、阶段和重试属性聚合排障 - 背景:Bark Battle v1 需要把创作、生成、结果、发布、详情和正式运行态收成一条闭环,避免把草稿试玩、公开广场和正式成绩混在一起
- 决策:外部 API 调用未成功时,`api-server` 必须同时发送 OTLP 失败观测并写入 `tracking_event`。当前通用 VectorEngine `gpt-image-2` 图片生成 / 编辑适配器记录 `external_api_call_failure``scope_kind = module``scope_id = provider``module_key = external-api`metadata 包含 endpoint、operation、failureStage、statusCode、statusClass、timeout、retryable、errorMessage、latencyMs、promptChars、referenceImageCount、imageModel 和 rawExcerpt - 决策:`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
- 落库方式:优先复用 tracking outbox 异步批量写入outbox 不可写或因保护阈值拒绝时回退同步直写 SpacetimeDB。不新增 SpacetimeDB 表,不让 reducer 做外部 I/O - 影响范围:`BarkBattleConfigEditor``BarkBattleGeneratingView``BarkBattleResultView``BarkBattleRuntimeShell``PlatformEntryFlowShellImpl``appPageRoutes`、Bark Battle creation/runtime client、公开广场聚合与相关交互测试
- 影响范围:`server-rs/crates/api-server/src/external_api_audit.rs``server-rs/crates/api-server/src/openai_image_generation.rs``server-rs/crates/api-server/src/telemetry.rs`、tracking outbox、后端架构文档和开发运维文档 - 验证方式:提交表单后先进入生成页;生成页部分失败仍能落到结果页;结果页只出现单槽重试 / 重新生成 / 上传;发布后先到 `/works/detail?work=BB-xxxxxxxx` 再进正式 runtime正式 runtime 会要求麦克风并写基础统计,草稿试玩可 mock 且不写正式 run公开广场读取 `bark_battle_gallery_view`
- 验证方式:执行 `cargo test -p api-server external_api_audit --manifest-path server-rs/Cargo.toml -- --nocapture``cargo test -p api-server openai_image_generation --manifest-path server-rs/Cargo.toml -- --nocapture``cargo check -p api-server --manifest-path server-rs/Cargo.toml``npm run check:encoding`
- 关联文档:`docs/【后端架构】server-rs与SpacetimeDB数据契约-2026-05-15.md``docs/【开发运维】本地开发验证与生产运维-2026-05-15.md`
## 2026-05-21 拼图参考图主链改为 OSS assetObjectId 与只读签名 URL
- 背景release 上拼图图生图生成草稿时,旧链路把上传图转成 Data URL/base64 放进创作 action JSON body容易先触发 Nginx `413 Request Entity Too Large`,也让外部模型调用前的 HTTP body 过大。
- 决策:浏览器参考图先通过资产直传票据上传 OSS并确认 `asset_object`;拼图 action 主链只提交 `referenceImageAssetObjectId(s)``api-server` 按当前登录用户校验 asset owner、bucket、kind、图片 MIME 和大小后签发 OSS 只读 URL传给 VectorEngine 的 generation fallback 使用;需要 edits multipart 时由后端用该签名 URL 拉取字节,不再让前端把图片塞进 JSON body。
- 兼容边界:旧 `referenceImageSrc(s)` Data URL 与历史 `/generated-*` 路径仅保留给旧草稿、旧入口和迁移期请求;调大 Nginx `client_max_body_size` 只作为兼容兜底,不是长期创作主链。
- 影响范围:拼图创作前端、`packages/shared` / `shared-contracts` action DTO、`api-server` 拼图 VectorEngine 编排、资产确认和 `spacetime-client` 资产读取 facade。
- 验证方式:前端 payload 中 AI 重绘优先出现 `referenceImageAssetObjectId(s)``referenceImageSrc(s)` 不再携带 Data URL后端 `puzzle_vector_engine_generation_prefers_signed_reference_url``puzzle_reference_image_sources_prefer_asset_object_ids``puzzle_asset_object_reference_requires_matching_owner` 通过。
- 关联文档:`docs/【玩法创作】平台入口与玩法链路-2026-05-15.md``docs/【后端架构】server-rs与SpacetimeDB数据契约-2026-05-15.md``docs/【开发运维】本地开发验证与生产运维-2026-05-15.md`
## 2026-05-21 Nginx 通用 API 入口放行创作参考图请求体
- 背景release 上拼图结果页重绘动作携带参考图 Data URL 时Nginx access log 出现 `413``request_time=0.000``upstream_status=-`,说明请求被反代层默认 1 MiB 上限拦截,未进入 `api-server`
- 决策:发布、开发服和容器 Nginx 模板的通用 `location ~ ^/api(?:/|$)` 统一设置 `client_max_body_size 64m`。该值只作为反代放行和旧 Data URL 请求兼容兜底,具体业务请求体和图片字节上限继续由 `api-server` 路由 `DefaultBodyLimit`、OSS asset 确认和业务校验控制,不能替代接口级限制;拼图参考图长期主链见同日 `OSS assetObjectId` 决策。
- 影响范围:`deploy/nginx/genarrative.conf``deploy/nginx/genarrative-dev-http.conf``deploy/container/nginx.conf`、Nginx README、生产运维文档和 release 排障口径。
- 验证方式:目标机 `nginx -T 2>/dev/null | grep client_max_body_size` 应看到 `client_max_body_size 64m;`;大于 1 MiB 的参考图请求不再在 Nginx 层直接 413access log 应出现有效 `upstream_status`
- 关联文档:`deploy/nginx/README.md``docs/【开发运维】本地开发验证与生产运维-2026-05-15.md`
## 2026-05-22 抓大鹅素材生成改为关卡整图派生三图
- 背景:旧抓大鹅素材链路按物品 5x5 sheet、纯背景和独立容器图分开生产难以保证背景、UI、容器和物品风格一致也让结果页继续暴露背景 / 容器重生成入口。
- 决策:抓大鹅草稿生成先用 `gpt-image-2` 无参考图生成竖屏 `9:16` 完整关卡画面;关卡画面完成后,以它作为参考并发生成三张可运行资产:`1K 1:1` UI spritesheet、`1K 9:16` 关卡背景图、`2K 1:1` 物品 spritesheet。UI 与物品 spritesheet 都固定要求纯绿色绿幕背景,后端上传 OSS 前扣成真实透明 PNG。物品 spritesheet 固定 `10*10`,每行两种物品、每种五个形态。运行态和编辑器都按 alpha 连通域矩形检测解析 UI 和物品图集,不按固定像素坐标切图。
- 兼容:新增字段继续存入现有 `generatedItemAssets[].backgroundAsset` / `generatedBackgroundAsset` JSON不新增 SpacetimeDB schema 字段。历史 `containerImage*` 字段只作兼容;如果它与 `uiSpritesheetImage*` 同源,不得再作为运行态中心容器图。
- 影响范围:`server-rs/crates/api-server/src/match3d/*``server-rs/crates/shared-contracts/src/match3d_*``packages/shared/src/contracts/match3dWorks.ts``src/components/match3d-result/Match3DResultView.tsx``src/components/match3d-runtime/Match3DRuntimeShell.tsx``src/services/match3dSpritesheetParser.ts`
- 验证方式:执行 `cargo test -p api-server match3d --manifest-path server-rs\Cargo.toml``npm run test -- src/components/match3d-result/Match3DResultView.test.tsx src/components/match3d-runtime/Match3DRuntimeShell.test.tsx src/services/match3dSpritesheetParser.test.ts src/services/match3dGeneratedModelCache.test.ts``npm run typecheck``npm run check:encoding`
- 关联文档:`docs/【玩法创作】平台入口与玩法链路-2026-05-15.md` - 关联文档:`docs/【玩法创作】平台入口与玩法链路-2026-05-15.md`
## 2026-05-22 汪汪声浪运行态与作品外显信息收口
- 背景Bark Battle v1 在正式运行态、图片生成提示词和作品外部卡片上仍存在体验漂移:能量条推满后还要等计时结束、进入正式 runtime 后还要二次点击声控、角色形象 prompt 会默认注入狗主体、草稿 / 已发布卡片外部看不到创作者。
- 决策:能量条到玩家或对手边界即结算;正式 `published` runtime 从作品详情启动后立即申请真实麦克风权限,授权成功后立刻进入倒计时,并使用 start run 返回的 `runtimeConfig` 作为本局前端规则参数;结束后弹出独立结算弹窗,运行态固定提供返回按钮。玩家 / 对手形象图提示词保持用户填写的形象描述,只要求单个完整形象、正面和透明背景,不把非狗描述改写成狗;草稿架、已发布作品架、统一作品详情和公开广场列表都展示后端返回的 `authorDisplayName`。Bark Battle 卡片封面按竞技背景、玩家形象、对手形象、入口参考图兜底works summary 优先读取 `publishedSnapshotJson` 的最终发布素材。拟声词进入配置 JSON未手动编辑时随主题 / 形象描述重算,手动编辑后保持创作者自定义;触发阈值降到 `0.35`、冷却降到 `150ms`,后端 `BarkBattleRuleset.min_bark_gap_ms` 同步为 `150`,局内有效触发后快速随机展示高能词池。
- 影响范围:`BarkBattleSession``BarkBattleRuntimeShell``BarkBattleConfigEditor``BarkBattleConfig`、Bark Battle 生图 prompt、Bark Battle works/gallery summary、创作中心作品架卡片、公开作品码、`module-bark-battle` ruleset 和玩法链路文档。
- 验证方式:能量条推到 `100/-100` 的领域测试应提前 finished发布态 runtime mount 后应自动调用麦克风 sampler、登记正式 run 并使用服务端 runtimeConfigprompt 单测应覆盖透明背景、正面和非狗描述不强注入狗;作品架测试应覆盖草稿与已发布卡片作者展示和封面兜底;拟声词测试应覆盖主题自动重算、自定义保持和随机展示。
- 关联文档:`docs/【玩法创作】平台入口与玩法链路-2026-05-15.md`
## 2026-05-18 Rust 手写模块入口统一不用 mod.rs ## 2026-05-18 Rust 手写模块入口统一不用 mod.rs
@@ -88,6 +68,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 批量入库。
@@ -330,6 +311,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 仍会展示手输手机号和短信验证码绑定页,体验上多了一步。
@@ -733,18 +715,14 @@
- 影响范围:`api-server` tracking 中间件、SpacetimeDB tracking procedure、部署数据目录、OTLP 指标和运维排障。 - 影响范围:`api-server` tracking 中间件、SpacetimeDB tracking procedure、部署数据目录、OTLP 指标和运维排障。
- 验证方式:数据库不可用时公开 route 请求不失败且 outbox 文件保留;恢复后批量写入成功并删除本地 sealed 文件;关键事件仍立即影响任务 / 统计。 - 验证方式:数据库不可用时公开 route 请求不失败且 outbox 文件保留;恢复后批量写入成功并删除本地 sealed 文件;关键事件仍立即影响任务 / 统计。
## 2026-05-19 跳一跳平台公开链路采用独立玩法路由 ## 2026-05-19 汪汪声浪默认开放并区分草稿试玩与正式运行态
- 背景:跳一跳玩法已接入平台入口、推荐、公开详情、试玩和运行态,后续继续扩展公开广场或推荐流时需要避免把它当成拼图兼容分支 - 背景:`bark-battle` 已具备草稿结果页、发布链路与运行态 API继续在入口层标记“敬请期待”会阻断创作闭环同时草稿试玩不应污染正式成绩统计
- 决策:跳一跳公开路由统一依赖 `sourceType='jump-hop'``JH-*` public code平台首页、推荐、公开作品列表/详情、试玩和运行态都按 `jump-hop` 独立玩法分发。后端仍是作品、运行和发布状态的业务真相,前端只做展示、交互和临时 UI 状态,不在页面层补业务规则或权限判断 - 决策:默认入口改为 `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
- 影响范围:平台入口、推荐流、公开详情、试玩启动、跳一跳运行态、`api-server` / SpacetimeDB 公开投影和 shared contracts - 验证方式:入口配置响应应返回汪汪声浪可创建和专属参考图;发布后地址应为 `/works/detail?work=BB-xxxxxxxx`;草稿试玩不调用 runtime run API正式 runtime 无麦克风时不登记正式 run结算后提交派生指标
- 验证方式:从平台推荐或公开详情进入跳一跳作品时,路由 source type 为 `jump-hop`、public code 为 `JH-*`,运行态启动消费后端返回的完整 profile / run 数据;后端 smoke 统一使用 `npm run dev:api-server` 启动并检查 `/healthz`
- 关联文档:`docs/prd/【玩法创作】跳一跳俯视角玩法模板PRD-2026-05-19.md``docs/【玩法创作】平台入口与玩法链路-2026-05-15.md``docs/【开发运维】本地开发验证与生产运维-2026-05-15.md`
# 2026-05-20 陶泥儿主视觉配色回收为暖白/陶土橙 ## 2026-05-20 汪汪声浪生成页负责三图自动生成
- 背景:用户要求只替换产品各界面的 UI 颜色,不改布局,并以两张陶泥儿主视觉图作为配色依据 - 背景:结果页承载预览、修补和发布,若继续放“一次生成”按钮会把初始生成和结果修补职责混在一起
- 决策:平台亮色主题的主色回收到暖白 / 米杏底、陶土橙主按钮、深棕正文与浅杏边框;后台管理也同步切换到同一暖橙体系。主题变量优先通过 `src/index.css``--platform-*` token 统一控制,零散组件只做必要的局部替换 - 决策:初始三图生成改由 `bark-battle-generating` 独立生成页自动执行,目标槽位只有玩家形象、对手形象和竞技背景;表单术语统一为 `themeDescription`、玩家形象描述和对手形象描述,不再回退 `themePreset`、狗狗皮肤预设或“角色设定”。部分失败也进入结果页。结果页不再提供一次生成按钮,音频配置和排名配置不进入 v1 公开闭环;结果页只保留单槽重试、重新生成和上传。发布时 SpacetimeDB `bark_battle_published_config.config_json` 使用规范化后的最终 `publishedSnapshot``published_snapshot_json` 同步保存同一份快照
- 影响范围:主站平台壳层、常用表单 / 按钮 / 卡片 / 背景、后台管理 UI、业务进度条和小游戏结果条的通用强调色。 - 验证方式:表单提交后进入 `bark-battle-generating`结果页不会出现一次生成按钮、音频槽、皮肤预设入口或排名配置Bark Battle 发布后正式 runtime 应读取结果页最终图片素材而不是初始草稿素材。
- 验证方式:优先检查 `src/index.css``apps/admin-web/src/styles/admin.css` 是否还存在旧粉色主色;再用编码检查和可执行的本地 typecheck / build 验证。
- 关联文档:`docs/【项目基线】当前产品与工程约束-2026-05-15.md`

View File

@@ -152,6 +152,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
- 现象:结果页批量重新生成物品后,试玩或正式运行态的物品类型和图片对应关系漂移,或者用户输入一个不存在名称后被当作新物品追加。 - 现象:结果页批量重新生成物品后,试玩或正式运行态的物品类型和图片对应关系漂移,或者用户输入一个不存在名称后被当作新物品追加。
@@ -1212,10 +1242,26 @@
- 验证:`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
- 现象:用户有 RPG / 拼图运行态存档,但平台底部 `草稿` Tab 只展示作品架,个人中心只有点击 `玩过` 后才可能看到“可继续”,导致看起来没有存档选择入口 - 现象:如果草稿结果页试玩和发布后 runtime 共用同一写成绩路径,未发布或未确认资源的草稿试玩会污染正式单局、排行榜和作品统计
- 原因:`/api/profile/save-archives` 已在入口 bootstrap 加载,但前端只把 `saveEntries` 注入 `ProfilePlayedWorksModal`;没有独立的存档入口 - 原因:`BarkBattleRuntimeShell` 同时承担草稿预览和发布后运行态,需要由调用方显式传入 `runtimeMode` 区分是否写正式 run
- 处理:个人中心 `常用功能` 必须保留 `存档` 快捷入口,点击后打开独立存档选择弹窗并复用 `SaveArchiveCard`;恢复仍走 `/api/profile/save-archives/{worldKey}`,拼图存档继续走拼图 resume 分支RPG 走 `handleContinueGame(snapshot)` - 处理:草稿结果页试玩保持 `runtimeMode=draft`,只做本地预览;发布成功后先进入 `/works/detail?work=BB-xxxxxxxx`,再从详情页以 `runtimeMode=published` 进入正式 runtime并在开始/结算时分别调用 `startBarkBattleRun``finishBarkBattleRun`
- 验证:`npm run test -- src/components/rpg-entry/RpgEntryFlowShell.agent.interaction.test.tsx -t "profile page exposes save archive picker"` - 验证:草稿试玩不触发 start / finish run正式 runtime 必须先通过麦克风授权,再写 start run 和结算派生指标
- 关联:`src/components/rpg-entry/RpgEntryHomeView.tsx``src/components/platform-entry/PlatformEntryFlowShellImpl.tsx``src/components/rpg-entry/useRpgEntryBootstrap.ts``docs/【玩法创作】平台入口与玩法链路-2026-05-15.md` - 关联:`src/components/platform-entry/PlatformEntryFlowShellImpl.tsx``src/games/bark-battle/ui/BarkBattleRuntimeShell.tsx``src/services/bark-battle-runtime/barkBattleRuntimeClient.ts`
## 汪汪声浪移动端创作表单不要再套一层纵向滚动
- 现象:移动端创作 Tab 里进入汪汪声浪表单后,页面右侧出现不自然的内层滚动条,最后的形象描述输入框容易被“生成草稿”按钮、键盘或底部 TabBar 挤压 / 遮挡;顶部玩法卡首尾也可能贴边显得被裁。
- 原因:外层 `.platform-tab-panel` 已经是纵向滚动容器,创作页中间又有多层 `overflow-hidden`,旧的 `BarkBattleConfigEditor` 根节点再加 `overflow-y-auto`,形成外层 Tab 面板 + 内层表单的套滚动;底部按钮只预留 safe-area不预留真实操作区距离顶部玩法卡横向滚动条隐藏且首尾没有 scroll padding。
- 处理:移动端让 Bark Battle 表单跟随父级滚动,`lg` 以上才恢复表单内滚动;创作页容器移动端使用 `overflow-visible` 和 safe-area 底部 padding顶部模板 tablist 加 `scroll-px-3` / 横向 padding移动端卡片宽度收窄避免首尾 ring 和圆角贴边裁切。
- 验证:`npm run test -- src/components/bark-battle-creation/BarkBattleConfigEditor.test.tsx``npm run test -- src/components/rpg-entry/RpgEntryFlowShell.agent.interaction.test.tsx -t "create tab shows template tabs"`、移动端视口检查最后一个输入框与“生成草稿”按钮不重叠。
- 关联:`src/components/bark-battle-creation/BarkBattleConfigEditor.tsx``src/components/platform-entry/PlatformEntryFlowShellImpl.tsx``docs/【玩法创作】平台入口与玩法链路-2026-05-15.md`
## 汪汪声浪拟声词不要被默认狗主题锁死
- 现象:创作者把主题或形象改成机甲、猫、骑士等非狗主题后,局内仍播放 `轰汪!``汪爆!` 这类狗叫词,表现像系统强行把主题带回狗。
- 原因:拟声词 textarea 如果一开始就填入默认小狗词池,并且始终作为自定义 `onomatopoeia` 提交runtime 会优先使用该字段,无法再根据新的 `themeDescription` / `playerImageDescription` / `opponentImageDescription` 走主题 fallback。
- 处理:`BarkBattleConfigEditor` 需要区分“系统默认词池”和“创作者已手动编辑”。未手动编辑时随主题 / 形象描述自动重算;手动编辑后才冻结为自定义词池。默认词池只在命中狗相关关键词时加入狗叫词,非狗主题使用科技、幻想或通用高能词。
- 验证:`npm run test -- src/components/bark-battle-creation/BarkBattleConfigEditor.test.tsx src/games/bark-battle/ui/__tests__/BarkBattleRuntimeShell.test.tsx`,并确认非狗主题的拟声词不含 `汪`
- 关联:`src/components/bark-battle-creation/BarkBattleConfigEditor.tsx``src/games/bark-battle/application/BarkBattleConfig.ts``src/games/bark-battle/ui/BarkBattleRuntimeShell.tsx`

View File

@@ -204,11 +204,38 @@ RPG / 拼图等运行态存档选择入口统一在个人中心 `常用功能 >
当前领域语言: 当前领域语言:
- 有效声浪触发:麦克风归一化响度在冷却结束后达到阈值的一次计分输入。 - 有效声浪触发:麦克风归一化响度在冷却结束后达到阈值的一次计分输入。
- 能量条:玩家与对手当前声浪优势的连续对抗刻度。 - 能量条:玩家与对手当前声浪优势的连续对抗刻度,推到玩家或对手一侧边界时本局立即结算
- 主题 / 竞技背景描述:配置字段为 `themeDescription`,用于生成竞技背景并表达整体场景,不再使用 `themePreset` 或狗狗皮肤预设。
- 玩家 / 对手形象描述:配置字段为 `playerImageDescription` / `opponentImageDescription`,对外统一称“形象描述”,不再称“角色设定”。
- 后端裁决结果:后端根据 start run 与 finish 派生指标校验后的正式单局结果。 - 后端裁决结果:后端根据 start run 与 finish 派生指标校验后的正式单局结果。
- 排行榜分榜:按 `workId + difficultyPreset + rulesetVersion` 拆分,只收录后端裁决玩家胜利的成绩 - 基础统计:只记录正式 `published` run 的开始、结算和派生指标,草稿试玩不写正式统计
- 公开广场:统一读取 `bark_battle_gallery_view` 这类 read model不再由前端自己拼公开列表。
- 创作者信息:草稿架、已发布作品架、统一作品详情和公开广场都必须展示后端返回的 `authorDisplayName`,不得只在详情页内层可见。
- 拟声词:配置字段为 `onomatopoeia`。创作者未手动编辑时,前端根据主题 / 竞技背景描述、玩家形象描述和对手形象描述生成高能词池;创作者手动编辑后按自定义词池发布。默认词池只在命中狗相关主题时加入狗叫词,不能把非狗主题强行带回狗语义。
当前入口状态为 `visible=true``open=false`,创作 Tab 展示为“敬请期待”,不进入轻配置表单或 runtime。后续重新开放时仍沿用创作 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 表单:填写作品标题、简介、主题 / 竞技背景描述、玩家形象描述、对手形象描述、拟声词和难度。拟声词支持换行、逗号、顿号、斜杠或竖线分隔;未手动编辑时随主题 / 形象描述自动重算,手动编辑后保持创作者自定义。
- 草稿编译:`POST /api/creation/bark-battle/drafts` 写入配置 JSON返回包含 `draftId`、稳定 `workId``configVersion``rulesetVersion` 的草稿结果。
- 生成页:`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 路径写回草稿配置。
- 发布:结果页确认后必须携带草稿返回的同一个 `workId` 和结果页最终 `publishedSnapshot` 调用 `POST /api/creation/bark-battle/works/publish`SpacetimeDB 发布态的 `config_json` 必须使用该最终快照works summary 若拿到 `publishedSnapshotJson` 也优先使用最终快照映射封面三图。发布成功后先进入统一作品详情页,再由详情页进入正式 runtime缺少 `workId` 的旧草稿状态需要重新生成草稿。
- 作品架Bark Battle 草稿 / 已发布列表优先读取后端 `/works`,但创建、生成完成、保存或发布后的本地摘要必须在后端 read model 尚未回读到同 `workId` 前继续保留;创作中心作品架同时接入 pending shelf 兜底,避免 ready 且三图齐全的草稿在刷新窗口期从“我的草稿 / 已发布”中消失。
- 试玩与正式 runtime草稿试玩使用 `runtimeMode=draft` 和 mock 输入,不写正式 run正式 runtime 使用 `runtimeMode=published`,进入运行态后直接申请真实麦克风权限,授权成功后立刻进入倒计时,启动对局时调用 `POST /api/runtime/bark-battle/works/{workId}/runs` 登记 start run并以返回的 `runtimeConfig` 作为本局前端规则参数;结算时调用 `POST /api/runtime/bark-battle/runs/{runId}/finish` 写入基础统计派生指标;对局会在能量条推到任一侧边界时提前结算并弹出独立结算弹窗,运行态内固定提供返回按钮。
支持的创作者可替换内容:
- 基础信息:作品标题、简介、主题 / 竞技背景描述(`themeDescription`)、玩家形象描述、对手形象描述和难度。
- 生成素材:玩家形象、对手形象和竞技背景三个槽位可单槽重试、重新生成或上传;形象图保持正面和透明背景,不把非狗形象描述改写成狗。
- 拟声词:最多保留前 `24` 个有效词;默认池按狗、机甲 / 科技、幻想 / 骑士等主题补充高能短词,并叠加通用“炸场 / 破阵 / 声浪拉满”等基础词。局内只要有效声浪触发就随机快速展示,避免连续重复。
- 运行态输入:正式 runtime 必须真实麦克风;草稿试玩允许 mock不写正式统计。
这些创作字段写入 Bark Battle 配置 JSON发布后由 runtime 和基础统计链路读取;对局时长、反作弊校验和后端裁决仍由规则集与后端控制,不能通过前端替换项改变。当前声浪触发口径为前端默认阈值 `0.35`、有效触发冷却 `150ms`,后端 `BarkBattleRuleset``min_bark_gap_ms` 也保持 `150ms`,用于正式成绩校验的物理触发上限。历史排名相关后端字段暂保留兼容,但 v1 公开闭环不展示音频、皮肤预设或排名配置入口。
## 方洞挑战 ## 方洞挑战

View File

@@ -1,24 +1,34 @@
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',
configVersion: 2,
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',
opponentCharacterImageSrc: 'https://example.test/opponent.png',
uiBackgroundImageSrc: '/generated-bark-battle/ui/background.png',
difficultyPreset: 'normal', difficultyPreset: 'normal',
leaderboardEnabled: true,
updatedAt: '2026-05-13T03:00:00.000Z', updatedAt: '2026-05-13T03:00:00.000Z',
}; };
@@ -26,15 +36,105 @@ describe('Bark Battle shared contracts', () => {
expect(draft.difficultyPreset).toBe('normal'); expect(draft.difficultyPreset).toBe('normal');
expect(Object.keys(draft)).toEqual([ expect(Object.keys(draft)).toEqual([
'draftId', 'draftId',
'workId',
'configVersion',
'rulesetVersion',
'title', 'title',
'description', 'description',
'themePreset', 'themeDescription',
'playerDogSkinPreset', 'playerImageDescription',
'opponentDogSkinPreset', 'opponentImageDescription',
'onomatopoeia',
'playerCharacterImageSrc',
'opponentCharacterImageSrc',
'uiBackgroundImageSrc',
'difficultyPreset', 'difficultyPreset',
'leaderboardEnabled',
'updatedAt', 'updatedAt',
]); ]);
expect(draft.playerCharacterImageSrc).toContain('/generated-bark-battle/');
expect('barkSoundSrc' in draft).toBe(false);
expect('leaderboardEnabled' in draft).toBe(false);
});
test('draft config update contract persists generated image slots only', () => {
const update: BarkBattleDraftConfigUpdateRequest = {
draftId: 'draft-bark-1',
workId: 'BB-12345678',
configVersion: 2,
rulesetVersion: 'bark-battle-ruleset-v1',
title: '汪汪声浪挑战',
description: '轻配置草稿',
themeDescription: '傍晚城市公园里的声浪擂台',
playerImageDescription: '戴红围巾的柯基主角',
opponentImageDescription: '蓝色运动头带的哈士奇对手',
onomatopoeia: ['轰!', '燃起来!', '破阵!'],
playerCharacterImageSrc: '/generated-bark-battle/player/image.png',
opponentCharacterImageSrc: '/generated-bark-battle/opponent/image.png',
uiBackgroundImageSrc: '/generated-bark-battle/ui/background.png',
difficultyPreset: 'normal',
};
expect(Object.keys(update)).toEqual([
'draftId',
'workId',
'configVersion',
'rulesetVersion',
'title',
'description',
'themeDescription',
'playerImageDescription',
'opponentImageDescription',
'onomatopoeia',
'playerCharacterImageSrc',
'opponentCharacterImageSrc',
'uiBackgroundImageSrc',
'difficultyPreset',
]);
expect('barkSoundSrc' in update).toBe(false);
expect('leaderboardEnabled' in update).toBe(false);
});
test('image generation contract uses dedicated Bark Battle slots and backend prompt result', () => {
expect(BARK_BATTLE_ASSET_SLOTS).toEqual([
'player-character',
'opponent-character',
'ui-background',
]);
const request: BarkBattleImageAssetGenerateRequest = {
slot: 'opponent-character',
draftId: 'bark-battle-draft-1',
config: {
title: '汪汪冠军杯',
description: '',
themeDescription: '霓虹公园擂台',
playerImageDescription: '红围巾柴犬',
opponentImageDescription: '蓝头带哈士奇',
onomatopoeia: ['轰汪!', '炸场!', '冲啊!'],
difficultyPreset: 'normal',
},
};
const response: BarkBattleGeneratedImageAsset = {
imageSrc: '/generated-bark-battle-assets/draft/opponent/image.webp',
assetId: 'asset-1',
sourceType: 'generated',
model: 'gpt-image-2',
size: '1024*1024',
taskId: 'task-1',
prompt: '后端拼装后的对手形象 prompt',
};
expect(JSON.parse(JSON.stringify(request))).toMatchObject({
slot: 'opponent-character',
config: {
opponentImageDescription: '蓝头带哈士奇',
onomatopoeia: ['轰汪!', '炸场!', '冲啊!'],
},
});
expect(JSON.parse(JSON.stringify(response))).toMatchObject({
imageSrc: '/generated-bark-battle-assets/draft/opponent/image.webp',
prompt: '后端拼装后的对手形象 prompt',
});
}); });
test('finish accepted player_win fixture exposes backend adjudication result', () => { test('finish accepted player_win fixture exposes backend adjudication result', () => {

View File

@@ -16,24 +16,65 @@ export type BarkBattleFinishStatus =
export type BarkBattlePlayTypeId = 'bark-battle'; export type BarkBattlePlayTypeId = 'bark-battle';
export interface BarkBattleConfigEditorPayload { export const BARK_BATTLE_ASSET_SLOTS = [
'player-character',
'opponent-character',
'ui-background',
] as const;
export type BarkBattleAssetSlot = (typeof BARK_BATTLE_ASSET_SLOTS)[number];
export interface BarkBattleReplacementConfig {
playerCharacterImageSrc?: string;
opponentCharacterImageSrc?: string;
uiBackgroundImageSrc?: string;
}
export type BarkBattleOnomatopoeia = string[];
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;
@@ -50,15 +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;
opponentCharacterImageSrc?: string;
uiBackgroundImageSrc?: 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;
@@ -70,10 +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;
leaderboardEnabled: boolean; onomatopoeia?: BarkBattleOnomatopoeia;
playerCharacterImageSrc?: string;
opponentCharacterImageSrc?: string;
uiBackgroundImageSrc?: string;
updatedAt: string; updatedAt: string;
} }

Binary file not shown.

After

Width:  |  Height:  |  Size: 69 KiB

View File

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

File diff suppressed because it is too large Load Diff

View File

@@ -208,6 +208,25 @@ mod tests {
assert_eq!(resolve_creation_entry_route_id("/healthz"), None); assert_eq!(resolve_creation_entry_route_id("/healthz"), None);
} }
#[test]
fn test_creation_entry_config_response_opens_bark_battle() {
let config = test_creation_entry_config_response();
let bark_battle = config
.creation_types
.iter()
.find(|item| item.id == "bark-battle")
.expect("test creation entry config should include bark-battle");
assert_eq!(bark_battle.title, "汪汪声浪");
assert!(bark_battle.visible);
assert!(bark_battle.open);
assert_eq!(bark_battle.badge, "可创建");
assert_eq!(
bark_battle.image_src,
"/creation-type-references/bark-battle.webp"
);
}
#[test] #[test]
fn test_creation_entry_config_response_keeps_baby_object_match_coming_soon() { fn test_creation_entry_config_response_keeps_baby_object_match_coming_soon() {
let config = test_creation_entry_config_response(); let config = test_creation_entry_config_response();

View File

@@ -1539,11 +1539,111 @@ pub(super) fn slice_match3d_material_sheet(
image: &DownloadedOpenAiImage, image: &DownloadedOpenAiImage,
item_names: &[String], item_names: &[String],
) -> Result<Vec<Vec<Match3DSlicedItemImage>>, AppError> { ) -> Result<Vec<Vec<Match3DSlicedItemImage>>, AppError> {
slice_generated_asset_sheet_two_items_per_row( // 中文注释:素材图提示词固定要求 5*5 均匀排布;切图也固定按 5 行 5 列定位格子。
image, // 每个格子内再基于前景像素二次校准,避免固定内缩裁断物品边缘。
item_names, let source = image::load_from_memory(image.bytes.as_slice()).map_err(|error| {
MATCH3D_MATERIAL_GRID_SIZE as usize, AppError::from_status(StatusCode::BAD_GATEWAY).with_details(json!({
MATCH3D_ITEM_VIEW_COUNT, "provider": "match3d-assets",
"message": format!("抓大鹅素材图解码失败:{error}"),
}))
})?;
// 中文注释:素材图按绿幕背景生成;先把整张 sheet 的绿幕转成 alpha再进入格子裁切。
let source = apply_match3d_material_green_screen_alpha(source);
let (width, height) = source.dimensions();
let row_count = MATCH3D_MATERIAL_GRID_SIZE;
let cell_width = width / MATCH3D_MATERIAL_GRID_SIZE;
let cell_height = height / row_count;
if cell_width == 0 || cell_height == 0 {
return Err(
AppError::from_status(StatusCode::BAD_GATEWAY).with_details(json!({
"provider": "match3d-assets",
"message": "抓大鹅素材图尺寸过小,无法切割",
})),
);
}
let mut slices = Vec::with_capacity(item_names.len());
for item_index in 0..item_names.len().min(MATCH3D_MATERIAL_ITEM_BATCH_SIZE) {
let row = item_index as u32;
let mut views = Vec::with_capacity(MATCH3D_ITEM_VIEW_COUNT);
for view_index in 0..MATCH3D_ITEM_VIEW_COUNT {
let col = view_index as u32;
let (crop_x, crop_y, crop_width, crop_height) =
resolve_match3d_material_cell_crop(&source, row_count, row, col);
let cropped = source.crop_imm(crop_x, crop_y, crop_width, crop_height);
let cleaned = crop_match3d_material_view_edge_matte(cropped);
let mut cursor = std::io::Cursor::new(Vec::new());
cleaned
.write_to(&mut cursor, ImageFormat::Png)
.map_err(|error| {
AppError::from_status(StatusCode::BAD_GATEWAY).with_details(json!({
"provider": "match3d-assets",
"message": format!("抓大鹅素材图切割失败:{error}"),
}))
})?;
views.push(Match3DSlicedItemImage {
bytes: cursor.into_inner(),
});
}
slices.push(views);
}
Ok(slices)
}
fn resolve_match3d_material_cell_crop(
source: &image::DynamicImage,
row_count: u32,
row: u32,
col: u32,
) -> (u32, u32, u32, u32) {
let (image_width, image_height) = source.dimensions();
let cell = resolve_match3d_material_cell_bounds(image_width, image_height, row_count, row, col);
let Some(foreground) = detect_match3d_material_foreground_bounds(source, cell) else {
return cell.to_crop_tuple();
};
let cell_width = cell.width();
let cell_height = cell.height();
let pad_x = (cell_width / 16).clamp(4, 16);
let pad_y = (cell_height / 16).clamp(4, 16);
let crop = Match3DMaterialCellBounds {
x0: foreground.x0.saturating_sub(pad_x).max(cell.x0),
y0: foreground.y0.saturating_sub(pad_y).max(cell.y0),
x1: foreground.x1.saturating_add(pad_x).min(cell.x1),
y1: foreground.y1.saturating_add(pad_y).min(cell.y1),
};
crop.to_crop_tuple()
}
pub(super) fn crop_match3d_material_view_edge_matte(
image: image::DynamicImage,
) -> image::DynamicImage {
let mut image = image.to_rgba8();
let (width, height) = image.dimensions();
remove_match3d_material_view_edge_matte(image.as_mut(), width as usize, height as usize);
let bounds = detect_match3d_material_visible_bounds(&image).unwrap_or_else(|| {
Match3DMaterialCellBounds {
x0: 0,
y0: 0,
x1: width,
y1: height,
}
});
if bounds.x0 == 0 && bounds.y0 == 0 && bounds.x1 == width && bounds.y1 == height {
return image::DynamicImage::ImageRgba8(image);
}
image::DynamicImage::ImageRgba8(
image::imageops::crop_imm(
&image,
bounds.x0,
bounds.y0,
bounds.width(),
bounds.height(),
)
.to_image(),
) )
.map(|rows| { .map(|rows| {
rows.into_iter() rows.into_iter()

View File

@@ -1,5 +1,7 @@
use super::*; use super::*;
use super::*;
fn test_match3d_generated_item_asset(index: u32, name: &str) -> Match3DGeneratedItemAsset { fn test_match3d_generated_item_asset(index: u32, name: &str) -> Match3DGeneratedItemAsset {
Match3DGeneratedItemAsset { Match3DGeneratedItemAsset {
item_id: format!("match3d-item-{index}"), item_id: format!("match3d-item-{index}"),
@@ -147,17 +149,17 @@ fn match3d_item_image_path_segments_stay_unique_for_chinese_names() {
} }
#[test] #[test]
fn match3d_material_sheet_slicing_uses_fixed_ten_by_ten_two_items_per_row() { fn match3d_material_sheet_slicing_uses_fixed_five_by_five_rows() {
let width = 1000; let width = 500;
let height = 1000; let height = 500;
let item_names = vec!["樱桃".to_string(), "苹果".to_string(), "香蕉".to_string()]; let item_names = vec!["樱桃".to_string(), "苹果".to_string(), "香蕉".to_string()];
let mut sheet = image::RgbaImage::new(width, height); let mut sheet = image::RgbaImage::new(width, height);
for row in 0..10 { for row in 0..5 {
for col in 0..10 { for col in 0..5 {
let color = image::Rgba([ let color = image::Rgba([
32 + row as u8 * 16, 32 + row as u8 * 40,
24 + col as u8 * 18, 24 + col as u8 * 36,
210 - row as u8 * 12, 210 - row as u8 * 30,
255, 255,
]); ]);
for y in row * 100..(row + 1) * 100 { for y in row * 100..(row + 1) * 100 {
@@ -180,12 +182,9 @@ fn match3d_material_sheet_slicing_uses_fixed_ten_by_ten_two_items_per_row() {
let slices = slice_match3d_material_sheet(&image, &item_names).expect("sheet should slice"); let slices = slice_match3d_material_sheet(&image, &item_names).expect("sheet should slice");
assert_eq!(slices.len(), 3); assert_eq!(slices.len(), 3);
for (item_index, views) in slices.iter().enumerate() { for (row, views) in slices.iter().enumerate() {
let row = item_index / 2;
let start_col = (item_index % 2) * MATCH3D_ITEM_VIEW_COUNT;
assert_eq!(views.len(), MATCH3D_ITEM_VIEW_COUNT); assert_eq!(views.len(), MATCH3D_ITEM_VIEW_COUNT);
for (view_index, view) in views.iter().enumerate() { for (col, view) in views.iter().enumerate() {
let col = start_col + view_index;
let decoded = image::load_from_memory(view.bytes.as_slice()) let decoded = image::load_from_memory(view.bytes.as_slice())
.expect("view should decode") .expect("view should decode")
.to_rgba8(); .to_rgba8();
@@ -193,12 +192,12 @@ fn match3d_material_sheet_slicing_uses_fixed_ten_by_ten_two_items_per_row() {
assert_eq!( assert_eq!(
pixel.0, pixel.0,
[ [
32 + row as u8 * 16, 32 + row as u8 * 40,
24 + col as u8 * 18, 24 + col as u8 * 36,
210 - row as u8 * 12, 210 - row as u8 * 30,
255, 255,
], ],
"item {item_index} view {view_index} should be cut from the fixed 10*10 grid" "row {row} col {col} should be cut from the fixed 5*5 grid row"
); );
} }
} }
@@ -206,8 +205,8 @@ fn match3d_material_sheet_slicing_uses_fixed_ten_by_ten_two_items_per_row() {
#[test] #[test]
fn match3d_material_sheet_slicing_keeps_near_edge_foreground_pixels() { fn match3d_material_sheet_slicing_keeps_near_edge_foreground_pixels() {
let width = 1000; let width = 500;
let height = 1000; let height = 500;
let item_names = vec!["樱桃".to_string(), "苹果".to_string(), "香蕉".to_string()]; let item_names = vec!["樱桃".to_string(), "苹果".to_string(), "香蕉".to_string()];
let mut sheet = image::RgbaImage::from_pixel(width, height, image::Rgba([255, 255, 255, 255])); let mut sheet = image::RgbaImage::from_pixel(width, height, image::Rgba([255, 255, 255, 255]));
for y in 1..5 { for y in 1..5 {
@@ -619,52 +618,6 @@ fn match3d_background_image_postprocess_removes_transparent_pixels() {
); );
} }
#[test]
fn match3d_level_scene_prompt_uses_requested_theme_and_full_ui_layout() {
let prompt = build_match3d_level_scene_generation_prompt(&config("重庆火锅", 12, 4));
assert!(prompt.contains("重庆火锅"));
assert!(prompt.contains("第1关 重庆火锅"));
assert!(prompt.contains("返回按钮位于顶部左上角"));
assert!(prompt.contains("设置按钮"));
assert!(prompt.contains("和主题匹配的容器"));
assert!(prompt.contains("移出"));
assert!(prompt.contains("凑齐"));
assert!(prompt.contains("打乱"));
}
#[test]
fn match3d_derived_asset_prompts_match_three_sheet_pipeline() {
let config = config("水果", 12, 4);
let ui_prompt = build_match3d_ui_spritesheet_prompt();
let background_prompt = build_match3d_background_from_scene_prompt();
let item_prompt = build_match3d_material_sheet_prompt(
&config,
&["草莓".to_string(), "苹果".to_string(), "香蕉".to_string()],
);
assert!(ui_prompt.contains("返回按钮"));
assert!(ui_prompt.contains("设置按钮"));
assert!(ui_prompt.contains("方格素材"));
assert!(ui_prompt.contains("纯绿色绿幕背景spritesheet"));
assert!(ui_prompt.contains("绿幕扣成透明"));
assert!(background_prompt.contains("移除画面中的所有UI组件"));
assert!(background_prompt.contains("完整保留容器和背景"));
assert!(item_prompt.contains("10行*10列"));
assert!(item_prompt.contains("纯绿色绿幕背景"));
assert!(item_prompt.contains("扣成透明"));
assert!(item_prompt.contains("每一行包含两种物品"));
assert!(item_prompt.contains("五个不同形态"));
}
#[test]
fn match3d_hardcore_generated_item_count_is_capped_by_ten_by_ten_sheet() {
assert_eq!(
resolve_match3d_generated_item_count(&config("水果", 21, 8)),
20
);
}
#[test] #[test]
fn match3d_work_metadata_parses_gpt4o_json() { fn match3d_work_metadata_parses_gpt4o_json() {
let metadata = parse_match3d_work_metadata( let metadata = parse_match3d_work_metadata(
@@ -736,69 +689,38 @@ fn match3d_legacy_item_asset_without_size_defaults_to_large() {
} }
#[test] #[test]
fn match3d_draft_item_plan_rounds_up_to_full_ten_by_ten_sheet() { fn match3d_draft_item_plan_rounds_up_to_full_five_item_sheets() {
let plan = parse_match3d_draft_plan( let plan = parse_match3d_draft_plan(
r#"{"gameName":"果园大鹅宴","tags":["水果","抓大鹅"],"backgroundMusic":{"title":"果园轻舞","style":"轻快, 休闲","prompt":""},"backgroundPrompt":"果园主题竖屏纯背景","items":[{"name":"草莓","soundPrompt":"草莓点击音效"},{"name":"苹果","soundPrompt":"苹果点击音效"},{"name":"香蕉","soundPrompt":"香蕉点击音效"},{"name":"葡萄","soundPrompt":"葡萄点击音效"},{"name":"西瓜","soundPrompt":"西瓜点击音效"},{"name":"梨子","soundPrompt":"梨子点击音效"},{"name":"桃子","soundPrompt":"桃子点击音效"},{"name":"橙子","soundPrompt":"橙子点击音效"},{"name":"蓝莓","soundPrompt":"蓝莓点击音效"}]}"#, r#"{"gameName":"果园大鹅宴","tags":["水果","抓大鹅"],"backgroundMusic":{"title":"果园轻舞","style":"轻快, 休闲","prompt":""},"backgroundPrompt":"果园主题竖屏纯背景","items":[{"name":"草莓","soundPrompt":"草莓点击音效"},{"name":"苹果","soundPrompt":"苹果点击音效"},{"name":"香蕉","soundPrompt":"香蕉点击音效"},{"name":"葡萄","soundPrompt":"葡萄点击音效"},{"name":"西瓜","soundPrompt":"西瓜点击音效"},{"name":"梨子","soundPrompt":"梨子点击音效"},{"name":"桃子","soundPrompt":"桃子点击音效"},{"name":"橙子","soundPrompt":"橙子点击音效"},{"name":"蓝莓","soundPrompt":"蓝莓点击音效"}]}"#,
&config("水果", 12, 4), &config("水果", 12, 4),
) )
.expect("draft plan should parse"); .expect("draft plan should parse");
assert_eq!(plan.items.len(), 20); assert_eq!(plan.items.len(), 10);
assert_eq!(plan.items[8].name, "蓝莓"); assert_eq!(plan.items[8].name, "蓝莓");
assert_ne!(plan.items[9].name, "蓝莓"); assert_ne!(plan.items[9].name, "蓝莓");
} }
#[test] #[test]
fn match3d_generated_item_count_uses_full_ten_by_ten_sheet_capacity() { fn match3d_generated_item_count_rounds_up_to_five_multiples() {
assert_eq!( assert_eq!(
resolve_match3d_generated_item_count(&config("水果", 8, 2)), resolve_match3d_generated_item_count(&config("水果", 8, 2)),
20 5
); );
assert_eq!( assert_eq!(
resolve_match3d_generated_item_count(&config("水果", 12, 4)), resolve_match3d_generated_item_count(&config("水果", 12, 4)),
20 10
); );
assert_eq!( assert_eq!(
resolve_match3d_generated_item_count(&config("水果", 16, 6)), resolve_match3d_generated_item_count(&config("水果", 16, 6)),
20
);
assert_eq!(
resolve_match3d_generated_item_count(&config("水果", 21, 8)),
20
);
}
#[test]
fn match3d_gameplay_item_count_uses_difficulty_loading_limit() {
assert_eq!(
resolve_match3d_gameplay_item_count(&config("水果", 8, 2)),
3
);
assert_eq!(
resolve_match3d_gameplay_item_count(&config("水果", 12, 4)),
9
);
assert_eq!(
resolve_match3d_gameplay_item_count(&config("水果", 16, 6)),
15 15
); );
assert_eq!( assert_eq!(
resolve_match3d_gameplay_item_count(&config("水果", 21, 8)), resolve_match3d_generated_item_count(&config("水果", 21, 8)),
20 25
); );
} }
#[test]
fn match3d_material_sheet_cell_indices_stay_inside_ten_by_ten_grid() {
let first = resolve_match3d_material_sheet_cell_indices(0, 0);
let second = resolve_match3d_material_sheet_cell_indices(1, 0);
let twentieth_last_view = resolve_match3d_material_sheet_cell_indices(19, 4);
assert_eq!(first, (1, 1));
assert_eq!(second, (1, 6));
assert_eq!(twentieth_last_view, (10, 10));
}
#[test] #[test]
fn match3d_generated_assets_require_only_images_when_click_sound_is_closed() { fn match3d_generated_assets_require_only_images_when_click_sound_is_closed() {
let assets = vec![test_match3d_generated_item_asset(1, "草莓")]; let assets = vec![test_match3d_generated_item_asset(1, "草莓")];
@@ -811,11 +733,12 @@ fn match3d_generated_assets_require_only_images_when_click_sound_is_closed() {
} }
#[test] #[test]
fn match3d_item_asset_points_cost_counts_ten_by_ten_sheet_batches() { fn match3d_item_asset_points_cost_counts_five_item_batches() {
assert_eq!(calculate_match3d_item_assets_points_cost(0), 0); assert_eq!(calculate_match3d_item_assets_points_cost(0), 0);
assert_eq!(calculate_match3d_item_assets_points_cost(1), 2); assert_eq!(calculate_match3d_item_assets_points_cost(1), 2);
assert_eq!(calculate_match3d_item_assets_points_cost(20), 2); assert_eq!(calculate_match3d_item_assets_points_cost(5), 2);
assert_eq!(calculate_match3d_item_assets_points_cost(21), 4); assert_eq!(calculate_match3d_item_assets_points_cost(6), 4);
assert_eq!(calculate_match3d_item_assets_points_cost(10), 4);
} }
#[test] #[test]
@@ -854,7 +777,7 @@ fn match3d_item_asset_append_plan_pads_generation_without_persisting_padding() {
); );
assert_eq!(plan.requested_item_names, vec!["苹果", "香蕉", "梨子"]); assert_eq!(plan.requested_item_names, vec!["苹果", "香蕉", "梨子"]);
assert_eq!(plan.padded_item_names.len(), 20); assert_eq!(plan.padded_item_names.len(), 5);
assert_eq!(&plan.padded_item_names[..3], ["苹果", "香蕉", "梨子"]); assert_eq!(&plan.padded_item_names[..3], ["苹果", "香蕉", "梨子"]);
assert_eq!( assert_eq!(
calculate_match3d_item_assets_points_cost(plan.requested_item_names.len()), calculate_match3d_item_assets_points_cost(plan.requested_item_names.len()),
@@ -951,7 +874,6 @@ fn match3d_regenerated_asset_keeps_stable_identity_and_side_assets() {
container_image_object_key: None, container_image_object_key: None,
status: "image_ready".to_string(), status: "image_ready".to_string(),
error: None, error: None,
..Default::default()
}); });
let mut generated_asset = test_match3d_generated_item_asset(99, "新草莓"); let mut generated_asset = test_match3d_generated_item_asset(99, "新草莓");
generated_asset.image_src = generated_asset.image_src =
@@ -977,19 +899,20 @@ fn match3d_regenerated_asset_keeps_stable_identity_and_side_assets() {
} }
#[test] #[test]
fn match3d_material_sheet_prompt_requires_uniform_ten_by_ten_transparent_layout() { fn match3d_material_sheet_prompt_requires_uniform_five_by_five_layout() {
let prompt = build_match3d_material_sheet_prompt( let prompt = build_match3d_material_sheet_prompt(
&config("水果", 12, 4), &config("水果", 12, 4),
&["草莓".to_string(), "苹果".to_string(), "香蕉".to_string()], &["草莓".to_string(), "苹果".to_string(), "香蕉".to_string()],
); );
assert!(prompt.contains("10行*10列spritesheet图")); assert!(prompt.contains("5行*5列"));
assert!(prompt.contains("纯绿色绿幕背景")); assert!(prompt.contains("严格5*5均匀排布"));
assert!(prompt.contains("绿幕背景"));
assert!(prompt.contains("#00FF00")); assert!(prompt.contains("#00FF00"));
assert!(prompt.contains("素材间距严格均匀分布")); assert!(prompt.contains("单个素材格宽度的1/4空白间距"));
assert!(prompt.contains("每一行包含两种物品")); assert!(prompt.contains("约25%单格宽度"));
assert!(prompt.contains("每种物品的五个不同形态")); assert!(prompt.contains("禁止主体跨格"));
assert!(prompt.contains("严禁出现两种高相似度的物品")); assert!(prompt.contains("贴边或越界"));
} }
#[test] #[test]
@@ -1000,53 +923,16 @@ fn match3d_material_sheet_prompt_hardens_pixel_retro_style() {
let prompt = build_match3d_material_sheet_prompt(&config, &["草莓".to_string()]); let prompt = build_match3d_material_sheet_prompt(&config, &["草莓".to_string()]);
let negative_prompt = build_match3d_material_sheet_negative_prompt(&config); let negative_prompt = build_match3d_material_sheet_negative_prompt(&config);
assert!(prompt.contains("10行*10列spritesheet图")); assert!(prompt.contains("64x64"));
assert!(prompt.contains("纯绿色绿幕背景")); assert!(prompt.contains("整数倍放大"));
assert!(prompt.contains("禁止抗锯齿"));
assert!(prompt.contains("真实 3D 渲染"));
assert!(prompt.contains("PBR 材质"));
assert!(negative_prompt.contains("抗锯齿")); assert!(negative_prompt.contains("抗锯齿"));
assert!(negative_prompt.contains("平滑插画")); assert!(negative_prompt.contains("平滑插画"));
assert!(negative_prompt.contains("真实 3D 渲染")); assert!(negative_prompt.contains("真实 3D 渲染"));
} }
#[test]
fn match3d_spritesheet_green_screen_postprocess_turns_background_transparent() {
let width = 100;
let height = 100;
let mut image = image::RgbaImage::from_pixel(width, height, image::Rgba([0, 255, 0, 255]));
for y in 32..68 {
for x in 32..68 {
image.put_pixel(x, y, image::Rgba([220, 32, 48, 255]));
}
}
let mut encoded = std::io::Cursor::new(Vec::new());
image::DynamicImage::ImageRgba8(image)
.write_to(&mut encoded, ImageFormat::Png)
.expect("spritesheet should encode");
let processed = make_match3d_spritesheet_image_transparent(DownloadedOpenAiImage {
bytes: encoded.into_inner(),
mime_type: "image/png".to_string(),
extension: "png".to_string(),
})
.expect("spritesheet should postprocess");
let decoded = image::load_from_memory(processed.bytes.as_slice())
.expect("processed spritesheet should decode")
.to_rgba8();
assert_eq!(processed.mime_type, "image/png");
assert_eq!(processed.extension, "png");
assert_eq!(
decoded.get_pixel(0, 0).0[3],
0,
"绿幕背景必须在上传 OSS 前扣成透明 alpha"
);
assert_eq!(
decoded.get_pixel(width / 2, height / 2).0,
[220, 32, 48, 255],
"物品主体不能被绿幕去背误删"
);
}
#[test] #[test]
fn match3d_material_sheet_request_uses_vector_engine_gemini_contract() { fn match3d_material_sheet_request_uses_vector_engine_gemini_contract() {
let body = build_match3d_vector_engine_gemini_image_request_body( let body = build_match3d_vector_engine_gemini_image_request_body(
@@ -1176,7 +1062,6 @@ fn match3d_background_asset_requires_background_and_container_images() {
container_image_object_key: None, container_image_object_key: None,
status: "image_ready".to_string(), status: "image_ready".to_string(),
error: None, error: None,
..Default::default()
}; };
let with_container = Match3DGeneratedBackgroundAsset { let with_container = Match3DGeneratedBackgroundAsset {
container_prompt: Some("果园容器".to_string()), container_prompt: Some("果园容器".to_string()),
@@ -1223,7 +1108,6 @@ fn match3d_default_cover_prefers_generated_container_ui_image() {
container_image_object_key: None, container_image_object_key: None,
status: "image_ready".to_string(), status: "image_ready".to_string(),
error: None, error: None,
..Default::default()
}), }),
status: "image_ready".to_string(), status: "image_ready".to_string(),
error: None, error: None,
@@ -1274,21 +1158,6 @@ fn match3d_public_reference_image_paths_are_limited_to_known_assets() {
); );
} }
#[test]
fn match3d_container_reference_image_is_embedded_for_api_only_deploy() {
let reference = load_match3d_container_reference_image()
.expect("container reference image should be compiled into api-server");
assert_eq!(reference.mime_type, "image/png");
assert_eq!(reference.file_name, "match3d-container-reference.png");
assert!(
reference
.bytes
.starts_with(&[137, 80, 78, 71, 13, 10, 26, 10]),
"container reference image should be PNG bytes"
);
}
#[test] #[test]
fn match3d_cover_reference_prompt_marks_reference_images() { fn match3d_cover_reference_prompt_marks_reference_images() {
let prompt = build_match3d_cover_reference_generation_prompt("水果封面", true); let prompt = build_match3d_cover_reference_generation_prompt("水果封面", true);
@@ -1299,8 +1168,8 @@ fn match3d_cover_reference_prompt_marks_reference_images() {
} }
#[test] #[test]
fn match3d_cover_reference_generation_prompt_preserves_uploaded_image() { fn match3d_cover_edit_prompt_preserves_uploaded_image() {
let prompt = build_match3d_cover_uploaded_reference_prompt("水果封面"); let prompt = build_match3d_cover_edit_prompt("水果封面");
assert!(prompt.contains("上传的封面图作为第一优先级")); assert!(prompt.contains("上传的封面图作为第一优先级"));
assert!(prompt.contains("保留主图的主体、构图、视角和主要配色")); assert!(prompt.contains("保留主图的主体、构图、视角和主要配色"));
@@ -1343,7 +1212,6 @@ fn match3d_fallback_work_profile_keeps_generated_background_asset() {
), ),
status: "image_ready".to_string(), status: "image_ready".to_string(),
error: None, error: None,
..Default::default()
}), }),
status: "image_ready".to_string(), status: "image_ready".to_string(),
error: None, error: None,
@@ -1481,7 +1349,6 @@ fn match3d_agent_session_response_hydrates_persisted_ui_assets() {
), ),
status: "image_ready".to_string(), status: "image_ready".to_string(),
error: None, error: None,
..Default::default()
}), }),
status: "image_ready".to_string(), status: "image_ready".to_string(),
error: None, error: None,
@@ -1557,7 +1424,6 @@ fn match3d_agent_session_response_keeps_draft_ui_assets_without_work_detail_hydr
), ),
status: "image_ready".to_string(), status: "image_ready".to_string(),
error: None, error: None,
..Default::default()
}), }),
status: "image_ready".to_string(), status: "image_ready".to_string(),
error: None, error: None,
@@ -1941,7 +1807,6 @@ fn match3d_work_summary_marks_complete_generated_assets_ready() {
), ),
status: "image_ready".to_string(), status: "image_ready".to_string(),
error: None, error: None,
..Default::default()
}), }),
..test_match3d_generated_item_asset(1, "草莓") ..test_match3d_generated_item_asset(1, "草莓")
}]; }];

View File

@@ -716,44 +716,6 @@ pub(super) fn load_match3d_container_reference_image() -> Result<OpenAiReference
}) })
} }
pub(super) fn build_match3d_level_scene_generation_prompt(config: &Match3DConfigJson) -> String {
let theme = config.theme_text.trim();
let theme = if theme.is_empty() {
MATCH3D_DEFAULT_THEME
} else {
theme
};
let style_clause = resolve_match3d_asset_style_prompt(config)
.map(|style| format!("\n整体美术风格要求:{style}"))
.unwrap_or_default();
format!(
concat!(
"生成抓大鹅游戏关卡画面要求画面中所有元素精致且风格高度一致画面中所有UI细节饱满精致、完成度高、顶级游戏品质\n\n",
"抓大鹅主题描述:\n",
"{theme}{style_clause}\n\n",
"画面元素:\n",
"返回按钮位于顶部左上角顶部中间显示关卡标题“第1关 重庆火锅”和倒计时时间,右上角显示设置按钮\n",
"画面中间是一个和主题匹配的容器,宽度与画面宽度同宽,紧贴画面横向边缘\n",
"底部还有三个道具按钮分别为“移出”、“凑齐”、“打乱”"
),
theme = theme,
style_clause = style_clause,
)
}
pub(super) fn build_match3d_ui_spritesheet_prompt() -> String {
"提取画面中的UI元素将返回按钮、设置按钮、方格素材不含边框仅保留一个、移出按钮、凑齐按钮、打乱按钮的顺序从上到下从左到右整理成纯绿色绿幕背景spritesheet。背景必须是统一纯绿色绿幕高饱和亮绿色接近 #00FF00背景平整无纹理、无渐变、无阴影、无场景内容后端会在生图后将绿幕扣成透明并把透明背景 PNG 存到 OSS。UI 素材自身不得使用接近 #00FF00 的高饱和纯绿;绿色题材只能使用深绿、橄榄绿、金绿或蓝绿,并用清晰描边与绿幕区分。".to_string()
}
pub(super) fn build_match3d_background_from_scene_prompt() -> String {
"移除画面中的所有UI组件和容器中的内含物完整保留容器和背景补全被UI覆盖的背景内容".to_string()
}
pub(super) fn build_match3d_item_spritesheet_prompt() -> String {
"固定生成10行*10列spritesheet图统一纯绿色绿幕背景高饱和亮绿色接近 #00FF00背景平整无纹理、无渐变、无阴影、无场景内容后端会在生图后将绿幕扣成透明并把透明背景 PNG 存到 OSS。素材间距严格均匀分布任意两个素材间距相同物品来自参考图中画面中心容器中的2D素材。每一行包含两种物品每种物品的五个不同形态。物品素材自身不得使用接近 #00FF00 的高饱和纯绿;绿色物品只能使用深绿、橄榄绿、金绿或蓝绿,并用清晰描边与绿幕区分。严禁出现两种高相似度的物品".to_string()
}
pub(super) fn build_match3d_background_generation_prompt( pub(super) fn build_match3d_background_generation_prompt(
config: &Match3DConfigJson, config: &Match3DConfigJson,
prompt: &str, prompt: &str,

View File

@@ -7,7 +7,9 @@ use crate::{
auth::require_bearer_auth, auth::require_bearer_auth,
bark_battle::{ bark_battle::{
create_bark_battle_draft, finish_bark_battle_run, get_bark_battle_run, create_bark_battle_draft, finish_bark_battle_run, get_bark_battle_run,
get_bark_battle_runtime_config, publish_bark_battle_work, start_bark_battle_run, generate_bark_battle_image_asset, get_bark_battle_runtime_config,
list_bark_battle_gallery, list_bark_battle_works, publish_bark_battle_work,
start_bark_battle_run, update_bark_battle_draft_config,
}, },
state::AppState, state::AppState,
}; };
@@ -21,6 +23,20 @@ pub fn router(state: AppState) -> Router<AppState> {
require_bearer_auth, require_bearer_auth,
)), )),
) )
.route(
"/api/creation/bark-battle/drafts/{draft_id}/config",
post(update_bark_battle_draft_config).route_layer(middleware::from_fn_with_state(
state.clone(),
require_bearer_auth,
)),
)
.route(
"/api/creation/bark-battle/images/generate",
post(generate_bark_battle_image_asset).route_layer(middleware::from_fn_with_state(
state.clone(),
require_bearer_auth,
)),
)
.route( .route(
"/api/creation/bark-battle/works/publish", "/api/creation/bark-battle/works/publish",
post(publish_bark_battle_work).route_layer(middleware::from_fn_with_state( post(publish_bark_battle_work).route_layer(middleware::from_fn_with_state(
@@ -28,6 +44,17 @@ pub fn router(state: AppState) -> Router<AppState> {
require_bearer_auth, require_bearer_auth,
)), )),
) )
.route(
"/api/runtime/bark-battle/works",
get(list_bark_battle_works).route_layer(middleware::from_fn_with_state(
state.clone(),
require_bearer_auth,
)),
)
.route(
"/api/runtime/bark-battle/gallery",
get(list_bark_battle_gallery),
)
.route( .route(
"/api/runtime/bark-battle/works/{work_id}/config", "/api/runtime/bark-battle/works/{work_id}/config",
get(get_bark_battle_runtime_config).route_layer(middleware::from_fn_with_state( get(get_bark_battle_runtime_config).route_layer(middleware::from_fn_with_state(

View File

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

View File

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

View File

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

View File

@@ -263,9 +263,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]
@@ -539,8 +543,9 @@ mod tests {
#[test] #[test]
fn runtime_profile_beijing_day_key_uses_business_day_boundary() { fn runtime_profile_beijing_day_key_uses_business_day_boundary() {
let before_beijing_midnight = 1_714_927_999_999_999; // 中文注释2024-05-06 00:00:00 Asia/Shanghai 前后 1 微秒。
let after_beijing_midnight = 1_714_928_000_000_000; let before_beijing_midnight = 1_714_924_799_999_999;
let after_beijing_midnight = 1_714_924_800_000_000;
assert_eq!( assert_eq!(
runtime_profile_beijing_day_key(before_beijing_midnight), runtime_profile_beijing_day_key(before_beijing_midnight),

View File

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

View File

@@ -30,18 +30,44 @@ 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)]
#[serde(rename_all = "camelCase")]
pub struct BarkBattleReplacementConfig {
#[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>,
}
#[derive(Clone, Debug, Serialize, Deserialize, PartialEq)] #[derive(Clone, Debug, Serialize, Deserialize, PartialEq)]
#[serde(rename_all = "camelCase")] #[serde(rename_all = "camelCase")]
pub struct BarkBattleConfigEditorPayload { 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")]
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)] #[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)]
@@ -50,12 +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")]
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)] #[serde(default)]
pub difficulty_preset: BarkBattleDifficultyPreset, pub difficulty_preset: BarkBattleDifficultyPreset,
pub leaderboard_enabled: bool,
} }
impl From<BarkBattleDraftCreateRequest> for BarkBattleConfigEditorPayload { impl From<BarkBattleDraftCreateRequest> for BarkBattleConfigEditorPayload {
@@ -63,11 +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,
opponent_character_image_src: value.opponent_character_image_src,
ui_background_image_src: value.ui_background_image_src,
difficulty_preset: value.difficulty_preset, difficulty_preset: value.difficulty_preset,
leaderboard_enabled: value.leaderboard_enabled,
} }
} }
} }
@@ -82,19 +163,56 @@ 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 {
pub draft_id: String, 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, 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")]
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)] #[serde(default)]
pub difficulty_preset: BarkBattleDifficultyPreset, pub difficulty_preset: BarkBattleDifficultyPreset,
pub leaderboard_enabled: bool,
pub updated_at: String, pub updated_at: String,
} }
@@ -102,13 +220,19 @@ impl Default for BarkBattleDraftConfig {
fn default() -> Self { fn default() -> Self {
Self { Self {
draft_id: String::new(), draft_id: String::new(),
work_id: None,
config_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,
opponent_character_image_src: None,
ui_background_image_src: None,
difficulty_preset: BarkBattleDifficultyPreset::Normal, difficulty_preset: BarkBattleDifficultyPreset::Normal,
leaderboard_enabled: true,
updated_at: String::new(), updated_at: String::new(),
} }
} }
@@ -126,11 +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")]
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 difficulty_preset: BarkBattleDifficultyPreset,
pub leaderboard_enabled: bool,
pub updated_at: String, pub updated_at: String,
pub published_at: String, pub published_at: String,
} }
@@ -148,13 +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,
pub leaderboard_enabled: bool, #[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 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 {
@@ -355,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();
@@ -409,7 +711,22 @@ mod tests {
fn optional_fields_are_omitted_when_absent() { fn optional_fields_are_omitted_when_absent() {
let draft = BarkBattleDraftConfig::default(); let draft = BarkBattleDraftConfig::default();
let payload = serde_json::to_value(draft).expect("draft should serialize"); let payload = serde_json::to_value(draft).expect("draft should serialize");
assert!(!payload.as_object().unwrap().contains_key("workId"));
assert!(!payload.as_object().unwrap().contains_key("configVersion"));
assert!(!payload.as_object().unwrap().contains_key("rulesetVersion"));
assert!(!payload.as_object().unwrap().contains_key("description")); assert!(!payload.as_object().unwrap().contains_key("description"));
assert!(
!payload
.as_object()
.unwrap()
.contains_key("playerCharacterImageSrc")
);
assert!(
!payload
.as_object()
.unwrap()
.contains_key("uiBackgroundImageSrc")
);
let response = BarkBattlePersonalHistoryResponse { let response = BarkBattlePersonalHistoryResponse {
work_id: None, work_id: None,
@@ -429,6 +746,155 @@ mod tests {
assert!(!payload.as_object().unwrap().contains_key("bestSummary")); assert!(!payload.as_object().unwrap().contains_key("bestSummary"));
} }
#[test]
fn draft_config_serializes_persistent_identity_fields() {
let draft = BarkBattleDraftConfig {
draft_id: "bark-battle-draft-1".to_string(),
work_id: Some("bark-battle-work-1".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: None,
player_character_image_src: None,
opponent_character_image_src: None,
ui_background_image_src: None,
difficulty_preset: BarkBattleDifficultyPreset::Normal,
updated_at: "2026-05-14T10:00:00.000Z".to_string(),
};
let payload = serde_json::to_value(draft).expect("draft should serialize");
assert_eq!(payload["draftId"], json!("bark-battle-draft-1"));
assert_eq!(payload["workId"], json!("bark-battle-work-1"));
assert_eq!(payload["configVersion"], json!(2));
assert_eq!(payload["rulesetVersion"], json!("bark-battle-ruleset-v1"));
}
#[test]
fn draft_config_update_request_serializes_generated_assets() {
let update = BarkBattleDraftConfigUpdateRequest {
draft_id: "bark-battle-draft-1".to_string(),
work_id: Some("BB-12345678".to_string()),
config_version: Some(2),
ruleset_version: Some("bark-battle-ruleset-v1".to_string()),
title: "汪汪测试杯".to_string(),
description: None,
theme_description: "阳光草坪".to_string(),
player_image_description: "主角".to_string(),
opponent_image_description: "对手".to_string(),
onomatopoeia: Some(vec!["炸场!".to_string(), "破阵!".to_string()]),
player_character_image_src: Some("/generated-bark-battle/player.png".to_string()),
opponent_character_image_src: Some("/generated-bark-battle/opponent.png".to_string()),
ui_background_image_src: Some("/generated-bark-battle/background.png".to_string()),
difficulty_preset: BarkBattleDifficultyPreset::Normal,
};
let payload = serde_json::to_value(update).expect("draft update should serialize");
assert_eq!(payload["draftId"], json!("bark-battle-draft-1"));
assert_eq!(payload["workId"], json!("BB-12345678"));
assert_eq!(
payload["playerCharacterImageSrc"],
json!("/generated-bark-battle/player.png")
);
assert_eq!(
payload["opponentCharacterImageSrc"],
json!("/generated-bark-battle/opponent.png")
);
assert_eq!(
payload["uiBackgroundImageSrc"],
json!("/generated-bark-battle/background.png")
);
assert!(!payload.as_object().unwrap().contains_key("barkSoundSrc"));
assert!(
!payload
.as_object()
.unwrap()
.contains_key("leaderboardEnabled")
);
}
#[test]
fn image_generation_request_uses_dedicated_asset_slot_and_result_prompt() {
let request = BarkBattleImageAssetGenerateRequest {
slot: BarkBattleAssetSlot::OpponentCharacter,
draft_id: Some("bark-battle-draft-1".to_string()),
config: BarkBattleConfigEditorPayload {
title: "汪汪冠军杯".to_string(),
description: Some(String::new()),
theme_description: "霓虹公园擂台".to_string(),
player_image_description: "红围巾柴犬".to_string(),
opponent_image_description: "蓝头带哈士奇".to_string(),
onomatopoeia: Some(vec!["轰汪!".to_string(), "炸场!".to_string()]),
player_character_image_src: None,
opponent_character_image_src: None,
ui_background_image_src: None,
difficulty_preset: BarkBattleDifficultyPreset::Normal,
},
};
let payload = serde_json::to_value(request).expect("request should serialize");
assert_eq!(payload["slot"], json!("opponent-character"));
assert_eq!(
payload["config"]["opponentImageDescription"],
json!("蓝头带哈士奇")
);
let response = BarkBattleGeneratedImageAsset {
image_src: "/generated-bark-battle-assets/draft/opponent/image.webp".to_string(),
asset_id: "asset-1".to_string(),
source_type: Some("generated".to_string()),
model: "gpt-image-2".to_string(),
size: "1024*1024".to_string(),
task_id: "task-1".to_string(),
prompt: "后端拼装后的对手形象 prompt".to_string(),
actual_prompt: None,
};
let payload = serde_json::to_value(response).expect("response should serialize");
assert_eq!(
payload["imageSrc"],
json!("/generated-bark-battle-assets/draft/opponent/image.webp")
);
assert_eq!(payload["prompt"], json!("后端拼装后的对手形象 prompt"));
}
#[test]
fn replacement_sources_serialize_as_camel_case_config_fields() {
let config = BarkBattleConfigEditorPayload {
title: "周末狗狗杯".to_string(),
description: Some("轻配置草稿".to_string()),
theme_description: "霓虹公园".to_string(),
player_image_description: "柴犬主角".to_string(),
opponent_image_description: "哈士奇对手".to_string(),
onomatopoeia: Some(vec!["轰汪!".to_string(), "冲啊!".to_string()]),
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(config).expect("config should serialize");
assert_eq!(
payload["playerCharacterImageSrc"],
json!("/generated-bark-battle/player.png")
);
assert_eq!(
payload["opponentCharacterImageSrc"],
json!("https://example.test/opponent.png")
);
assert_eq!(
payload["uiBackgroundImageSrc"],
json!("/generated-bark-battle/ui.png")
);
assert!(!payload.as_object().unwrap().contains_key("barkSoundSrc"));
}
#[test] #[test]
fn finish_response_serializes_player_win_and_accepted() { fn finish_response_serializes_player_win_and_accepted() {
let response = BarkBattleRunFinishResponse { let response = BarkBattleRunFinishResponse {

View File

@@ -1,4 +1,5 @@
use super::*; use super::*;
use std::collections::HashMap;
pub type BarkBattleDraftCreateRecordInput = BarkBattleDraftCreateInput; pub type BarkBattleDraftCreateRecordInput = BarkBattleDraftCreateInput;
pub type BarkBattleDraftConfigUpsertRecordInput = BarkBattleDraftConfigUpsertInput; pub type BarkBattleDraftConfigUpsertRecordInput = BarkBattleDraftConfigUpsertInput;
@@ -44,6 +45,32 @@ impl SpacetimeClient {
.await .await
} }
pub async fn get_bark_battle_draft_config(
&self,
draft_id: String,
owner_user_id: String,
) -> Result<BarkBattleDraftConfigRecord, SpacetimeClientError> {
self.read_after_connect("get_bark_battle_draft_config", move |connection| {
let row = connection
.db()
.bark_battle_draft_config()
.draft_id()
.find(&draft_id)
.ok_or_else(|| {
SpacetimeClientError::procedure_failed(Some(
"bark_battle draft 不存在".to_string(),
))
})?;
if row.owner_user_id != owner_user_id {
return Err(SpacetimeClientError::procedure_failed(Some(
"bark_battle draft owner 不匹配".to_string(),
)));
}
Ok(map_bark_battle_draft_config_row(row))
})
.await
}
pub async fn publish_bark_battle_work( pub async fn publish_bark_battle_work(
&self, &self,
input: BarkBattleWorkPublishRecordInput, input: BarkBattleWorkPublishRecordInput,
@@ -142,4 +169,83 @@ impl SpacetimeClient {
}) })
.await .await
} }
pub async fn list_bark_battle_works(
&self,
owner_user_id: String,
) -> Result<Vec<serde_json::Value>, SpacetimeClientError> {
self.read_after_connect("list_bark_battle_works", move |connection| {
let owner_user_id = owner_user_id.as_str();
let drafts: Vec<serde_json::Value> = connection
.db()
.bark_battle_draft_config()
.iter()
.filter(|row| row.owner_user_id == owner_user_id)
.map(map_bark_battle_draft_config_row)
.collect();
let published: Vec<serde_json::Value> = connection
.db()
.bark_battle_published_config()
.iter()
.filter(|row| row.owner_user_id == owner_user_id)
.map(map_bark_battle_published_config_row)
.collect();
let mut works_by_id: HashMap<String, serde_json::Value> = HashMap::new();
for work in published.into_iter().chain(drafts) {
let Some(work_id) = work
.get("workId")
.and_then(serde_json::Value::as_str)
.filter(|value| !value.trim().is_empty())
.map(ToString::to_string)
else {
continue;
};
works_by_id.entry(work_id).or_insert(work);
}
let mut works: Vec<serde_json::Value> = works_by_id.into_values().collect();
works.sort_by(|left: &serde_json::Value, right: &serde_json::Value| {
let left_updated_at = left
.get("updatedAtMicros")
.and_then(serde_json::Value::as_i64)
.unwrap_or_default();
let right_updated_at = right
.get("updatedAtMicros")
.and_then(serde_json::Value::as_i64)
.unwrap_or_default();
right_updated_at.cmp(&left_updated_at)
});
Ok(works)
})
.await
}
pub async fn list_bark_battle_gallery(
&self,
) -> Result<Vec<serde_json::Value>, SpacetimeClientError> {
self.read_after_connect("list_bark_battle_gallery", move |connection| {
let recent_play_counts = public_work_recent_play_counts(connection, "bark-battle");
let mut items = connection
.db()
.bark_battle_gallery_view()
.iter()
.collect::<Vec<_>>();
items.sort_by(|left, right| {
right
.updated_at_micros
.cmp(&left.updated_at_micros)
.then_with(|| left.work_id.cmp(&right.work_id))
});
Ok(items
.into_iter()
.map(|item| {
let recent_play_count_7d =
recent_play_counts.get(&item.work_id).copied().unwrap_or(0);
map_bark_battle_gallery_view_row(item, recent_play_count_7d)
})
.collect())
})
.await
}
} }

View File

@@ -560,6 +560,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 jump_hop_gallery_card_view", "SELECT * FROM jump_hop_gallery_card_view",
"SELECT * FROM custom_world_gallery_entry", "SELECT * FROM custom_world_gallery_entry",
@@ -582,6 +583,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",
"SELECT * FROM asset_object", "SELECT * FROM asset_object",

View File

@@ -127,8 +127,9 @@ pub(crate) use self::auth::{
map_auth_store_snapshot_import_procedure_result, map_auth_store_snapshot_procedure_result, map_auth_store_snapshot_import_procedure_result, map_auth_store_snapshot_procedure_result,
}; };
pub(crate) use self::bark_battle::{ pub(crate) use self::bark_battle::{
map_bark_battle_draft_config_procedure_result, map_bark_battle_run_procedure_result, map_bark_battle_draft_config_procedure_result, map_bark_battle_draft_config_row,
map_bark_battle_runtime_config_procedure_result, map_bark_battle_gallery_view_row, map_bark_battle_published_config_row,
map_bark_battle_run_procedure_result, map_bark_battle_runtime_config_procedure_result,
}; };
pub(crate) use self::big_fish::{ pub(crate) use self::big_fish::{
map_big_fish_gallery_view_row, map_big_fish_run_procedure_result, map_big_fish_gallery_view_row, map_big_fish_run_procedure_result,

View File

@@ -36,6 +36,70 @@ pub(crate) fn map_bark_battle_run_procedure_result(
.map(bark_battle_run_to_value) .map(bark_battle_run_to_value)
} }
pub(crate) fn map_bark_battle_draft_config_row(
row: BarkBattleDraftConfigRow,
) -> BarkBattleDraftConfigRecord {
serde_json::json!({
"draftId": row.draft_id,
"ownerUserId": row.owner_user_id,
"workId": row.work_id,
"configVersion": row.config_version,
"rulesetVersion": row.ruleset_version,
"difficultyPreset": row.difficulty_preset,
"configJson": row.config_json,
"editorStateJson": row.editor_state_json,
"createdAtMicros": row.created_at.to_micros_since_unix_epoch(),
"updatedAtMicros": row.updated_at.to_micros_since_unix_epoch(),
})
}
pub(crate) fn map_bark_battle_published_config_row(
row: BarkBattlePublishedConfigRow,
) -> BarkBattleRuntimeConfigRecord {
serde_json::json!({
"workId": row.work_id,
"ownerUserId": row.owner_user_id,
"sourceDraftId": row.source_draft_id,
"configVersion": row.config_version,
"rulesetVersion": row.ruleset_version,
"difficultyPreset": row.difficulty_preset,
"configJson": row.config_json,
"publishedSnapshotJson": row.published_snapshot_json,
"publishedAtMicros": row.published_at.to_micros_since_unix_epoch(),
"updatedAtMicros": row.updated_at.to_micros_since_unix_epoch(),
})
}
pub(crate) fn map_bark_battle_gallery_view_row(
row: BarkBattleGalleryViewRow,
recent_play_count_7d: u32,
) -> serde_json::Value {
serde_json::json!({
"workId": row.work_id,
"ownerUserId": row.owner_user_id,
"sourceDraftId": row.source_draft_id,
"configVersion": row.config_version,
"rulesetVersion": row.ruleset_version,
"difficultyPreset": row.difficulty_preset,
"title": row.title,
"description": row.description,
"themeDescription": row.theme_description,
"playerImageDescription": row.player_image_description,
"opponentImageDescription": row.opponent_image_description,
"onomatopoeia": row.onomatopoeia,
"playerCharacterImageSrc": row.player_character_image_src,
"opponentCharacterImageSrc": row.opponent_character_image_src,
"uiBackgroundImageSrc": row.ui_background_image_src,
"status": "published",
"publishReady": true,
"playCount": row.play_count,
"finishCount": row.finish_count,
"recentPlayCount7d": recent_play_count_7d,
"updatedAtMicros": row.updated_at_micros,
"publishedAtMicros": row.published_at_micros,
})
}
fn bark_battle_draft_config_to_value(snapshot: BarkBattleDraftConfigSnapshot) -> serde_json::Value { fn bark_battle_draft_config_to_value(snapshot: BarkBattleDraftConfigSnapshot) -> serde_json::Value {
serde_json::json!({ serde_json::json!({
"draftId": snapshot.draft_id, "draftId": snapshot.draft_id,
@@ -44,7 +108,6 @@ fn bark_battle_draft_config_to_value(snapshot: BarkBattleDraftConfigSnapshot) ->
"configVersion": snapshot.config_version, "configVersion": snapshot.config_version,
"rulesetVersion": snapshot.ruleset_version, "rulesetVersion": snapshot.ruleset_version,
"difficultyPreset": snapshot.difficulty_preset, "difficultyPreset": snapshot.difficulty_preset,
"leaderboardEnabled": snapshot.leaderboard_enabled,
"configJson": snapshot.config_json, "configJson": snapshot.config_json,
"editorStateJson": snapshot.editor_state_json, "editorStateJson": snapshot.editor_state_json,
"createdAtMicros": snapshot.created_at_micros, "createdAtMicros": snapshot.created_at_micros,
@@ -62,7 +125,6 @@ fn bark_battle_runtime_config_to_value(
"configVersion": snapshot.config_version, "configVersion": snapshot.config_version,
"rulesetVersion": snapshot.ruleset_version, "rulesetVersion": snapshot.ruleset_version,
"difficultyPreset": snapshot.difficulty_preset, "difficultyPreset": snapshot.difficulty_preset,
"leaderboardEnabled": snapshot.leaderboard_enabled,
"configJson": snapshot.config_json, "configJson": snapshot.config_json,
"publishedSnapshotJson": snapshot.published_snapshot_json, "publishedSnapshotJson": snapshot.published_snapshot_json,
"publishedAtMicros": snapshot.published_at_micros, "publishedAtMicros": snapshot.published_at_micros,
@@ -78,7 +140,6 @@ fn bark_battle_run_to_value(snapshot: BarkBattleRunSnapshot) -> serde_json::Valu
"configVersion": snapshot.config_version, "configVersion": snapshot.config_version,
"rulesetVersion": snapshot.ruleset_version, "rulesetVersion": snapshot.ruleset_version,
"difficultyPreset": snapshot.difficulty_preset, "difficultyPreset": snapshot.difficulty_preset,
"leaderboardEnabled": snapshot.leaderboard_enabled,
"status": snapshot.status, "status": snapshot.status,
"clientStartedAtMicros": snapshot.client_started_at_micros, "clientStartedAtMicros": snapshot.client_started_at_micros,
"serverStartedAtMicros": snapshot.server_started_at_micros, "serverStartedAtMicros": snapshot.server_started_at_micros,
@@ -92,3 +153,38 @@ fn bark_battle_run_to_value(snapshot: BarkBattleRunSnapshot) -> serde_json::Valu
"scoreId": snapshot.score_id, "scoreId": snapshot.score_id,
}) })
} }
#[cfg(test)]
mod tests {
use super::*;
#[test]
fn bark_battle_gallery_mapper_keeps_custom_onomatopoeia() {
let row = BarkBattleGalleryViewRow {
work_id: "BB-33333333".to_string(),
owner_user_id: "user-3".to_string(),
source_draft_id: Some("bark-battle-draft-3".to_string()),
config_version: 1,
ruleset_version: "bark-battle-ruleset-v1".to_string(),
difficulty_preset: "normal".to_string(),
title: "声浪公开赛".to_string(),
description: "画廊映射测试".to_string(),
theme_description: "霓虹竞技场".to_string(),
player_image_description: "星际猫骑士".to_string(),
opponent_image_description: "机器人拳手".to_string(),
onomatopoeia: vec!["轰!".to_string(), "炸场!".to_string()],
player_character_image_src: Some("/assets/player.png".to_string()),
opponent_character_image_src: Some("/assets/opponent.png".to_string()),
ui_background_image_src: Some("/assets/background.png".to_string()),
play_count: 8,
finish_count: 5,
updated_at_micros: 1_713_686_401_234_567,
published_at_micros: 1_713_686_401_234_000,
};
let value = map_bark_battle_gallery_view_row(row, 3);
assert_eq!(value["onomatopoeia"], serde_json::json!(["轰!", "炸场!"]));
assert_eq!(value["recentPlayCount7d"], serde_json::json!(3));
}
}

View File

@@ -99,6 +99,8 @@ pub mod bark_battle_draft_config_snapshot_type;
pub mod bark_battle_draft_config_table; pub mod bark_battle_draft_config_table;
pub mod bark_battle_draft_config_upsert_input_type; pub mod bark_battle_draft_config_upsert_input_type;
pub mod bark_battle_draft_create_input_type; pub mod bark_battle_draft_create_input_type;
pub mod bark_battle_gallery_view_row_type;
pub mod bark_battle_gallery_view_table;
pub mod bark_battle_leaderboard_entry_row_type; pub mod bark_battle_leaderboard_entry_row_type;
pub mod bark_battle_leaderboard_entry_table; pub mod bark_battle_leaderboard_entry_table;
pub mod bark_battle_personal_best_projection_row_type; pub mod bark_battle_personal_best_projection_row_type;
@@ -1078,6 +1080,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;
@@ -2249,6 +2253,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>,
@@ -2384,6 +2389,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)?,
), ),
@@ -3159,6 +3167,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,
@@ -3237,6 +3249,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)?),
@@ -3553,6 +3568,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)?),
@@ -3845,6 +3863,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>,
@@ -4006,6 +4025,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,
@@ -5107,6 +5131,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);
@@ -5210,6 +5235,7 @@ impl __sdk::SpacetimeModule for RemoteModule {
"auth_store_projection_meta", "auth_store_projection_meta",
"auth_store_snapshot", "auth_store_snapshot",
"bark_battle_draft_config", "bark_battle_draft_config",
"bark_battle_gallery_view",
"bark_battle_leaderboard_entry", "bark_battle_leaderboard_entry",
"bark_battle_personal_best_projection", "bark_battle_personal_best_projection",
"bark_battle_published_config", "bark_battle_published_config",

View File

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

View File

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

View File

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

View File

@@ -0,0 +1,106 @@
// THIS FILE IS AUTOMATICALLY GENERATED BY SPACETIMEDB. EDITS TO THIS FILE
// WILL NOT BE SAVED. MODIFY TABLES IN YOUR MODULE SOURCE CODE INSTEAD.
#![allow(unused, clippy::all)]
use spacetimedb_sdk::__codegen::{self as __sdk, __lib, __sats, __ws};
#[derive(__lib::ser::Serialize, __lib::de::Deserialize, Clone, PartialEq, Debug)]
#[sats(crate = __lib)]
pub struct BarkBattleGalleryViewRow {
pub work_id: String,
pub owner_user_id: String,
pub source_draft_id: Option<String>,
pub config_version: u64,
pub ruleset_version: String,
pub difficulty_preset: String,
pub title: String,
pub description: String,
pub theme_description: String,
pub player_image_description: String,
pub opponent_image_description: String,
pub onomatopoeia: Vec<String>,
pub player_character_image_src: Option<String>,
pub opponent_character_image_src: Option<String>,
pub ui_background_image_src: Option<String>,
pub play_count: u64,
pub finish_count: u64,
pub updated_at_micros: i64,
pub published_at_micros: i64,
}
impl __sdk::InModule for BarkBattleGalleryViewRow {
type Module = super::RemoteModule;
}
/// Column accessor struct for the table `BarkBattleGalleryViewRow`.
///
/// Provides typed access to columns for query building.
pub struct BarkBattleGalleryViewRowCols {
pub work_id: __sdk::__query_builder::Col<BarkBattleGalleryViewRow, String>,
pub owner_user_id: __sdk::__query_builder::Col<BarkBattleGalleryViewRow, String>,
pub source_draft_id: __sdk::__query_builder::Col<BarkBattleGalleryViewRow, Option<String>>,
pub config_version: __sdk::__query_builder::Col<BarkBattleGalleryViewRow, u64>,
pub ruleset_version: __sdk::__query_builder::Col<BarkBattleGalleryViewRow, String>,
pub difficulty_preset: __sdk::__query_builder::Col<BarkBattleGalleryViewRow, String>,
pub title: __sdk::__query_builder::Col<BarkBattleGalleryViewRow, String>,
pub description: __sdk::__query_builder::Col<BarkBattleGalleryViewRow, String>,
pub theme_description: __sdk::__query_builder::Col<BarkBattleGalleryViewRow, String>,
pub player_image_description: __sdk::__query_builder::Col<BarkBattleGalleryViewRow, String>,
pub opponent_image_description: __sdk::__query_builder::Col<BarkBattleGalleryViewRow, String>,
pub onomatopoeia: __sdk::__query_builder::Col<BarkBattleGalleryViewRow, Vec<String>>,
pub player_character_image_src:
__sdk::__query_builder::Col<BarkBattleGalleryViewRow, Option<String>>,
pub opponent_character_image_src:
__sdk::__query_builder::Col<BarkBattleGalleryViewRow, Option<String>>,
pub ui_background_image_src:
__sdk::__query_builder::Col<BarkBattleGalleryViewRow, Option<String>>,
pub play_count: __sdk::__query_builder::Col<BarkBattleGalleryViewRow, u64>,
pub finish_count: __sdk::__query_builder::Col<BarkBattleGalleryViewRow, u64>,
pub updated_at_micros: __sdk::__query_builder::Col<BarkBattleGalleryViewRow, i64>,
pub published_at_micros: __sdk::__query_builder::Col<BarkBattleGalleryViewRow, i64>,
}
impl __sdk::__query_builder::HasCols for BarkBattleGalleryViewRow {
type Cols = BarkBattleGalleryViewRowCols;
fn cols(table_name: &'static str) -> Self::Cols {
BarkBattleGalleryViewRowCols {
work_id: __sdk::__query_builder::Col::new(table_name, "work_id"),
owner_user_id: __sdk::__query_builder::Col::new(table_name, "owner_user_id"),
source_draft_id: __sdk::__query_builder::Col::new(table_name, "source_draft_id"),
config_version: __sdk::__query_builder::Col::new(table_name, "config_version"),
ruleset_version: __sdk::__query_builder::Col::new(table_name, "ruleset_version"),
difficulty_preset: __sdk::__query_builder::Col::new(table_name, "difficulty_preset"),
title: __sdk::__query_builder::Col::new(table_name, "title"),
description: __sdk::__query_builder::Col::new(table_name, "description"),
theme_description: __sdk::__query_builder::Col::new(table_name, "theme_description"),
player_image_description: __sdk::__query_builder::Col::new(
table_name,
"player_image_description",
),
opponent_image_description: __sdk::__query_builder::Col::new(
table_name,
"opponent_image_description",
),
onomatopoeia: __sdk::__query_builder::Col::new(table_name, "onomatopoeia"),
player_character_image_src: __sdk::__query_builder::Col::new(
table_name,
"player_character_image_src",
),
opponent_character_image_src: __sdk::__query_builder::Col::new(
table_name,
"opponent_character_image_src",
),
ui_background_image_src: __sdk::__query_builder::Col::new(
table_name,
"ui_background_image_src",
),
play_count: __sdk::__query_builder::Col::new(table_name, "play_count"),
finish_count: __sdk::__query_builder::Col::new(table_name, "finish_count"),
updated_at_micros: __sdk::__query_builder::Col::new(table_name, "updated_at_micros"),
published_at_micros: __sdk::__query_builder::Col::new(
table_name,
"published_at_micros",
),
}
}
}

View File

@@ -0,0 +1,114 @@
// THIS FILE IS AUTOMATICALLY GENERATED BY SPACETIMEDB. EDITS TO THIS FILE
// WILL NOT BE SAVED. MODIFY TABLES IN YOUR MODULE SOURCE CODE INSTEAD.
#![allow(unused, clippy::all)]
use super::bark_battle_gallery_view_row_type::BarkBattleGalleryViewRow;
use spacetimedb_sdk::__codegen::{self as __sdk, __lib, __sats, __ws};
/// Table handle for the table `bark_battle_gallery_view`.
///
/// Obtain a handle from the [`BarkBattleGalleryViewTableAccess::bark_battle_gallery_view`] method on [`super::RemoteTables`],
/// like `ctx.db.bark_battle_gallery_view()`.
///
/// Users are encouraged not to explicitly reference this type,
/// but to directly chain method calls,
/// like `ctx.db.bark_battle_gallery_view().on_insert(...)`.
pub struct BarkBattleGalleryViewTableHandle<'ctx> {
imp: __sdk::TableHandle<BarkBattleGalleryViewRow>,
ctx: std::marker::PhantomData<&'ctx super::RemoteTables>,
}
#[allow(non_camel_case_types)]
/// Extension trait for access to the table `bark_battle_gallery_view`.
///
/// Implemented for [`super::RemoteTables`].
pub trait BarkBattleGalleryViewTableAccess {
#[allow(non_snake_case)]
/// Obtain a [`BarkBattleGalleryViewTableHandle`], which mediates access to the table `bark_battle_gallery_view`.
fn bark_battle_gallery_view(&self) -> BarkBattleGalleryViewTableHandle<'_>;
}
impl BarkBattleGalleryViewTableAccess for super::RemoteTables {
fn bark_battle_gallery_view(&self) -> BarkBattleGalleryViewTableHandle<'_> {
BarkBattleGalleryViewTableHandle {
imp: self
.imp
.get_table::<BarkBattleGalleryViewRow>("bark_battle_gallery_view"),
ctx: std::marker::PhantomData,
}
}
}
pub struct BarkBattleGalleryViewInsertCallbackId(__sdk::CallbackId);
pub struct BarkBattleGalleryViewDeleteCallbackId(__sdk::CallbackId);
impl<'ctx> __sdk::Table for BarkBattleGalleryViewTableHandle<'ctx> {
type Row = BarkBattleGalleryViewRow;
type EventContext = super::EventContext;
fn count(&self) -> u64 {
self.imp.count()
}
fn iter(&self) -> impl Iterator<Item = BarkBattleGalleryViewRow> + '_ {
self.imp.iter()
}
type InsertCallbackId = BarkBattleGalleryViewInsertCallbackId;
fn on_insert(
&self,
callback: impl FnMut(&Self::EventContext, &Self::Row) + Send + 'static,
) -> BarkBattleGalleryViewInsertCallbackId {
BarkBattleGalleryViewInsertCallbackId(self.imp.on_insert(Box::new(callback)))
}
fn remove_on_insert(&self, callback: BarkBattleGalleryViewInsertCallbackId) {
self.imp.remove_on_insert(callback.0)
}
type DeleteCallbackId = BarkBattleGalleryViewDeleteCallbackId;
fn on_delete(
&self,
callback: impl FnMut(&Self::EventContext, &Self::Row) + Send + 'static,
) -> BarkBattleGalleryViewDeleteCallbackId {
BarkBattleGalleryViewDeleteCallbackId(self.imp.on_delete(Box::new(callback)))
}
fn remove_on_delete(&self, callback: BarkBattleGalleryViewDeleteCallbackId) {
self.imp.remove_on_delete(callback.0)
}
}
#[doc(hidden)]
pub(super) fn register_table(client_cache: &mut __sdk::ClientCache<super::RemoteModule>) {
let _table =
client_cache.get_or_make_table::<BarkBattleGalleryViewRow>("bark_battle_gallery_view");
}
#[doc(hidden)]
pub(super) fn parse_table_update(
raw_updates: __ws::v2::TableUpdate,
) -> __sdk::Result<__sdk::TableUpdate<BarkBattleGalleryViewRow>> {
__sdk::TableUpdate::parse_table_update(raw_updates).map_err(|e| {
__sdk::InternalError::failed_parse("TableUpdate<BarkBattleGalleryViewRow>", "TableUpdate")
.with_cause(e)
.into()
})
}
#[allow(non_camel_case_types)]
/// Extension trait for query builder access to the table `BarkBattleGalleryViewRow`.
///
/// Implemented for [`__sdk::QueryTableAccessor`].
pub trait bark_battle_gallery_viewQueryTableAccess {
#[allow(non_snake_case)]
/// Get a query builder for the table `BarkBattleGalleryViewRow`.
fn bark_battle_gallery_view(&self) -> __sdk::__query_builder::Table<BarkBattleGalleryViewRow>;
}
impl bark_battle_gallery_viewQueryTableAccess for __sdk::QueryTableAccessor {
fn bark_battle_gallery_view(&self) -> __sdk::__query_builder::Table<BarkBattleGalleryViewRow> {
__sdk::__query_builder::Table::new("bark_battle_gallery_view")
}
}

View File

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

View File

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

View File

@@ -1,6 +1,7 @@
use crate::*; use crate::*;
use serde::{Serialize, de::DeserializeOwned}; use serde::{Serialize, de::DeserializeOwned};
use sha2::{Digest, Sha256}; use sha2::{Digest, Sha256};
use spacetimedb::AnonymousViewContext;
pub(crate) mod tables; pub(crate) mod tables;
mod types; mod types;
@@ -8,6 +9,38 @@ mod types;
pub use tables::*; pub use tables::*;
pub use types::*; pub use types::*;
/// Bark Battle 公开广场列表投影。
///
/// HTTP gallery 订阅该 public view 后读取本地 cacheview 只从已发布配置和统计投影
/// 组装 v1 公开字段,避免每个公开列表请求重新调用 procedure 热路径。
#[spacetimedb::view(accessor = bark_battle_gallery_view, public)]
pub fn bark_battle_gallery_view(ctx: &AnonymousViewContext) -> Vec<BarkBattleGalleryViewRow> {
let mut items = ctx
.db
.bark_battle_published_config()
.by_bark_battle_published_owner_user_id()
.filter(""..)
.filter_map(|row| match build_bark_battle_gallery_view_row(ctx, &row) {
Ok(item) => Some(item),
Err(error) => {
log::warn!(
"汪汪声浪公开广场 view 跳过损坏的作品投影 work_id={}: {}",
row.work_id,
error
);
None
}
})
.collect::<Vec<_>>();
items.sort_by(|left, right| {
right
.updated_at_micros
.cmp(&left.updated_at_micros)
.then_with(|| left.work_id.cmp(&right.work_id))
});
items
}
#[spacetimedb::procedure] #[spacetimedb::procedure]
pub fn create_bark_battle_draft( pub fn create_bark_battle_draft(
ctx: &mut ProcedureContext, ctx: &mut ProcedureContext,
@@ -106,17 +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,
opponent_character_image_src: None,
ui_background_image_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(),
@@ -125,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(),
@@ -145,12 +184,10 @@ fn update_bark_battle_draft_config_tx(
require_non_empty(&input.draft_id, "bark_battle draft_id")?; require_non_empty(&input.draft_id, "bark_battle draft_id")?;
require_non_empty(&input.owner_user_id, "bark_battle owner_user_id")?; require_non_empty(&input.owner_user_id, "bark_battle owner_user_id")?;
require_non_empty(&input.work_id, "bark_battle work_id")?; require_non_empty(&input.work_id, "bark_battle work_id")?;
let editor_config = parse_editor_config(&input.config_json)?; let mut editor_config = parse_editor_config(&input.config_json)?;
validate_editor_config_snapshot(&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
@@ -162,15 +199,15 @@ 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 = input.config_json;
row.updated_at = updated_at; row.updated_at = updated_at;
ctx.db ctx.db
.bark_battle_draft_config() .bark_battle_draft_config()
@@ -196,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(),
@@ -203,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,
@@ -293,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,
@@ -479,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(),
@@ -495,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(),
@@ -503,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()
@@ -522,12 +605,34 @@ fn require_non_empty(value: &str, label: &str) -> Result<(), String> {
} }
} }
fn validate_editor_config_snapshot(config: &BarkBattleEditorConfigSnapshot) -> Result<(), String> { fn normalize_editor_config_snapshot(
normalize_title(Some(&config.title))?; config: &mut BarkBattleEditorConfigSnapshot,
normalize_required_preset(&config.theme_preset, "theme_preset")?; ) -> Result<(), String> {
normalize_required_preset(&config.player_dog_skin_preset, "player_dog_skin_preset")?; config.title = normalize_title(Some(&config.title))?;
normalize_required_preset(&config.opponent_dog_skin_preset, "opponent_dog_skin_preset")?; config.theme_description =
normalize_difficulty(Some(&config.difficulty_preset))?; normalize_required_description(&config.theme_description, "theme_description")?;
config.player_image_description = normalize_required_description(
&config.player_image_description,
"player_image_description",
)?;
config.opponent_image_description = normalize_required_description(
&config.opponent_image_description,
"opponent_image_description",
)?;
config.onomatopoeia = normalize_onomatopoeia(std::mem::take(&mut config.onomatopoeia));
config.player_character_image_src = normalize_optional_asset_source(
config.player_character_image_src.as_deref(),
"player_character_image_src",
)?;
config.opponent_character_image_src = normalize_optional_asset_source(
config.opponent_character_image_src.as_deref(),
"opponent_character_image_src",
)?;
config.ui_background_image_src = normalize_optional_asset_source(
config.ui_background_image_src.as_deref(),
"ui_background_image_src",
)?;
config.difficulty_preset = normalize_difficulty(Some(&config.difficulty_preset))?;
Ok(()) Ok(())
} }
@@ -546,12 +651,37 @@ 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(
value: Option<&str>,
field_name: &str,
) -> Result<Option<String>, String> {
let Some(value) = value.map(str::trim).filter(|value| !value.is_empty()) else {
return Ok(None);
};
if value.chars().count() > 512 {
return Err(format!("bark_battle {field_name} 不能超过 512 个字符"));
}
Ok(Some(value.to_string()))
} }
fn normalize_ruleset_version(value: &str) -> Result<String, String> { fn normalize_ruleset_version(value: &str) -> Result<String, String> {
@@ -639,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(),
@@ -870,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,
}; };
@@ -884,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,
@@ -910,4 +1037,84 @@ mod tests {
assert!(normalize_title(Some(" 标题 ")).is_ok()); assert!(normalize_title(Some(" 标题 ")).is_ok());
assert!(normalize_title(Some(" ")).is_err()); assert!(normalize_title(Some(" ")).is_err());
} }
#[test]
fn published_snapshot_is_normalized_as_runtime_config() {
let mut editor_config = parse_editor_config(
&serde_json::json!({
"title": " 汪汪测试杯 ",
"description": "",
"themeDescription": " 阳光草坪 ",
"playerImageDescription": " 主角柴犬 ",
"opponentImageDescription": " 对手哈士奇 ",
"onomatopoeia": [" 轰汪! ", "冲啊冲啊冲啊冲啊冲啊!", ""],
"playerCharacterImageSrc": "/generated-bark-battle-assets/player.png",
"opponentCharacterImageSrc": "/generated-bark-battle-assets/opponent.png",
"uiBackgroundImageSrc": "/generated-bark-battle-assets/ui.png",
"difficultyPreset": "normal"
})
.to_string(),
)
.expect("published snapshot should parse");
normalize_editor_config_snapshot(&mut editor_config)
.expect("published snapshot should normalize");
let config_json = to_json_string(&editor_config);
assert!(config_json.contains("/generated-bark-battle-assets/player.png"));
assert!(config_json.contains("/generated-bark-battle-assets/opponent.png"));
assert!(config_json.contains("/generated-bark-battle-assets/ui.png"));
assert!(config_json.contains("阳光草坪"));
assert!(config_json.contains("轰汪!"));
assert!(config_json.contains("冲啊冲啊冲啊冲啊"));
assert!(!config_json.contains("冲啊冲啊冲啊冲啊冲啊!"));
assert!(!config_json.contains("\"title\":\" 汪汪测试杯 \""));
assert!(!config_json.contains("themePreset"));
assert!(!config_json.contains("playerDogSkinPreset"));
assert!(!config_json.contains("opponentDogSkinPreset"));
}
#[test]
fn bark_battle_gallery_view_row_exposes_custom_onomatopoeia() {
let mut editor_config = parse_editor_config(
&serde_json::json!({
"title": "声浪公开赛",
"description": "画廊映射测试",
"themeDescription": "霓虹竞技场",
"playerImageDescription": "星际猫骑士",
"opponentImageDescription": "机器人拳手",
"onomatopoeia": [" 轰! ", "炸场!", ""],
"difficultyPreset": "normal"
})
.to_string(),
)
.expect("gallery config should parse");
normalize_editor_config_snapshot(&mut editor_config)
.expect("gallery config should normalize");
let row = BarkBattleGalleryViewRow {
work_id: "BB-33333333".to_string(),
owner_user_id: "user-3".to_string(),
source_draft_id: Some("bark-battle-draft-3".to_string()),
config_version: 1,
ruleset_version: BARK_BATTLE_DEFAULT_RULESET_VERSION.to_string(),
difficulty_preset: editor_config.difficulty_preset.clone(),
title: editor_config.title,
description: editor_config.description,
theme_description: editor_config.theme_description,
player_image_description: editor_config.player_image_description,
opponent_image_description: editor_config.opponent_image_description,
onomatopoeia: editor_config.onomatopoeia,
player_character_image_src: editor_config.player_character_image_src,
opponent_character_image_src: editor_config.opponent_character_image_src,
ui_background_image_src: editor_config.ui_background_image_src,
play_count: 8,
finish_count: 5,
updated_at_micros: 1_713_686_401_234_567,
published_at_micros: 1_713_686_401_234_000,
};
assert_eq!(row.onomatopoeia, vec!["轰!".to_string(), "炸场!".to_string()]);
}
} }

View File

@@ -24,11 +24,10 @@ pub struct BarkBattleDraftCreateInput {
pub work_id: String, pub work_id: String,
pub title: Option<String>, pub title: Option<String>,
pub description: Option<String>, pub description: Option<String>,
pub theme_preset: String, pub theme_description: String,
pub player_dog_skin_preset: String, pub player_image_description: String,
pub opponent_dog_skin_preset: String, pub opponent_image_description: String,
pub difficulty_preset: Option<String>, pub difficulty_preset: Option<String>,
pub leaderboard_enabled: Option<bool>,
pub editor_state_json: Option<String>, pub editor_state_json: Option<String>,
pub created_at_micros: i64, pub created_at_micros: i64,
} }
@@ -41,7 +40,6 @@ pub struct BarkBattleDraftConfigUpsertInput {
pub config_version: u64, pub config_version: u64,
pub ruleset_version: String, pub ruleset_version: String,
pub difficulty_preset: String, pub difficulty_preset: String,
pub leaderboard_enabled: bool,
pub config_json: String, pub config_json: String,
pub updated_at_micros: i64, pub updated_at_micros: i64,
} }
@@ -116,11 +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")]
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: 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)]
@@ -132,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,
@@ -148,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,
@@ -164,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,
@@ -177,3 +179,31 @@ pub struct BarkBattleRunSnapshot {
pub leaderboard_score: Option<u64>, pub leaderboard_score: Option<u64>,
pub score_id: Option<String>, pub score_id: Option<String>,
} }
/// Bark Battle 公开广场只读投影行。
///
/// 该结构只暴露 v1 公共卡片需要的配置和基础统计,不把内部排行榜开关或旧皮肤 /
/// 音效预设重新带回公开语义。
#[derive(Clone, Debug, PartialEq, Serialize, Deserialize, SpacetimeType)]
#[serde(rename_all = "camelCase")]
pub struct BarkBattleGalleryViewRow {
pub work_id: String,
pub owner_user_id: String,
pub source_draft_id: Option<String>,
pub config_version: u64,
pub ruleset_version: String,
pub difficulty_preset: String,
pub title: String,
pub description: String,
pub theme_description: String,
pub player_image_description: String,
pub opponent_image_description: String,
pub onomatopoeia: Vec<String>,
pub player_character_image_src: Option<String>,
pub opponent_character_image_src: Option<String>,
pub ui_background_image_src: Option<String>,
pub play_count: u64,
pub finish_count: u64,
pub updated_at_micros: i64,
pub published_at_micros: i64,
}

View File

@@ -181,17 +181,7 @@ fn seed_creation_entry_config_if_missing(ctx: &ReducerContext) {
migrate_rpg_entry_from_old_hidden_default(ctx, now); migrate_rpg_entry_from_old_hidden_default(ctx, now);
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,
@@ -205,21 +195,21 @@ fn seed_creation_entry_config_if_missing(ctx: &ReducerContext) {
); );
} }
fn migrate_rpg_entry_from_old_hidden_default(ctx: &ReducerContext, now: Timestamp) { fn migrate_bark_battle_entry_to_open_default(ctx: &ReducerContext, now: Timestamp) {
let id = "rpg".to_string(); let id = "bark-battle".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 {
return; return;
}; };
// 中文注释:只开放历史默认隐藏的 RPG 入口,不覆盖后台入口开关后续手动配置。 // 中文注释:只纠偏系统默认汪汪声浪入口,不覆盖后台手动改过标题、排序或可见性的配置。
let still_old_hidden_default = row.title == "文字冒险" let still_system_default = row.title == "汪汪声浪"
&& row.subtitle == "经典 RPG 体验" && row.subtitle == "声控对战挑战"
&& row.badge == "内测" && row.visible
&& row.image_src == "/creation-type-references/rpg.webp" && row.sort_order == 85
&& !row.visible && (row.image_src == "/creation-type-references/creative-agent.webp"
&& row.open || row.image_src == "/creation-type-references/bark-battle.webp")
&& row.sort_order == 10; && ((row.badge == "敬请期待" && !row.open) || (row.badge == "可创建" && row.open));
if !still_old_hidden_default { if !still_system_default {
return; return;
} }
@@ -228,7 +218,7 @@ fn migrate_rpg_entry_from_old_hidden_default(ctx: &ReducerContext, now: Timestam
.id() .id()
.update(CreationEntryTypeConfig { .update(CreationEntryTypeConfig {
badge: "可创建".to_string(), badge: "可创建".to_string(),
visible: true, image_src: "/creation-type-references/bark-battle.webp".to_string(),
open: true, open: true,
updated_at: now, updated_at: now,
..row ..row

View File

@@ -7,54 +7,118 @@ 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 publish a Bark Battle work', async () => { it('allows creators to edit v1 descriptions and compile a Bark Battle draft', async () => {
const onPublish = vi.fn(); const onPreview = vi.fn();
render(<BarkBattleConfigEditor isBusy={false} onPublish={onPublish} />); render(<BarkBattleConfigEditor isBusy={false} onPreview={onPreview} />);
expect(screen.getByRole('heading', { name: '汪汪声浪大作战' })).toBeTruthy(); expect(screen.getByRole('heading', { name: '汪汪声浪大作战' })).toBeTruthy();
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.getByLabelText('开启排行榜') as HTMLInputElement).checked).toBe(true); 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.selectOptions(screen.getByLabelText('玩家狗狗'), 'shiba'); await userEvent.type(screen.getByLabelText('主题/场景描述'), '霓虹公园声浪擂台');
await userEvent.selectOptions(screen.getByLabelText('对手狗狗'), 'husky'); await userEvent.clear(screen.getByLabelText('玩家形象描述'));
await userEvent.type(screen.getByLabelText('玩家形象描述'), '红围巾柴犬');
await userEvent.clear(screen.getByLabelText('对手形象描述'));
await userEvent.type(screen.getByLabelText('对手形象描述'), '蓝头带哈士奇');
await userEvent.clear(screen.getByLabelText('拟声词'));
await userEvent.type(
screen.getByLabelText('拟声词'),
'炸场!\n冲啊 / 破阵、Boom!',
);
await userEvent.selectOptions(screen.getByLabelText('难度预设'), 'hard'); await userEvent.selectOptions(screen.getByLabelText('难度预设'), 'hard');
await userEvent.click(screen.getByLabelText('开启排行榜')); await userEvent.click(screen.getByRole('button', { name: '生成草稿' }));
await userEvent.click(screen.getByRole('button', { name: '发布并试玩' }));
expect(onPublish).toHaveBeenCalledWith({ expect(onPreview).toHaveBeenCalledWith({
title: '周末狗狗杯', title: '狗狗冠军杯',
description: '', description: '',
themePreset: 'neon-park', themeDescription: '霓虹公园声浪擂台',
playerDogSkinPreset: 'shiba', playerImageDescription: '红围巾柴犬',
opponentDogSkinPreset: 'husky', opponentImageDescription: '蓝头带哈士奇',
onomatopoeia: ['炸场!', '冲啊!', '破阵!', 'Boom!'],
difficultyPreset: 'hard', difficultyPreset: 'hard',
leaderboardEnabled: false,
}); });
}); });
it('requires a non-empty title before publishing', async () => { it('uses a louder theme-aware default onomatopoeia pool without locking to dogs', async () => {
const onPublish = vi.fn(); const onPreview = vi.fn();
render(<BarkBattleConfigEditor isBusy={false} onPublish={onPublish} />); 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 () => {
const onPreview = vi.fn();
render(<BarkBattleConfigEditor isBusy={false} onPreview={onPreview} />);
await userEvent.clear(screen.getByLabelText('作品标题')); await userEvent.clear(screen.getByLabelText('作品标题'));
await userEvent.click(screen.getByRole('button', { name: '发布并试玩' })); await userEvent.click(screen.getByRole('button', { name: '生成草稿' }));
expect(onPublish).not.toHaveBeenCalled(); expect(onPreview).not.toHaveBeenCalled();
expect(screen.getByText('请先填写作品标题')).toBeTruthy(); expect(screen.getByText('请先填写作品标题')).toBeTruthy();
}); });
it('can render as an embedded creation form without a local page header', () => { it('can render as an embedded creation form without a local page header', () => {
const onPublish = vi.fn(); const onPreview = vi.fn();
render( render(
<BarkBattleConfigEditor <BarkBattleConfigEditor
error="发布失败" error="外部错误"
isBusy={false} isBusy={false}
onPublish={onPublish} onPreview={onPreview}
showBackButton={false} showBackButton={false}
title={null} title={null}
/>, />,
@@ -63,6 +127,32 @@ describe('BarkBattleConfigEditor', () => {
expect(screen.queryByRole('heading', { name: '汪汪声浪大作战' })).toBeNull(); expect(screen.queryByRole('heading', { name: '汪汪声浪大作战' })).toBeNull();
expect(screen.queryByRole('button', { name: '返回' })).toBeNull(); expect(screen.queryByRole('button', { name: '返回' })).toBeNull();
expect(screen.getByLabelText('汪汪声浪轻配置编辑器')).toBeTruthy(); expect(screen.getByLabelText('汪汪声浪轻配置编辑器')).toBeTruthy();
expect(screen.getByText('发布失败')).toBeTruthy(); expect(screen.getByText('外部错误')).toBeTruthy();
});
it('keeps the mobile form in the parent scroll flow with a safe submit footer', () => {
const onPreview = vi.fn();
render(
<BarkBattleConfigEditor
isBusy={false}
onPreview={onPreview}
showBackButton={false}
title={null}
/>,
);
const editor = screen.getByLabelText('汪汪声浪轻配置编辑器');
expect(editor.className).toContain('overflow-visible');
expect(editor.className).toContain('lg:overflow-y-auto');
expect(editor.className).not.toContain('overflow-y-auto overscroll-y-contain pr-0.5');
const themeLabel = screen.getByText('主题/场景描述');
expect(themeLabel.className).toContain('bg-rose-50');
const submitFooter = screen
.getByRole('button', { name: '生成草稿' })
.closest('div');
expect(submitFooter?.className).toContain('shrink-0');
expect(submitFooter?.className).toContain('safe-area-inset-bottom');
}); });
}); });

View File

@@ -1,88 +1,144 @@
import { ArrowLeft, Loader2, Trophy, WandSparkles } 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 = {
isBusy?: boolean; isBusy?: boolean;
error?: string | null; error?: string | null;
onPublish: (payload: BarkBattleConfigEditorPayload) => void | Promise<void>; onPreview: (payload: BarkBattleConfigEditorPayload) => void | Promise<void>;
onBack?: () => void; onBack?: () => void;
showBackButton?: boolean; showBackButton?: boolean;
title?: string | null; title?: string | null;
}; };
const THEME_OPTIONS = [
{ value: 'sunny-yard', label: '阳光院子' },
{ value: 'neon-park', label: '霓虹公园' },
{ value: 'moonlight-rooftop', label: '月光天台' },
];
const DOG_SKIN_OPTIONS = [
{ value: 'corgi', label: '柯基' },
{ value: 'shiba', label: '柴犬' },
{ value: 'husky', 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,
error: externalError = null, error: externalError = null,
onPublish, onPreview,
onBack, onBack,
showBackButton = true, showBackButton = true,
title: headingTitle = '汪汪声浪大作战', title: headingTitle = '汪汪声浪大作战',
}: 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('corgi'); DEFAULT_THEME_DESCRIPTION,
const [opponentDogSkinPreset, setOpponentDogSkinPreset] = useState('husky'); );
const [playerImageDescription, setPlayerImageDescription] = useState(
DEFAULT_PLAYER_IMAGE_DESCRIPTION,
);
const [opponentImageDescription, setOpponentImageDescription] = useState(
DEFAULT_OPPONENT_IMAGE_DESCRIPTION,
);
const [isOnomatopoeiaCustomized, setIsOnomatopoeiaCustomized] =
useState(false);
const [onomatopoeiaText, setOnomatopoeiaText] = useState(() =>
buildDefaultOnomatopoeiaText({
themeDescription: DEFAULT_THEME_DESCRIPTION,
playerImageDescription: DEFAULT_PLAYER_IMAGE_DESCRIPTION,
opponentImageDescription: DEFAULT_OPPONENT_IMAGE_DESCRIPTION,
}),
);
const [difficultyPreset, setDifficultyPreset] = useState<BarkBattleDifficultyPreset>('normal'); const [difficultyPreset, setDifficultyPreset] = useState<BarkBattleDifficultyPreset>('normal');
const [leaderboardEnabled, setLeaderboardEnabled] = useState(true);
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(),
onomatopoeia,
difficultyPreset, difficultyPreset,
leaderboardEnabled,
}), }),
[ [
title, title,
description, description,
themePreset, themeDescription,
playerDogSkinPreset, playerImageDescription,
opponentDogSkinPreset, opponentImageDescription,
onomatopoeia,
difficultyPreset, difficultyPreset,
leaderboardEnabled,
], ],
); );
const handlePublish = () => { const runValidatedAction = (
action: (payload: BarkBattleConfigEditorPayload) => void | Promise<void>,
) => {
if (!payload.title) { if (!payload.title) {
setLocalError('请先填写作品标题'); setLocalError('请先填写作品标题');
return; return;
} }
if (!payload.themeDescription) {
setLocalError('请先填写主题/场景描述');
return;
}
if (!payload.playerImageDescription || !payload.opponentImageDescription) {
setLocalError('请先填写双方形象描述');
return;
}
setLocalError(null); setLocalError(null);
void onPublish(payload); void action(payload);
}; };
const visibleError = localError ?? externalError; const visibleError = localError ?? externalError;
return ( return (
<section <section
className="platform-remap-surface mx-auto flex h-full min-h-0 w-full max-w-5xl flex-col overflow-hidden" 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 ? (
@@ -101,7 +157,7 @@ export function BarkBattleConfigEditor({
</div> </div>
) : null} ) : null}
<div className="flex min-h-0 flex-1 flex-col overflow-hidden"> <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">
@@ -116,13 +172,11 @@ export function BarkBattleConfigEditor({
) : null} ) : null}
<div <div
className={`grid min-h-0 flex-1 grid-rows-[minmax(0,1fr)_auto] gap-3 lg:grid-cols-[minmax(0,1.12fr)_minmax(17rem,0.88fr)] lg:grid-rows-1 ${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 min-h-0 flex-col gap-3 overflow-y-auto 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}
@@ -134,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}
@@ -150,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-[var(--platform-surface-hover-border)] focus:bg-white focus:ring-2 focus:ring-[var(--platform-warm-border)]"
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}
@@ -190,64 +221,61 @@ export function BarkBattleConfigEditor({
))} ))}
</select> </select>
</label> </label>
</div>
<label className="block"> <label className="block shrink-0">
<span className="mb-2 block text-sm font-black text-[var(--platform-text-strong)]"> <span className={ACCENT_FIELD_LABEL_CLASS}>
/
</span> </span>
<select <textarea
value={playerDogSkinPreset} value={themeDescription}
disabled={isBusy} disabled={isBusy}
onChange={(event) => onChange={(event) => setThemeDescription(event.target.value)}
setPlayerDogSkinPreset(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}
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-[var(--platform-surface-hover-border)] focus:bg-white focus:ring-2 focus:ring-[var(--platform-warm-border)]" placeholder=""
aria-label="玩家狗狗" aria-label="主题/场景描述"
> />
{DOG_SKIN_OPTIONS.map((option) => ( </label>
<option key={option.value} value={option.value}>
{option.label} <div className="grid shrink-0 gap-2.5 sm:grid-cols-2">
</option> <label className="block">
))} <span className={FIELD_LABEL_CLASS}></span>
</select> <textarea
value={playerImageDescription}
disabled={isBusy}
onChange={(event) => setPlayerImageDescription(event.target.value)}
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"
maxLength={220}
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}
<select
value={opponentDogSkinPreset}
disabled={isBusy} disabled={isBusy}
onChange={(event) => onChange={(event) => setOpponentImageDescription(event.target.value)}
setOpponentDogSkinPreset(event.target.value) 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"
} maxLength={220}
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-[var(--platform-surface-hover-border)] focus:bg-white focus:ring-2 focus:ring-[var(--platform-warm-border)]" aria-label="对手形象描述"
aria-label="对手狗狗" />
>
{DOG_SKIN_OPTIONS.map((option) => (
<option key={option.value} value={option.value}>
{option.label}
</option>
))}
</select>
</label> </label>
</div> </div>
<label className="flex shrink-0 items-center justify-between gap-3 rounded-[1.05rem] border border-[var(--platform-subpanel-border)] bg-white/72 px-4 py-3 text-sm font-black text-[var(--platform-text-strong)] shadow-[inset_0_1px_0_rgba(255,255,255,0.76)]"> <label className="block shrink-0">
<span className="inline-flex items-center gap-2"> <span className={ACCENT_FIELD_LABEL_CLASS}></span>
<Trophy className="h-4 w-4 text-amber-500" /> <textarea
value={onomatopoeiaText}
</span>
<input
aria-label="开启排行榜"
type="checkbox"
checked={leaderboardEnabled}
disabled={isBusy} disabled={isBusy}
onChange={(event) => onChange={(event) => {
setLeaderboardEnabled(event.target.checked) setIsOnomatopoeiaCustomized(true);
} setOnomatopoeiaText(event.target.value);
className="h-5 w-5 accent-[#ff4f6a]" }}
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> </label>
@@ -262,20 +290,20 @@ export function BarkBattleConfigEditor({
</div> </div>
</div> </div>
<div className="mt-2 flex shrink-0 justify-center pb-[max(0.25rem,env(safe-area-inset-bottom))] sm:mt-3"> <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}
onClick={handlePublish} onClick={() => runValidatedAction(onPreview)}
className={`platform-button platform-button--primary min-h-10 px-4 py-2 text-sm sm:min-h-11 sm:px-5 ${isBusy ? 'cursor-not-allowed opacity-55' : ''}`} className={`platform-button platform-button--primary min-h-10 px-4 py-2 text-sm sm:min-h-11 sm:px-5 ${isBusy ? 'cursor-not-allowed opacity-55' : ''}`}
> >
<span className="inline-flex flex-wrap items-center justify-center gap-1.5 sm:gap-2"> <span className="inline-flex flex-wrap items-center justify-center gap-1.5 sm:gap-2">
{isBusy ? ( {isBusy ? (
<Loader2 className="h-4 w-4 animate-spin" /> <Loader2 className="h-4 w-4 animate-spin" />
) : ( ) : (
<WandSparkles className="h-4 w-4" /> <Play className="h-4 w-4" />
)} )}
<span>{isBusy ? '发布中' : '发布并试玩'}</span> <span>{isBusy ? '处理中' : '生成草稿'}</span>
</span> </span>
</button> </button>
</div> </div>

View File

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

View File

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

View File

@@ -1,21 +1,10 @@
import type { BarkBattleConfigEditorPayload } from '../../../packages/shared/src/contracts/barkBattle'; import type { BarkBattleConfigEditorPayload } from '../../../packages/shared/src/contracts/barkBattle';
import { ResolvedAssetImage } from '../ResolvedAssetImage';
type BarkBattlePreviewCardProps = { type BarkBattlePreviewCardProps = {
config: BarkBattleConfigEditorPayload; config: BarkBattleConfigEditorPayload;
}; };
const THEME_LABELS: Record<string, string> = {
'sunny-yard': '阳光院子',
'neon-park': '霓虹公园',
'moonlight-rooftop': '月光天台',
};
const DOG_LABELS: Record<string, string> = {
corgi: '柯基',
shiba: '柴犬',
husky: '哈士奇',
};
const DIFFICULTY_LABELS = { const DIFFICULTY_LABELS = {
easy: '轻松', easy: '轻松',
normal: '标准', normal: '标准',
@@ -25,47 +14,79 @@ const DIFFICULTY_LABELS = {
export function BarkBattlePreviewCard({ config }: BarkBattlePreviewCardProps) { export function BarkBattlePreviewCard({ config }: BarkBattlePreviewCardProps) {
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="mb-4 flex min-h-[8.5rem] items-center justify-center 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))] text-5xl 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"
> >
<span> VS </span> {config.uiBackgroundImageSrc ? (
<ResolvedAssetImage
src={config.uiBackgroundImageSrc}
alt=""
className="absolute inset-0 h-full w-full object-cover opacity-70"
/>
) : null}
<span className="relative grid place-items-center">
{config.playerCharacterImageSrc ? (
<ResolvedAssetImage
src={config.playerCharacterImageSrc}
alt=""
className="h-14 w-14 object-contain drop-shadow-xl sm:h-24 sm:w-24"
/>
) : (
<span className="text-4xl sm:text-6xl">🐕</span>
)}
</span>
<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
</span>
<span className="relative grid place-items-center">
{config.opponentCharacterImageSrc ? (
<ResolvedAssetImage
src={config.opponentCharacterImageSrc}
alt=""
className="h-14 w-14 object-contain drop-shadow-xl sm:h-24 sm:w-24"
/>
) : (
<span className="text-4xl sm:text-6xl">🐶</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)]">
{DOG_LABELS[config.playerDogSkinPreset] ?? config.playerDogSkinPreset} {config.playerImageDescription || '玩家'}
{' vs '} {' vs '}
{DOG_LABELS[config.opponentDogSkinPreset] ?? 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.leaderboardEnabled ? '开启' : '关闭'} {config.onomatopoeia?.slice(0, 3).join(' / ') || '炸场!'}
</dd> </dd>
</div> </div>
</dl> </dl>

View File

@@ -0,0 +1,212 @@
/* @vitest-environment jsdom */
import { render, screen, waitFor, within } from '@testing-library/react';
import userEvent from '@testing-library/user-event';
import { describe, expect, it, vi } from 'vitest';
import {
regenerateBarkBattleImageAsset,
uploadBarkBattleAsset,
} from '../../services/bark-battle-creation';
import { BarkBattleResultView } from './BarkBattleResultView';
vi.mock('../../services/bark-battle-creation', () => ({
regenerateBarkBattleImageAsset: vi.fn(),
uploadBarkBattleAsset: vi.fn(),
}));
vi.mock('../ResolvedAssetImage', () => ({
ResolvedAssetImage: ({
src,
alt,
...props
}: {
src?: string | null;
alt?: string;
}) => <img src={src ?? ''} alt={alt ?? ''} {...props} />,
}));
const draft = {
draftId: 'bark-battle-draft-1',
workId: 'bark-battle-work-1',
title: '汪汪冠军杯',
description: '',
themeDescription: '霓虹公园擂台',
playerImageDescription: '红围巾柴犬',
opponentImageDescription: '蓝头带哈士奇',
difficultyPreset: 'normal' as const,
configVersion: 1,
rulesetVersion: 'bark-battle-ruleset-v1',
updatedAt: '2026-05-14T10:00:00.000Z',
};
describe('BarkBattleResultView', () => {
it('exposes draft preview actions before publish', async () => {
const user = userEvent.setup();
const onStartTestRun = vi.fn();
const onPublish = vi.fn();
render(
<BarkBattleResultView
draft={draft}
onBack={() => {}}
onDraftChange={() => {}}
onStartTestRun={onStartTestRun}
onPublish={onPublish}
/>,
);
expect(screen.getByText('霓虹公园擂台')).toBeTruthy();
await user.click(screen.getByRole('button', { name: '试玩' }));
expect(onStartTestRun).toHaveBeenCalledWith(draft);
expect(onPublish).not.toHaveBeenCalled();
await user.click(screen.getByRole('button', { name: '发布' }));
expect(onPublish).toHaveBeenCalledWith(draft);
});
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 onDraftChange = vi.fn();
vi.mocked(uploadBarkBattleAsset).mockResolvedValue({
assetObjectId: 'asset-player-1',
assetKind: 'bark_battle_player_character_image',
objectKey: 'generated-bark-battle-assets/player.png',
assetSrc: '/generated-bark-battle-assets/player.png',
});
render(
<BarkBattleResultView
draft={draft}
onBack={() => {}}
onDraftChange={onDraftChange}
onStartTestRun={() => {}}
onPublish={() => {}}
/>,
);
const playerSlot = screen
.getByRole('heading', { name: '玩家形象' })
.closest('article');
expect(playerSlot).toBeTruthy();
const fileInput = within(playerSlot as HTMLElement).getByLabelText(
'上传玩家形象文件',
) as HTMLInputElement;
await user.upload(
fileInput,
new File(['image-bytes'], 'player.png', { type: 'image/png' }),
);
await waitFor(() => {
expect(uploadBarkBattleAsset).toHaveBeenCalledWith(
expect.objectContaining({
slot: 'player-character',
draftId: 'bark-battle-draft-1',
}),
);
});
expect(onDraftChange).toHaveBeenCalledWith(
expect.objectContaining({
playerCharacterImageSrc: '/generated-bark-battle-assets/player.png',
}),
);
});
it('does not render the raw object key or asset path in the slot summary', () => {
render(
<BarkBattleResultView
draft={{
...draft,
playerCharacterImageSrc: 'generated-bark-battle-assets/player-character/very-long-object-key.png',
}}
onBack={() => {}}
onDraftChange={() => {}}
onStartTestRun={() => {}}
onPublish={() => {}}
/>,
);
const playerSlot = screen.getByRole('heading', { name: '玩家形象' }).closest('article');
expect(playerSlot).toBeTruthy();
expect(within(playerSlot as HTMLElement).getByText('已替换')).toBeTruthy();
expect(
within(playerSlot as HTMLElement).queryByText(
'generated-bark-battle-assets/player-character/very-long-object-key.png',
),
).toBeNull();
expect(within(playerSlot as HTMLElement).queryByText(/objectKey|object key/i)).toBeNull();
});
it('keeps result assets to three image slots with per-slot regeneration only', async () => {
const user = userEvent.setup();
const onDraftChange = vi.fn();
vi.mocked(regenerateBarkBattleImageAsset).mockResolvedValue({
imageSrc: '/generated-bark-battle-assets/player.png',
assetId: 'asset-player',
model: 'gpt-image-2-all',
size: '1024*1024',
taskId: 'task-player',
prompt: 'player',
});
render(
<BarkBattleResultView
draft={draft}
onBack={() => {}}
onDraftChange={onDraftChange}
onStartTestRun={() => {}}
onPublish={() => {}}
/>,
);
expect(screen.getByRole('heading', { name: '玩家形象' })).toBeTruthy();
expect(screen.getByRole('heading', { name: '对手形象' })).toBeTruthy();
expect(screen.getByRole('heading', { name: 'UI背景' })).toBeTruthy();
expect(screen.queryByText('狗叫音效')).toBeNull();
expect(screen.queryByRole('button', { name: '一次生成' })).toBeNull();
const playerSlot = screen
.getByRole('heading', { name: '玩家形象' })
.closest('article');
expect(playerSlot).toBeTruthy();
await user.click(
within(playerSlot as HTMLElement).getByRole('button', { name: '重新生成' }),
);
await waitFor(() => {
expect(regenerateBarkBattleImageAsset).toHaveBeenCalledWith({
slot: 'player-character',
config: expect.objectContaining({
title: '汪汪冠军杯',
themeDescription: '霓虹公园擂台',
}),
draftId: 'bark-battle-draft-1',
});
});
expect(onDraftChange).toHaveBeenCalledWith(
expect.objectContaining({
playerCharacterImageSrc: '/generated-bark-battle-assets/player.png',
}),
);
});
});

View File

@@ -0,0 +1,329 @@
import {
ArrowLeft,
CheckCircle2,
ImagePlus,
Loader2,
Play,
RefreshCw,
Upload,
} from 'lucide-react';
import { type ChangeEvent, type ReactNode, useMemo, useRef, useState } from 'react';
import type {
BarkBattleConfigEditorPayload,
BarkBattleDraftConfig,
} from '../../../packages/shared/src/contracts/barkBattle';
import {
type BarkBattleAssetSlot,
regenerateBarkBattleImageAsset,
uploadBarkBattleAsset,
} from '../../services/bark-battle-creation';
import { BarkBattlePreviewCard } from './BarkBattlePreviewCard';
type BarkBattleResultViewProps = {
draft: BarkBattleDraftConfig;
isBusy?: boolean;
error?: string | null;
onBack: () => void;
onDraftChange: (draft: BarkBattleDraftConfig) => void;
onStartTestRun: (draft: BarkBattleDraftConfig) => void;
onPublish: (draft: BarkBattleDraftConfig) => void;
};
const SLOT_LABELS = {
'player-character': '玩家形象',
'opponent-character': '对手形象',
'ui-background': 'UI背景',
} satisfies Record<BarkBattleAssetSlot, string>;
function mapDraftToConfig(draft: BarkBattleDraftConfig): BarkBattleConfigEditorPayload {
return {
title: draft.title,
description: draft.description,
themeDescription: draft.themeDescription,
playerImageDescription: draft.playerImageDescription,
opponentImageDescription: draft.opponentImageDescription,
onomatopoeia: draft.onomatopoeia,
...(draft.playerCharacterImageSrc
? { playerCharacterImageSrc: draft.playerCharacterImageSrc }
: {}),
...(draft.opponentCharacterImageSrc
? { opponentCharacterImageSrc: draft.opponentCharacterImageSrc }
: {}),
...(draft.uiBackgroundImageSrc
? { uiBackgroundImageSrc: draft.uiBackgroundImageSrc }
: {}),
difficultyPreset: draft.difficultyPreset,
};
}
function applyAssetToDraft(
draft: BarkBattleDraftConfig,
slot: BarkBattleAssetSlot,
assetSrc: string,
): BarkBattleDraftConfig {
const updatedAt = new Date().toISOString();
if (slot === 'player-character') {
return { ...draft, playerCharacterImageSrc: assetSrc, updatedAt };
}
if (slot === 'opponent-character') {
return { ...draft, opponentCharacterImageSrc: assetSrc, updatedAt };
}
if (slot === 'ui-background') {
return { ...draft, uiBackgroundImageSrc: assetSrc, updatedAt };
}
return { ...draft, updatedAt };
}
function getSlotAssetSrc(draft: BarkBattleDraftConfig, slot: BarkBattleAssetSlot) {
if (slot === 'player-character') {
return draft.playerCharacterImageSrc ?? '';
}
if (slot === 'opponent-character') {
return draft.opponentCharacterImageSrc ?? '';
}
if (slot === 'ui-background') {
return draft.uiBackgroundImageSrc ?? '';
}
return '';
}
function ResultActionButton({
children,
disabled,
onClick,
tone = 'secondary',
}: {
children: ReactNode;
disabled?: boolean;
onClick: () => void;
tone?: 'primary' | 'secondary';
}) {
return (
<button
type="button"
disabled={disabled}
onClick={onClick}
className={`platform-button ${
tone === 'primary' ? 'platform-button--primary' : 'platform-button--secondary'
} min-h-10 justify-center text-sm disabled:cursor-not-allowed disabled:opacity-55 sm:min-h-11`}
>
{children}
</button>
);
}
function BarkBattleAssetSlotControl({
draft,
slot,
disabled,
onChange,
onError,
}: {
draft: BarkBattleDraftConfig;
slot: BarkBattleAssetSlot;
disabled: boolean;
onChange: (draft: BarkBattleDraftConfig) => void;
onError: (message: string | null) => void;
}) {
const fileInputRef = useRef<HTMLInputElement | null>(null);
const [isUploading, setIsUploading] = useState(false);
const [isRegenerating, setIsRegenerating] = useState(false);
const assetSrc = getSlotAssetSrc(draft, slot);
const assetStatus = assetSrc ? '已替换' : '未替换';
const handleUpload = async (event: ChangeEvent<HTMLInputElement>) => {
const file = event.currentTarget.files?.[0] ?? null;
event.currentTarget.value = '';
if (!file) {
return;
}
setIsUploading(true);
onError(null);
try {
const asset = await uploadBarkBattleAsset({
slot,
file,
draftId: draft.draftId,
});
const nextDraft = applyAssetToDraft(draft, slot, asset.assetSrc);
onChange(nextDraft);
} catch (error) {
onError(error instanceof Error ? error.message : '上传素材失败。');
} finally {
setIsUploading(false);
}
};
const handleRegenerate = async () => {
setIsRegenerating(true);
onError(null);
try {
const result = await regenerateBarkBattleImageAsset({
slot,
config: mapDraftToConfig(draft),
draftId: draft.draftId,
});
const nextDraft = applyAssetToDraft(draft, slot, result.imageSrc);
onChange(nextDraft);
} catch (error) {
onError(error instanceof Error ? error.message : '重新生成素材失败。');
} finally {
setIsRegenerating(false);
}
};
const isSlotBusy = isUploading || isRegenerating;
return (
<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-2 sm:gap-3">
<div className="min-w-0">
<h3 className="m-0 text-xs font-black text-[var(--platform-text-strong)] sm:text-sm">
{SLOT_LABELS[slot]}
</h3>
<div className="mt-0.5 truncate text-[11px] font-semibold text-[var(--platform-text-soft)] sm:mt-1 sm:text-xs">
{assetStatus}
</div>
</div>
{isSlotBusy ? (
<Loader2 className="h-4 w-4 shrink-0 animate-spin text-[var(--platform-text-soft)]" />
) : (
<ImagePlus className="h-4 w-4 shrink-0 text-[var(--platform-text-soft)]" />
)}
</div>
<div className="mt-2 grid grid-cols-2 gap-1.5 sm:mt-3 sm:gap-2">
<input
ref={fileInputRef}
type="file"
accept="image/png,image/jpeg,image/webp"
className="hidden"
aria-label={`上传${SLOT_LABELS[slot]}文件`}
onChange={handleUpload}
/>
<button
type="button"
disabled={disabled || isSlotBusy}
onClick={() => fileInputRef.current?.click()}
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" />
</button>
<button
type="button"
disabled={disabled || isSlotBusy}
onClick={handleRegenerate}
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" />
</button>
</div>
</article>
);
}
export function BarkBattleResultView({
draft,
isBusy = false,
error = null,
onBack,
onDraftChange,
onStartTestRun,
onPublish,
}: BarkBattleResultViewProps) {
const [localError, setLocalError] = useState<string | null>(null);
const previewConfig = useMemo(() => mapDraftToConfig(draft), [draft]);
const visibleError = localError ?? error;
const isActionBusy = isBusy;
return (
<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-4xl flex-col">
<div className="mb-2 flex shrink-0 items-center justify-between gap-2 sm:mb-3 sm:gap-3">
<button
type="button"
onClick={onBack}
disabled={isActionBusy}
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" />
</button>
<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>
</div>
<div className="min-h-0 flex-1 overflow-y-auto pr-0.5">
<section className="grid gap-2.5 lg:grid-cols-[minmax(0,0.94fr)_minmax(18rem,0.86fr)] lg: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-3 shadow-[inset_0_1px_0_rgba(255,255,255,0.74)] sm:p-4">
<div className="text-xs font-black text-[var(--platform-text-soft)] sm:text-sm">
稿
</div>
<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 || '未命名声浪竞技场'}
</h1>
</div>
<div className="grid gap-2 sm:grid-cols-2">
{(
[
'player-character',
'opponent-character',
'ui-background',
] as const
).map((slot) => (
<BarkBattleAssetSlotControl
key={slot}
draft={draft}
slot={slot}
disabled={isActionBusy}
onChange={(nextDraft) => {
setLocalError(null);
onDraftChange(nextDraft);
}}
onError={setLocalError}
/>
))}
</div>
</div>
<BarkBattlePreviewCard config={previewConfig} />
</section>
{visibleError ? (
<div className="platform-banner platform-banner--danger mt-3 rounded-2xl text-sm leading-6">
{visibleError}
</div>
) : null}
</div>
<div className="mt-3 grid shrink-0 gap-2 pb-[max(0.25rem,env(safe-area-inset-bottom))] sm:grid-cols-2">
<ResultActionButton
disabled={isActionBusy}
onClick={() => onStartTestRun(draft)}
>
<Play className="h-4 w-4" />
</ResultActionButton>
<ResultActionButton
tone="primary"
disabled={isActionBusy}
onClick={() => onPublish(draft)}
>
{isBusy ? (
<Loader2 className="h-4 w-4 animate-spin" />
) : (
<CheckCircle2 className="h-4 w-4" />
)}
</ResultActionButton>
</div>
</div>
</div>
);
}
export default BarkBattleResultView;

View File

@@ -4,6 +4,7 @@ import { fireEvent, render, screen } from '@testing-library/react';
import userEvent from '@testing-library/user-event'; import userEvent from '@testing-library/user-event';
import { afterEach, expect, test, vi } from 'vitest'; import { afterEach, expect, test, vi } from 'vitest';
import type { BarkBattleWorkSummary } from '../../../packages/shared/src/contracts/barkBattle';
import type { CustomWorldWorkSummary } from '../../../packages/shared/src/contracts/customWorldAgent'; import type { CustomWorldWorkSummary } from '../../../packages/shared/src/contracts/customWorldAgent';
import type { BabyObjectMatchDraft } from '../../../packages/shared/src/contracts/edutainmentBabyObject'; import type { BabyObjectMatchDraft } from '../../../packages/shared/src/contracts/edutainmentBabyObject';
import type { SquareHoleWorkSummary } from '../../../packages/shared/src/contracts/squareHoleWorks'; import type { SquareHoleWorkSummary } from '../../../packages/shared/src/contracts/squareHoleWorks';
@@ -237,6 +238,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();
@@ -607,6 +644,47 @@ test('creation hub shows delete action for baby object match drafts', async () =
expect(onOpenBabyObjectMatchDetail).not.toHaveBeenCalled(); expect(onOpenBabyObjectMatchDetail).not.toHaveBeenCalled();
}); });
test('creation hub works-only tab filters bark battle draft and published works', async () => {
const user = userEvent.setup();
const onOpenBarkBattleDetail = vi.fn();
render(
<CustomWorldCreationHub
mode="works-only"
items={[]}
barkBattleItems={[barkBattleDraftItem, barkBattlePublishedItem]}
loading={false}
error={null}
onRetry={() => {}}
onCreateType={noopCreateType}
onOpenDraft={() => {}}
onEnterPublished={() => {}}
onOpenBarkBattleDetail={onOpenBarkBattleDetail}
entryConfig={testEntryConfig}
creationTypes={testCreationTypes}
/>,
);
expect(screen.getByRole('button', { name: '全部 2' })).toBeTruthy();
expect(screen.getByRole('button', { name: '草稿 1' })).toBeTruthy();
expect(screen.getByRole('button', { name: '已发布 1' })).toBeTruthy();
expect(screen.getByText('竖屏声浪草稿')).toBeTruthy();
expect(screen.getByText('竖屏声浪已发布')).toBeTruthy();
await user.click(screen.getByRole('button', { name: '草稿 1' }));
expect(screen.getByText('竖屏声浪草稿')).toBeTruthy();
expect(screen.queryByText('竖屏声浪已发布')).toBeNull();
await user.click(screen.getByRole('button', { name: '已发布 1' }));
expect(screen.queryByText('竖屏声浪草稿')).toBeNull();
expect(screen.getByText('竖屏声浪已发布')).toBeTruthy();
await user.click(
screen.getByRole('button', { name: //u }),
);
expect(onOpenBarkBattleDetail).toHaveBeenCalledWith(barkBattlePublishedItem);
});
test('creation hub published work delete action is revealed without opening card', async () => { test('creation hub published work delete action is revealed without opening card', async () => {
const user = userEvent.setup(); const user = userEvent.setup();
const onDeletePuzzle = vi.fn(); const onDeletePuzzle = vi.fn();

View File

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

View File

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

View File

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

View File

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

File diff suppressed because it is too large Load Diff

View File

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

View File

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

View File

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

View File

@@ -31,11 +31,8 @@ export type SelectionStage =
| 'square-hole-generating' | 'square-hole-generating'
| 'square-hole-result' | 'square-hole-result'
| 'square-hole-runtime' | 'square-hole-runtime'
| 'jump-hop-workspace' | 'bark-battle-generating'
| 'jump-hop-generating' | 'bark-battle-result'
| 'jump-hop-result'
| 'jump-hop-runtime'
| 'jump-hop-gallery-detail'
| 'bark-battle-runtime' | 'bark-battle-runtime'
| 'creative-agent-workspace' | 'creative-agent-workspace'
| 'visual-novel-agent-workspace' | 'visual-novel-agent-workspace'

View File

@@ -5,24 +5,25 @@ 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 {
BabyObjectMatchDraft,
CreateBabyObjectMatchDraftRequest,
} from '../../../packages/shared/src/contracts/edutainmentBabyObject';
import type { import type {
CustomWorldAgentSessionSnapshot, CustomWorldAgentSessionSnapshot,
CustomWorldWorkSummary, CustomWorldWorkSummary,
} from '../../../packages/shared/src/contracts/customWorldAgent'; } from '../../../packages/shared/src/contracts/customWorldAgent';
import type {
BabyObjectMatchDraft,
CreateBabyObjectMatchDraftRequest,
} from '../../../packages/shared/src/contracts/edutainmentBabyObject';
import type { Match3DAgentSessionSnapshot } from '../../../packages/shared/src/contracts/match3dAgent'; import type { Match3DAgentSessionSnapshot } from '../../../packages/shared/src/contracts/match3dAgent';
import type { Match3DRunSnapshot } from '../../../packages/shared/src/contracts/match3dRuntime'; import type { Match3DRunSnapshot } from '../../../packages/shared/src/contracts/match3dRuntime';
import type { Match3DWorkSummary } from '../../../packages/shared/src/contracts/match3dWorks'; import type { Match3DWorkSummary } from '../../../packages/shared/src/contracts/match3dWorks';
import type { PuzzleAgentActionRequest } from '../../../packages/shared/src/contracts/puzzleAgentActions';
import type { import type {
PuzzleAnchorPack, PuzzleAnchorPack,
PuzzleResultDraft, PuzzleResultDraft,
} from '../../../packages/shared/src/contracts/puzzleAgentDraft'; } from '../../../packages/shared/src/contracts/puzzleAgentDraft';
import type { PuzzleAgentActionRequest } from '../../../packages/shared/src/contracts/puzzleAgentActions';
import type { import type {
CreatePuzzleAgentSessionRequest, CreatePuzzleAgentSessionRequest,
PuzzleAgentSessionSnapshot, PuzzleAgentSessionSnapshot,
@@ -43,13 +44,13 @@ import {
import { ApiClientError } from '../../services/apiClient'; import { ApiClientError } from '../../services/apiClient';
import type { AuthUser } from '../../services/authService'; import type { AuthUser } from '../../services/authService';
import { import {
createBabyObjectMatchDraft, createBarkBattleDraft,
deleteLocalBabyObjectMatchDraft, generateAllBarkBattleImageAssets,
listLocalBabyObjectMatchDrafts, listBarkBattleGallery,
publishBabyObjectMatchWork, listBarkBattleWorks,
regenerateBabyObjectMatchDraftAssets, publishBarkBattleWork,
saveBabyObjectMatchDraft, updateBarkBattleDraftConfig,
} from '../../services/edutainment-baby-object'; } from '../../services/bark-battle-creation';
import { import {
createBigFishCreationSession, createBigFishCreationSession,
getBigFishCreationSession, getBigFishCreationSession,
@@ -61,10 +62,6 @@ import {
submitBigFishInput, submitBigFishInput,
} from '../../services/big-fish-runtime'; } from '../../services/big-fish-runtime';
import { listBigFishWorks } from '../../services/big-fish-works'; import { listBigFishWorks } from '../../services/big-fish-works';
import {
createBarkBattleDraft,
publishBarkBattleWork,
} from '../../services/bark-battle-creation';
import { import {
type CreationEntryConfig, type CreationEntryConfig,
fetchCreationEntryConfig, fetchCreationEntryConfig,
@@ -76,6 +73,14 @@ import {
streamCreativeAgentMessage, streamCreativeAgentMessage,
streamCreativeDraftEdit, streamCreativeDraftEdit,
} from '../../services/creative-agent'; } from '../../services/creative-agent';
import {
createBabyObjectMatchDraft,
deleteLocalBabyObjectMatchDraft,
listLocalBabyObjectMatchDrafts,
publishBabyObjectMatchWork,
regenerateBabyObjectMatchDraftAssets,
saveBabyObjectMatchDraft,
} from '../../services/edutainment-baby-object';
import { match3dCreationClient } from '../../services/match3d-creation'; import { match3dCreationClient } from '../../services/match3d-creation';
import { createServerMatch3DRuntimeAdapter } from '../../services/match3d-runtime'; import { createServerMatch3DRuntimeAdapter } from '../../services/match3d-runtime';
import { import {
@@ -487,7 +492,13 @@ 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(),
updateBarkBattleDraftConfig: vi.fn(),
uploadBarkBattleAsset: vi.fn(),
})); }));
vi.mock('../../services/edutainment-baby-object', () => ({ vi.mock('../../services/edutainment-baby-object', () => ({
@@ -1001,20 +1012,19 @@ vi.mock('../bark-battle-creation/BarkBattleConfigEditor', () => ({
isBusy, isBusy,
showBackButton, showBackButton,
title, title,
onPublish, onPreview,
}: { }: {
error?: string | null; error?: string | null;
isBusy?: boolean; isBusy?: boolean;
showBackButton?: boolean; showBackButton?: boolean;
title?: string | null; title?: string | null;
onPublish: (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">
@@ -1033,18 +1043,50 @@ vi.mock('../bark-battle-creation/BarkBattleConfigEditor', () => ({
type="button" type="button"
disabled={isBusy} disabled={isBusy}
onClick={() => { onClick={() => {
onPublish({ onPreview({
title: '汪汪测试杯', title: '汪汪测试杯',
description: '', description: '',
themePreset: 'sunny-yard', themeDescription: '阳光草坪声浪竞技场',
playerDogSkinPreset: 'corgi', playerImageDescription: '戴红色围巾的柯基选手',
opponentDogSkinPreset: 'husky', opponentImageDescription: '蓝色护目镜哈士奇对手',
difficultyPreset: 'normal', difficultyPreset: 'normal',
leaderboardEnabled: true,
}); });
}} }}
> >
稿
</button>
</div>
),
}));
vi.mock('../bark-battle-creation/BarkBattleResultView', () => ({
BarkBattleResultView: ({
draft,
onBack,
onPublish,
onStartTestRun,
}: {
draft: {
title: string;
draftId: string;
workId?: string;
};
onBack: () => void;
onPublish: (draft: unknown) => void;
onStartTestRun: (draft: unknown) => void;
}) => (
<div className="bark-battle-result-view-mock">
<div>{draft.title}</div>
<div>稿ID{draft.draftId}</div>
<div>ID{draft.workId ?? 'missing-work'}</div>
<button type="button" onClick={() => onStartTestRun(draft)}>
</button>
<button type="button" onClick={() => onPublish(draft)}>
</button>
<button type="button" onClick={onBack}>
</button> </button>
</div> </div>
), ),
@@ -1099,14 +1141,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>
@@ -1288,6 +1343,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]
@@ -2822,15 +2905,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',
@@ -2839,11 +2968,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',
}); });
@@ -3218,6 +3346,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');
@@ -3294,32 +3425,200 @@ test('create tab switches bark battle into the embedded config form', async () =
expect(publishBarkBattleWork).not.toHaveBeenCalled(); expect(publishBarkBattleWork).not.toHaveBeenCalled();
}); });
test('bark battle publish preview returns to the embedded config 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 />);
await openCreateTemplateHub(user); await openCreateTemplateHub(user);
await user.click(screen.getByRole('tab', { name: '汪汪声浪' })); await user.click(screen.getByRole('tab', { name: '汪汪声浪' }));
await user.click(await screen.findByRole('button', { name: '发布并试玩' })); await user.click(await screen.findByRole('button', { name: '生成草稿' }));
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('作品IDbark-battle-work-1')).toBeTruthy();
expect(publishBarkBattleWork).not.toHaveBeenCalled();
await user.click(screen.getByRole('button', { name: '试玩' }));
expect(await screen.findByText(/汪汪声浪运行态:汪汪测试杯/u)).toBeTruthy(); expect(await screen.findByText(/汪汪声浪运行态:汪汪测试杯/u)).toBeTruthy();
await user.click(screen.getByRole('button', { name: '返回配置' })); await user.click(screen.getByRole('button', { name: '返回配置' }));
expect(await screen.findByText('汪汪声浪配置表单')).toBeTruthy(); expect(await screen.findByText(/汪汪声浪结果页:汪汪测试杯/u)).toBeTruthy();
await user.click(screen.getByRole('button', { name: '发布' }));
expect(publishBarkBattleWork).toHaveBeenCalledWith({
draftId: 'bark-battle-draft-1',
workId: 'bark-battle-work-1',
publishedSnapshot: expect.objectContaining({
title: '汪汪测试杯',
themeDescription: '阳光草坪声浪竞技场',
playerImageDescription: '戴红色围巾的柯基选手',
opponentImageDescription: '蓝色护目镜哈士奇对手',
}),
});
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();
});
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( 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 () => {
@@ -4554,7 +4853,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();
@@ -4602,7 +4901,7 @@ test('completed baby object match draft viewed immediately does not keep unread
expect(screen.queryByText('宝贝识物结果页')).toBeNull(); expect(screen.queryByText('宝贝识物结果页')).toBeNull();
}); });
expect(await screen.findByLabelText('物品 A')).toBeTruthy(); expect(await screen.findByLabelText('物品 A')).toBeTruthy();
await user.click(screen.getByRole('button', { name: '返回' })); await user.click(await screen.findByRole('button', { name: '返回' }));
await openDraftHub(user); await openDraftHub(user);
expect( expect(

View File

@@ -119,6 +119,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 {
@@ -127,6 +128,7 @@ import {
formatPlatformWorkDisplayName, formatPlatformWorkDisplayName,
formatPlatformWorkDisplayTag, formatPlatformWorkDisplayTag,
formatPlatformWorldTime, formatPlatformWorldTime,
isBarkBattleGalleryEntry,
isBigFishGalleryEntry, isBigFishGalleryEntry,
isEdutainmentGalleryEntry, isEdutainmentGalleryEntry,
isJumpHopGalleryEntry, isJumpHopGalleryEntry,
@@ -266,6 +268,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';
@@ -305,6 +308,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' },
]; ];
@@ -418,6 +422,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">
@@ -612,8 +653,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"
@@ -716,8 +758,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"
@@ -876,8 +919,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"
/> />
@@ -1269,8 +1313,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"
/> />
@@ -1350,8 +1395,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"
/> />
@@ -1417,8 +1463,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"
/> />
@@ -1743,6 +1790,8 @@ function buildPublicGalleryCardKey(entry: PlatformPublicGalleryCard) {
? 'square-hole' ? 'square-hole'
: isVisualNovelGalleryEntry(entry) : isVisualNovelGalleryEntry(entry)
? 'visual-novel' ? 'visual-novel'
: isBarkBattleGalleryEntry(entry)
? 'bark-battle'
: isEdutainmentGalleryEntry(entry) : isEdutainmentGalleryEntry(entry)
? `edutainment:${entry.templateId}` ? `edutainment:${entry.templateId}`
: 'rpg'; : 'rpg';
@@ -1855,10 +1904,10 @@ function describePublicGalleryCardKind(entry: PlatformPublicGalleryCard) {
? '抓鹅' ? '抓鹅'
: isSquareHoleGalleryEntry(entry) : isSquareHoleGalleryEntry(entry)
? '方洞' ? '方洞'
: isJumpHopGalleryEntry(entry)
? '跳一跳'
: isVisualNovelGalleryEntry(entry) : isVisualNovelGalleryEntry(entry)
? '视觉' ? '视觉'
: isBarkBattleGalleryEntry(entry)
? '汪汪'
: isEdutainmentGalleryEntry(entry) : isEdutainmentGalleryEntry(entry)
? entry.templateName ? entry.templateName
: describePlatformThemeLabel(entry.themeMode); : describePlatformThemeLabel(entry.themeMode);
@@ -2029,6 +2078,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';
} }
@@ -6032,12 +6085,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 ? (
desktopHeroEntry ? (
<PlatformWorkCoverArtwork
entry={desktopHeroEntry}
imageSrc={desktopHeroCover}
alt=""
className="absolute inset-0 h-full w-full object-cover opacity-34"
/>
) : (
<ResolvedAssetBackdrop <ResolvedAssetBackdrop
src={desktopHeroCover} src={desktopHeroCover}
alt="" alt=""
aria-hidden="true" aria-hidden="true"
className="absolute inset-0 h-full w-full object-cover opacity-34" 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">
@@ -6081,10 +6143,10 @@ export function RpgEntryHomeView({
> >
<div className="relative aspect-[1.35/1] overflow-hidden"> <div className="relative aspect-[1.35/1] overflow-hidden">
{coverImage ? ( {coverImage ? (
<ResolvedAssetBackdrop <PlatformWorkCoverArtwork
src={coverImage} entry={entry}
imageSrc={coverImage}
alt="" alt=""
aria-hidden="true"
className="h-full w-full object-cover" className="h-full w-full object-cover"
/> />
) : null} ) : null}

View File

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

View File

@@ -1,3 +1,4 @@
import type { BarkBattleWorkSummary } from '../../../packages/shared/src/contracts/barkBattle';
import type { BigFishWorkSummary } from '../../../packages/shared/src/contracts/bigFishWorkSummary'; import type { BigFishWorkSummary } from '../../../packages/shared/src/contracts/bigFishWorkSummary';
import type { BabyObjectMatchDraft } from '../../../packages/shared/src/contracts/edutainmentBabyObject'; import type { BabyObjectMatchDraft } from '../../../packages/shared/src/contracts/edutainmentBabyObject';
import { BABY_OBJECT_MATCH_EDUTAINMENT_TAG } from '../../../packages/shared/src/contracts/edutainmentBabyObject'; import { BABY_OBJECT_MATCH_EDUTAINMENT_TAG } from '../../../packages/shared/src/contracts/edutainmentBabyObject';
@@ -26,6 +27,7 @@ import { buildCustomWorldPlayableCharacters } from '../../data/characterPresets'
import { resolveCustomWorldCampSceneImage } from '../../data/customWorldVisuals'; import { resolveCustomWorldCampSceneImage } from '../../data/customWorldVisuals';
import { import {
buildBabyObjectMatchPublicWorkCode, buildBabyObjectMatchPublicWorkCode,
buildBarkBattlePublicWorkCode,
buildBigFishPublicWorkCode, buildBigFishPublicWorkCode,
buildJumpHopPublicWorkCode, buildJumpHopPublicWorkCode,
buildMatch3DPublicWorkCode, buildMatch3DPublicWorkCode,
@@ -49,6 +51,7 @@ export type PlatformWorldCardLike =
| PlatformPuzzleGalleryCard | PlatformPuzzleGalleryCard
| PlatformJumpHopGalleryCard | PlatformJumpHopGalleryCard
| PlatformVisualNovelGalleryCard | PlatformVisualNovelGalleryCard
| PlatformBarkBattleGalleryCard
| PlatformEdutainmentGalleryCard; | PlatformEdutainmentGalleryCard;
export type PlatformPuzzleGalleryCard = { export type PlatformPuzzleGalleryCard = {
@@ -226,6 +229,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
@@ -234,6 +265,7 @@ export type PlatformPublicGalleryCard =
| PlatformPuzzleGalleryCard | PlatformPuzzleGalleryCard
| PlatformJumpHopGalleryCard | PlatformJumpHopGalleryCard
| PlatformVisualNovelGalleryCard | PlatformVisualNovelGalleryCard
| PlatformBarkBattleGalleryCard
| PlatformEdutainmentGalleryCard; | PlatformEdutainmentGalleryCard;
export function isLibraryWorldEntry( export function isLibraryWorldEntry(
@@ -284,6 +316,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 {
@@ -506,6 +544,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,
@@ -561,6 +657,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';
} }
@@ -728,6 +828,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),
@@ -822,6 +928,10 @@ export function resolvePlatformPublicWorkCode(
return entry.publicWorkCode; return entry.publicWorkCode;
} }
if (isBarkBattleGalleryEntry(entry)) {
return entry.publicWorkCode;
}
return entry.publicWorkCode; return entry.publicWorkCode;
} }
@@ -843,3 +953,31 @@ export function describePlatformThemeLabel(
return '回响'; return '回响';
} }
} }
function normalizePlatformOptionalImageSrc(value?: string | null) {
return value?.trim() || null;
}
function describeBarkBattleDifficultyLabel(
difficulty: BarkBattleWorkSummary['difficultyPreset'],
) {
switch (difficulty) {
case 'easy':
return '轻松';
case 'hard':
return '高能';
default:
return '普通';
}
}
function buildBarkBattleThemeTags(work: BarkBattleWorkSummary) {
return [
'汪汪声浪',
describeBarkBattleDifficultyLabel(work.difficultyPreset),
work.themeDescription,
]
.map((tag) => tag.trim())
.filter(Boolean)
.slice(0, 3);
}

View File

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

View File

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

View File

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

View File

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

View File

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

View File

@@ -1,4 +1,5 @@
.bark-battle-hud { .bark-battle-hud {
position: relative;
min-height: 100svh; min-height: 100svh;
color: #fff7ed; color: #fff7ed;
background: radial-gradient(circle at 50% 15%, rgba(251, 191, 36, 0.35), transparent 28%), linear-gradient(180deg, #1f1147 0%, #521b4f 48%, #130a28 100%); background: radial-gradient(circle at 50% 15%, rgba(251, 191, 36, 0.35), transparent 28%), linear-gradient(180deg, #1f1147 0%, #521b4f 48%, #130a28 100%);
@@ -10,7 +11,34 @@
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 {
position: absolute;
inset: 0;
width: 100%;
height: 100%;
object-fit: cover;
opacity: 0.82;
}
.bark-battle-hud__topline { .bark-battle-hud__topline {
position: relative;
z-index: 1;
display: grid; display: grid;
gap: 10px; gap: 10px;
} }
@@ -38,6 +66,8 @@
.bark-battle-energy__side--opponent { background: linear-gradient(90deg, #60a5fa, #a78bfa); } .bark-battle-energy__side--opponent { background: linear-gradient(90deg, #60a5fa, #a78bfa); }
.bark-battle-arena { .bark-battle-arena {
position: relative;
z-index: 1;
flex: 1; flex: 1;
min-height: 0; min-height: 0;
display: grid; display: grid;
@@ -54,6 +84,10 @@
} }
.bark-battle-dog__body { .bark-battle-dog__body {
display: grid;
width: clamp(112px, 34vw, 170px);
aspect-ratio: 1;
place-items: center;
font-size: clamp(92px, 30vw, 150px); font-size: clamp(92px, 30vw, 150px);
filter: drop-shadow(0 18px 22px rgba(0, 0, 0, 0.42)); filter: drop-shadow(0 18px 22px rgba(0, 0, 0, 0.42));
} }
@@ -62,6 +96,12 @@
transform: rotateY(180deg) translateY(4px); transform: rotateY(180deg) translateY(4px);
} }
.bark-battle-dog__image {
width: 100%;
height: 100%;
object-fit: contain;
}
.bark-battle-dog__label, .bark-battle-dog__label,
.bark-battle-dog__burst, .bark-battle-dog__burst,
.bark-battle-vs { .bark-battle-vs {
@@ -92,8 +132,27 @@
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;
z-index: 1;
display: flex; display: flex;
gap: 10px; gap: 10px;
justify-content: center; justify-content: center;
@@ -114,6 +173,20 @@
background: linear-gradient(135deg, #facc15, #c7653d); background: linear-gradient(135deg, #facc15, #c7653d);
} }
.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;
@@ -127,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;
@@ -276,3 +381,10 @@
42% { opacity: 1; } 42% { opacity: 1; }
to { transform: translateY(-80px) scale(1.14); opacity: 0; } to { transform: translateY(-80px) scale(1.14); opacity: 0; }
} }
@keyframes barkBattleCountdownPulse {
from { transform: scale(0.84); opacity: 0; }
24% { opacity: 1; }
78% { transform: scale(1.06); opacity: 1; }
to { transform: scale(1.18); opacity: 0; }
}

View File

@@ -1,5 +1,6 @@
import './BarkBattleHud.css'; import './BarkBattleHud.css';
import { ResolvedAssetImage } from '../../../components/ResolvedAssetImage';
import type { BarkBattleSnapshot } from '../domain/BarkBattleTypes'; import type { BarkBattleSnapshot } from '../domain/BarkBattleTypes';
type BarkBattleHudProps = { type BarkBattleHudProps = {
@@ -10,6 +11,13 @@ 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;
opponentCharacterImageSrc?: string | null;
uiBackgroundImageSrc?: string | null;
}; };
const failureText = { const failureText = {
@@ -32,13 +40,30 @@ export function BarkBattleHud({
onMockBark, onMockBark,
onMockQuiet, onMockQuiet,
onRestart, onRestart,
enableMockControls = true,
runtimeError = null,
playerBurstText = '汪',
opponentBurstText = '反击',
playerCharacterImageSrc,
opponentCharacterImageSrc,
uiBackgroundImageSrc,
}: BarkBattleHudProps) { }: BarkBattleHudProps) {
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="汪汪声浪大作战">
{uiBackgroundImageSrc ? (
<ResolvedAssetImage
src={uiBackgroundImageSrc}
alt=""
aria-hidden="true"
className="bark-battle-hud__background-image"
/>
) : null}
<header className="bark-battle-hud__topline"> <header className="bark-battle-hud__topline">
<div className="bark-battle-hud__timer">{(snapshot.remainingMs / 1000).toFixed(1)}s</div> <div className="bark-battle-hud__timer">{(snapshot.remainingMs / 1000).toFixed(1)}s</div>
<div <div
@@ -65,29 +90,70 @@ export function BarkBattleHud({
</div> </div>
) : ( ) : (
<div className="bark-battle-arena" aria-label="竖屏声浪竞技场"> <div className="bark-battle-arena" aria-label="竖屏声浪竞技场">
<div key={`player-${playerPulseKey}`} className="bark-battle-dog bark-battle-dog--player" 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">
<span className="bark-battle-dog__body">🐕</span> {opponentBurstText}
<span className="bark-battle-dog__label"> · {snapshot.player.barkCount}</span> </span>
<span className="bark-battle-dog__body">
{opponentCharacterImageSrc ? (
<ResolvedAssetImage
src={opponentCharacterImageSrc}
alt=""
className="bark-battle-dog__image"
/>
) : (
'🐶'
)}
</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={`opponent-${opponentPulseKey}`} className="bark-battle-dog bark-battle-dog--opponent" 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">
<span className="bark-battle-dog__body">🐶</span> {playerBurstText}
<span className="bark-battle-dog__label"> · {snapshot.opponent.barkCount}</span> </span>
<span className="bark-battle-dog__body">
{playerCharacterImageSrc ? (
<ResolvedAssetImage
src={playerCharacterImageSrc}
alt=""
className="bark-battle-dog__image"
/>
) : (
'🐕'
)}
</span>
<span className="bark-battle-dog__label"> · {snapshot.player.barkCount}</span>
</div> </div>
</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}
{enableMockControls ? (
<button type="button" onPointerDown={onMockBark} onPointerUp={onMockQuiet} onClick={onMockBark}> <button type="button" onPointerDown={onMockBark} onPointerUp={onMockQuiet} onClick={onMockBark}>
</button> </button>
) : null}
{snapshot.phase === 'finished' ? ( {snapshot.phase === 'finished' ? (
<button type="button" onClick={onRestart}></button> <button type="button" onClick={onRestart}></button>
) : null} ) : null}

View File

@@ -1,8 +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 {
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';
@@ -12,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;
}; };
@@ -26,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,
@@ -42,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 },
@@ -63,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(
@@ -78,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 {
@@ -89,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(
@@ -100,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);
@@ -107,17 +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 [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 activeRunRef = useRef<BarkBattleActiveRun | null>(null);
const pendingRunStartRef = useRef<Promise<boolean> | null>(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;
useEffect(() => {
lastOnomatopoeiaRef.current = '';
setPlayerBurstText(onomatopoeiaPool[0] ?? '炸场!');
}, [onomatopoeiaPool]);
const appendDebugEvent = useCallback((text: string) => { const appendDebugEvent = useCallback((text: string) => {
debugEventIdRef.current += 1; debugEventIdRef.current += 1;
@@ -125,70 +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);
appendDebugEvent(`玩家叫声触发 #${nextSnapshot.player.barkCount} · 能量 ${Math.round(nextSnapshot.energy)}`); recordRuntimeTrigger(nextSnapshot.player.power);
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);
appendDebugEvent(`对手反击强度 ${(nextSnapshot.opponent.power * 100).toFixed(0)}%`); appendDebugEvent(
`对手反击强度 ${(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]); 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);
} }
@@ -196,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();
@@ -228,27 +684,58 @@ 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}>
{onExit ? (
<button
className="bark-battle-runtime__back-button"
type="button"
onClick={exitRuntime}
>
</button>
) : null}
<BarkBattleHud <BarkBattleHud
snapshot={snapshot} snapshot={snapshot}
playerPulseKey={playerPulseKey} playerPulseKey={playerPulseKey}
opponentPulseKey={opponentPulseKey} opponentPulseKey={opponentPulseKey}
playerCharacterImageSrc={activePublishedConfig?.playerCharacterImageSrc}
opponentCharacterImageSrc={activePublishedConfig?.opponentCharacterImageSrc}
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 ? (
<aside
className={`bark-battle-debug-panel${isDebugExpanded ? ' bark-battle-debug-panel--expanded' : ''}`}
aria-label="调试面板"
>
<header> <header>
<strong></strong> <strong></strong>
<button <button
@@ -263,16 +750,34 @@ export function BarkBattleRuntimeShell({
</header> </header>
<div className="bark-battle-debug-panel__body"> <div className="bark-battle-debug-panel__body">
<div className="bark-battle-debug-panel__controls"> <div className="bark-battle-debug-panel__controls">
<button type="button" onClick={startMock}></button> <button type="button" onClick={startMock}>
<button type="button" onClick={finishNow}></button>
<button type="button" onClick={restart}></button> </button>
{onExit ? <button type="button" onClick={onExit}></button> : null} <button type="button" onClick={finishNow}>
</button>
<button type="button" onClick={restart}>
</button>
{onExit ? (
<button type="button" onClick={onExit}>
</button>
) : null}
</div> </div>
{workId ? ( {workId ? (
<p className="bark-battle-debug-panel__work-id">{workId}</p> <p className="bark-battle-debug-panel__work-id">{workId}</p>
) : null} ) : null}
{runtimeError ? (
<p className="bark-battle-debug-panel__work-id" role="alert">
{runtimeError}
</p>
) : null}
<div className="bark-battle-debug-metrics" aria-label="触发反馈"> <div className="bark-battle-debug-metrics" aria-label="触发反馈">
<span className="bark-battle-debug-metrics__wide">{inputMode === 'microphone' ? '真实麦克风' : 'Mock 输入'}</span> <span className="bark-battle-debug-metrics__wide">
{inputMode === 'microphone' ? '真实麦克风' : 'Mock 输入'}
</span>
<span>{(liveInputVolume * 100).toFixed(0)}%</span> <span>{(liveInputVolume * 100).toFixed(0)}%</span>
<span>{controller.getSampleClockMs()}ms</span> <span>{controller.getSampleClockMs()}ms</span>
<span>{snapshot.player.barkCount}</span> <span>{snapshot.player.barkCount}</span>
@@ -281,7 +786,11 @@ export function BarkBattleRuntimeShell({
<span>{Math.round(snapshot.energy)}</span> <span>{Math.round(snapshot.energy)}</span>
</div> </div>
<ol className="bark-battle-debug-events" aria-label="触发日志"> <ol className="bark-battle-debug-events" aria-label="触发日志">
{debugEvents.length ? debugEvents.map((event) => <li key={event.id}>{event.text}</li>) : <li></li>} {debugEvents.length ? (
debugEvents.map((event) => <li key={event.id}>{event.text}</li>)
) : (
<li></li>
)}
</ol> </ol>
{DEBUG_CONFIG_FIELDS.map((field) => ( {DEBUG_CONFIG_FIELDS.map((field) => (
<label key={field.key}> <label key={field.key}>
@@ -303,8 +812,51 @@ export function BarkBattleRuntimeShell({
))} ))}
</div> </div>
</aside> </aside>
{particleText ? <div className="bark-battle-particles">{particleText}</div> : null} ) : null}
{snapshot.result ? <BarkBattleResultPanel result={snapshot.result} onRestart={restart} /> : 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="对战结算"
>
<p className="bark-battle-result__eyebrow"></p>
<h2>{resolveResultTitle(snapshot.result.winner)}</h2>
<div className="bark-battle-result__stats">
<span>
<strong>{snapshot.result.playerBarkCount}</strong>
</span>
<span>
<strong>{snapshot.result.opponentBarkCount}</strong>
</span>
<span>
<strong>{snapshot.result.score}</strong>
</span>
</div>
<div className="bark-battle-result__actions">
{onExit ? (
<button type="button" onClick={exitRuntime}>
</button>
) : null}
<button
className="bark-battle-primary-button"
type="button"
onClick={restart}
>
</button>
</div>
</section>
</div>
) : null}
</main> </main>
); );
} }

View File

@@ -1,11 +1,22 @@
/* @vitest-environment jsdom */ /* @vitest-environment jsdom */
import { render, screen } from '@testing-library/react'; import { render, screen } from '@testing-library/react';
import { describe, expect, it } from 'vitest'; import { describe, expect, it, vi } from 'vitest';
import type { BarkBattleSnapshot } from '../../domain/BarkBattleTypes'; import type { BarkBattleSnapshot } from '../../domain/BarkBattleTypes';
import { BarkBattleHud } from '../BarkBattleHud'; import { BarkBattleHud } from '../BarkBattleHud';
vi.mock('../../../../components/ResolvedAssetImage', () => ({
ResolvedAssetImage: ({
src,
alt,
...props
}: {
src?: string | null;
alt?: string;
}) => <img src={src ?? ''} alt={alt ?? ''} {...props} />,
}));
function buildSnapshot(overrides: Partial<BarkBattleSnapshot> = {}): BarkBattleSnapshot { function buildSnapshot(overrides: Partial<BarkBattleSnapshot> = {}): BarkBattleSnapshot {
return { return {
phase: 'playing', phase: 'playing',
@@ -33,6 +44,8 @@ describe('BarkBattleHud', () => {
expect(screen.getByLabelText('玩家狗狗背对屏幕')).toBeTruthy(); expect(screen.getByLabelText('玩家狗狗背对屏幕')).toBeTruthy();
expect(screen.getByLabelText('对手狗狗面向屏幕')).toBeTruthy(); expect(screen.getByLabelText('对手狗狗面向屏幕')).toBeTruthy();
expect(screen.getByLabelText('声浪能量条').getAttribute('aria-valuenow')).toBe('40'); expect(screen.getByLabelText('声浪能量条').getAttribute('aria-valuenow')).toBe('40');
const arenaText = screen.getByLabelText('竖屏声浪竞技场').textContent ?? '';
expect(arenaText.indexOf('对手 · 1')).toBeLessThan(arenaText.indexOf('你 · 3'));
}); });
it('energy 正负值会改变玩家侧和对手侧占比', () => { it('energy 正负值会改变玩家侧和对手侧占比', () => {
@@ -54,4 +67,19 @@ describe('BarkBattleHud', () => {
); );
expect(screen.getByRole('button', { name: '重新授权' })).toBeTruthy(); expect(screen.getByRole('button', { name: '重新授权' })).toBeTruthy();
}); });
it('展示自定义角色形象和 UI 背景', () => {
render(
<BarkBattleHud
snapshot={buildSnapshot()}
playerCharacterImageSrc="/generated-bark-battle/player.png"
opponentCharacterImageSrc="https://example.test/opponent.png"
uiBackgroundImageSrc="/generated-bark-battle/ui.png"
/>,
);
expect(document.querySelector('img[src="/generated-bark-battle/player.png"]')).toBeTruthy();
expect(document.querySelector('img[src="https://example.test/opponent.png"]')).toBeTruthy();
expect(document.querySelector('img[src="/generated-bark-battle/ui.png"]')).toBeTruthy();
});
}); });

View File

@@ -1,21 +1,241 @@
/* @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 } 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';
const runtimeClientMock = vi.hoisted(() => ({
startBarkBattleRun: vi.fn(),
finishBarkBattleRun: vi.fn(),
}));
const microphoneInputMock = vi.hoisted(() => ({
startBrowserMicrophoneSampler: vi.fn(),
}));
vi.mock('../../../../services/bark-battle-runtime', () => runtimeClientMock);
vi.mock('../../infrastructure/BrowserMicrophoneInput', () => microphoneInputMock);
vi.mock('../../../../components/ResolvedAssetImage', () => ({
ResolvedAssetImage: ({
src,
alt,
...props
}: {
src?: string | null;
alt?: string;
}) => <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 调试面板', () => {
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(
<BarkBattleRuntimeShell
runtimeMode="published"
publishedConfig={createPublishedConfig()}
/>,
);
expect(document.querySelector('audio')).toBeNull();
expect(
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 () => {
render(<BarkBattleRuntimeShell />); render(<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();
@@ -34,18 +254,321 @@ describe('BarkBattleRuntimeShell 调试面板', () => {
}); });
it('真实声控入口在不支持麦克风时展示失败原因mock 开始不请求权限', async () => { it('真实声控入口在不支持麦克风时展示失败原因mock 开始不请求权限', async () => {
microphoneInputMock.startBrowserMicrophoneSampler.mockRejectedValueOnce(
Object.assign(new Error('unsupported'), { reason: 'unsupported' }),
);
render(<BarkBattleRuntimeShell />); render(<BarkBattleRuntimeShell />);
const debugPanel = screen.getByLabelText('调试面板'); const debugPanel = screen.getByLabelText('调试面板');
await userEvent.click(within(debugPanel).getByRole('button', { name: '展开' })); await userEvent.click(
within(debugPanel).getByRole('button', { name: '展开' }),
);
await userEvent.click(screen.getByRole('button', { name: '开始声控' })); await userEvent.click(screen.getByRole('button', { name: '开始声控' }));
expect(screen.getByText('当前浏览器不支持麦克风输入')).toBeTruthy(); expect(screen.getByText('当前浏览器不支持麦克风输入')).toBeTruthy();
expect(screen.getAllByText(/unsupported/u).length).toBeGreaterThan(0); expect(
screen.getAllByText(/unsupported/u).length,
).toBeGreaterThan(0);
await userEvent.click(screen.getByRole('button', { name: '开始' })); await userEvent.click(screen.getByRole('button', { name: '开始' }));
expect(screen.getAllByText(/ mock /u).length).toBeGreaterThan(0); expect(
screen.getAllByText(/ mock /u).length,
).toBeGreaterThan(0);
expect(screen.getByText(/Mock /u)).toBeTruthy(); expect(screen.getByText(/Mock /u)).toBeTruthy();
}); });
it('草稿试玩不会登记正式对局', async () => {
render(<BarkBattleRuntimeShell />);
const debugPanel = screen.getByLabelText('调试面板');
await userEvent.click(
within(debugPanel).getByRole('button', { name: '展开' }),
);
await userEvent.click(screen.getByRole('button', { name: '开始' }));
expect(runtimeClientMock.startBarkBattleRun).not.toHaveBeenCalled();
expect(runtimeClientMock.finishBarkBattleRun).not.toHaveBeenCalled();
});
it('发布态不渲染 mock 控制和调试面板,并自动申请麦克风权限', async () => {
microphoneInputMock.startBrowserMicrophoneSampler.mockResolvedValueOnce({
stop: vi.fn(),
});
runtimeClientMock.startBarkBattleRun.mockResolvedValueOnce(
createRunStartResponse(),
);
render(
<BarkBattleRuntimeShell
runtimeMode="published"
publishedConfig={createPublishedConfig()}
/>,
);
expect(screen.queryByLabelText('调试面板')).toBeNull();
expect(screen.queryByRole('button', { name: '开始' })).toBeNull();
expect(screen.queryByRole('button', { name: '结束' })).toBeNull();
expect(screen.queryByRole('button', { name: '模拟叫声' })).toBeNull();
await waitFor(() => {
expect(
microphoneInputMock.startBrowserMicrophoneSampler,
).toHaveBeenCalledTimes(1);
});
await waitFor(() => {
expect(runtimeClientMock.startBarkBattleRun).toHaveBeenCalled();
});
expect(
runtimeClientMock.startBarkBattleRun.mock.calls[0]?.[0],
).toBe('work-bark-1');
expect(createRunStartResponse().runtimeConfig.minBarkGapMs).toBe(150);
expect(runtimeClientMock.finishBarkBattleRun).not.toHaveBeenCalled();
});
it('发布态进入运行态后展示可点击的返回按钮', async () => {
const handleExit = vi.fn();
microphoneInputMock.startBrowserMicrophoneSampler.mockResolvedValueOnce({
stop: vi.fn(),
});
runtimeClientMock.startBarkBattleRun.mockResolvedValueOnce(
createRunStartResponse(),
);
render(
<BarkBattleRuntimeShell
runtimeMode="published"
publishedConfig={createPublishedConfig()}
onExit={handleExit}
/>,
);
const backButton = screen.getByRole('button', { name: '返回' });
await userEvent.click(backButton);
expect(handleExit).toHaveBeenCalledTimes(1);
});
it('结束后弹出独立结果弹窗,并提供返回和再来一局', async () => {
const handleExit = vi.fn();
render(<BarkBattleRuntimeShell onExit={handleExit} />);
const debugPanel = screen.getByLabelText('调试面板');
await userEvent.click(
within(debugPanel).getByRole('button', { name: '展开' }),
);
await userEvent.click(screen.getByRole('button', { name: '开始' }));
await userEvent.click(screen.getByRole('button', { name: '模拟叫声' }));
await userEvent.click(screen.getByRole('button', { name: '结束' }));
const resultDialog = screen.getByRole('dialog', { name: '对战结算' });
expect(resultDialog.getAttribute('aria-modal')).toBe('true');
expect(resultDialog.closest('.bark-battle-hud')).toBeNull();
expect(within(resultDialog).getByText('本局结束')).toBeTruthy();
expect(within(resultDialog).getByText('玩家叫声')).toBeTruthy();
expect(within(resultDialog).getByText('对手压制')).toBeTruthy();
expect(within(resultDialog).getByText('声浪分')).toBeTruthy();
expect(
within(resultDialog).getByRole('button', { name: '再来一局' }),
).toBeTruthy();
await userEvent.click(
within(resultDialog).getByRole('button', { name: '返回' }),
);
expect(handleExit).toHaveBeenCalledTimes(1);
});
it('发布态麦克风失败不会登记正式对局', async () => {
microphoneInputMock.startBrowserMicrophoneSampler.mockRejectedValueOnce(
Object.assign(new Error('permission-denied'), {
reason: 'permission-denied',
}),
);
render(
<BarkBattleRuntimeShell
runtimeMode="published"
publishedConfig={createPublishedConfig()}
/>,
);
await waitFor(() => {
expect(screen.getByText('麦克风授权被拒绝')).toBeTruthy();
});
expect(screen.queryByLabelText('调试面板')).toBeNull();
expect(runtimeClientMock.startBarkBattleRun).not.toHaveBeenCalled();
expect(runtimeClientMock.finishBarkBattleRun).not.toHaveBeenCalled();
});
it('发布态启动后会直接申请麦克风权限,授权成功后登记 start run 并进入倒计时', async () => {
microphoneInputMock.startBrowserMicrophoneSampler.mockResolvedValueOnce({
stop: vi.fn(),
});
runtimeClientMock.startBarkBattleRun.mockResolvedValueOnce(
createRunStartResponse(),
);
runtimeClientMock.finishBarkBattleRun.mockReturnValueOnce(
new Promise(() => {}),
);
render(
<BarkBattleRuntimeShell
runtimeMode="published"
publishedConfig={createPublishedConfig()}
/>,
);
await waitFor(() => {
expect(runtimeClientMock.startBarkBattleRun).toHaveBeenCalledWith(
'work-bark-1',
expect.objectContaining({
sourceRoute: expect.any(String),
clientRuntimeVersion: expect.any(String),
}),
);
});
expect(screen.getByLabelText(//u)).toBeTruthy();
expect(screen.queryByRole('button', { name: '开始声控' })).toBeNull();
expect(screen.queryByLabelText('调试面板')).toBeNull();
expect(screen.queryByRole('button', { name: '模拟叫声' })).toBeNull();
expect(runtimeClientMock.finishBarkBattleRun).not.toHaveBeenCalled();
});
it('发布态正式对局使用 start run 返回的服务端 runtimeConfig', async () => {
microphoneInputMock.startBrowserMicrophoneSampler.mockResolvedValueOnce({
stop: vi.fn(),
});
const started = createRunStartResponse();
runtimeClientMock.startBarkBattleRun.mockResolvedValueOnce({
...started,
runtimeConfig: {
...started.runtimeConfig,
durationMs: 1000,
drawThreshold: 3,
minBarkGapMs: 150,
},
});
runtimeClientMock.finishBarkBattleRun.mockReturnValueOnce(
new Promise(() => {}),
);
vi.useFakeTimers();
render(
<BarkBattleRuntimeShell
runtimeMode="published"
publishedConfig={createPublishedConfig()}
/>,
);
await act(async () => {
await Promise.resolve();
await Promise.resolve();
});
expect(runtimeClientMock.startBarkBattleRun).toHaveBeenCalled();
act(() => {
vi.advanceTimersByTime(5000);
});
expect(runtimeClientMock.finishBarkBattleRun).toHaveBeenCalledWith(
'run-bark-1',
expect.objectContaining({
durationMs: 1000,
}),
);
});
it('发布态正式对局使用服务端 runtimeConfig 刷新自定义拟声词和素材', async () => {
microphoneInputMock.startBrowserMicrophoneSampler.mockImplementationOnce(
async (onSample: (volume: number, atMs: number) => void) => {
onSample(0.9, 0);
return { stop: vi.fn() };
},
);
const started = createRunStartResponse();
runtimeClientMock.startBarkBattleRun.mockResolvedValueOnce({
...started,
runtimeConfig: {
...started.runtimeConfig,
onomatopoeia: ['喵能爆裂!'],
playerCharacterImageSrc: '/server/player.png',
opponentCharacterImageSrc: '/server/opponent.png',
uiBackgroundImageSrc: '/server/background.png',
},
});
runtimeClientMock.finishBarkBattleRun.mockReturnValueOnce(
new Promise(() => {}),
);
vi.useFakeTimers();
render(
<BarkBattleRuntimeShell
runtimeMode="published"
publishedConfig={createPublishedConfig({ onomatopoeia: undefined })}
/>,
);
await act(async () => {
await Promise.resolve();
await Promise.resolve();
});
expect(runtimeClientMock.startBarkBattleRun).toHaveBeenCalled();
act(() => {
vi.advanceTimersByTime(3100);
});
expect(
document.querySelector('img[src="/server/player.png"]'),
).toBeTruthy();
expect(screen.getByText('喵能爆裂!')).toBeTruthy();
});
it('发布态真实麦克风对局结算后提交派生指标', async () => {
microphoneInputMock.startBrowserMicrophoneSampler.mockResolvedValueOnce({
stop: vi.fn(),
});
runtimeClientMock.startBarkBattleRun.mockResolvedValueOnce(
createRunStartResponse(),
);
runtimeClientMock.finishBarkBattleRun.mockReturnValueOnce(
new Promise(() => {}),
);
vi.useFakeTimers();
render(
<BarkBattleRuntimeShell
runtimeMode="published"
publishedConfig={createPublishedConfig()}
/>,
);
await act(async () => {
await Promise.resolve();
});
expect(runtimeClientMock.startBarkBattleRun).toHaveBeenCalled();
act(() => {
vi.advanceTimersByTime(34_000);
});
expect(runtimeClientMock.finishBarkBattleRun).toHaveBeenCalledWith(
'run-bark-1',
expect.objectContaining({
runId: 'run-bark-1',
runToken: 'token-bark-1',
workId: 'work-bark-1',
configVersion: 2,
rulesetVersion: 'bark-battle-ruleset-v1',
difficultyPreset: 'hard',
derivedMetrics: expect.objectContaining({
triggerCount: expect.any(Number),
finalEnergy: expect.any(Number),
}),
clientResult: expect.any(String),
}),
);
act(() => {
vi.clearAllTimers();
});
vi.useRealTimers();
});
}); });

View File

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

View File

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

View File

@@ -1,8 +1,14 @@
import type { import type {
BarkBattleAssetSlot,
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 { import {
type ApiRequestOptions, type ApiRequestOptions,
@@ -10,7 +16,13 @@ import {
requestJson, requestJson,
} from '../apiClient'; } from '../apiClient';
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_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,
@@ -27,6 +39,171 @@ export type BarkBattleCreationRequestOptions = Pick<
| 'clearAuthOnUnauthorized' | 'clearAuthOnUnauthorized'
>; >;
export type BarkBattleUploadedAsset = {
assetObjectId: string;
assetKind: string;
objectKey: 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 = {
upload: {
bucket: string;
host: string;
objectKey: string;
legacyPublicPath: string;
formFields: Record<string, string | null | undefined>;
};
};
type ConfirmAssetObjectResponse = {
assetObject: {
assetObjectId: string;
objectKey: string;
assetKind: string;
};
};
const SLOT_UPLOAD_CONFIG = {
'player-character': {
acceptKind: 'image',
assetKind: 'bark_battle_player_character_image',
legacyPrefix: 'generated-bark-battle-assets',
maxSizeBytes: BARK_BATTLE_ASSET_UPLOAD_MAX_IMAGE_BYTES,
},
'opponent-character': {
acceptKind: 'image',
assetKind: 'bark_battle_opponent_character_image',
legacyPrefix: 'generated-bark-battle-assets',
maxSizeBytes: BARK_BATTLE_ASSET_UPLOAD_MAX_IMAGE_BYTES,
},
'ui-background': {
acceptKind: 'image',
assetKind: 'bark_battle_ui_background_image',
legacyPrefix: 'generated-bark-battle-assets',
maxSizeBytes: BARK_BATTLE_ASSET_UPLOAD_MAX_IMAGE_BYTES,
},
} satisfies Record<
BarkBattleAssetSlot,
{
acceptKind: 'image';
assetKind: string;
legacyPrefix: string;
maxSizeBytes: number;
}
>;
const MIME_BY_EXTENSION: Record<string, string> = {
jpeg: 'image/jpeg',
jpg: 'image/jpeg',
png: 'image/png',
webp: 'image/webp',
};
function resolveUploadContentType(file: File) {
if (file.type.trim()) {
return file.type.trim();
}
const extension = file.name.split('.').pop()?.trim().toLowerCase() ?? '';
return MIME_BY_EXTENSION[extension] ?? 'application/octet-stream';
}
function validateBarkBattleUploadFile(slot: BarkBattleAssetSlot, file: File) {
const config = SLOT_UPLOAD_CONFIG[slot];
const contentType = resolveUploadContentType(file);
if (file.size <= 0) {
throw new Error('素材文件为空,请重新选择。');
}
if (file.size > config.maxSizeBytes) {
throw new Error('素材文件过大,请压缩后再上传。');
}
if (config.acceptKind === 'image' && !contentType.startsWith('image/')) {
throw new Error('请选择图片素材。');
}
return contentType;
}
function normalizeAssetPathSegment(value: string) {
return value
.trim()
.replace(/[^a-zA-Z0-9_-]+/gu, '-')
.replace(/^-+|-+$/gu, '')
.slice(0, 72);
}
function buildUploadPathSegments(slot: BarkBattleAssetSlot, draftId?: string) {
return [
'bark-battle',
normalizeAssetPathSegment(draftId || 'draft') || 'draft',
slot,
String(Date.now()),
];
}
async function postDirectUploadFile(
upload: DirectUploadTicketResponse['upload'],
file: File,
) {
const formData = new FormData();
Object.entries(upload.formFields).forEach(([key, value]) => {
if (value !== null && value !== undefined) {
formData.append(key, value);
}
});
formData.append('file', file);
const response = await fetch(upload.host, {
method: 'POST',
body: formData,
});
if (!response.ok) {
throw new Error('上传平台资产失败。');
}
}
function withBarkBattleGenerationTimeout<T>(
promise: Promise<T>,
slot: BarkBattleAssetSlot,
): Promise<T> {
let timeoutId: ReturnType<typeof setTimeout> | null = null;
const timeout = new Promise<never>((_, reject) => {
timeoutId = setTimeout(() => {
reject(new Error(`${slot} 生成超时`));
}, BARK_BATTLE_GENERATION_SLOT_TIMEOUT_MS);
});
return Promise.race([promise, timeout]).finally(() => {
if (timeoutId) {
clearTimeout(timeoutId);
}
});
}
function resolveBarkBattleGenerationFailureMessage(error: unknown) {
if (error instanceof Error && error.message.trim()) {
return error.message.trim();
}
return '汪汪声浪素材生成失败。';
}
export function createBarkBattleDraft( export function createBarkBattleDraft(
payload: BarkBattleDraftCreateRequest, payload: BarkBattleDraftCreateRequest,
options: BarkBattleCreationRequestOptions = {}, options: BarkBattleCreationRequestOptions = {},
@@ -49,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 = {},
@@ -71,7 +273,179 @@ 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: {
slot: BarkBattleAssetSlot;
file: File;
draftId?: string | null;
}): Promise<BarkBattleUploadedAsset> {
const contentType = validateBarkBattleUploadFile(payload.slot, payload.file);
const config = SLOT_UPLOAD_CONFIG[payload.slot];
const ticket = await requestJson<DirectUploadTicketResponse>(
'/api/assets/direct-upload-tickets',
{
method: 'POST',
headers: { 'Content-Type': 'application/json' },
body: JSON.stringify({
legacyPrefix: config.legacyPrefix,
pathSegments: buildUploadPathSegments(
payload.slot,
payload.draftId ?? undefined,
),
fileName: payload.file.name,
contentType,
access: 'private',
maxSizeBytes: config.maxSizeBytes,
metadata: {
asset_kind: config.assetKind,
bark_battle_slot: payload.slot,
},
}),
},
'创建汪汪声浪素材上传凭证失败',
);
await postDirectUploadFile(ticket.upload, payload.file);
const confirmed = await requestJson<ConfirmAssetObjectResponse>(
'/api/assets/objects/confirm',
{
method: 'POST',
headers: { 'Content-Type': 'application/json' },
body: JSON.stringify({
bucket: ticket.upload.bucket,
objectKey: ticket.upload.objectKey,
contentType,
contentLength: payload.file.size,
assetKind: config.assetKind,
accessPolicy: 'private',
profileId: payload.draftId?.trim() || null,
entityId: payload.slot,
}),
},
'确认汪汪声浪素材失败',
);
return {
assetObjectId: confirmed.assetObject.assetObjectId,
assetKind: confirmed.assetObject.assetKind,
objectKey: confirmed.assetObject.objectKey,
assetSrc: ticket.upload.legacyPublicPath,
};
}
export function regenerateBarkBattleImageAsset(payload: {
slot: BarkBattleAssetSlot;
config: BarkBattleConfigEditorPayload;
draftId?: string | null;
}): Promise<BarkBattleGeneratedImageAsset> {
const request: BarkBattleImageAssetGenerateRequest = {
slot: payload.slot,
draftId: payload.draftId ?? null,
config: payload.config,
};
return requestJson<BarkBattleGeneratedImageAsset>(
`${BARK_BATTLE_CREATION_API_BASE}/images/generate`,
{
method: 'POST',
headers: { 'Content-Type': 'application/json' },
body: JSON.stringify(request),
},
'生成汪汪声浪素材失败',
{
retry: BARK_BATTLE_CREATION_WRITE_RETRY,
timeoutMs: BARK_BATTLE_GENERATION_SLOT_TIMEOUT_MS,
},
);
}
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,
publish: publishBarkBattleWork, publish: publishBarkBattleWork,
updateDraftConfig: updateBarkBattleDraftConfig,
uploadAsset: uploadBarkBattleAsset,
}; };

View File

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

View File

@@ -1,4 +1,4 @@
export function normalizePublicCodeText(value: string) { export function normalizePublicCodeText(value: string) {
return value return value
.trim() .trim()
.replace(/[^a-zA-Z0-9]/gu, '') .replace(/[^a-zA-Z0-9]/gu, '')
@@ -53,12 +53,18 @@ export function buildBabyObjectMatchPublicWorkCode(profileId: string) {
return `BO-${suffix}`; return `BO-${suffix}`;
} }
export function buildJumpHopPublicWorkCode(profileId: string) { function normalizeBarkBattlePublicWorkCodeSuffix(workId: string) {
const normalized = normalizePublicCodeText(profileId); const normalized = normalizePublicCodeText(workId);
const fallback = normalized || '00000000'; const withoutPrefix = normalized.startsWith('BB')
const suffix = fallback.slice(-8).padStart(8, '0'); ? normalized.slice(2)
: normalized;
const fallback = withoutPrefix || normalized || '00000000';
return `JH-${suffix}`; 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) {
@@ -133,12 +139,13 @@ export function isSameBabyObjectMatchPublicWorkCode(
); );
} }
export function isSameJumpHopPublicWorkCode(keyword: string, profileId: string) { export function isSameBarkBattlePublicWorkCode(keyword: string, workId: string) {
const normalizedKeyword = normalizePublicCodeText(keyword); const normalizedKeyword = normalizePublicCodeText(keyword);
return ( return (
normalizedKeyword === normalizedKeyword ===
normalizePublicCodeText(buildJumpHopPublicWorkCode(profileId)) || normalizePublicCodeText(buildBarkBattlePublicWorkCode(workId)) ||
normalizedKeyword === normalizePublicCodeText(profileId) normalizedKeyword === normalizePublicCodeText(workId) ||
normalizedKeyword === normalizeBarkBattlePublicWorkCodeSuffix(workId)
); );
} }