codex/bark-battle #30
@@ -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 model;api-server 是否需要订阅缓存,避免每请求 procedure 热路径。
|
||||
9. **runtime 模式差异**:`draft` 和 `published` 的输入能力、mock/debug 开关、鉴权、开始条件是否不同。
|
||||
10. **正式统计口径**:哪些 runtime 事件写正式统计;草稿试玩、mock、debug 是否必须排除。
|
||||
11. **规则参数归属**:哪些配置是创作者可编辑;哪些阈值、时长、冷却、计分、反作弊、裁决规则必须留在后端规则集。
|
||||
12. **旧数据策略**:旧草稿、旧发布配置、旧分享码是迁移、降级展示、重新生成,还是明确不兼容。
|
||||
|
||||
- `AGENTS.md`
|
||||
- `.hermes/shared-memory/`
|
||||
|
||||
@@ -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 层直接 413,access 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 应读取结果页最终图片素材而不是初始草稿素材。
|
||||
|
||||
@@ -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', () => {
|
||||
|
||||
@@ -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 |
@@ -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()
|
||||
|
||||
@@ -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,
|
||||
),
|
||||
|
||||
@@ -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),
|
||||
|
||||
@@ -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
Reference in New Issue
Block a user