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
@@ -1,6 +1,10 @@
---
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
@@ -44,7 +48,46 @@ description: 在 Genarrative 新增、开放或重构玩法创作工具时,按
### 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`
- `.hermes/shared-memory/`
+22 -44
View File
@@ -16,41 +16,21 @@
---
## 2026-05-21 外部 API 失败必须 OTLP 上报并落库
## 2026-05-20 汪汪声浪 v1 公开闭环计划
- 背景:图片生成等外部供应商调用失败时,仅返回 502/504 或普通日志无法支持后续按 provider、阶段和重试属性聚合排障
- 决策:外部 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
- 落库方式:优先复用 tracking outbox 异步批量写入;outbox 不可写或因保护阈值拒绝时回退同步直写 SpacetimeDB。不新增 SpacetimeDB 表,不让 reducer 做外部 I/O
- 影响范围:`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、后端架构文档和开发运维文档
- 验证方式:执行 `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`
- 背景:Bark Battle v1 需要把创作、生成、结果、发布、详情和正式运行态收成一条闭环,避免把草稿试玩、公开广场和正式成绩混在一起
- 决策:`bark-battle` 入口改为 6 字段表单(作品标题、简介、主题 / 竞技背景描述 `themeDescription`、玩家形象描述、对手形象描述、难度);提交后进入 `bark-battle-generating` 独立生成页,自动生成玩家形象、对手形象和竞技背景三图,部分失败也继续进入结果页。旧“角色设定 / 狗狗皮肤预设 / themePreset”统一退场,配置和文档只使用“形象描述 / themeDescription”。结果页只保留单槽重试、重新生成和上传,不再保留一次生成按钮、音频配置入口、皮肤预设入口或排名配置。发布后先跳统一作品详情页 `/works/detail?work=BB-xxxxxxxx`,再由详情页进入正式 `published` runtime;正式 runtime 必须真实麦克风,`draft` 可试玩、可 mock 且不写正式统计。公开广场统一读取 `bark_battle_gallery_view` read model
- 影响范围:`BarkBattleConfigEditor``BarkBattleGeneratingView``BarkBattleResultView``BarkBattleRuntimeShell``PlatformEntryFlowShellImpl``appPageRoutes`、Bark Battle creation/runtime client、公开广场聚合与相关交互测试
- 验证方式:提交表单后先进入生成页;生成页部分失败仍能落到结果页;结果页只出现单槽重试 / 重新生成 / 上传;发布后先到 `/works/detail?work=BB-xxxxxxxx` 再进正式 runtime;正式 runtime 会要求麦克风并写基础统计,草稿试玩可 mock 且不写正式 run;公开广场读取 `bark_battle_gallery_view`
- 关联文档:`docs/【玩法创作】平台入口与玩法链路-2026-05-15.md`
## 2026-05-22 汪汪声浪运行态与作品外显信息收口
- 背景:Bark Battle v1 在正式运行态、图片生成提示词和作品外部卡片上仍存在体验漂移:能量条推满后还要等计时结束、进入正式 runtime 后还要二次点击声控、角色形象 prompt 会默认注入狗主体、草稿 / 已发布卡片外部看不到创作者。
- 决策:能量条到玩家或对手边界即结算;正式 `published` runtime 从作品详情启动后立即申请真实麦克风权限,授权成功后立刻进入倒计时,并使用 start run 返回的 `runtimeConfig` 作为本局前端规则参数;结束后弹出独立结算弹窗,运行态固定提供返回按钮。玩家 / 对手形象图提示词保持用户填写的形象描述,只要求单个完整形象、正面和透明背景,不把非狗描述改写成狗;草稿架、已发布作品架、统一作品详情和公开广场列表都展示后端返回的 `authorDisplayName`。Bark Battle 卡片封面按竞技背景、玩家形象、对手形象、入口参考图兜底;works summary 优先读取 `publishedSnapshotJson` 的最终发布素材。拟声词进入配置 JSON,未手动编辑时随主题 / 形象描述重算,手动编辑后保持创作者自定义;触发阈值降到 `0.35`、冷却降到 `150ms`,后端 `BarkBattleRuleset.min_bark_gap_ms` 同步为 `150`,局内有效触发后快速随机展示高能词池。
- 影响范围:`BarkBattleSession``BarkBattleRuntimeShell``BarkBattleConfigEditor``BarkBattleConfig`、Bark Battle 生图 prompt、Bark Battle works/gallery summary、创作中心作品架卡片、公开作品码、`module-bark-battle` ruleset 和玩法链路文档。
- 验证方式:能量条推到 `100/-100` 的领域测试应提前 finished;发布态 runtime mount 后应自动调用麦克风 sampler、登记正式 run 并使用服务端 runtimeConfig;prompt 单测应覆盖透明背景、正面和非狗描述不强注入狗;作品架测试应覆盖草稿与已发布卡片作者展示和封面兜底;拟声词测试应覆盖主题自动重算、自定义保持和随机展示。
- 关联文档:`docs/【玩法创作】平台入口与玩法链路-2026-05-15.md`
## 2026-05-18 Rust 手写模块入口统一不用 mod.rs
@@ -88,6 +68,7 @@
- 影响范围:`jenkins/Jenkinsfile.production-stdb-module-build` 及后续所有同类 Windows 构建流水线。
- 验证方式:Jenkins 日志中应能看到 `[jenkins-powershell] user:``[jenkins-powershell] exe:`Checkout 阶段会打印当前 `HEAD` 与请求 commit,并在 `COMMIT_HASH` 为空或一致时直接继续;不再停在 `PipelineNodeTreeScanner... Cannot run program "powershell"` 或重复 `git clean` 的退出码 5。
- 关联文档:`docs/【开发运维】本地开发验证与生产运维-2026-05-15.md``.hermes/shared-memory/pitfalls.md`
## 2026-05-19 tracking outbox 改为 rotate 后异步 flush
- 背景:普通 route tracking 写入压力上来后,不能让 HTTP 请求线程等待 SpacetimeDB 批量入库。
@@ -330,6 +311,7 @@
- 影响范围:平台个人页、登录弹窗、法律 Markdown 渲染和前端认证交互测试。
- 验证方式:执行 `npm run test -- src/components/auth/AuthGate.test.tsx src/components/rpg-entry/RpgEntryHomeView.recharge.test.tsx`、触碰文件 ESLint、`npm run check:encoding`
- 关联文档:`docs/prd/PROFILE_LEGAL_INFO_AND_AUTH_AGREEMENT_PRD_2026-05-12.md`
## 2026-05-12 微信小程序待绑定手机号优先走原生手机号授权
- 背景:微信小程序 `web-view` 壳登录后若返回 `pending_bind_phone`,H5 仍会展示手输手机号和短信验证码绑定页,体验上多了一步。
@@ -733,18 +715,14 @@
- 影响范围:`api-server` tracking 中间件、SpacetimeDB tracking procedure、部署数据目录、OTLP 指标和运维排障。
- 验证方式:数据库不可用时公开 route 请求不失败且 outbox 文件保留;恢复后批量写入成功并删除本地 sealed 文件;关键事件仍立即影响任务 / 统计。
## 2026-05-19 跳一跳平台公开链路采用独立玩法路由
## 2026-05-19 汪汪声浪默认开放并区分草稿试玩与正式运行态
- 背景:跳一跳玩法已接入平台入口、推荐、公开详情、试玩和运行态,后续继续扩展公开广场或推荐流时需要避免把它当成拼图兼容分支
- 决策:跳一跳公开路由统一依赖 `sourceType='jump-hop'``JH-*` public code;平台首页、推荐、公开作品列表/详情、试玩和运行态都按 `jump-hop` 独立玩法分发。后端仍是作品、运行和发布状态的业务真相,前端只做展示、交互和临时 UI 状态,不在页面层补业务规则或权限判断
- 影响范围:平台入口、推荐流、公开详情、试玩启动、跳一跳运行态、`api-server` / SpacetimeDB 公开投影和 shared contracts
- 验证方式:从平台推荐或公开详情进入跳一跳作品时,路由 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`
- 背景:`bark-battle` 已具备草稿结果页、发布链路与运行态 API,继续在入口层标记“敬请期待”会阻断创作闭环;同时草稿试玩不应污染正式成绩统计
- 决策:默认入口改为 `visible=true``open=true``badge=可创建`,参考图固定为 `/creation-type-references/bark-battle.webp`。系统默认迁移只纠偏未被后台人工改过的汪汪声浪入口。发布后先进入统一作品详情页 `/works/detail?work=BB-xxxxxxxx`;正式 runtime 使用 `runtimeMode=published` 并必须真实麦克风,调用 `startBarkBattleRun` / `finishBarkBattleRun` 写正式 run;草稿结果页试玩仍使用 `runtimeMode=draft`,允许 mock 且不写正式 run
- 验证方式:入口配置响应应返回汪汪声浪可创建和专属参考图;发布后地址应为 `/works/detail?work=BB-xxxxxxxx`;草稿试玩不调用 runtime run API;正式 runtime 无麦克风时不登记正式 run,结算后提交派生指标
# 2026-05-20 陶泥儿主视觉配色回收为暖白/陶土橙
## 2026-05-20 汪汪声浪生成页负责三图自动生成
- 背景:用户要求只替换产品各界面的 UI 颜色,不改布局,并以两张陶泥儿主视觉图作为配色依据
- 决策:平台亮色主题的主色回收到暖白 / 米杏底、陶土橙主按钮、深棕正文与浅杏边框;后台管理也同步切换到同一暖橙体系。主题变量优先通过 `src/index.css``--platform-*` token 统一控制,零散组件只做必要的局部替换
- 影响范围:主站平台壳层、常用表单 / 按钮 / 卡片 / 背景、后台管理 UI、业务进度条和小游戏结果条的通用强调色
- 验证方式:优先检查 `src/index.css``apps/admin-web/src/styles/admin.css` 是否还存在旧粉色主色;再用编码检查和可执行的本地 typecheck / build 验证。
- 关联文档:`docs/【项目基线】当前产品与工程约束-2026-05-15.md`
- 背景:结果页承载预览、修补和发布,若继续放“一次生成”按钮会把初始生成和结果修补职责混在一起
- 决策:初始三图生成改由 `bark-battle-generating` 独立生成页自动执行,目标槽位只有玩家形象、对手形象和竞技背景;表单术语统一为 `themeDescription`、玩家形象描述和对手形象描述,不再回退 `themePreset`、狗狗皮肤预设或“角色设定”。部分失败也进入结果页。结果页不再提供一次生成按钮,音频配置和排名配置不进入 v1 公开闭环;结果页只保留单槽重试、重新生成和上传。发布时 SpacetimeDB `bark_battle_published_config.config_json` 使用规范化后的最终 `publishedSnapshot``published_snapshot_json` 同步保存同一份快照
- 验证方式:表单提交后进入 `bark-battle-generating`;结果页不会出现一次生成按钮、音频槽、皮肤预设入口或排名配置;Bark Battle 发布后正式 runtime 应读取结果页最终图片素材而不是初始草稿素材
+52 -6
View File
@@ -152,6 +152,36 @@
- 验证:点击汪汪声浪后直接看到创作页内嵌表单,不再出现独立配置页;测试应覆盖内嵌表单与 runtime 返回路径。
- 关联:`src/components/platform-entry/PlatformEntryFlowShellImpl.tsx``src/components/bark-battle-creation/BarkBattleConfigEditor.tsx``src/components/rpg-entry/RpgEntryFlowShell.agent.interaction.test.tsx`
## 汪汪声浪发布态不要丢失结果页最终素材
- 现象:结果页上传或批量生成玩家形象、对手形象、UI 背景后,发布进入正式 runtime 仍可能显示初始草稿素材或兜底视觉。
- 原因:`publish_bark_battle_work` 如果只把结果页最终状态保存到 `published_snapshot_json`,但正式 runtime 读取的 `config_json` 仍来自草稿行旧值,就会丢失结果页局部替换。
- 处理:发布时把最终 `publishedSnapshot` 解析为 `BarkBattleEditorConfigSnapshot`、规范化后同时写入 `bark_battle_published_config.config_json``published_snapshot_json`;首轮自动生成只由 `bark-battle-generating` 负责,结果页仅覆盖已接入的玩家形象、对手形象和竞技背景图片槽位,不再提供音频配置入口。
- 验证:发布后 runtime config 应包含结果页最终 `playerCharacterImageSrc``opponentCharacterImageSrc``uiBackgroundImageSrc`
## 汪汪声浪 v1 生成页和正式运行态要分开
- 现象:如果把初始三图自动生成、结果页修补、公开发布和正式运行态混在一页,创作者容易误以为一次生成和正式运行是同一职责。
- 原因:`bark-battle-generating` 才应该承担玩家形象、对手形象和竞技背景的自动生成;结果页只做单槽修补,正式 runtime 又必须切到真实麦克风和正式统计。
- 处理:表单提交后先进入独立生成页,部分失败仍进结果页;结果页只保留单槽重试、重新生成和上传,不再保留一次生成按钮、音频配置入口、皮肤预设入口或排名配置。发布后先到统一作品详情页,再进正式 runtime;草稿试玩允许 mock,不写正式 run。
- 验证:生成页负责首轮自动产出三图;结果页不出现一次生成按钮、音频配置入口、皮肤预设入口或排名配置;正式 runtime 必须麦克风可用且会写正式 run,草稿试玩不写正式统计。
## 汪汪声浪生成页不要只停留在前端内存草稿
- 现象:点击“生成草稿”后生成页一直转圈,或刷新 / 回到草稿架后看不到三图素材。
- 原因:生成页只在前端内存里合并玩家形象、对手形象和竞技背景,没有把生成结果写回 `bark_battle_draft_config.config_json`;另外 BFF 若在刚创建草稿后先读 `spacetime-client` 订阅 cache 再保存,cache 可能短暂落后,导致保存失败或返回旧快照。
- 处理:生成页三图完成后调用 `POST /api/creation/bark-battle/drafts/{draftId}/config` 持久化;保存接口直接把请求快照交给 SpacetimeDB procedure,由模块事务校验 owner / work,并在 HTTP 回包用本次请求里的三图字段覆盖,避免订阅 cache 滞后;保存请求必须设置前端超时,保存失败也进入结果页并标记部分失败。
- 验证:`npm run test -- src/components/bark-battle-creation/BarkBattleGeneratingView.test.tsx src/services/bark-battle-creation/barkBattleCreationClient.test.ts src/components/bark-battle-creation/BarkBattleResultView.test.tsx packages/shared/src/contracts/barkBattle.test.ts``npm run test -- src/components/rpg-entry/RpgEntryFlowShell.agent.interaction.test.tsx -t "bark battle"``cargo check --manifest-path server-rs\Cargo.toml -p api-server`
- 关联:`src/components/bark-battle-creation/BarkBattleGeneratingView.tsx``src/services/bark-battle-creation/barkBattleCreationClient.ts``server-rs/crates/api-server/src/bark_battle.rs``server-rs/crates/spacetime-module/src/bark_battle.rs`
## 汪汪声浪三图不要复用 RPG 场景图链路
- 现象:玩家形象和对手形象看起来走了场景图片 prompt;生成页三个槽位同时转圈,但只有第一个真实生成,首图返回后三个槽位一起停止或只显示首图。
- 原因:前端曾复用 `/api/runtime/custom-world/scene-image`,三类素材都被当成 RPG landmark scene image;生成页又只用父级 draft 判断 ready,批量 Promise 结束后才一次性合并结果,缺少逐槽状态。
- 处理:Bark Battle 生图统一走 `POST /api/creation/bark-battle/images/generate`,请求体包含 `slot` 和 v1 配置;后端在 `api-server/src/bark_battle.rs``player-character``opponent-character``ui-background` 分别拼装正式 prompt,写入 `generated-bark-battle-assets`,并返回 `prompt/actualPrompt`。前端 `generateAllBarkBattleImageAssets` 保持三槽 `Promise.allSettled` 并通过 `onSlotComplete` 逐槽刷新生成页状态。
- 验证:`npm run test -- src/services/bark-battle-creation/barkBattleCreationClient.test.ts src/components/bark-battle-creation/BarkBattleGeneratingView.test.tsx packages/shared/src/contracts/barkBattle.test.ts``cargo test -p shared-contracts bark_battle --manifest-path server-rs\Cargo.toml``cargo check --manifest-path server-rs\Cargo.toml -p platform-oss -p api-server`
- 关联:`src/services/bark-battle-creation/barkBattleCreationClient.ts``src/components/bark-battle-creation/BarkBattleGeneratingView.tsx``server-rs/crates/api-server/src/bark_battle.rs``server-rs/crates/platform-oss/src/lib.rs`
## 抓大鹅批量重新生成物品不要新增 itemId
- 现象:结果页批量重新生成物品后,试玩或正式运行态的物品类型和图片对应关系漂移,或者用户输入一个不存在名称后被当作新物品追加。
@@ -1212,10 +1242,26 @@
- 验证:`npm test -- src/components/rpg-entry/RpgEntryFlowShell.agent.interaction.test.tsx -t "persisted generating"`
- 关联:`src/components/platform-entry/PlatformEntryFlowShellImpl.tsx``src/components/rpg-entry/RpgEntryFlowShell.agent.interaction.test.tsx``docs/【玩法创作】平台入口与玩法链路-2026-05-15.md`
## 存档选择入口不要只藏在“玩过”弹窗里
## 汪汪声浪草稿试玩不要写正式 run
- 现象:用户有 RPG / 拼图运行态存档,但平台底部 `草稿` Tab 只展示作品架,个人中心只有点击 `玩过` 后才可能看到“可继续”,导致看起来没有存档选择入口
- 原因:`/api/profile/save-archives` 已在入口 bootstrap 加载,但前端只把 `saveEntries` 注入 `ProfilePlayedWorksModal`;没有独立的存档入口
- 处理:个人中心 `常用功能` 必须保留 `存档` 快捷入口,点击后打开独立存档选择弹窗并复用 `SaveArchiveCard`;恢复仍走 `/api/profile/save-archives/{worldKey}`,拼图存档继续走拼图 resume 分支,RPG 走 `handleContinueGame(snapshot)`
- 验证:`npm run test -- src/components/rpg-entry/RpgEntryFlowShell.agent.interaction.test.tsx -t "profile page exposes save archive picker"`
- 关联:`src/components/rpg-entry/RpgEntryHomeView.tsx``src/components/platform-entry/PlatformEntryFlowShellImpl.tsx``src/components/rpg-entry/useRpgEntryBootstrap.ts``docs/【玩法创作】平台入口与玩法链路-2026-05-15.md`
- 现象:如果草稿结果页试玩和发布后 runtime 共用同一写成绩路径,未发布或未确认资源的草稿试玩会污染正式单局、排行榜和作品统计
- 原因:`BarkBattleRuntimeShell` 同时承担草稿预览和发布后运行态,需要由调用方显式传入 `runtimeMode` 区分是否写正式 run
- 处理:草稿结果页试玩保持 `runtimeMode=draft`,只做本地预览;发布成功后先进入 `/works/detail?work=BB-xxxxxxxx`,再从详情页以 `runtimeMode=published` 进入正式 runtime,并在开始/结算时分别调用 `startBarkBattleRun``finishBarkBattleRun`
- 验证:草稿试玩不触发 start / finish run;正式 runtime 必须先通过麦克风授权,再写 start run 和结算派生指标
- 关联:`src/components/platform-entry/PlatformEntryFlowShellImpl.tsx``src/games/bark-battle/ui/BarkBattleRuntimeShell.tsx``src/services/bark-battle-runtime/barkBattleRuntimeClient.ts`
## 汪汪声浪移动端创作表单不要再套一层纵向滚动
- 现象:移动端创作 Tab 里进入汪汪声浪表单后,页面右侧出现不自然的内层滚动条,最后的形象描述输入框容易被“生成草稿”按钮、键盘或底部 TabBar 挤压 / 遮挡;顶部玩法卡首尾也可能贴边显得被裁。
- 原因:外层 `.platform-tab-panel` 已经是纵向滚动容器,创作页中间又有多层 `overflow-hidden`,旧的 `BarkBattleConfigEditor` 根节点再加 `overflow-y-auto`,形成外层 Tab 面板 + 内层表单的套滚动;底部按钮只预留 safe-area,不预留真实操作区距离;顶部玩法卡横向滚动条隐藏且首尾没有 scroll padding。
- 处理:移动端让 Bark Battle 表单跟随父级滚动,`lg` 以上才恢复表单内滚动;创作页容器移动端使用 `overflow-visible` 和 safe-area 底部 padding;顶部模板 tablist 加 `scroll-px-3` / 横向 padding,移动端卡片宽度收窄,避免首尾 ring 和圆角贴边裁切。
- 验证:`npm run test -- src/components/bark-battle-creation/BarkBattleConfigEditor.test.tsx``npm run test -- src/components/rpg-entry/RpgEntryFlowShell.agent.interaction.test.tsx -t "create tab shows template tabs"`、移动端视口检查最后一个输入框与“生成草稿”按钮不重叠。
- 关联:`src/components/bark-battle-creation/BarkBattleConfigEditor.tsx``src/components/platform-entry/PlatformEntryFlowShellImpl.tsx``docs/【玩法创作】平台入口与玩法链路-2026-05-15.md`
## 汪汪声浪拟声词不要被默认狗主题锁死
- 现象:创作者把主题或形象改成机甲、猫、骑士等非狗主题后,局内仍播放 `轰汪!``汪爆!` 这类狗叫词,表现像系统强行把主题带回狗。
- 原因:拟声词 textarea 如果一开始就填入默认小狗词池,并且始终作为自定义 `onomatopoeia` 提交,runtime 会优先使用该字段,无法再根据新的 `themeDescription` / `playerImageDescription` / `opponentImageDescription` 走主题 fallback。
- 处理:`BarkBattleConfigEditor` 需要区分“系统默认词池”和“创作者已手动编辑”。未手动编辑时随主题 / 形象描述自动重算;手动编辑后才冻结为自定义词池。默认词池只在命中狗相关关键词时加入狗叫词,非狗主题使用科技、幻想或通用高能词。
- 验证:`npm run test -- src/components/bark-battle-creation/BarkBattleConfigEditor.test.tsx src/games/bark-battle/ui/__tests__/BarkBattleRuntimeShell.test.tsx`,并确认非狗主题的拟声词不含 `汪`
- 关联:`src/components/bark-battle-creation/BarkBattleConfigEditor.tsx``src/games/bark-battle/application/BarkBattleConfig.ts``src/games/bark-battle/ui/BarkBattleRuntimeShell.tsx`
@@ -204,11 +204,38 @@ RPG / 拼图等运行态存档选择入口统一在个人中心 `常用功能 >
当前领域语言:
- 有效声浪触发:麦克风归一化响度在冷却结束后达到阈值的一次计分输入。
- 能量条:玩家与对手当前声浪优势的连续对抗刻度。
- 能量条:玩家与对手当前声浪优势的连续对抗刻度,推到玩家或对手一侧边界时本局立即结算
- 主题 / 竞技背景描述:配置字段为 `themeDescription`,用于生成竞技背景并表达整体场景,不再使用 `themePreset` 或狗狗皮肤预设。
- 玩家 / 对手形象描述:配置字段为 `playerImageDescription` / `opponentImageDescription`,对外统一称“形象描述”,不再称“角色设定”。
- 后端裁决结果:后端根据 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 公开闭环不展示音频、皮肤预设或排名配置入口。
## 方洞挑战
@@ -1,24 +1,34 @@
import { describe, expect, test } from 'vitest';
import {
BARK_BATTLE_ASSET_SLOTS,
BARK_BATTLE_DIFFICULTY_PRESETS,
type BarkBattleDraftConfig,
type BarkBattleDraftConfigUpdateRequest,
type BarkBattleFinishResponse,
type BarkBattleGeneratedImageAsset,
type BarkBattleImageAssetGenerateRequest,
type BarkBattlePersonalBestSummary,
type BarkBattleWorkStats,
} from './barkBattle';
describe('Bark Battle shared contracts', () => {
test('default draft config fixture uses normal difficulty and camelCase fields', () => {
test('default draft config fixture uses normal difficulty and v1 description fields', () => {
const draft: BarkBattleDraftConfig = {
draftId: 'draft-bark-1',
workId: 'work-bark-1',
configVersion: 2,
rulesetVersion: 'bark-battle-ruleset-v1',
title: '汪汪声浪挑战',
description: '轻配置草稿',
themePreset: 'city-park',
playerDogSkinPreset: 'corgi',
opponentDogSkinPreset: 'husky',
themeDescription: '傍晚城市公园里的声浪擂台',
playerImageDescription: '戴红围巾的柯基主角',
opponentImageDescription: '蓝色运动头带的哈士奇对手',
onomatopoeia: ['轰汪!', '嗷呜!', '咚咚!'],
playerCharacterImageSrc: '/generated-bark-battle/player/image.png',
opponentCharacterImageSrc: 'https://example.test/opponent.png',
uiBackgroundImageSrc: '/generated-bark-battle/ui/background.png',
difficultyPreset: 'normal',
leaderboardEnabled: true,
updatedAt: '2026-05-13T03:00:00.000Z',
};
@@ -26,15 +36,105 @@ describe('Bark Battle shared contracts', () => {
expect(draft.difficultyPreset).toBe('normal');
expect(Object.keys(draft)).toEqual([
'draftId',
'workId',
'configVersion',
'rulesetVersion',
'title',
'description',
'themePreset',
'playerDogSkinPreset',
'opponentDogSkinPreset',
'themeDescription',
'playerImageDescription',
'opponentImageDescription',
'onomatopoeia',
'playerCharacterImageSrc',
'opponentCharacterImageSrc',
'uiBackgroundImageSrc',
'difficultyPreset',
'leaderboardEnabled',
'updatedAt',
]);
expect(draft.playerCharacterImageSrc).toContain('/generated-bark-battle/');
expect('barkSoundSrc' in draft).toBe(false);
expect('leaderboardEnabled' in draft).toBe(false);
});
test('draft config update contract persists generated image slots only', () => {
const update: BarkBattleDraftConfigUpdateRequest = {
draftId: 'draft-bark-1',
workId: 'BB-12345678',
configVersion: 2,
rulesetVersion: 'bark-battle-ruleset-v1',
title: '汪汪声浪挑战',
description: '轻配置草稿',
themeDescription: '傍晚城市公园里的声浪擂台',
playerImageDescription: '戴红围巾的柯基主角',
opponentImageDescription: '蓝色运动头带的哈士奇对手',
onomatopoeia: ['轰!', '燃起来!', '破阵!'],
playerCharacterImageSrc: '/generated-bark-battle/player/image.png',
opponentCharacterImageSrc: '/generated-bark-battle/opponent/image.png',
uiBackgroundImageSrc: '/generated-bark-battle/ui/background.png',
difficultyPreset: 'normal',
};
expect(Object.keys(update)).toEqual([
'draftId',
'workId',
'configVersion',
'rulesetVersion',
'title',
'description',
'themeDescription',
'playerImageDescription',
'opponentImageDescription',
'onomatopoeia',
'playerCharacterImageSrc',
'opponentCharacterImageSrc',
'uiBackgroundImageSrc',
'difficultyPreset',
]);
expect('barkSoundSrc' in update).toBe(false);
expect('leaderboardEnabled' in update).toBe(false);
});
test('image generation contract uses dedicated Bark Battle slots and backend prompt result', () => {
expect(BARK_BATTLE_ASSET_SLOTS).toEqual([
'player-character',
'opponent-character',
'ui-background',
]);
const request: BarkBattleImageAssetGenerateRequest = {
slot: 'opponent-character',
draftId: 'bark-battle-draft-1',
config: {
title: '汪汪冠军杯',
description: '',
themeDescription: '霓虹公园擂台',
playerImageDescription: '红围巾柴犬',
opponentImageDescription: '蓝头带哈士奇',
onomatopoeia: ['轰汪!', '炸场!', '冲啊!'],
difficultyPreset: 'normal',
},
};
const response: BarkBattleGeneratedImageAsset = {
imageSrc: '/generated-bark-battle-assets/draft/opponent/image.webp',
assetId: 'asset-1',
sourceType: 'generated',
model: 'gpt-image-2',
size: '1024*1024',
taskId: 'task-1',
prompt: '后端拼装后的对手形象 prompt',
};
expect(JSON.parse(JSON.stringify(request))).toMatchObject({
slot: 'opponent-character',
config: {
opponentImageDescription: '蓝头带哈士奇',
onomatopoeia: ['轰汪!', '炸场!', '冲啊!'],
},
});
expect(JSON.parse(JSON.stringify(response))).toMatchObject({
imageSrc: '/generated-bark-battle-assets/draft/opponent/image.webp',
prompt: '后端拼装后的对手形象 prompt',
});
});
test('finish accepted player_win fixture exposes backend adjudication result', () => {
+105 -14
View File
@@ -16,24 +16,65 @@ export type BarkBattleFinishStatus =
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;
description?: string;
themePreset: string;
playerDogSkinPreset: string;
opponentDogSkinPreset: string;
themeDescription: string;
playerImageDescription: string;
opponentImageDescription: string;
onomatopoeia?: BarkBattleOnomatopoeia;
difficultyPreset: BarkBattleDifficultyPreset;
leaderboardEnabled: boolean;
}
export interface BarkBattleDraftCreateRequest extends BarkBattleConfigEditorPayload {}
export interface BarkBattleDraftConfigUpdateRequest
extends BarkBattleConfigEditorPayload {
draftId: string;
workId?: string | null;
configVersion?: number;
rulesetVersion?: string;
}
export interface BarkBattleWorkPublishRequest {
draftId: string;
workId?: string;
workId: string;
publishedSnapshot?: BarkBattleConfigEditorPayload;
}
export interface BarkBattleImageAssetGenerateRequest {
slot: BarkBattleAssetSlot;
draftId?: string | null;
config: BarkBattleConfigEditorPayload;
}
export interface BarkBattleGeneratedImageAsset {
imageSrc: string;
assetId: string;
sourceType?: 'generated' | string;
model: string;
size: string;
taskId: string;
prompt: string;
actualPrompt?: string;
}
export interface BarkBattleDraftConfig extends BarkBattleConfigEditorPayload {
draftId: string;
workId?: string;
@@ -50,15 +91,62 @@ export interface BarkBattlePublishedConfig {
playTypeId: BarkBattlePlayTypeId;
title: string;
description?: string;
themePreset: string;
playerDogSkinPreset: string;
opponentDogSkinPreset: string;
themeDescription: string;
playerImageDescription: string;
opponentImageDescription: string;
onomatopoeia?: BarkBattleOnomatopoeia;
playerCharacterImageSrc?: string;
opponentCharacterImageSrc?: string;
uiBackgroundImageSrc?: string;
difficultyPreset: BarkBattleDifficultyPreset;
leaderboardEnabled: boolean;
updatedAt: string;
publishedAt: string;
}
export type BarkBattleWorkStatus = 'draft' | 'published';
export type BarkBattleGenerationStatus =
| 'pending_assets'
| 'ready'
| 'partial_failed'
| string;
export interface BarkBattleWorkSummary {
workId: string;
draftId?: string | null;
ownerUserId: string;
authorDisplayName: string;
title: string;
summary: string;
themeDescription: string;
playerImageDescription: string;
opponentImageDescription: string;
onomatopoeia?: BarkBattleOnomatopoeia;
playerCharacterImageSrc?: string | null;
opponentCharacterImageSrc?: string | null;
uiBackgroundImageSrc?: string | null;
difficultyPreset: BarkBattleDifficultyPreset;
status: BarkBattleWorkStatus;
generationStatus?: BarkBattleGenerationStatus | null;
publishReady: boolean;
playCount: number;
finishCount?: number;
winCount?: number;
drawCount?: number;
lossCount?: number;
recentPlayCount7d?: number;
updatedAt: string;
publishedAt?: string | null;
}
export interface BarkBattleWorksResponse {
items: BarkBattleWorkSummary[];
}
export interface BarkBattleWorkDetailResponse {
item: BarkBattleWorkSummary;
}
export interface BarkBattleRuntimeConfig {
workId: string;
configVersion: number;
@@ -70,10 +158,13 @@ export interface BarkBattleRuntimeConfig {
drawThreshold: number;
minBarkGapMs: number;
difficultyPreset: BarkBattleDifficultyPreset;
themePreset: string;
playerDogSkinPreset: string;
opponentDogSkinPreset: string;
leaderboardEnabled: boolean;
themeDescription: string;
playerImageDescription: string;
opponentImageDescription: string;
onomatopoeia?: BarkBattleOnomatopoeia;
playerCharacterImageSrc?: string;
opponentCharacterImageSrc?: string;
uiBackgroundImageSrc?: string;
updatedAt: string;
}
Binary file not shown.

After

Width:  |  Height:  |  Size: 69 KiB

+1
View File
@@ -1027,6 +1027,7 @@ mod tests {
"/generated-puzzle-assets/session-1/candidate/image.png",
"/generated-custom-world-scenes/world-1/camp/scene.png",
"/generated-custom-world-covers/world-1/cover.webp",
"/generated-bark-battle-assets/draft/player/image.webp",
"/generated-qwen-sprites/master/candidate-01.png",
] {
let response = app
File diff suppressed because it is too large Load Diff
@@ -208,6 +208,25 @@ mod tests {
assert_eq!(resolve_creation_entry_route_id("/healthz"), None);
}
#[test]
fn test_creation_entry_config_response_opens_bark_battle() {
let config = test_creation_entry_config_response();
let bark_battle = config
.creation_types
.iter()
.find(|item| item.id == "bark-battle")
.expect("test creation entry config should include bark-battle");
assert_eq!(bark_battle.title, "汪汪声浪");
assert!(bark_battle.visible);
assert!(bark_battle.open);
assert_eq!(bark_battle.badge, "可创建");
assert_eq!(
bark_battle.image_src,
"/creation-type-references/bark-battle.webp"
);
}
#[test]
fn test_creation_entry_config_response_keeps_baby_object_match_coming_soon() {
let config = test_creation_entry_config_response();
@@ -1539,11 +1539,111 @@ pub(super) fn slice_match3d_material_sheet(
image: &DownloadedOpenAiImage,
item_names: &[String],
) -> Result<Vec<Vec<Match3DSlicedItemImage>>, AppError> {
slice_generated_asset_sheet_two_items_per_row(
image,
item_names,
MATCH3D_MATERIAL_GRID_SIZE as usize,
MATCH3D_ITEM_VIEW_COUNT,
// 中文注释:素材图提示词固定要求 5*5 均匀排布;切图也固定按 5 行 5 列定位格子。
// 每个格子内再基于前景像素二次校准,避免固定内缩裁断物品边缘。
let source = image::load_from_memory(image.bytes.as_slice()).map_err(|error| {
AppError::from_status(StatusCode::BAD_GATEWAY).with_details(json!({
"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| {
rows.into_iter()
+83 -218
View File
@@ -1,5 +1,7 @@
use super::*;
use super::*;
fn test_match3d_generated_item_asset(index: u32, name: &str) -> Match3DGeneratedItemAsset {
Match3DGeneratedItemAsset {
item_id: format!("match3d-item-{index}"),
@@ -147,17 +149,17 @@ fn match3d_item_image_path_segments_stay_unique_for_chinese_names() {
}
#[test]
fn match3d_material_sheet_slicing_uses_fixed_ten_by_ten_two_items_per_row() {
let width = 1000;
let height = 1000;
fn match3d_material_sheet_slicing_uses_fixed_five_by_five_rows() {
let width = 500;
let height = 500;
let item_names = vec!["樱桃".to_string(), "苹果".to_string(), "香蕉".to_string()];
let mut sheet = image::RgbaImage::new(width, height);
for row in 0..10 {
for col in 0..10 {
for row in 0..5 {
for col in 0..5 {
let color = image::Rgba([
32 + row as u8 * 16,
24 + col as u8 * 18,
210 - row as u8 * 12,
32 + row as u8 * 40,
24 + col as u8 * 36,
210 - row as u8 * 30,
255,
]);
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");
assert_eq!(slices.len(), 3);
for (item_index, views) in slices.iter().enumerate() {
let row = item_index / 2;
let start_col = (item_index % 2) * MATCH3D_ITEM_VIEW_COUNT;
for (row, views) in slices.iter().enumerate() {
assert_eq!(views.len(), MATCH3D_ITEM_VIEW_COUNT);
for (view_index, view) in views.iter().enumerate() {
let col = start_col + view_index;
for (col, view) in views.iter().enumerate() {
let decoded = image::load_from_memory(view.bytes.as_slice())
.expect("view should decode")
.to_rgba8();
@@ -193,12 +192,12 @@ fn match3d_material_sheet_slicing_uses_fixed_ten_by_ten_two_items_per_row() {
assert_eq!(
pixel.0,
[
32 + row as u8 * 16,
24 + col as u8 * 18,
210 - row as u8 * 12,
32 + row as u8 * 40,
24 + col as u8 * 36,
210 - row as u8 * 30,
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]
fn match3d_material_sheet_slicing_keeps_near_edge_foreground_pixels() {
let width = 1000;
let height = 1000;
let width = 500;
let height = 500;
let item_names = vec!["樱桃".to_string(), "苹果".to_string(), "香蕉".to_string()];
let mut sheet = image::RgbaImage::from_pixel(width, height, image::Rgba([255, 255, 255, 255]));
for y in 1..5 {
@@ -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]
fn match3d_work_metadata_parses_gpt4o_json() {
let metadata = parse_match3d_work_metadata(
@@ -736,69 +689,38 @@ fn match3d_legacy_item_asset_without_size_defaults_to_large() {
}
#[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(
r#"{"gameName":"果园大鹅宴","tags":["水果","抓大鹅"],"backgroundMusic":{"title":"果园轻舞","style":"轻快, 休闲","prompt":""},"backgroundPrompt":"果园主题竖屏纯背景","items":[{"name":"草莓","soundPrompt":"草莓点击音效"},{"name":"苹果","soundPrompt":"苹果点击音效"},{"name":"香蕉","soundPrompt":"香蕉点击音效"},{"name":"葡萄","soundPrompt":"葡萄点击音效"},{"name":"西瓜","soundPrompt":"西瓜点击音效"},{"name":"梨子","soundPrompt":"梨子点击音效"},{"name":"桃子","soundPrompt":"桃子点击音效"},{"name":"橙子","soundPrompt":"橙子点击音效"},{"name":"蓝莓","soundPrompt":"蓝莓点击音效"}]}"#,
&config("水果", 12, 4),
)
.expect("draft plan should parse");
assert_eq!(plan.items.len(), 20);
assert_eq!(plan.items.len(), 10);
assert_eq!(plan.items[8].name, "蓝莓");
assert_ne!(plan.items[9].name, "蓝莓");
}
#[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!(
resolve_match3d_generated_item_count(&config("水果", 8, 2)),
20
5
);
assert_eq!(
resolve_match3d_generated_item_count(&config("水果", 12, 4)),
20
10
);
assert_eq!(
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
);
assert_eq!(
resolve_match3d_gameplay_item_count(&config("水果", 21, 8)),
20
resolve_match3d_generated_item_count(&config("水果", 21, 8)),
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]
fn match3d_generated_assets_require_only_images_when_click_sound_is_closed() {
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]
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(1), 2);
assert_eq!(calculate_match3d_item_assets_points_cost(20), 2);
assert_eq!(calculate_match3d_item_assets_points_cost(21), 4);
assert_eq!(calculate_match3d_item_assets_points_cost(5), 2);
assert_eq!(calculate_match3d_item_assets_points_cost(6), 4);
assert_eq!(calculate_match3d_item_assets_points_cost(10), 4);
}
#[test]
@@ -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.padded_item_names.len(), 20);
assert_eq!(plan.padded_item_names.len(), 5);
assert_eq!(&plan.padded_item_names[..3], ["苹果", "香蕉", "梨子"]);
assert_eq!(
calculate_match3d_item_assets_points_cost(plan.requested_item_names.len()),
@@ -951,7 +874,6 @@ fn match3d_regenerated_asset_keeps_stable_identity_and_side_assets() {
container_image_object_key: None,
status: "image_ready".to_string(),
error: None,
..Default::default()
});
let mut generated_asset = test_match3d_generated_item_asset(99, "新草莓");
generated_asset.image_src =
@@ -977,19 +899,20 @@ fn match3d_regenerated_asset_keeps_stable_identity_and_side_assets() {
}
#[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(
&config("水果", 12, 4),
&["草莓".to_string(), "苹果".to_string(), "香蕉".to_string()],
);
assert!(prompt.contains("10行*10列spritesheet图"));
assert!(prompt.contains("纯绿色绿幕背景"));
assert!(prompt.contains("5行*5列"));
assert!(prompt.contains("严格5*5均匀排布"));
assert!(prompt.contains("绿幕背景"));
assert!(prompt.contains("#00FF00"));
assert!(prompt.contains("素材间距严格均匀分布"));
assert!(prompt.contains("每一行包含两种物品"));
assert!(prompt.contains("每种物品的五个不同形态"));
assert!(prompt.contains("严禁出现两种高相似度的物品"));
assert!(prompt.contains("单个素材格宽度的1/4空白间距"));
assert!(prompt.contains("约25%单格宽度"));
assert!(prompt.contains("禁止主体跨格"));
assert!(prompt.contains("贴边或越界"));
}
#[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 negative_prompt = build_match3d_material_sheet_negative_prompt(&config);
assert!(prompt.contains("10行*10列spritesheet图"));
assert!(prompt.contains("纯绿色绿幕背景"));
assert!(prompt.contains("64x64"));
assert!(prompt.contains("整数倍放大"));
assert!(prompt.contains("禁止抗锯齿"));
assert!(prompt.contains("真实 3D 渲染"));
assert!(prompt.contains("PBR 材质"));
assert!(negative_prompt.contains("抗锯齿"));
assert!(negative_prompt.contains("平滑插画"));
assert!(negative_prompt.contains("真实 3D 渲染"));
}
#[test]
fn match3d_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]
fn match3d_material_sheet_request_uses_vector_engine_gemini_contract() {
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,
status: "image_ready".to_string(),
error: None,
..Default::default()
};
let with_container = Match3DGeneratedBackgroundAsset {
container_prompt: Some("果园容器".to_string()),
@@ -1223,7 +1108,6 @@ fn match3d_default_cover_prefers_generated_container_ui_image() {
container_image_object_key: None,
status: "image_ready".to_string(),
error: None,
..Default::default()
}),
status: "image_ready".to_string(),
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]
fn match3d_cover_reference_prompt_marks_reference_images() {
let prompt = build_match3d_cover_reference_generation_prompt("水果封面", true);
@@ -1299,8 +1168,8 @@ fn match3d_cover_reference_prompt_marks_reference_images() {
}
#[test]
fn match3d_cover_reference_generation_prompt_preserves_uploaded_image() {
let prompt = build_match3d_cover_uploaded_reference_prompt("水果封面");
fn match3d_cover_edit_prompt_preserves_uploaded_image() {
let prompt = build_match3d_cover_edit_prompt("水果封面");
assert!(prompt.contains("上传的封面图作为第一优先级"));
assert!(prompt.contains("保留主图的主体、构图、视角和主要配色"));
@@ -1343,7 +1212,6 @@ fn match3d_fallback_work_profile_keeps_generated_background_asset() {
),
status: "image_ready".to_string(),
error: None,
..Default::default()
}),
status: "image_ready".to_string(),
error: None,
@@ -1481,7 +1349,6 @@ fn match3d_agent_session_response_hydrates_persisted_ui_assets() {
),
status: "image_ready".to_string(),
error: None,
..Default::default()
}),
status: "image_ready".to_string(),
error: None,
@@ -1557,7 +1424,6 @@ fn match3d_agent_session_response_keeps_draft_ui_assets_without_work_detail_hydr
),
status: "image_ready".to_string(),
error: None,
..Default::default()
}),
status: "image_ready".to_string(),
error: None,
@@ -1820,44 +1686,44 @@ fn match3d_required_item_images_require_five_views() {
assert!(!has_match3d_required_item_images(&assets, 3));
let five_view_assets = (1..=3)
.map(|index| Match3DGeneratedItemAsset {
item_id: format!("match3d-item-{index}"),
item_name: format!("物品{index}"),
item_size: Some(MATCH3D_ITEM_SIZE_LARGE.to_string()),
image_src: Some(format!(
"/generated-match3d-assets/s/p/items/i{index}/views/view-01.png"
)),
image_object_key: Some(format!(
"generated-match3d-assets/s/p/items/i{index}/views/view-01.png"
)),
image_views: (1..=MATCH3D_ITEM_VIEW_COUNT)
.map(|view_index| Match3DGeneratedItemImageView {
view_id: format!("view-{view_index:02}"),
view_index: view_index as u32,
image_src: Some(format!(
"/generated-match3d-assets/s/p/items/i{index}/views/view-{view_index:02}.png"
)),
image_object_key: Some(format!(
"generated-match3d-assets/s/p/items/i{index}/views/view-{view_index:02}.png"
)),
})
.collect(),
model_src: None,
model_object_key: None,
model_file_name: None,
task_uuid: None,
subscription_key: None,
sound_prompt: None,
background_music_title: None,
background_music_style: None,
background_music_prompt: None,
background_music: None,
click_sound: None,
background_asset: None,
status: "image_ready".to_string(),
error: None,
})
.collect::<Vec<_>>();
.map(|index| Match3DGeneratedItemAsset {
item_id: format!("match3d-item-{index}"),
item_name: format!("物品{index}"),
item_size: Some(MATCH3D_ITEM_SIZE_LARGE.to_string()),
image_src: Some(format!(
"/generated-match3d-assets/s/p/items/i{index}/views/view-01.png"
)),
image_object_key: Some(format!(
"generated-match3d-assets/s/p/items/i{index}/views/view-01.png"
)),
image_views: (1..=MATCH3D_ITEM_VIEW_COUNT)
.map(|view_index| Match3DGeneratedItemImageView {
view_id: format!("view-{view_index:02}"),
view_index: view_index as u32,
image_src: Some(format!(
"/generated-match3d-assets/s/p/items/i{index}/views/view-{view_index:02}.png"
)),
image_object_key: Some(format!(
"generated-match3d-assets/s/p/items/i{index}/views/view-{view_index:02}.png"
)),
})
.collect(),
model_src: None,
model_object_key: None,
model_file_name: None,
task_uuid: None,
subscription_key: None,
sound_prompt: None,
background_music_title: None,
background_music_style: None,
background_music_prompt: None,
background_music: None,
click_sound: None,
background_asset: None,
status: "image_ready".to_string(),
error: None,
})
.collect::<Vec<_>>();
assert!(has_match3d_required_item_images(&five_view_assets, 3));
}
@@ -1941,7 +1807,6 @@ fn match3d_work_summary_marks_complete_generated_assets_ready() {
),
status: "image_ready".to_string(),
error: None,
..Default::default()
}),
..test_match3d_generated_item_asset(1, "草莓")
}];
@@ -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(
config: &Match3DConfigJson,
prompt: &str,
@@ -7,7 +7,9 @@ use crate::{
auth::require_bearer_auth,
bark_battle::{
create_bark_battle_draft, finish_bark_battle_run, get_bark_battle_run,
get_bark_battle_runtime_config, publish_bark_battle_work, start_bark_battle_run,
generate_bark_battle_image_asset, get_bark_battle_runtime_config,
list_bark_battle_gallery, list_bark_battle_works, publish_bark_battle_work,
start_bark_battle_run, update_bark_battle_draft_config,
},
state::AppState,
};
@@ -21,6 +23,20 @@ pub fn router(state: AppState) -> Router<AppState> {
require_bearer_auth,
)),
)
.route(
"/api/creation/bark-battle/drafts/{draft_id}/config",
post(update_bark_battle_draft_config).route_layer(middleware::from_fn_with_state(
state.clone(),
require_bearer_auth,
)),
)
.route(
"/api/creation/bark-battle/images/generate",
post(generate_bark_battle_image_asset).route_layer(middleware::from_fn_with_state(
state.clone(),
require_bearer_auth,
)),
)
.route(
"/api/creation/bark-battle/works/publish",
post(publish_bark_battle_work).route_layer(middleware::from_fn_with_state(
@@ -28,6 +44,17 @@ pub fn router(state: AppState) -> Router<AppState> {
require_bearer_auth,
)),
)
.route(
"/api/runtime/bark-battle/works",
get(list_bark_battle_works).route_layer(middleware::from_fn_with_state(
state.clone(),
require_bearer_auth,
)),
)
.route(
"/api/runtime/bark-battle/gallery",
get(list_bark_battle_gallery),
)
.route(
"/api/runtime/bark-battle/works/{work_id}/config",
get(get_bark_battle_runtime_config).route_layer(middleware::from_fn_with_state(
@@ -72,7 +72,7 @@ impl BarkBattleRuleset {
standard_duration_ms: 30_000,
min_duration_ms: 28_000,
max_duration_ms: 35_000,
min_bark_gap_ms: 250,
min_bark_gap_ms: 150,
trigger_count_tolerance: 2,
min_volume: 0.0,
max_volume: 1.0,
@@ -174,6 +174,7 @@ mod tests {
#[test]
fn flags_trigger_count_above_physical_limit_with_tolerance() {
let ruleset = BarkBattleRuleset::v1();
assert_eq!(ruleset.min_bark_gap_ms, 150);
let mut input = metrics(30_000);
input.trigger_count = input.duration_ms / ruleset.min_bark_gap_ms
+ u64::from(ruleset.trigger_count_tolerance)
@@ -153,10 +153,10 @@ pub fn default_creation_entry_type_snapshots(
"bark-battle",
"汪汪声浪",
"声控对战挑战",
"敬请期待",
"/creation-type-references/creative-agent.webp",
"可创建",
"/creation-type-references/bark-battle.webp",
true,
true,
false,
85,
updated_at_micros,
),
+9 -4
View File
@@ -263,9 +263,13 @@ mod tests {
assert_eq!(bark_battle.title, "汪汪声浪");
assert!(bark_battle.visible);
assert!(!bark_battle.open);
assert_eq!(bark_battle.badge, "敬请期待");
assert!(bark_battle.open);
assert_eq!(bark_battle.badge, "可创建");
assert_eq!(bark_battle.sort_order, 85);
assert_eq!(
bark_battle.image_src,
"/creation-type-references/bark-battle.webp"
);
}
#[test]
@@ -539,8 +543,9 @@ mod tests {
#[test]
fn runtime_profile_beijing_day_key_uses_business_day_boundary() {
let before_beijing_midnight = 1_714_927_999_999_999;
let after_beijing_midnight = 1_714_928_000_000_000;
// 中文注释:2024-05-06 00:00:00 Asia/Shanghai 前后 1 微秒。
let before_beijing_midnight = 1_714_924_799_999_999;
let after_beijing_midnight = 1_714_924_800_000_000;
assert_eq!(
runtime_profile_beijing_day_key(before_beijing_midnight),
+6 -1
View File
@@ -20,7 +20,7 @@ const OSS_V4_REQUEST: &str = "aliyun_v4_request";
const OSS_V4_SERVICE: &str = "oss";
const OSS_UNSIGNED_PAYLOAD: &str = "UNSIGNED-PAYLOAD";
pub const LEGACY_PUBLIC_PREFIXES: [&str; 10] = [
pub const LEGACY_PUBLIC_PREFIXES: [&str; 11] = [
"generated-character-drafts",
"generated-characters",
"generated-animations",
@@ -30,6 +30,7 @@ pub const LEGACY_PUBLIC_PREFIXES: [&str; 10] = [
"generated-puzzle-assets",
"generated-custom-world-scenes",
"generated-custom-world-covers",
"generated-bark-battle-assets",
"generated-qwen-sprites",
];
@@ -51,6 +52,7 @@ pub enum LegacyAssetPrefix {
PuzzleAssets,
CustomWorldScenes,
CustomWorldCovers,
BarkBattleAssets,
QwenSprites,
}
@@ -238,6 +240,7 @@ impl LegacyAssetPrefix {
"generated-puzzle-assets" => Some(Self::PuzzleAssets),
"generated-custom-world-scenes" => Some(Self::CustomWorldScenes),
"generated-custom-world-covers" => Some(Self::CustomWorldCovers),
"generated-bark-battle-assets" => Some(Self::BarkBattleAssets),
"generated-qwen-sprites" => Some(Self::QwenSprites),
_ => None,
}
@@ -254,6 +257,7 @@ impl LegacyAssetPrefix {
Self::PuzzleAssets => "generated-puzzle-assets",
Self::CustomWorldScenes => "generated-custom-world-scenes",
Self::CustomWorldCovers => "generated-custom-world-covers",
Self::BarkBattleAssets => "generated-bark-battle-assets",
Self::QwenSprites => "generated-qwen-sprites",
}
}
@@ -1315,6 +1319,7 @@ mod tests {
);
assert!(LEGACY_PUBLIC_PREFIXES.contains(&"generated-puzzle-assets"));
assert!(LEGACY_PUBLIC_PREFIXES.contains(&"generated-match3d-assets"));
assert!(LEGACY_PUBLIC_PREFIXES.contains(&"generated-bark-battle-assets"));
assert_eq!(LegacyAssetPrefix::parse("unknown"), None);
}
File diff suppressed because it is too large Load Diff
@@ -1,4 +1,5 @@
use super::*;
use std::collections::HashMap;
pub type BarkBattleDraftCreateRecordInput = BarkBattleDraftCreateInput;
pub type BarkBattleDraftConfigUpsertRecordInput = BarkBattleDraftConfigUpsertInput;
@@ -44,6 +45,32 @@ impl SpacetimeClient {
.await
}
pub async fn get_bark_battle_draft_config(
&self,
draft_id: String,
owner_user_id: String,
) -> Result<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(
&self,
input: BarkBattleWorkPublishRecordInput,
@@ -142,4 +169,83 @@ impl SpacetimeClient {
})
.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
}
}
@@ -560,6 +560,7 @@ impl SpacetimeClient {
) -> Result<Vec<SubscriptionHandle>, SpacetimeClientError> {
let mut subscriptions = Vec::new();
for query in [
"SELECT * FROM bark_battle_gallery_view",
"SELECT * FROM puzzle_gallery_card_view",
"SELECT * FROM jump_hop_gallery_card_view",
"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 = 'visual-novel'",
"SELECT * FROM public_work_play_daily_stat WHERE source_type = 'big-fish'",
"SELECT * FROM public_work_play_daily_stat WHERE source_type = 'bark-battle'",
"SELECT * FROM creation_entry_config",
"SELECT * FROM creation_entry_type_config",
"SELECT * FROM asset_object",
@@ -127,8 +127,9 @@ pub(crate) use self::auth::{
map_auth_store_snapshot_import_procedure_result, map_auth_store_snapshot_procedure_result,
};
pub(crate) use self::bark_battle::{
map_bark_battle_draft_config_procedure_result, map_bark_battle_run_procedure_result,
map_bark_battle_runtime_config_procedure_result,
map_bark_battle_draft_config_procedure_result, map_bark_battle_draft_config_row,
map_bark_battle_gallery_view_row, map_bark_battle_published_config_row,
map_bark_battle_run_procedure_result, map_bark_battle_runtime_config_procedure_result,
};
pub(crate) use self::big_fish::{
map_big_fish_gallery_view_row, map_big_fish_run_procedure_result,
@@ -36,6 +36,70 @@ pub(crate) fn map_bark_battle_run_procedure_result(
.map(bark_battle_run_to_value)
}
pub(crate) fn map_bark_battle_draft_config_row(
row: BarkBattleDraftConfigRow,
) -> BarkBattleDraftConfigRecord {
serde_json::json!({
"draftId": row.draft_id,
"ownerUserId": row.owner_user_id,
"workId": row.work_id,
"configVersion": row.config_version,
"rulesetVersion": row.ruleset_version,
"difficultyPreset": row.difficulty_preset,
"configJson": row.config_json,
"editorStateJson": row.editor_state_json,
"createdAtMicros": row.created_at.to_micros_since_unix_epoch(),
"updatedAtMicros": row.updated_at.to_micros_since_unix_epoch(),
})
}
pub(crate) fn map_bark_battle_published_config_row(
row: BarkBattlePublishedConfigRow,
) -> BarkBattleRuntimeConfigRecord {
serde_json::json!({
"workId": row.work_id,
"ownerUserId": row.owner_user_id,
"sourceDraftId": row.source_draft_id,
"configVersion": row.config_version,
"rulesetVersion": row.ruleset_version,
"difficultyPreset": row.difficulty_preset,
"configJson": row.config_json,
"publishedSnapshotJson": row.published_snapshot_json,
"publishedAtMicros": row.published_at.to_micros_since_unix_epoch(),
"updatedAtMicros": row.updated_at.to_micros_since_unix_epoch(),
})
}
pub(crate) fn map_bark_battle_gallery_view_row(
row: BarkBattleGalleryViewRow,
recent_play_count_7d: u32,
) -> serde_json::Value {
serde_json::json!({
"workId": row.work_id,
"ownerUserId": row.owner_user_id,
"sourceDraftId": row.source_draft_id,
"configVersion": row.config_version,
"rulesetVersion": row.ruleset_version,
"difficultyPreset": row.difficulty_preset,
"title": row.title,
"description": row.description,
"themeDescription": row.theme_description,
"playerImageDescription": row.player_image_description,
"opponentImageDescription": row.opponent_image_description,
"onomatopoeia": row.onomatopoeia,
"playerCharacterImageSrc": row.player_character_image_src,
"opponentCharacterImageSrc": row.opponent_character_image_src,
"uiBackgroundImageSrc": row.ui_background_image_src,
"status": "published",
"publishReady": true,
"playCount": row.play_count,
"finishCount": row.finish_count,
"recentPlayCount7d": recent_play_count_7d,
"updatedAtMicros": row.updated_at_micros,
"publishedAtMicros": row.published_at_micros,
})
}
fn bark_battle_draft_config_to_value(snapshot: BarkBattleDraftConfigSnapshot) -> serde_json::Value {
serde_json::json!({
"draftId": snapshot.draft_id,
@@ -44,7 +108,6 @@ fn bark_battle_draft_config_to_value(snapshot: BarkBattleDraftConfigSnapshot) ->
"configVersion": snapshot.config_version,
"rulesetVersion": snapshot.ruleset_version,
"difficultyPreset": snapshot.difficulty_preset,
"leaderboardEnabled": snapshot.leaderboard_enabled,
"configJson": snapshot.config_json,
"editorStateJson": snapshot.editor_state_json,
"createdAtMicros": snapshot.created_at_micros,
@@ -62,7 +125,6 @@ fn bark_battle_runtime_config_to_value(
"configVersion": snapshot.config_version,
"rulesetVersion": snapshot.ruleset_version,
"difficultyPreset": snapshot.difficulty_preset,
"leaderboardEnabled": snapshot.leaderboard_enabled,
"configJson": snapshot.config_json,
"publishedSnapshotJson": snapshot.published_snapshot_json,
"publishedAtMicros": snapshot.published_at_micros,
@@ -78,7 +140,6 @@ fn bark_battle_run_to_value(snapshot: BarkBattleRunSnapshot) -> serde_json::Valu
"configVersion": snapshot.config_version,
"rulesetVersion": snapshot.ruleset_version,
"difficultyPreset": snapshot.difficulty_preset,
"leaderboardEnabled": snapshot.leaderboard_enabled,
"status": snapshot.status,
"clientStartedAtMicros": snapshot.client_started_at_micros,
"serverStartedAtMicros": snapshot.server_started_at_micros,
@@ -92,3 +153,38 @@ fn bark_battle_run_to_value(snapshot: BarkBattleRunSnapshot) -> serde_json::Valu
"scoreId": snapshot.score_id,
})
}
#[cfg(test)]
mod tests {
use super::*;
#[test]
fn bark_battle_gallery_mapper_keeps_custom_onomatopoeia() {
let row = BarkBattleGalleryViewRow {
work_id: "BB-33333333".to_string(),
owner_user_id: "user-3".to_string(),
source_draft_id: Some("bark-battle-draft-3".to_string()),
config_version: 1,
ruleset_version: "bark-battle-ruleset-v1".to_string(),
difficulty_preset: "normal".to_string(),
title: "声浪公开赛".to_string(),
description: "画廊映射测试".to_string(),
theme_description: "霓虹竞技场".to_string(),
player_image_description: "星际猫骑士".to_string(),
opponent_image_description: "机器人拳手".to_string(),
onomatopoeia: vec!["轰!".to_string(), "炸场!".to_string()],
player_character_image_src: Some("/assets/player.png".to_string()),
opponent_character_image_src: Some("/assets/opponent.png".to_string()),
ui_background_image_src: Some("/assets/background.png".to_string()),
play_count: 8,
finish_count: 5,
updated_at_micros: 1_713_686_401_234_567,
published_at_micros: 1_713_686_401_234_000,
};
let value = map_bark_battle_gallery_view_row(row, 3);
assert_eq!(value["onomatopoeia"], serde_json::json!(["轰!", "炸场!"]));
assert_eq!(value["recentPlayCount7d"], serde_json::json!(3));
}
}
@@ -99,6 +99,8 @@ pub mod bark_battle_draft_config_snapshot_type;
pub mod bark_battle_draft_config_table;
pub mod bark_battle_draft_config_upsert_input_type;
pub mod bark_battle_draft_create_input_type;
pub mod bark_battle_gallery_view_row_type;
pub mod bark_battle_gallery_view_table;
pub mod bark_battle_leaderboard_entry_row_type;
pub mod bark_battle_leaderboard_entry_table;
pub mod bark_battle_personal_best_projection_row_type;
@@ -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_upsert_input_type::BarkBattleDraftConfigUpsertInput;
pub use bark_battle_draft_create_input_type::BarkBattleDraftCreateInput;
pub use bark_battle_gallery_view_row_type::BarkBattleGalleryViewRow;
pub use bark_battle_gallery_view_table::*;
pub use bark_battle_leaderboard_entry_row_type::BarkBattleLeaderboardEntryRow;
pub use bark_battle_leaderboard_entry_table::*;
pub use bark_battle_personal_best_projection_row_type::BarkBattlePersonalBestProjectionRow;
@@ -2249,6 +2253,7 @@ pub struct DbUpdate {
auth_store_projection_meta: __sdk::TableUpdate<AuthStoreProjectionMeta>,
auth_store_snapshot: __sdk::TableUpdate<AuthStoreSnapshot>,
bark_battle_draft_config: __sdk::TableUpdate<BarkBattleDraftConfigRow>,
bark_battle_gallery_view: __sdk::TableUpdate<BarkBattleGalleryViewRow>,
bark_battle_leaderboard_entry: __sdk::TableUpdate<BarkBattleLeaderboardEntryRow>,
bark_battle_personal_best_projection: __sdk::TableUpdate<BarkBattlePersonalBestProjectionRow>,
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_table::parse_table_update(table_update)?,
),
"bark_battle_gallery_view" => db_update.bark_battle_gallery_view.append(
bark_battle_gallery_view_table::parse_table_update(table_update)?,
),
"bark_battle_leaderboard_entry" => db_update.bark_battle_leaderboard_entry.append(
bark_battle_leaderboard_entry_table::parse_table_update(table_update)?,
),
@@ -3159,6 +3167,10 @@ impl __sdk::DbUpdate for DbUpdate {
&self.visual_novel_work_profile,
)
.with_updates_by_pk(|row| &row.profile_id);
diff.bark_battle_gallery_view = cache.apply_diff_to_table::<BarkBattleGalleryViewRow>(
"bark_battle_gallery_view",
&self.bark_battle_gallery_view,
);
diff.big_fish_gallery_view = cache.apply_diff_to_table::<BigFishWorkSummarySnapshot>(
"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
.append(__sdk::parse_row_list_as_inserts(table_rows.rows)?),
"bark_battle_gallery_view" => db_update
.bark_battle_gallery_view
.append(__sdk::parse_row_list_as_inserts(table_rows.rows)?),
"bark_battle_leaderboard_entry" => db_update
.bark_battle_leaderboard_entry
.append(__sdk::parse_row_list_as_inserts(table_rows.rows)?),
@@ -3553,6 +3568,9 @@ impl __sdk::DbUpdate for DbUpdate {
"bark_battle_draft_config" => db_update
.bark_battle_draft_config
.append(__sdk::parse_row_list_as_deletes(table_rows.rows)?),
"bark_battle_gallery_view" => db_update
.bark_battle_gallery_view
.append(__sdk::parse_row_list_as_deletes(table_rows.rows)?),
"bark_battle_leaderboard_entry" => db_update
.bark_battle_leaderboard_entry
.append(__sdk::parse_row_list_as_deletes(table_rows.rows)?),
@@ -3845,6 +3863,7 @@ pub struct AppliedDiff<'r> {
auth_store_projection_meta: __sdk::TableAppliedDiff<'r, AuthStoreProjectionMeta>,
auth_store_snapshot: __sdk::TableAppliedDiff<'r, AuthStoreSnapshot>,
bark_battle_draft_config: __sdk::TableAppliedDiff<'r, BarkBattleDraftConfigRow>,
bark_battle_gallery_view: __sdk::TableAppliedDiff<'r, BarkBattleGalleryViewRow>,
bark_battle_leaderboard_entry: __sdk::TableAppliedDiff<'r, BarkBattleLeaderboardEntryRow>,
bark_battle_personal_best_projection:
__sdk::TableAppliedDiff<'r, BarkBattlePersonalBestProjectionRow>,
@@ -4006,6 +4025,11 @@ impl<'r> __sdk::AppliedDiff<'r> for AppliedDiff<'r> {
&self.bark_battle_draft_config,
event,
);
callbacks.invoke_table_row_callbacks::<BarkBattleGalleryViewRow>(
"bark_battle_gallery_view",
&self.bark_battle_gallery_view,
event,
);
callbacks.invoke_table_row_callbacks::<BarkBattleLeaderboardEntryRow>(
"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_snapshot_table::register_table(client_cache);
bark_battle_draft_config_table::register_table(client_cache);
bark_battle_gallery_view_table::register_table(client_cache);
bark_battle_leaderboard_entry_table::register_table(client_cache);
bark_battle_personal_best_projection_table::register_table(client_cache);
bark_battle_published_config_table::register_table(client_cache);
@@ -5210,6 +5235,7 @@ impl __sdk::SpacetimeModule for RemoteModule {
"auth_store_projection_meta",
"auth_store_snapshot",
"bark_battle_draft_config",
"bark_battle_gallery_view",
"bark_battle_leaderboard_entry",
"bark_battle_personal_best_projection",
"bark_battle_published_config",

Some files were not shown because too many files have changed in this diff Show More