diff --git a/.codex/skills/genarrative-play-type-integration/SKILL.md b/.codex/skills/genarrative-play-type-integration/SKILL.md index 61256e7b3..63d14982e 100644 --- a/.codex/skills/genarrative-play-type-integration/SKILL.md +++ b/.codex/skills/genarrative-play-type-integration/SKILL.md @@ -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/` diff --git a/.hermes/shared-memory/decision-log.md b/.hermes/shared-memory/decision-log.md index 350c179b7..50476ac8a 100644 --- a/.hermes/shared-memory/decision-log.md +++ b/.hermes/shared-memory/decision-log.md @@ -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 应读取结果页最终图片素材而不是初始草稿素材。 diff --git a/.hermes/shared-memory/pitfalls.md b/.hermes/shared-memory/pitfalls.md index 0d7a1bea8..82dfa7a88 100644 --- a/.hermes/shared-memory/pitfalls.md +++ b/.hermes/shared-memory/pitfalls.md @@ -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`。 diff --git a/docs/【玩法创作】平台入口与玩法链路-2026-05-15.md b/docs/【玩法创作】平台入口与玩法链路-2026-05-15.md index 5bc4f59d0..4d2be6e89 100644 --- a/docs/【玩法创作】平台入口与玩法链路-2026-05-15.md +++ b/docs/【玩法创作】平台入口与玩法链路-2026-05-15.md @@ -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 公开闭环不展示音频、皮肤预设或排名配置入口。 ## 方洞挑战 diff --git a/packages/shared/src/contracts/barkBattle.test.ts b/packages/shared/src/contracts/barkBattle.test.ts index ae2822c4f..d9f6aebc9 100644 --- a/packages/shared/src/contracts/barkBattle.test.ts +++ b/packages/shared/src/contracts/barkBattle.test.ts @@ -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', () => { diff --git a/packages/shared/src/contracts/barkBattle.ts b/packages/shared/src/contracts/barkBattle.ts index 14449115e..18b23ef21 100644 --- a/packages/shared/src/contracts/barkBattle.ts +++ b/packages/shared/src/contracts/barkBattle.ts @@ -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; } diff --git a/public/creation-type-references/bark-battle.webp b/public/creation-type-references/bark-battle.webp new file mode 100644 index 000000000..ef48f407b Binary files /dev/null and b/public/creation-type-references/bark-battle.webp differ diff --git a/server-rs/crates/api-server/src/app.rs b/server-rs/crates/api-server/src/app.rs index 6560dd056..ce7e6c8ee 100644 --- a/server-rs/crates/api-server/src/app.rs +++ b/server-rs/crates/api-server/src/app.rs @@ -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 diff --git a/server-rs/crates/api-server/src/bark_battle.rs b/server-rs/crates/api-server/src/bark_battle.rs index 34bb66dbb..f3fb1933e 100644 --- a/server-rs/crates/api-server/src/bark_battle.rs +++ b/server-rs/crates/api-server/src/bark_battle.rs @@ -6,30 +6,49 @@ use axum::{ http::{HeaderName, StatusCode, header}, response::Response, }; +use module_assets::{ + AssetObjectAccessPolicy, build_asset_entity_binding_input, build_asset_object_upsert_input, + generate_asset_binding_id, generate_asset_object_id, +}; use module_bark_battle::{BARK_BATTLE_RULESET_VERSION_V1, BarkBattleRuleset}; +use platform_oss::{LegacyAssetPrefix, OssHeadObjectRequest, OssObjectAccess}; use serde::Deserialize; use serde_json::{Value, json}; use shared_contracts::bark_battle::{ - BarkBattleConfigEditorPayload, BarkBattleDerivedMetrics, BarkBattleDifficultyPreset, - BarkBattleDraftConfig, BarkBattleDraftCreateRequest, BarkBattleFinishStatus, - BarkBattlePublishedConfig, BarkBattleRunFinishRequest, BarkBattleRunFinishResponse, - BarkBattleRunStartRequest, BarkBattleRunStartResponse, BarkBattleScoreSummary, - BarkBattleServerResult, BarkBattleWorkPublishRequest, + BarkBattleAssetSlot, BarkBattleConfigEditorPayload, BarkBattleDerivedMetrics, + BarkBattleDifficultyPreset, BarkBattleDraftConfig, BarkBattleDraftConfigUpdateRequest, + BarkBattleDraftCreateRequest, BarkBattleFinishStatus, BarkBattleGeneratedImageAsset, + BarkBattleImageAssetGenerateRequest, BarkBattlePublishedConfig, BarkBattleRunFinishRequest, + BarkBattleRunFinishResponse, BarkBattleRunStartRequest, BarkBattleRunStartResponse, + BarkBattleScoreSummary, BarkBattleServerResult, BarkBattleWorkPublishRequest, + BarkBattleWorkSummary, BarkBattleWorksResponse, }; use shared_kernel::{ build_prefixed_uuid_id, format_rfc3339, format_timestamp_micros, offset_datetime_to_unix_micros, parse_rfc3339, }; use spacetime_client::{ - BarkBattleDraftCreateRecordInput, BarkBattleRunFinishRecordInput, BarkBattleRunRecord, - BarkBattleRunStartRecordInput, BarkBattleWorkPublishRecordInput, SpacetimeClientError, + BarkBattleDraftConfigUpsertRecordInput, BarkBattleDraftCreateRecordInput, + BarkBattleRunFinishRecordInput, BarkBattleRunRecord, BarkBattleRunStartRecordInput, + BarkBattleWorkPublishRecordInput, SpacetimeClientError, }; use time::{Duration as TimeDuration, OffsetDateTime}; use crate::{ api_response::json_success_body, + asset_billing::execute_billable_asset_operation, auth::AuthenticatedAccessToken, + generated_image_assets::{ + GeneratedImageAssetAdapter, GeneratedImageAssetDataUrl, + adapter::{GeneratedImageAssetAdapterMetadata, GeneratedImageAssetPersistInput}, + normalize_generated_image_asset_mime, + }, http_error::AppError, + openai_image_generation::{ + GPT_IMAGE_2_MODEL, build_openai_image_http_client, create_openai_image_generation, + require_openai_image_settings, + }, + platform_errors::map_oss_error, request_context::RequestContext, state::AppState, work_play_tracking::{WorkPlayTrackingDraft, record_work_play_start_after_success}, @@ -37,11 +56,14 @@ use crate::{ const BARK_BATTLE_RUNTIME_PROVIDER: &str = "bark-battle-runtime"; const BARK_BATTLE_DRAFT_ID_PREFIX: &str = "bark-battle-draft-"; -const BARK_BATTLE_WORK_ID_PREFIX: &str = "bark-battle-work-"; +const BARK_BATTLE_WORK_ID_PREFIX: &str = "BB-"; const BARK_BATTLE_RUN_ID_PREFIX: &str = "bark-battle-run-"; const BARK_BATTLE_RUN_TOKEN_PREFIX: &str = "bark-battle-token-"; +const BARK_BATTLE_IMAGE_ID_PREFIX: &str = "bark-battle-image-"; const BARK_BATTLE_PLAY_TYPE_ID: &str = "bark-battle"; const BARK_BATTLE_RUN_TTL_SECONDS: i64 = 10 * 60; +const BARK_BATTLE_CHARACTER_IMAGE_SIZE: &str = "1024*1024"; +const BARK_BATTLE_BACKGROUND_IMAGE_SIZE: &str = "1024*1792"; #[derive(Clone, Debug, Deserialize, serde::Serialize)] #[serde(rename_all = "camelCase")] @@ -73,11 +95,8 @@ struct BarkBattleRunSnapshotRecord { #[serde(rename_all = "camelCase")] struct BarkBattleDraftConfigSnapshotRecord { draft_id: String, - #[allow(dead_code)] work_id: String, - #[allow(dead_code)] config_version: u64, - #[allow(dead_code)] ruleset_version: String, #[serde(default)] config_json: String, @@ -105,6 +124,30 @@ pub async fn create_bark_battle_draft( ) -> Result, Response> { let Json(payload) = bark_battle_json(payload, &request_context)?; let now = current_utc_micros(); + let editor_config = BarkBattleConfigEditorPayload { + title: payload.title.clone(), + description: payload.description.clone(), + theme_description: payload.theme_description.clone(), + player_image_description: payload.player_image_description.clone(), + opponent_image_description: payload.opponent_image_description.clone(), + onomatopoeia: payload.onomatopoeia.clone(), + player_character_image_src: normalize_optional_bark_battle_asset_source( + &request_context, + payload.player_character_image_src.as_deref(), + "playerCharacterImageSrc", + )?, + opponent_character_image_src: normalize_optional_bark_battle_asset_source( + &request_context, + payload.opponent_character_image_src.as_deref(), + "opponentCharacterImageSrc", + )?, + ui_background_image_src: normalize_optional_bark_battle_asset_source( + &request_context, + payload.ui_background_image_src.as_deref(), + "uiBackgroundImageSrc", + )?, + difficulty_preset: payload.difficulty_preset.clone(), + }; let draft = state .spacetime_client() .create_bark_battle_draft(BarkBattleDraftCreateRecordInput { @@ -113,13 +156,12 @@ pub async fn create_bark_battle_draft( work_id: build_prefixed_uuid_id(BARK_BATTLE_WORK_ID_PREFIX), title: Some(payload.title), description: payload.description, - theme_preset: payload.theme_preset, - player_dog_skin_preset: payload.player_dog_skin_preset, - opponent_dog_skin_preset: payload.opponent_dog_skin_preset, + theme_description: payload.theme_description, + player_image_description: payload.player_image_description, + opponent_image_description: payload.opponent_image_description, difficulty_preset: Some( difficulty_to_spacetime_string(&payload.difficulty_preset).to_string(), ), - leaderboard_enabled: Some(payload.leaderboard_enabled), editor_state_json: Some("{}".to_string()), created_at_micros: now, }) @@ -127,10 +169,163 @@ pub async fn create_bark_battle_draft( .map_err(|error| { bark_battle_error_response(&request_context, map_bark_battle_client_error(error)) })?; - let draft = map_draft_config_record(draft, &request_context)?; + let draft_snapshot = parse_draft_snapshot_record(draft, &request_context)?; + let config_json = serialize_bark_battle_editor_config(&request_context, &editor_config)?; + let updated = state + .spacetime_client() + .update_bark_battle_draft_config(BarkBattleDraftConfigUpsertRecordInput { + draft_id: draft_snapshot.draft_id, + owner_user_id: authenticated.claims().user_id().to_string(), + work_id: draft_snapshot.work_id, + config_version: draft_snapshot.config_version.saturating_add(1), + ruleset_version: draft_snapshot.ruleset_version, + difficulty_preset: difficulty_to_spacetime_string(&editor_config.difficulty_preset) + .to_string(), + config_json, + updated_at_micros: now, + }) + .await + .map_err(|error| { + bark_battle_error_response(&request_context, map_bark_battle_client_error(error)) + })?; + let draft = map_draft_config_record(updated, &request_context)?; Ok(json_success_body(Some(&request_context), draft)) } +pub async fn update_bark_battle_draft_config( + State(state): State, + Path(draft_id): Path, + Extension(request_context): Extension, + Extension(authenticated): Extension, + payload: Result, JsonRejection>, +) -> Result, Response> { + ensure_non_empty(&request_context, &draft_id, "draftId")?; + let Json(payload) = bark_battle_json(payload, &request_context)?; + if payload.draft_id.trim() != draft_id { + return Err(bark_battle_bad_request( + &request_context, + "draftId 与路径参数不一致", + )); + } + let Some(work_id) = payload + .work_id + .as_deref() + .map(str::trim) + .filter(|value| !value.is_empty()) + .map(ToString::to_string) + else { + return Err(bark_battle_bad_request( + &request_context, + "workId 缺失,请重新生成草稿后再保存素材。", + )); + }; + let owner_user_id = authenticated.claims().user_id().to_string(); + let now = current_utc_micros(); + let next_config_version = payload + .config_version + .map(u64::from) + .unwrap_or(1) + .saturating_add(1); + let ruleset_version = payload + .ruleset_version + .as_deref() + .map(str::trim) + .filter(|value| !value.is_empty()) + .unwrap_or(BARK_BATTLE_RULESET_VERSION_V1) + .to_string(); + let editor_config = BarkBattleConfigEditorPayload { + title: payload.title, + description: payload.description, + theme_description: payload.theme_description, + player_image_description: payload.player_image_description, + opponent_image_description: payload.opponent_image_description, + onomatopoeia: payload.onomatopoeia, + player_character_image_src: normalize_optional_bark_battle_asset_source( + &request_context, + payload.player_character_image_src.as_deref(), + "playerCharacterImageSrc", + )?, + opponent_character_image_src: normalize_optional_bark_battle_asset_source( + &request_context, + payload.opponent_character_image_src.as_deref(), + "opponentCharacterImageSrc", + )?, + ui_background_image_src: normalize_optional_bark_battle_asset_source( + &request_context, + payload.ui_background_image_src.as_deref(), + "uiBackgroundImageSrc", + )?, + difficulty_preset: payload.difficulty_preset, + }; + let config_json = serialize_bark_battle_editor_config(&request_context, &editor_config)?; + let updated = state + .spacetime_client() + .update_bark_battle_draft_config(BarkBattleDraftConfigUpsertRecordInput { + draft_id, + owner_user_id, + work_id, + config_version: next_config_version, + ruleset_version, + difficulty_preset: difficulty_to_spacetime_string(&editor_config.difficulty_preset) + .to_string(), + config_json, + updated_at_micros: now, + }) + .await + .map_err(|error| { + bark_battle_error_response(&request_context, map_bark_battle_client_error(error)) + })?; + let mut draft = map_draft_config_record(updated, &request_context)?; + // 中文注释:SpacetimeDB procedure 返回可能早于订阅缓存合并完成;HTTP 回包先以本次请求 + // 的 configJson 为准,避免前端拿到旧快照后误判“草稿没有素材”。 + draft.player_character_image_src = editor_config.player_character_image_src; + draft.opponent_character_image_src = editor_config.opponent_character_image_src; + draft.ui_background_image_src = editor_config.ui_background_image_src; + Ok(json_success_body(Some(&request_context), draft)) +} + +pub async fn generate_bark_battle_image_asset( + State(state): State, + Extension(request_context): Extension, + Extension(authenticated): Extension, + payload: Result, JsonRejection>, +) -> Result, Response> { + let Json(payload) = bark_battle_json(payload, &request_context)?; + let owner_user_id = authenticated.claims().user_id().to_string(); + let asset_id = build_prefixed_uuid_id(BARK_BATTLE_IMAGE_ID_PREFIX); + let prompt = build_bark_battle_image_prompt(&payload.slot, &payload.config); + let size = bark_battle_image_size(&payload.slot).to_string(); + let slot = payload.slot.clone(); + let draft_id = payload + .draft_id + .as_deref() + .map(str::trim) + .filter(|value| !value.is_empty()) + .map(ToString::to_string); + let result = execute_billable_asset_operation( + &state, + &owner_user_id, + bark_battle_slot_asset_kind(&slot), + asset_id.as_str(), + async { + generate_and_persist_bark_battle_image_asset( + &state, + &owner_user_id, + &slot, + draft_id.as_deref(), + asset_id.as_str(), + prompt.as_str(), + size.as_str(), + ) + .await + }, + ) + .await + .map_err(|error| bark_battle_error_response(&request_context, error))?; + + Ok(json_success_body(Some(&request_context), result)) +} + pub async fn publish_bark_battle_work( State(state): State, Extension(request_context): Extension, @@ -139,13 +334,18 @@ pub async fn publish_bark_battle_work( ) -> Result, Response> { let Json(payload) = bark_battle_json(payload, &request_context)?; ensure_non_empty(&request_context, &payload.draft_id, "draftId")?; - let work_id = payload + let Some(work_id) = payload .work_id .as_deref() .map(str::trim) .filter(|value| !value.is_empty()) .map(ToString::to_string) - .unwrap_or_else(|| build_prefixed_uuid_id(BARK_BATTLE_WORK_ID_PREFIX)); + else { + return Err(bark_battle_bad_request( + &request_context, + "workId 缺失,请重新生成草稿后再发布。", + )); + }; let published_snapshot_json = payload .published_snapshot .as_ref() @@ -177,6 +377,59 @@ pub async fn publish_bark_battle_work( Ok(json_success_body(Some(&request_context), published)) } +pub async fn list_bark_battle_works( + State(state): State, + Extension(request_context): Extension, + Extension(authenticated): Extension, +) -> Result, Response> { + let items = state + .spacetime_client() + .list_bark_battle_works(authenticated.claims().user_id().to_string()) + .await + .map_err(|error| { + bark_battle_error_response(&request_context, map_bark_battle_client_error(error)) + })?; + let items = items + .into_iter() + .map(|item| { + let author_display_name = + resolve_bark_battle_author_display_name_for_record(&state, &item); + map_work_summary_record(item, &request_context, author_display_name) + }) + .collect::, _>>()?; + + Ok(json_success_body( + Some(&request_context), + BarkBattleWorksResponse { items }, + )) +} + +pub async fn list_bark_battle_gallery( + State(state): State, + Extension(request_context): Extension, +) -> Result, Response> { + let items = state + .spacetime_client() + .list_bark_battle_gallery() + .await + .map_err(|error| { + bark_battle_error_response(&request_context, map_bark_battle_client_error(error)) + })?; + let items = items + .into_iter() + .map(|item| { + let author_display_name = + resolve_bark_battle_author_display_name_for_record(&state, &item); + map_work_summary_record(item, &request_context, author_display_name) + }) + .collect::, _>>()?; + + Ok(json_success_body( + Some(&request_context), + BarkBattleWorksResponse { items }, + )) +} + pub async fn get_bark_battle_runtime_config( State(state): State, Path(work_id): Path, @@ -473,13 +726,19 @@ fn map_draft_config_record( let editor_config = parse_editor_config_record(&snapshot.config_json, request_context)?; Ok(BarkBattleDraftConfig { draft_id: snapshot.draft_id, + work_id: Some(snapshot.work_id), + config_version: Some(snapshot.config_version.min(u64::from(u32::MAX)) as u32), + ruleset_version: Some(snapshot.ruleset_version), title: editor_config.title, description: editor_config.description, - theme_preset: editor_config.theme_preset, - player_dog_skin_preset: editor_config.player_dog_skin_preset, - opponent_dog_skin_preset: editor_config.opponent_dog_skin_preset, + theme_description: editor_config.theme_description, + player_image_description: editor_config.player_image_description, + opponent_image_description: editor_config.opponent_image_description, + onomatopoeia: editor_config.onomatopoeia, + player_character_image_src: editor_config.player_character_image_src, + opponent_character_image_src: editor_config.opponent_character_image_src, + ui_background_image_src: editor_config.ui_background_image_src, difficulty_preset: editor_config.difficulty_preset, - leaderboard_enabled: editor_config.leaderboard_enabled, updated_at: format_timestamp_micros(snapshot.updated_at_micros), }) } @@ -502,10 +761,13 @@ fn map_runtime_config_record( draw_threshold: ruleset.draw_threshold_energy as f32, min_bark_gap_ms: ruleset.min_bark_gap_ms, difficulty_preset: editor_config.difficulty_preset, - theme_preset: editor_config.theme_preset, - player_dog_skin_preset: editor_config.player_dog_skin_preset, - opponent_dog_skin_preset: editor_config.opponent_dog_skin_preset, - leaderboard_enabled: editor_config.leaderboard_enabled, + theme_description: editor_config.theme_description, + player_image_description: editor_config.player_image_description, + opponent_image_description: editor_config.opponent_image_description, + onomatopoeia: editor_config.onomatopoeia, + player_character_image_src: editor_config.player_character_image_src, + opponent_character_image_src: editor_config.opponent_character_image_src, + ui_background_image_src: editor_config.ui_background_image_src, updated_at: format_timestamp_micros(snapshot.updated_at_micros), }) } @@ -524,16 +786,288 @@ fn map_published_config_record( play_type_id: BARK_BATTLE_PLAY_TYPE_ID.to_string(), title: editor_config.title, description: editor_config.description, - theme_preset: editor_config.theme_preset, - player_dog_skin_preset: editor_config.player_dog_skin_preset, - opponent_dog_skin_preset: editor_config.opponent_dog_skin_preset, + theme_description: editor_config.theme_description, + player_image_description: editor_config.player_image_description, + opponent_image_description: editor_config.opponent_image_description, + onomatopoeia: editor_config.onomatopoeia, + player_character_image_src: editor_config.player_character_image_src, + opponent_character_image_src: editor_config.opponent_character_image_src, + ui_background_image_src: editor_config.ui_background_image_src, difficulty_preset: editor_config.difficulty_preset, - leaderboard_enabled: editor_config.leaderboard_enabled, updated_at: format_timestamp_micros(snapshot.updated_at_micros), published_at: format_timestamp_micros(snapshot.published_at_micros), }) } +fn map_work_summary_record( + value: Value, + request_context: &RequestContext, + author_display_name: String, +) -> Result { + let status = value + .get("status") + .and_then(Value::as_str) + .unwrap_or("draft") + .to_string(); + if status == "published" && value.get("configJson").is_none() { + return map_gallery_work_summary_record(value, request_context, author_display_name); + } + + let draft_id = value + .get("draftId") + .and_then(Value::as_str) + .map(ToString::to_string) + .or_else(|| { + value + .get("sourceDraftId") + .and_then(Value::as_str) + .map(ToString::to_string) + }); + let owner_user_id = value + .get("ownerUserId") + .and_then(Value::as_str) + .unwrap_or_default() + .to_string(); + let snapshot = parse_runtime_snapshot_record(value.clone(), request_context).or_else(|_| { + parse_draft_snapshot_record(value.clone(), request_context).map(draft_to_runtime_like) + })?; + let editor_config = resolve_work_summary_editor_config( + &snapshot.config_json, + value.get("publishedSnapshotJson").and_then(Value::as_str), + request_context, + )?; + let is_published = status == "published" || snapshot.published_at_micros > 0; + let generation_status = Some(resolve_generation_status( + editor_config.player_character_image_src.as_deref(), + editor_config.opponent_character_image_src.as_deref(), + editor_config.ui_background_image_src.as_deref(), + )); + let publish_ready = has_all_bark_battle_images(&editor_config); + + Ok(BarkBattleWorkSummary { + work_id: snapshot.work_id, + draft_id, + owner_user_id, + author_display_name, + title: editor_config.title, + summary: editor_config.description.unwrap_or_default(), + theme_description: editor_config.theme_description, + player_image_description: editor_config.player_image_description, + opponent_image_description: editor_config.opponent_image_description, + onomatopoeia: editor_config.onomatopoeia, + player_character_image_src: editor_config.player_character_image_src, + opponent_character_image_src: editor_config.opponent_character_image_src, + ui_background_image_src: editor_config.ui_background_image_src, + difficulty_preset: editor_config.difficulty_preset, + status: if is_published { "published" } else { "draft" }.to_string(), + generation_status, + publish_ready, + play_count: value.get("playCount").and_then(Value::as_u64).unwrap_or(0), + finish_count: value.get("finishCount").and_then(Value::as_u64), + win_count: None, + draw_count: None, + loss_count: None, + recent_play_count_7d: value.get("recentPlayCount7d").and_then(Value::as_u64), + updated_at: format_timestamp_micros(snapshot.updated_at_micros), + published_at: is_published.then(|| format_timestamp_micros(snapshot.published_at_micros)), + }) +} + +fn map_gallery_work_summary_record( + value: Value, + request_context: &RequestContext, + author_display_name: String, +) -> Result { + let difficulty = value + .get("difficultyPreset") + .and_then(Value::as_str) + .map(parse_difficulty) + .transpose() + .map_err(|error| bark_battle_error_response(request_context, error))? + .unwrap_or(BarkBattleDifficultyPreset::Normal); + let player_character_image_src = value + .get("playerCharacterImageSrc") + .and_then(Value::as_str) + .filter(|value| !value.trim().is_empty()) + .map(ToString::to_string); + let opponent_character_image_src = value + .get("opponentCharacterImageSrc") + .and_then(Value::as_str) + .filter(|value| !value.trim().is_empty()) + .map(ToString::to_string); + let ui_background_image_src = value + .get("uiBackgroundImageSrc") + .and_then(Value::as_str) + .filter(|value| !value.trim().is_empty()) + .map(ToString::to_string); + let updated_at_micros = value + .get("updatedAtMicros") + .and_then(Value::as_i64) + .unwrap_or_default(); + let published_at_micros = value + .get("publishedAtMicros") + .and_then(Value::as_i64) + .unwrap_or(updated_at_micros); + + Ok(BarkBattleWorkSummary { + work_id: read_required_json_string(&value, "workId", request_context)?, + draft_id: value + .get("sourceDraftId") + .and_then(Value::as_str) + .map(ToString::to_string), + owner_user_id: read_required_json_string(&value, "ownerUserId", request_context)?, + author_display_name, + title: read_required_json_string(&value, "title", request_context)?, + summary: value + .get("description") + .and_then(Value::as_str) + .unwrap_or_default() + .to_string(), + theme_description: read_required_json_string(&value, "themeDescription", request_context)?, + player_image_description: read_required_json_string( + &value, + "playerImageDescription", + request_context, + )?, + opponent_image_description: read_required_json_string( + &value, + "opponentImageDescription", + request_context, + )?, + onomatopoeia: read_optional_string_array(&value, "onomatopoeia"), + player_character_image_src, + opponent_character_image_src, + ui_background_image_src, + difficulty_preset: difficulty, + status: "published".to_string(), + generation_status: Some("ready".to_string()), + publish_ready: true, + play_count: value.get("playCount").and_then(Value::as_u64).unwrap_or(0), + finish_count: value.get("finishCount").and_then(Value::as_u64), + win_count: None, + draw_count: None, + loss_count: None, + recent_play_count_7d: value.get("recentPlayCount7d").and_then(Value::as_u64), + updated_at: format_timestamp_micros(updated_at_micros), + published_at: Some(format_timestamp_micros(published_at_micros)), + }) +} + +fn resolve_work_summary_editor_config( + config_json: &str, + published_snapshot_json: Option<&str>, + request_context: &RequestContext, +) -> Result { + let snapshot = published_snapshot_json + .map(str::trim) + .filter(|value| !value.is_empty()) + .unwrap_or(config_json); + parse_editor_config_record(snapshot, request_context) +} + +fn draft_to_runtime_like( + draft: BarkBattleDraftConfigSnapshotRecord, +) -> BarkBattleRuntimeConfigSnapshotRecord { + BarkBattleRuntimeConfigSnapshotRecord { + work_id: draft.work_id, + source_draft_id: Some(draft.draft_id), + config_version: draft.config_version, + ruleset_version: draft.ruleset_version, + config_json: draft.config_json, + published_at_micros: 0, + updated_at_micros: draft.updated_at_micros, + } +} + +fn resolve_generation_status( + player_src: Option<&str>, + opponent_src: Option<&str>, + background_src: Option<&str>, +) -> String { + if player_src.is_some() && opponent_src.is_some() && background_src.is_some() { + "ready".to_string() + } else { + "pending_assets".to_string() + } +} + +fn has_all_bark_battle_images(config: &BarkBattleConfigEditorPayload) -> bool { + config + .player_character_image_src + .as_deref() + .is_some_and(|value| !value.trim().is_empty()) + && config + .opponent_character_image_src + .as_deref() + .is_some_and(|value| !value.trim().is_empty()) + && config + .ui_background_image_src + .as_deref() + .is_some_and(|value| !value.trim().is_empty()) +} + +fn resolve_bark_battle_author_display_name_for_record(state: &AppState, value: &Value) -> String { + let owner_user_id = value + .get("ownerUserId") + .and_then(Value::as_str) + .unwrap_or_default(); + resolve_bark_battle_author_display_name(state, owner_user_id) +} + +fn resolve_bark_battle_author_display_name(state: &AppState, owner_user_id: &str) -> String { + let display_name = if owner_user_id.trim().is_empty() { + None + } else { + state + .auth_user_service() + .get_user_by_id(owner_user_id) + .ok() + .flatten() + .map(|user| user.display_name) + }; + normalize_author_display_name(display_name) +} + +fn normalize_author_display_name(display_name: Option) -> String { + display_name + .map(|value| value.trim().to_string()) + .filter(|value| !value.is_empty()) + .unwrap_or_else(|| "玩家".to_string()) +} + +fn read_required_json_string( + value: &Value, + field_name: &str, + request_context: &RequestContext, +) -> Result { + value + .get(field_name) + .and_then(Value::as_str) + .map(ToString::to_string) + .ok_or_else(|| { + bark_battle_error_response( + request_context, + AppError::from_status(StatusCode::BAD_GATEWAY).with_details(json!({ + "provider": BARK_BATTLE_RUNTIME_PROVIDER, + "message": format!("Bark Battle work summary 缺少字段: {field_name}"), + })), + ) + }) +} + +fn read_optional_string_array(value: &Value, field_name: &str) -> Option> { + let words: Vec = value + .get(field_name)? + .as_array()? + .iter() + .filter_map(Value::as_str) + .map(str::trim) + .filter(|word| !word.is_empty()) + .map(ToString::to_string) + .collect(); + (!words.is_empty()).then_some(words) +} + fn parse_editor_config_record( config_json: &str, request_context: &RequestContext, @@ -549,6 +1083,311 @@ fn parse_editor_config_record( }) } +fn serialize_bark_battle_editor_config( + request_context: &RequestContext, + editor_config: &BarkBattleConfigEditorPayload, +) -> Result { + serde_json::to_string(editor_config).map_err(|error| { + bark_battle_error_response( + request_context, + AppError::from_status(StatusCode::BAD_REQUEST).with_details(json!({ + "provider": BARK_BATTLE_RUNTIME_PROVIDER, + "message": format!("Bark Battle config JSON 序列化失败: {error}"), + })), + ) + }) +} + +fn build_bark_battle_image_prompt( + slot: &BarkBattleAssetSlot, + config: &BarkBattleConfigEditorPayload, +) -> String { + let title = config.title.trim(); + let description = config.description.as_deref().unwrap_or_default().trim(); + let theme_description = config.theme_description.trim(); + let player_description = config.player_image_description.trim(); + let opponent_description = config.opponent_image_description.trim(); + let slot_prompt = match slot { + BarkBattleAssetSlot::PlayerCharacter => format!( + "玩家形象描述:{player_description}。输出单个完整角色/形象,正面,主体完整,PNG 透明背景,适合叠加在竖屏声控对战游戏 HUD 中。" + ), + BarkBattleAssetSlot::OpponentCharacter => format!( + "对手形象描述:{opponent_description}。输出单个完整角色/形象,正面,主体完整,PNG 透明背景,适合叠加在竖屏声控对战游戏 HUD 中。" + ), + BarkBattleAssetSlot::UiBackground => format!( + "竞技背景描述:{theme_description}。输出竖屏移动端声浪竞技场背景,留出左右两侧角色站位和中部能量对抗空间,不包含具体角色。" + ), + }; + + let mut parts = vec![ + format!("本作品《{title}》专用素材。"), + format!("整体主题/场景:{theme_description}。"), + ]; + if !description.is_empty() { + parts.push(format!("作品简介:{description}。")); + } + if !matches!(slot, BarkBattleAssetSlot::UiBackground) { + parts.push(format!( + "玩家与对手的关系参考:玩家是「{player_description}」,对手是「{opponent_description}」。" + )); + } + parts.push(slot_prompt); + parts.push(match slot { + BarkBattleAssetSlot::UiBackground => { + "画面要求:9:16 竖屏游戏背景,明亮、有纵深、无文字、无 Logo、无按钮、无 UI 字、无水印、无角色、无狗狗。" + .to_string() + } + _ => { + "画面要求:1:1 角色图,透明背景,边缘清晰,无文字、无 Logo、无按钮、无水印,不要把竞技场背景画成主体。最终画面必须是 PNG 透明背景。" + .to_string() + } + }); + parts + .into_iter() + .map(|part| part.trim().to_string()) + .filter(|part| !part.is_empty()) + .collect::>() + .join("\n") +} + +fn bark_battle_image_size(slot: &BarkBattleAssetSlot) -> &'static str { + match slot { + BarkBattleAssetSlot::UiBackground => BARK_BATTLE_BACKGROUND_IMAGE_SIZE, + BarkBattleAssetSlot::PlayerCharacter | BarkBattleAssetSlot::OpponentCharacter => { + BARK_BATTLE_CHARACTER_IMAGE_SIZE + } + } +} + +fn bark_battle_slot_asset_kind(slot: &BarkBattleAssetSlot) -> &'static str { + match slot { + BarkBattleAssetSlot::PlayerCharacter => "bark_battle_player_character_image", + BarkBattleAssetSlot::OpponentCharacter => "bark_battle_opponent_character_image", + BarkBattleAssetSlot::UiBackground => "bark_battle_ui_background_image", + } +} + +fn bark_battle_slot_name(slot: &BarkBattleAssetSlot) -> &'static str { + match slot { + BarkBattleAssetSlot::PlayerCharacter => "player-character", + BarkBattleAssetSlot::OpponentCharacter => "opponent-character", + BarkBattleAssetSlot::UiBackground => "ui-background", + } +} + +fn bark_battle_slot_storage_segment(slot: &BarkBattleAssetSlot) -> &'static str { + match slot { + BarkBattleAssetSlot::PlayerCharacter => "player", + BarkBattleAssetSlot::OpponentCharacter => "opponent", + BarkBattleAssetSlot::UiBackground => "background", + } +} + +fn bark_battle_sanitize_path_segment(value: &str, fallback: &str) -> String { + let sanitized = value + .trim() + .chars() + .map(|ch| match ch { + '/' | '\\' | ':' | '*' | '?' | '"' | '<' | '>' | '|' => '-', + ch if ch.is_control() => '-', + ch => ch, + }) + .collect::() + .trim_matches('-') + .chars() + .take(72) + .collect::(); + if sanitized.trim().is_empty() { + fallback.to_string() + } else { + sanitized + } +} + +async fn generate_and_persist_bark_battle_image_asset( + state: &AppState, + owner_user_id: &str, + slot: &BarkBattleAssetSlot, + draft_id: Option<&str>, + asset_id: &str, + prompt: &str, + size: &str, +) -> Result { + let settings = require_openai_image_settings(state)?; + let http_client = build_openai_image_http_client(&settings)?; + let generated = create_openai_image_generation( + &http_client, + &settings, + prompt, + Some("文字、Logo、水印、按钮、UI 字、低清晰度、畸形肢体、多余角色、裁切主体"), + size, + 1, + &[], + "汪汪声浪素材生成失败", + ) + .await?; + let task_id = generated.task_id.clone(); + let actual_prompt = generated.actual_prompt.clone(); + let image = generated.images.into_iter().next().ok_or_else(|| { + AppError::from_status(StatusCode::BAD_GATEWAY).with_details(json!({ + "provider": "vector-engine", + "message": "汪汪声浪素材生成成功但未返回图片。", + })) + })?; + let image_src = persist_bark_battle_generated_image( + state, + owner_user_id, + slot, + draft_id, + asset_id, + task_id.as_str(), + image, + ) + .await?; + + Ok(BarkBattleGeneratedImageAsset { + image_src, + asset_id: asset_id.to_string(), + source_type: Some("generated".to_string()), + model: GPT_IMAGE_2_MODEL.to_string(), + size: size.to_string(), + task_id, + prompt: prompt.to_string(), + actual_prompt, + }) +} + +async fn persist_bark_battle_generated_image( + state: &AppState, + owner_user_id: &str, + slot: &BarkBattleAssetSlot, + draft_id: Option<&str>, + asset_id: &str, + task_id: &str, + image: crate::openai_image_generation::DownloadedOpenAiImage, +) -> Result { + let oss_client = state.oss_client().ok_or_else(|| { + AppError::from_status(StatusCode::SERVICE_UNAVAILABLE).with_details(json!({ + "provider": "aliyun-oss", + "reason": "OSS 未完成环境变量配置", + })) + })?; + let entity_id = draft_id + .map(str::trim) + .filter(|value| !value.is_empty()) + .unwrap_or(asset_id) + .to_string(); + let prepared = + GeneratedImageAssetAdapter::prepare_put_object(GeneratedImageAssetPersistInput { + prefix: LegacyAssetPrefix::BarkBattleAssets, + path_segments: vec![ + bark_battle_sanitize_path_segment(entity_id.as_str(), "draft"), + bark_battle_slot_storage_segment(slot).to_string(), + bark_battle_sanitize_path_segment(asset_id, "asset"), + ], + file_stem: "image".to_string(), + image: GeneratedImageAssetDataUrl { + format: normalize_generated_image_asset_mime(image.mime_type.as_str()), + bytes: image.bytes, + }, + access: OssObjectAccess::Private, + metadata: GeneratedImageAssetAdapterMetadata { + asset_kind: Some(bark_battle_slot_asset_kind(slot).to_string()), + owner_user_id: Some(owner_user_id.to_string()), + entity_kind: Some("bark_battle_draft".to_string()), + entity_id: Some(entity_id.clone()), + slot: Some(bark_battle_slot_name(slot).to_string()), + provider: Some("vector-engine".to_string()), + task_id: Some(task_id.to_string()), + }, + extra_metadata: Default::default(), + }) + .map_err(|error| { + AppError::from_status(StatusCode::INTERNAL_SERVER_ERROR).with_details(json!({ + "provider": "generated-image-assets", + "message": format!("准备汪汪声浪图片资产上传请求失败:{error:?}"), + })) + })?; + let persisted_mime_type = prepared.format.mime_type.clone(); + let http_client = reqwest::Client::new(); + let put_result = oss_client + .put_object(&http_client, prepared.request) + .await + .map_err(|error| map_oss_error(error, "aliyun-oss"))?; + let head = oss_client + .head_object( + &http_client, + OssHeadObjectRequest { + object_key: put_result.object_key.clone(), + }, + ) + .await + .map_err(|error| map_oss_error(error, "aliyun-oss"))?; + let now_micros = current_utc_micros(); + let asset_object = state + .spacetime_client() + .confirm_asset_object( + build_asset_object_upsert_input( + generate_asset_object_id(now_micros), + head.bucket, + head.object_key, + AssetObjectAccessPolicy::Private, + head.content_type.or(Some(persisted_mime_type)), + head.content_length, + head.etag, + bark_battle_slot_asset_kind(slot).to_string(), + Some(task_id.to_string()), + Some(owner_user_id.to_string()), + draft_id.map(ToString::to_string), + Some(entity_id.clone()), + now_micros, + ) + .map_err(|error| { + AppError::from_status(StatusCode::BAD_REQUEST).with_details(json!({ + "provider": "asset-object", + "message": error.to_string(), + })) + })?, + ) + .await + .map_err(|error| { + AppError::from_status(StatusCode::BAD_GATEWAY).with_details(json!({ + "provider": "spacetimedb", + "message": error.to_string(), + })) + })?; + state + .spacetime_client() + .bind_asset_object_to_entity( + build_asset_entity_binding_input( + generate_asset_binding_id(now_micros), + asset_object.asset_object_id, + "bark_battle_draft".to_string(), + entity_id, + bark_battle_slot_name(slot).to_string(), + bark_battle_slot_asset_kind(slot).to_string(), + Some(owner_user_id.to_string()), + draft_id.map(ToString::to_string), + now_micros, + ) + .map_err(|error| { + AppError::from_status(StatusCode::BAD_REQUEST).with_details(json!({ + "provider": "asset-entity-binding", + "message": error.to_string(), + })) + })?, + ) + .await + .map_err(|error| { + AppError::from_status(StatusCode::BAD_GATEWAY).with_details(json!({ + "provider": "spacetimedb", + "message": error.to_string(), + })) + })?; + + Ok(put_result.legacy_public_path) +} + fn bark_battle_snapshot_parse_error( request_context: &RequestContext, label: &str, @@ -592,6 +1431,23 @@ fn ensure_non_empty( Ok(()) } +fn normalize_optional_bark_battle_asset_source( + request_context: &RequestContext, + value: Option<&str>, + field_name: &str, +) -> Result, Response> { + let Some(value) = value.map(str::trim).filter(|value| !value.is_empty()) else { + return Ok(None); + }; + if value.chars().count() > 512 { + return Err(bark_battle_bad_request( + request_context, + &format!("{field_name} 不能超过 512 个字符"), + )); + } + Ok(Some(value.to_string())) +} + fn bark_battle_bad_request(request_context: &RequestContext, message: &str) -> Response { bark_battle_error_response( request_context, @@ -753,6 +1609,7 @@ fn format_rfc3339_or_timestamp_micros(micros: i64) -> String { #[cfg(test)] mod tests { use super::*; + use std::time::Duration; #[test] fn unit_and_energy_are_clamped_to_spacetime_millis() { @@ -773,4 +1630,254 @@ mod tests { 1_713_672_001_234_567 ); } + + #[test] + fn draft_config_mapping_includes_stable_work_identity() { + let request_context = RequestContext::new( + "test-request".to_string(), + "POST /api/creation/bark-battle/drafts".to_string(), + Duration::ZERO, + false, + ); + let config_json = json!({ + "title": "汪汪测试杯", + "description": "", + "themeDescription": "阳光草坪声浪擂台", + "playerImageDescription": "主角柴犬", + "opponentImageDescription": "对手哈士奇", + "onomatopoeia": ["轰汪!", "炸场!", "冲啊!"], + "difficultyPreset": "normal" + }) + .to_string(); + let row = json!({ + "draftId": "bark-battle-draft-1", + "workId": "BB-12345678", + "configVersion": 2, + "rulesetVersion": "bark-battle-ruleset-v1", + "configJson": config_json, + "updatedAtMicros": 1_713_686_401_234_567i64, + }); + + let draft = map_draft_config_record(row, &request_context) + .expect("draft config should map from SpacetimeDB snapshot"); + + assert_eq!(draft.draft_id, "bark-battle-draft-1"); + assert_eq!(draft.work_id.as_deref(), Some("BB-12345678")); + assert_eq!(draft.config_version, Some(2)); + assert_eq!( + draft.ruleset_version.as_deref(), + Some("bark-battle-ruleset-v1") + ); + } + + #[test] + fn bark_battle_image_prompts_are_slot_specific() { + let config = BarkBattleConfigEditorPayload { + title: "星环声浪挑战".to_string(), + description: Some("给朋友玩的声控对战".to_string()), + theme_description: "霓虹城市公园里的声浪擂台".to_string(), + player_image_description: "星际猫骑士".to_string(), + opponent_image_description: "机器人拳手".to_string(), + onomatopoeia: Some(vec![ + "轰!".to_string(), + "炸场!".to_string(), + "冲啊!".to_string(), + ]), + player_character_image_src: None, + opponent_character_image_src: None, + ui_background_image_src: None, + difficulty_preset: BarkBattleDifficultyPreset::Normal, + }; + + let player_prompt = + build_bark_battle_image_prompt(&BarkBattleAssetSlot::PlayerCharacter, &config); + assert!(player_prompt.contains("玩家形象描述:星际猫骑士")); + assert!(player_prompt.contains("正面")); + assert!(player_prompt.contains("PNG 透明背景")); + assert!(player_prompt.contains("透明背景")); + assert!(player_prompt.contains("1:1 角色图")); + assert!(!player_prompt.contains("狗")); + assert!(!player_prompt.contains("狗狗")); + assert!(!player_prompt.contains("小狗")); + assert!(!player_prompt.contains("犬")); + assert!(!player_prompt.contains("汪汪")); + assert!(!player_prompt.contains("横版 16:9 2D RPG 场景背景")); + assert!(!player_prompt.contains("远景剪影")); + + let opponent_prompt = + build_bark_battle_image_prompt(&BarkBattleAssetSlot::OpponentCharacter, &config); + assert!(opponent_prompt.contains("对手形象描述:机器人拳手")); + assert!(opponent_prompt.contains("正面")); + assert!(opponent_prompt.contains("PNG 透明背景")); + assert!(opponent_prompt.contains("透明背景")); + assert!(opponent_prompt.contains("1:1 角色图")); + assert!(!opponent_prompt.contains("狗")); + assert!(!opponent_prompt.contains("狗狗")); + assert!(!opponent_prompt.contains("小狗")); + assert!(!opponent_prompt.contains("犬")); + assert!(!opponent_prompt.contains("汪汪")); + assert!(!opponent_prompt.contains("横版 16:9 2D RPG 场景背景")); + assert!(!opponent_prompt.contains("远景剪影")); + + let background_prompt = + build_bark_battle_image_prompt(&BarkBattleAssetSlot::UiBackground, &config); + assert!(background_prompt.contains("竞技背景描述:霓虹城市公园里的声浪擂台")); + assert!(background_prompt.contains("9:16 竖屏游戏背景")); + assert!(background_prompt.contains("无角色、无狗狗")); + assert!(!background_prompt.contains("玩家与对手的关系参考")); + } + + #[test] + fn bark_battle_work_summary_mapping_uses_resolved_author_display_name() { + let request_context = RequestContext::new( + "test-request".to_string(), + "GET /api/creation/bark-battle/works".to_string(), + Duration::ZERO, + false, + ); + let config_json = json!({ + "title": "声浪测试局", + "description": "映射测试", + "themeDescription": "星环竞技场", + "playerImageDescription": "星际猫骑士", + "opponentImageDescription": "机器人拳手", + "onomatopoeia": ["轰!", "炸场!", "冲啊!"], + "difficultyPreset": "normal" + }) + .to_string(); + let work_row = json!({ + "draftId": "bark-battle-draft-2", + "workId": "BB-22222222", + "ownerUserId": "user-2", + "configVersion": 1, + "rulesetVersion": "bark-battle-ruleset-v1", + "configJson": config_json, + "updatedAtMicros": 1_713_686_401_234_567i64, + "status": "draft" + }); + + let work = map_work_summary_record(work_row, &request_context, " 星环作者 ".to_string()) + .expect("work summary should use provided author display name"); + + assert_eq!(work.author_display_name, " 星环作者 "); + } + + #[test] + fn bark_battle_gallery_mapping_uses_resolved_author_display_name() { + let request_context = RequestContext::new( + "test-request".to_string(), + "GET /api/creation/bark-battle/gallery".to_string(), + Duration::ZERO, + false, + ); + let gallery_row = json!({ + "status": "published", + "workId": "BB-33333333", + "ownerUserId": "user-3", + "sourceDraftId": "bark-battle-draft-3", + "title": "声浪公开赛", + "description": "画廊映射测试", + "themeDescription": "霓虹竞技场", + "playerImageDescription": "星际猫骑士", + "opponentImageDescription": "机器人拳手", + "onomatopoeia": ["轰!", "炸场!", "冲啊!"], + "playerCharacterImageSrc": "/assets/player.png", + "opponentCharacterImageSrc": "/assets/opponent.png", + "uiBackgroundImageSrc": "/assets/background.png", + "difficultyPreset": "normal", + "playCount": 8, + "finishCount": 5, + "recentPlayCount7d": 3, + "updatedAtMicros": 1_713_686_401_234_567i64, + "publishedAtMicros": 1_713_686_401_234_000i64 + }); + + let work = + map_work_summary_record(gallery_row, &request_context, "画廊作者".to_string()) + .expect("gallery summary should use provided author display name"); + + assert_eq!(work.author_display_name, "画廊作者"); + assert_eq!( + work.onomatopoeia, + Some(vec![ + "轰!".to_string(), + "炸场!".to_string(), + "冲啊!".to_string(), + ]) + ); + } + + #[test] + fn bark_battle_published_summary_uses_published_snapshot_assets() { + let request_context = RequestContext::new( + "test-request".to_string(), + "GET /api/creation/bark-battle/works".to_string(), + Duration::ZERO, + false, + ); + let draft_config_json = json!({ + "title": "声浪测试局", + "description": "发布前草稿", + "themeDescription": "草地竞技场", + "playerImageDescription": "柯基选手", + "opponentImageDescription": "哈士奇对手", + "difficultyPreset": "normal" + }) + .to_string(); + let published_snapshot_json = json!({ + "title": "声浪测试局", + "description": "发布后快照", + "themeDescription": "草地竞技场", + "playerImageDescription": "柯基选手", + "opponentImageDescription": "哈士奇对手", + "playerCharacterImageSrc": "/assets/player.png", + "opponentCharacterImageSrc": "/assets/opponent.png", + "uiBackgroundImageSrc": "/assets/background.png", + "difficultyPreset": "normal" + }) + .to_string(); + let work_row = json!({ + "sourceDraftId": "bark-battle-draft-published", + "workId": "BB-44444444", + "ownerUserId": "user-4", + "configVersion": 1, + "rulesetVersion": "bark-battle-ruleset-v1", + "configJson": draft_config_json, + "publishedSnapshotJson": published_snapshot_json, + "publishedAtMicros": 1_713_686_401_234_000i64, + "updatedAtMicros": 1_713_686_401_234_567i64 + }); + + let work = map_work_summary_record(work_row, &request_context, "发布作者".to_string()) + .expect("published summary should use published snapshot assets"); + + assert_eq!(work.status, "published"); + assert_eq!( + work.player_character_image_src.as_deref(), + Some("/assets/player.png") + ); + assert_eq!( + work.opponent_character_image_src.as_deref(), + Some("/assets/opponent.png") + ); + assert_eq!( + work.ui_background_image_src.as_deref(), + Some("/assets/background.png") + ); + assert_eq!(work.summary, "发布后快照"); + assert!(work.publish_ready); + } + + #[test] + fn normalize_author_display_name_trims_and_falls_back_to_player() { + assert_eq!( + normalize_author_display_name(Some(" 小陶 ".to_string())), + "小陶" + ); + assert_eq!( + normalize_author_display_name(Some(" ".to_string())), + "玩家" + ); + assert_eq!(normalize_author_display_name(None), "玩家"); + } } diff --git a/server-rs/crates/api-server/src/creation_entry_config.rs b/server-rs/crates/api-server/src/creation_entry_config.rs index 24e471d42..fe6ea73cd 100644 --- a/server-rs/crates/api-server/src/creation_entry_config.rs +++ b/server-rs/crates/api-server/src/creation_entry_config.rs @@ -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(); diff --git a/server-rs/crates/api-server/src/match3d/item_assets.rs b/server-rs/crates/api-server/src/match3d/item_assets.rs index 1365faee8..1ecf326d6 100644 --- a/server-rs/crates/api-server/src/match3d/item_assets.rs +++ b/server-rs/crates/api-server/src/match3d/item_assets.rs @@ -1539,11 +1539,111 @@ pub(super) fn slice_match3d_material_sheet( image: &DownloadedOpenAiImage, item_names: &[String], ) -> Result>, 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() diff --git a/server-rs/crates/api-server/src/match3d/tests.rs b/server-rs/crates/api-server/src/match3d/tests.rs index 40900f90b..905a16976 100644 --- a/server-rs/crates/api-server/src/match3d/tests.rs +++ b/server-rs/crates/api-server/src/match3d/tests.rs @@ -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::>(); + .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::>(); 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, "草莓") }]; diff --git a/server-rs/crates/api-server/src/match3d/works.rs b/server-rs/crates/api-server/src/match3d/works.rs index 7e04cef04..2a7105194 100644 --- a/server-rs/crates/api-server/src/match3d/works.rs +++ b/server-rs/crates/api-server/src/match3d/works.rs @@ -716,44 +716,6 @@ pub(super) fn load_match3d_container_reference_image() -> Result 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, diff --git a/server-rs/crates/api-server/src/modules/bark_battle.rs b/server-rs/crates/api-server/src/modules/bark_battle.rs index ea7c82192..3cd4f6b87 100644 --- a/server-rs/crates/api-server/src/modules/bark_battle.rs +++ b/server-rs/crates/api-server/src/modules/bark_battle.rs @@ -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 { 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 { 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( diff --git a/server-rs/crates/module-bark-battle/src/domain.rs b/server-rs/crates/module-bark-battle/src/domain.rs index 7436cb5a7..909fd4eba 100644 --- a/server-rs/crates/module-bark-battle/src/domain.rs +++ b/server-rs/crates/module-bark-battle/src/domain.rs @@ -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, diff --git a/server-rs/crates/module-bark-battle/src/scoring.rs b/server-rs/crates/module-bark-battle/src/scoring.rs index 12aff9c25..57fdf8b51 100644 --- a/server-rs/crates/module-bark-battle/src/scoring.rs +++ b/server-rs/crates/module-bark-battle/src/scoring.rs @@ -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) diff --git a/server-rs/crates/module-runtime/src/application.rs b/server-rs/crates/module-runtime/src/application.rs index 87002c8a5..5c86bbd67 100644 --- a/server-rs/crates/module-runtime/src/application.rs +++ b/server-rs/crates/module-runtime/src/application.rs @@ -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, ), diff --git a/server-rs/crates/module-runtime/src/lib.rs b/server-rs/crates/module-runtime/src/lib.rs index 490a2defc..9c2fd42ff 100644 --- a/server-rs/crates/module-runtime/src/lib.rs +++ b/server-rs/crates/module-runtime/src/lib.rs @@ -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), diff --git a/server-rs/crates/platform-oss/src/lib.rs b/server-rs/crates/platform-oss/src/lib.rs index 61a4f301a..59042f3c2 100644 --- a/server-rs/crates/platform-oss/src/lib.rs +++ b/server-rs/crates/platform-oss/src/lib.rs @@ -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); } diff --git a/server-rs/crates/shared-contracts/src/bark_battle.rs b/server-rs/crates/shared-contracts/src/bark_battle.rs index addbd723b..5fbef4f00 100644 --- a/server-rs/crates/shared-contracts/src/bark_battle.rs +++ b/server-rs/crates/shared-contracts/src/bark_battle.rs @@ -30,18 +30,44 @@ pub enum BarkBattleFinishStatus { Rejected, } +#[derive(Clone, Debug, Serialize, Deserialize, PartialEq, Eq)] +#[serde(rename_all = "kebab-case")] +pub enum BarkBattleAssetSlot { + PlayerCharacter, + OpponentCharacter, + UiBackground, +} + +#[derive(Clone, Debug, Default, Serialize, Deserialize, PartialEq)] +#[serde(rename_all = "camelCase")] +pub struct BarkBattleReplacementConfig { + #[serde(default, skip_serializing_if = "Option::is_none")] + pub player_character_image_src: Option, + #[serde(default, skip_serializing_if = "Option::is_none")] + pub opponent_character_image_src: Option, + #[serde(default, skip_serializing_if = "Option::is_none")] + pub ui_background_image_src: Option, +} + #[derive(Clone, Debug, Serialize, Deserialize, PartialEq)] #[serde(rename_all = "camelCase")] pub struct BarkBattleConfigEditorPayload { pub title: String, #[serde(default, skip_serializing_if = "Option::is_none")] pub description: Option, - pub theme_preset: String, - pub player_dog_skin_preset: String, - pub opponent_dog_skin_preset: String, + pub theme_description: String, + pub player_image_description: String, + pub opponent_image_description: String, + #[serde(default, skip_serializing_if = "Option::is_none")] + pub onomatopoeia: Option>, + #[serde(default, skip_serializing_if = "Option::is_none")] + pub player_character_image_src: Option, + #[serde(default, skip_serializing_if = "Option::is_none")] + pub opponent_character_image_src: Option, + #[serde(default, skip_serializing_if = "Option::is_none")] + pub ui_background_image_src: Option, #[serde(default)] pub difficulty_preset: BarkBattleDifficultyPreset, - pub leaderboard_enabled: bool, } #[derive(Clone, Debug, Serialize, Deserialize, PartialEq)] @@ -50,12 +76,19 @@ pub struct BarkBattleDraftCreateRequest { pub title: String, #[serde(default, skip_serializing_if = "Option::is_none")] pub description: Option, - pub theme_preset: String, - pub player_dog_skin_preset: String, - pub opponent_dog_skin_preset: String, + pub theme_description: String, + pub player_image_description: String, + pub opponent_image_description: String, + #[serde(default, skip_serializing_if = "Option::is_none")] + pub onomatopoeia: Option>, + #[serde(default, skip_serializing_if = "Option::is_none")] + pub player_character_image_src: Option, + #[serde(default, skip_serializing_if = "Option::is_none")] + pub opponent_character_image_src: Option, + #[serde(default, skip_serializing_if = "Option::is_none")] + pub ui_background_image_src: Option, #[serde(default)] pub difficulty_preset: BarkBattleDifficultyPreset, - pub leaderboard_enabled: bool, } impl From for BarkBattleConfigEditorPayload { @@ -63,11 +96,59 @@ impl From for BarkBattleConfigEditorPayload { Self { title: value.title, description: value.description, - theme_preset: value.theme_preset, - player_dog_skin_preset: value.player_dog_skin_preset, - opponent_dog_skin_preset: value.opponent_dog_skin_preset, + theme_description: value.theme_description, + player_image_description: value.player_image_description, + opponent_image_description: value.opponent_image_description, + onomatopoeia: value.onomatopoeia, + player_character_image_src: value.player_character_image_src, + opponent_character_image_src: value.opponent_character_image_src, + ui_background_image_src: value.ui_background_image_src, + difficulty_preset: value.difficulty_preset, + } + } +} + +#[derive(Clone, Debug, Serialize, Deserialize, PartialEq)] +#[serde(rename_all = "camelCase")] +pub struct BarkBattleDraftConfigUpdateRequest { + pub draft_id: String, + #[serde(default, skip_serializing_if = "Option::is_none")] + pub work_id: Option, + #[serde(default, skip_serializing_if = "Option::is_none")] + pub config_version: Option, + #[serde(default, skip_serializing_if = "Option::is_none")] + pub ruleset_version: Option, + pub title: String, + #[serde(default, skip_serializing_if = "Option::is_none")] + pub description: Option, + pub theme_description: String, + pub player_image_description: String, + pub opponent_image_description: String, + #[serde(default, skip_serializing_if = "Option::is_none")] + pub onomatopoeia: Option>, + #[serde(default, skip_serializing_if = "Option::is_none")] + pub player_character_image_src: Option, + #[serde(default, skip_serializing_if = "Option::is_none")] + pub opponent_character_image_src: Option, + #[serde(default, skip_serializing_if = "Option::is_none")] + pub ui_background_image_src: Option, + #[serde(default)] + pub difficulty_preset: BarkBattleDifficultyPreset, +} + +impl From for BarkBattleConfigEditorPayload { + fn from(value: BarkBattleDraftConfigUpdateRequest) -> Self { + Self { + title: value.title, + description: value.description, + theme_description: value.theme_description, + player_image_description: value.player_image_description, + opponent_image_description: value.opponent_image_description, + onomatopoeia: value.onomatopoeia, + player_character_image_src: value.player_character_image_src, + opponent_character_image_src: value.opponent_character_image_src, + ui_background_image_src: value.ui_background_image_src, difficulty_preset: value.difficulty_preset, - leaderboard_enabled: value.leaderboard_enabled, } } } @@ -82,19 +163,56 @@ pub struct BarkBattleWorkPublishRequest { pub published_snapshot: Option, } +#[derive(Clone, Debug, Serialize, Deserialize, PartialEq)] +#[serde(rename_all = "camelCase")] +pub struct BarkBattleImageAssetGenerateRequest { + pub slot: BarkBattleAssetSlot, + #[serde(default, skip_serializing_if = "Option::is_none")] + pub draft_id: Option, + pub config: BarkBattleConfigEditorPayload, +} + +#[derive(Clone, Debug, Serialize, Deserialize, PartialEq)] +#[serde(rename_all = "camelCase")] +pub struct BarkBattleGeneratedImageAsset { + pub image_src: String, + pub asset_id: String, + #[serde(default, skip_serializing_if = "Option::is_none")] + pub source_type: Option, + pub model: String, + pub size: String, + pub task_id: String, + pub prompt: String, + #[serde(default, skip_serializing_if = "Option::is_none")] + pub actual_prompt: Option, +} + #[derive(Clone, Debug, Serialize, Deserialize, PartialEq)] #[serde(rename_all = "camelCase")] pub struct BarkBattleDraftConfig { pub draft_id: String, + #[serde(default, skip_serializing_if = "Option::is_none")] + pub work_id: Option, + #[serde(default, skip_serializing_if = "Option::is_none")] + pub config_version: Option, + #[serde(default, skip_serializing_if = "Option::is_none")] + pub ruleset_version: Option, pub title: String, #[serde(default, skip_serializing_if = "Option::is_none")] pub description: Option, - pub theme_preset: String, - pub player_dog_skin_preset: String, - pub opponent_dog_skin_preset: String, + pub theme_description: String, + pub player_image_description: String, + pub opponent_image_description: String, + #[serde(default, skip_serializing_if = "Option::is_none")] + pub onomatopoeia: Option>, + #[serde(default, skip_serializing_if = "Option::is_none")] + pub player_character_image_src: Option, + #[serde(default, skip_serializing_if = "Option::is_none")] + pub opponent_character_image_src: Option, + #[serde(default, skip_serializing_if = "Option::is_none")] + pub ui_background_image_src: Option, #[serde(default)] pub difficulty_preset: BarkBattleDifficultyPreset, - pub leaderboard_enabled: bool, pub updated_at: String, } @@ -102,13 +220,19 @@ impl Default for BarkBattleDraftConfig { fn default() -> Self { Self { draft_id: String::new(), + work_id: None, + config_version: None, + ruleset_version: None, title: String::new(), description: None, - theme_preset: String::new(), - player_dog_skin_preset: String::new(), - opponent_dog_skin_preset: String::new(), + theme_description: String::new(), + player_image_description: String::new(), + opponent_image_description: String::new(), + onomatopoeia: None, + player_character_image_src: None, + opponent_character_image_src: None, + ui_background_image_src: None, difficulty_preset: BarkBattleDifficultyPreset::Normal, - leaderboard_enabled: true, updated_at: String::new(), } } @@ -126,11 +250,18 @@ pub struct BarkBattlePublishedConfig { pub title: String, #[serde(default, skip_serializing_if = "Option::is_none")] pub description: Option, - pub theme_preset: String, - pub player_dog_skin_preset: String, - pub opponent_dog_skin_preset: String, + pub theme_description: String, + pub player_image_description: String, + pub opponent_image_description: String, + #[serde(default, skip_serializing_if = "Option::is_none")] + pub onomatopoeia: Option>, + #[serde(default, skip_serializing_if = "Option::is_none")] + pub player_character_image_src: Option, + #[serde(default, skip_serializing_if = "Option::is_none")] + pub opponent_character_image_src: Option, + #[serde(default, skip_serializing_if = "Option::is_none")] + pub ui_background_image_src: Option, pub difficulty_preset: BarkBattleDifficultyPreset, - pub leaderboard_enabled: bool, pub updated_at: String, pub published_at: String, } @@ -148,13 +279,75 @@ pub struct BarkBattleRuntimeConfig { pub draw_threshold: f32, pub min_bark_gap_ms: u64, pub difficulty_preset: BarkBattleDifficultyPreset, - pub theme_preset: String, - pub player_dog_skin_preset: String, - pub opponent_dog_skin_preset: String, - pub leaderboard_enabled: bool, + pub theme_description: String, + pub player_image_description: String, + pub opponent_image_description: String, + #[serde(default, skip_serializing_if = "Option::is_none")] + pub onomatopoeia: Option>, + #[serde(default, skip_serializing_if = "Option::is_none")] + pub player_character_image_src: Option, + #[serde(default, skip_serializing_if = "Option::is_none")] + pub opponent_character_image_src: Option, + #[serde(default, skip_serializing_if = "Option::is_none")] + pub ui_background_image_src: Option, pub updated_at: String, } +#[derive(Clone, Debug, Serialize, Deserialize, PartialEq)] +#[serde(rename_all = "camelCase")] +pub struct BarkBattleWorkSummary { + pub work_id: String, + #[serde(default, skip_serializing_if = "Option::is_none")] + pub draft_id: Option, + pub owner_user_id: String, + pub author_display_name: String, + pub title: String, + pub summary: String, + pub theme_description: String, + pub player_image_description: String, + pub opponent_image_description: String, + #[serde(default, skip_serializing_if = "Option::is_none")] + pub onomatopoeia: Option>, + #[serde(default, skip_serializing_if = "Option::is_none")] + pub player_character_image_src: Option, + #[serde(default, skip_serializing_if = "Option::is_none")] + pub opponent_character_image_src: Option, + #[serde(default, skip_serializing_if = "Option::is_none")] + pub ui_background_image_src: Option, + pub difficulty_preset: BarkBattleDifficultyPreset, + pub status: String, + #[serde(default, skip_serializing_if = "Option::is_none")] + pub generation_status: Option, + pub publish_ready: bool, + pub play_count: u64, + #[serde(default, skip_serializing_if = "Option::is_none")] + pub finish_count: Option, + #[serde(default, skip_serializing_if = "Option::is_none")] + pub win_count: Option, + #[serde(default, skip_serializing_if = "Option::is_none")] + pub draw_count: Option, + #[serde(default, skip_serializing_if = "Option::is_none")] + pub loss_count: Option, + #[serde(default, skip_serializing_if = "Option::is_none")] + pub recent_play_count_7d: Option, + pub updated_at: String, + #[serde(default, skip_serializing_if = "Option::is_none")] + pub published_at: Option, +} + +#[derive(Clone, Debug, Default, Serialize, Deserialize, PartialEq)] +#[serde(rename_all = "camelCase")] +pub struct BarkBattleWorksResponse { + #[serde(default)] + pub items: Vec, +} + +#[derive(Clone, Debug, Serialize, Deserialize, PartialEq)] +#[serde(rename_all = "camelCase")] +pub struct BarkBattleWorkDetailResponse { + pub item: BarkBattleWorkSummary, +} + #[derive(Clone, Debug, Serialize, Deserialize, PartialEq)] #[serde(rename_all = "camelCase")] pub struct BarkBattleRunStartRequest { @@ -355,6 +548,115 @@ mod tests { use super::*; use serde_json::json; + #[test] + fn editor_and_runtime_contract_use_description_fields_only() { + let editor = BarkBattleConfigEditorPayload { + title: "周末狗狗杯".to_string(), + description: Some("轻配置草稿".to_string()), + theme_description: "霓虹公园里的欢乐擂台".to_string(), + player_image_description: "戴红围巾的柴犬主角".to_string(), + opponent_image_description: "蓝色护目镜哈士奇对手".to_string(), + onomatopoeia: Some(vec!["轰汪!".to_string(), "炸场!".to_string()]), + player_character_image_src: Some("/generated-bark-battle/player.png".to_string()), + opponent_character_image_src: Some("https://example.test/opponent.png".to_string()), + ui_background_image_src: Some("/generated-bark-battle/ui.png".to_string()), + difficulty_preset: BarkBattleDifficultyPreset::Hard, + }; + let payload = serde_json::to_value(editor).expect("config should serialize"); + + assert_eq!(payload["themeDescription"], json!("霓虹公园里的欢乐擂台")); + assert_eq!( + payload["playerImageDescription"], + json!("戴红围巾的柴犬主角") + ); + assert_eq!( + payload["opponentImageDescription"], + json!("蓝色护目镜哈士奇对手") + ); + assert_eq!(payload["onomatopoeia"], json!(["轰汪!", "炸场!"])); + for removed in [ + "themePreset", + "playerDogSkinPreset", + "opponentDogSkinPreset", + "barkSoundSrc", + "leaderboardEnabled", + ] { + assert!( + !payload.as_object().unwrap().contains_key(removed), + "{removed} must not remain in v1 public config payload" + ); + } + + let runtime = BarkBattleRuntimeConfig { + work_id: "bark-battle-work-1".to_string(), + config_version: 1, + ruleset_version: "bark-battle-ruleset-v1".to_string(), + play_type_id: "bark-battle".to_string(), + duration_ms: 30_000, + energy_min: 0.0, + energy_max: 100.0, + draw_threshold: 5.0, + min_bark_gap_ms: 220, + difficulty_preset: BarkBattleDifficultyPreset::Normal, + theme_description: "阳光草坪".to_string(), + player_image_description: "小柴犬".to_string(), + opponent_image_description: "大金毛".to_string(), + onomatopoeia: Some(vec!["轰汪!".to_string(), "燃起来!".to_string()]), + player_character_image_src: None, + opponent_character_image_src: None, + ui_background_image_src: None, + updated_at: "2026-05-20T00:00:00Z".to_string(), + }; + let payload = serde_json::to_value(runtime).expect("runtime should serialize"); + assert_eq!(payload["themeDescription"], json!("阳光草坪")); + assert!( + !payload + .as_object() + .unwrap() + .contains_key("leaderboardEnabled") + ); + assert!(!payload.as_object().unwrap().contains_key("barkSoundSrc")); + } + + #[test] + fn work_summary_responses_use_public_gallery_contract() { + let response = BarkBattleWorksResponse { + items: vec![BarkBattleWorkSummary { + work_id: "bark-battle-work-1".to_string(), + draft_id: Some("bark-battle-draft-1".to_string()), + owner_user_id: "user-1".to_string(), + author_display_name: "玩家".to_string(), + title: "汪汪测试杯".to_string(), + summary: "轻量公开卡片".to_string(), + theme_description: "阳光草坪".to_string(), + player_image_description: "小柴犬".to_string(), + opponent_image_description: "大金毛".to_string(), + onomatopoeia: Some(vec!["轰汪!".to_string(), "燃起来!".to_string()]), + player_character_image_src: None, + opponent_character_image_src: None, + ui_background_image_src: None, + difficulty_preset: BarkBattleDifficultyPreset::Normal, + status: "published".to_string(), + generation_status: Some("ready".to_string()), + publish_ready: true, + play_count: 3, + finish_count: Some(2), + win_count: Some(1), + draw_count: Some(1), + loss_count: Some(0), + recent_play_count_7d: Some(2), + updated_at: "2026-05-20T00:00:00Z".to_string(), + published_at: Some("2026-05-20T00:00:00Z".to_string()), + }], + }; + + let payload = serde_json::to_value(response).expect("works response should serialize"); + + assert_eq!(payload["items"][0]["themeDescription"], json!("阳光草坪")); + assert_eq!(payload["items"][0]["recentPlayCount7d"], json!(2)); + assert_eq!(payload["items"][0]["status"], json!("published")); + } + #[test] fn draft_config_defaults_to_normal_difficulty() { let config = BarkBattleDraftConfig::default(); @@ -409,7 +711,22 @@ mod tests { fn optional_fields_are_omitted_when_absent() { let draft = BarkBattleDraftConfig::default(); let payload = serde_json::to_value(draft).expect("draft should serialize"); + assert!(!payload.as_object().unwrap().contains_key("workId")); + assert!(!payload.as_object().unwrap().contains_key("configVersion")); + assert!(!payload.as_object().unwrap().contains_key("rulesetVersion")); assert!(!payload.as_object().unwrap().contains_key("description")); + assert!( + !payload + .as_object() + .unwrap() + .contains_key("playerCharacterImageSrc") + ); + assert!( + !payload + .as_object() + .unwrap() + .contains_key("uiBackgroundImageSrc") + ); let response = BarkBattlePersonalHistoryResponse { work_id: None, @@ -429,6 +746,155 @@ mod tests { assert!(!payload.as_object().unwrap().contains_key("bestSummary")); } + #[test] + fn draft_config_serializes_persistent_identity_fields() { + let draft = BarkBattleDraftConfig { + draft_id: "bark-battle-draft-1".to_string(), + work_id: Some("bark-battle-work-1".to_string()), + config_version: Some(2), + ruleset_version: Some("bark-battle-ruleset-v1".to_string()), + title: "汪汪测试杯".to_string(), + description: None, + theme_description: "阳光草坪".to_string(), + player_image_description: "主角".to_string(), + opponent_image_description: "对手".to_string(), + onomatopoeia: None, + player_character_image_src: None, + opponent_character_image_src: None, + ui_background_image_src: None, + difficulty_preset: BarkBattleDifficultyPreset::Normal, + updated_at: "2026-05-14T10:00:00.000Z".to_string(), + }; + + let payload = serde_json::to_value(draft).expect("draft should serialize"); + + assert_eq!(payload["draftId"], json!("bark-battle-draft-1")); + assert_eq!(payload["workId"], json!("bark-battle-work-1")); + assert_eq!(payload["configVersion"], json!(2)); + assert_eq!(payload["rulesetVersion"], json!("bark-battle-ruleset-v1")); + } + + #[test] + fn draft_config_update_request_serializes_generated_assets() { + let update = BarkBattleDraftConfigUpdateRequest { + draft_id: "bark-battle-draft-1".to_string(), + work_id: Some("BB-12345678".to_string()), + config_version: Some(2), + ruleset_version: Some("bark-battle-ruleset-v1".to_string()), + title: "汪汪测试杯".to_string(), + description: None, + theme_description: "阳光草坪".to_string(), + player_image_description: "主角".to_string(), + opponent_image_description: "对手".to_string(), + onomatopoeia: Some(vec!["炸场!".to_string(), "破阵!".to_string()]), + player_character_image_src: Some("/generated-bark-battle/player.png".to_string()), + opponent_character_image_src: Some("/generated-bark-battle/opponent.png".to_string()), + ui_background_image_src: Some("/generated-bark-battle/background.png".to_string()), + difficulty_preset: BarkBattleDifficultyPreset::Normal, + }; + + let payload = serde_json::to_value(update).expect("draft update should serialize"); + + assert_eq!(payload["draftId"], json!("bark-battle-draft-1")); + assert_eq!(payload["workId"], json!("BB-12345678")); + assert_eq!( + payload["playerCharacterImageSrc"], + json!("/generated-bark-battle/player.png") + ); + assert_eq!( + payload["opponentCharacterImageSrc"], + json!("/generated-bark-battle/opponent.png") + ); + assert_eq!( + payload["uiBackgroundImageSrc"], + json!("/generated-bark-battle/background.png") + ); + assert!(!payload.as_object().unwrap().contains_key("barkSoundSrc")); + assert!( + !payload + .as_object() + .unwrap() + .contains_key("leaderboardEnabled") + ); + } + + #[test] + fn image_generation_request_uses_dedicated_asset_slot_and_result_prompt() { + let request = BarkBattleImageAssetGenerateRequest { + slot: BarkBattleAssetSlot::OpponentCharacter, + draft_id: Some("bark-battle-draft-1".to_string()), + config: BarkBattleConfigEditorPayload { + title: "汪汪冠军杯".to_string(), + description: Some(String::new()), + theme_description: "霓虹公园擂台".to_string(), + player_image_description: "红围巾柴犬".to_string(), + opponent_image_description: "蓝头带哈士奇".to_string(), + onomatopoeia: Some(vec!["轰汪!".to_string(), "炸场!".to_string()]), + player_character_image_src: None, + opponent_character_image_src: None, + ui_background_image_src: None, + difficulty_preset: BarkBattleDifficultyPreset::Normal, + }, + }; + let payload = serde_json::to_value(request).expect("request should serialize"); + + assert_eq!(payload["slot"], json!("opponent-character")); + assert_eq!( + payload["config"]["opponentImageDescription"], + json!("蓝头带哈士奇") + ); + + let response = BarkBattleGeneratedImageAsset { + image_src: "/generated-bark-battle-assets/draft/opponent/image.webp".to_string(), + asset_id: "asset-1".to_string(), + source_type: Some("generated".to_string()), + model: "gpt-image-2".to_string(), + size: "1024*1024".to_string(), + task_id: "task-1".to_string(), + prompt: "后端拼装后的对手形象 prompt".to_string(), + actual_prompt: None, + }; + let payload = serde_json::to_value(response).expect("response should serialize"); + + assert_eq!( + payload["imageSrc"], + json!("/generated-bark-battle-assets/draft/opponent/image.webp") + ); + assert_eq!(payload["prompt"], json!("后端拼装后的对手形象 prompt")); + } + + #[test] + fn replacement_sources_serialize_as_camel_case_config_fields() { + let config = BarkBattleConfigEditorPayload { + title: "周末狗狗杯".to_string(), + description: Some("轻配置草稿".to_string()), + theme_description: "霓虹公园".to_string(), + player_image_description: "柴犬主角".to_string(), + opponent_image_description: "哈士奇对手".to_string(), + onomatopoeia: Some(vec!["轰汪!".to_string(), "冲啊!".to_string()]), + player_character_image_src: Some("/generated-bark-battle/player.png".to_string()), + opponent_character_image_src: Some("https://example.test/opponent.png".to_string()), + ui_background_image_src: Some("/generated-bark-battle/ui.png".to_string()), + difficulty_preset: BarkBattleDifficultyPreset::Hard, + }; + + let payload = serde_json::to_value(config).expect("config should serialize"); + + assert_eq!( + payload["playerCharacterImageSrc"], + json!("/generated-bark-battle/player.png") + ); + assert_eq!( + payload["opponentCharacterImageSrc"], + json!("https://example.test/opponent.png") + ); + assert_eq!( + payload["uiBackgroundImageSrc"], + json!("/generated-bark-battle/ui.png") + ); + assert!(!payload.as_object().unwrap().contains_key("barkSoundSrc")); + } + #[test] fn finish_response_serializes_player_win_and_accepted() { let response = BarkBattleRunFinishResponse { diff --git a/server-rs/crates/spacetime-client/src/bark_battle.rs b/server-rs/crates/spacetime-client/src/bark_battle.rs index 19af1cb55..1f47242cb 100644 --- a/server-rs/crates/spacetime-client/src/bark_battle.rs +++ b/server-rs/crates/spacetime-client/src/bark_battle.rs @@ -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 { + 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, SpacetimeClientError> { + self.read_after_connect("list_bark_battle_works", move |connection| { + let owner_user_id = owner_user_id.as_str(); + let drafts: Vec = 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 = 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 = 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 = 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, 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::>(); + 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 + } } diff --git a/server-rs/crates/spacetime-client/src/lib.rs b/server-rs/crates/spacetime-client/src/lib.rs index 1da061095..fb4922932 100644 --- a/server-rs/crates/spacetime-client/src/lib.rs +++ b/server-rs/crates/spacetime-client/src/lib.rs @@ -560,6 +560,7 @@ impl SpacetimeClient { ) -> Result, 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", diff --git a/server-rs/crates/spacetime-client/src/mapper.rs b/server-rs/crates/spacetime-client/src/mapper.rs index b7a0e7fb9..f2bc95305 100644 --- a/server-rs/crates/spacetime-client/src/mapper.rs +++ b/server-rs/crates/spacetime-client/src/mapper.rs @@ -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, diff --git a/server-rs/crates/spacetime-client/src/mapper/bark_battle.rs b/server-rs/crates/spacetime-client/src/mapper/bark_battle.rs index b8a5c0909..ae4c3253e 100644 --- a/server-rs/crates/spacetime-client/src/mapper/bark_battle.rs +++ b/server-rs/crates/spacetime-client/src/mapper/bark_battle.rs @@ -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)); + } +} diff --git a/server-rs/crates/spacetime-client/src/module_bindings.rs b/server-rs/crates/spacetime-client/src/module_bindings.rs index 5b6a593de..c9a71a7f5 100644 --- a/server-rs/crates/spacetime-client/src/module_bindings.rs +++ b/server-rs/crates/spacetime-client/src/module_bindings.rs @@ -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, auth_store_snapshot: __sdk::TableUpdate, bark_battle_draft_config: __sdk::TableUpdate, + bark_battle_gallery_view: __sdk::TableUpdate, bark_battle_leaderboard_entry: __sdk::TableUpdate, bark_battle_personal_best_projection: __sdk::TableUpdate, bark_battle_published_config: __sdk::TableUpdate, @@ -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::( + "bark_battle_gallery_view", + &self.bark_battle_gallery_view, + ); diff.big_fish_gallery_view = cache.apply_diff_to_table::( "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::( + "bark_battle_gallery_view", + &self.bark_battle_gallery_view, + event, + ); callbacks.invoke_table_row_callbacks::( "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", diff --git a/server-rs/crates/spacetime-client/src/module_bindings/bark_battle_draft_config_snapshot_type.rs b/server-rs/crates/spacetime-client/src/module_bindings/bark_battle_draft_config_snapshot_type.rs index 1271082f4..53a978e68 100644 --- a/server-rs/crates/spacetime-client/src/module_bindings/bark_battle_draft_config_snapshot_type.rs +++ b/server-rs/crates/spacetime-client/src/module_bindings/bark_battle_draft_config_snapshot_type.rs @@ -13,7 +13,6 @@ pub struct BarkBattleDraftConfigSnapshot { pub config_version: u64, pub ruleset_version: String, pub difficulty_preset: String, - pub leaderboard_enabled: bool, pub config_json: String, pub editor_state_json: String, pub created_at_micros: i64, diff --git a/server-rs/crates/spacetime-client/src/module_bindings/bark_battle_draft_config_upsert_input_type.rs b/server-rs/crates/spacetime-client/src/module_bindings/bark_battle_draft_config_upsert_input_type.rs index ee07d0c0c..15331a91b 100644 --- a/server-rs/crates/spacetime-client/src/module_bindings/bark_battle_draft_config_upsert_input_type.rs +++ b/server-rs/crates/spacetime-client/src/module_bindings/bark_battle_draft_config_upsert_input_type.rs @@ -13,7 +13,6 @@ pub struct BarkBattleDraftConfigUpsertInput { pub config_version: u64, pub ruleset_version: String, pub difficulty_preset: String, - pub leaderboard_enabled: bool, pub config_json: String, pub updated_at_micros: i64, } diff --git a/server-rs/crates/spacetime-client/src/module_bindings/bark_battle_draft_create_input_type.rs b/server-rs/crates/spacetime-client/src/module_bindings/bark_battle_draft_create_input_type.rs index 7e165bfb4..017388c80 100644 --- a/server-rs/crates/spacetime-client/src/module_bindings/bark_battle_draft_create_input_type.rs +++ b/server-rs/crates/spacetime-client/src/module_bindings/bark_battle_draft_create_input_type.rs @@ -12,11 +12,10 @@ pub struct BarkBattleDraftCreateInput { pub work_id: String, pub title: Option, pub description: Option, - pub theme_preset: String, - pub player_dog_skin_preset: String, - pub opponent_dog_skin_preset: String, + pub theme_description: String, + pub player_image_description: String, + pub opponent_image_description: String, pub difficulty_preset: Option, - pub leaderboard_enabled: Option, pub editor_state_json: Option, pub created_at_micros: i64, } diff --git a/server-rs/crates/spacetime-client/src/module_bindings/bark_battle_gallery_view_row_type.rs b/server-rs/crates/spacetime-client/src/module_bindings/bark_battle_gallery_view_row_type.rs new file mode 100644 index 000000000..f26c77e4c --- /dev/null +++ b/server-rs/crates/spacetime-client/src/module_bindings/bark_battle_gallery_view_row_type.rs @@ -0,0 +1,106 @@ +// THIS FILE IS AUTOMATICALLY GENERATED BY SPACETIMEDB. EDITS TO THIS FILE +// WILL NOT BE SAVED. MODIFY TABLES IN YOUR MODULE SOURCE CODE INSTEAD. + +#![allow(unused, clippy::all)] +use spacetimedb_sdk::__codegen::{self as __sdk, __lib, __sats, __ws}; + +#[derive(__lib::ser::Serialize, __lib::de::Deserialize, Clone, PartialEq, Debug)] +#[sats(crate = __lib)] +pub struct BarkBattleGalleryViewRow { + pub work_id: String, + pub owner_user_id: String, + pub source_draft_id: Option, + pub config_version: u64, + pub ruleset_version: String, + pub difficulty_preset: String, + pub title: String, + pub description: String, + pub theme_description: String, + pub player_image_description: String, + pub opponent_image_description: String, + pub onomatopoeia: Vec, + pub player_character_image_src: Option, + pub opponent_character_image_src: Option, + pub ui_background_image_src: Option, + pub play_count: u64, + pub finish_count: u64, + pub updated_at_micros: i64, + pub published_at_micros: i64, +} + +impl __sdk::InModule for BarkBattleGalleryViewRow { + type Module = super::RemoteModule; +} + +/// Column accessor struct for the table `BarkBattleGalleryViewRow`. +/// +/// Provides typed access to columns for query building. +pub struct BarkBattleGalleryViewRowCols { + pub work_id: __sdk::__query_builder::Col, + pub owner_user_id: __sdk::__query_builder::Col, + pub source_draft_id: __sdk::__query_builder::Col>, + pub config_version: __sdk::__query_builder::Col, + pub ruleset_version: __sdk::__query_builder::Col, + pub difficulty_preset: __sdk::__query_builder::Col, + pub title: __sdk::__query_builder::Col, + pub description: __sdk::__query_builder::Col, + pub theme_description: __sdk::__query_builder::Col, + pub player_image_description: __sdk::__query_builder::Col, + pub opponent_image_description: __sdk::__query_builder::Col, + pub onomatopoeia: __sdk::__query_builder::Col>, + pub player_character_image_src: + __sdk::__query_builder::Col>, + pub opponent_character_image_src: + __sdk::__query_builder::Col>, + pub ui_background_image_src: + __sdk::__query_builder::Col>, + pub play_count: __sdk::__query_builder::Col, + pub finish_count: __sdk::__query_builder::Col, + pub updated_at_micros: __sdk::__query_builder::Col, + pub published_at_micros: __sdk::__query_builder::Col, +} + +impl __sdk::__query_builder::HasCols for BarkBattleGalleryViewRow { + type Cols = BarkBattleGalleryViewRowCols; + fn cols(table_name: &'static str) -> Self::Cols { + BarkBattleGalleryViewRowCols { + work_id: __sdk::__query_builder::Col::new(table_name, "work_id"), + owner_user_id: __sdk::__query_builder::Col::new(table_name, "owner_user_id"), + source_draft_id: __sdk::__query_builder::Col::new(table_name, "source_draft_id"), + config_version: __sdk::__query_builder::Col::new(table_name, "config_version"), + ruleset_version: __sdk::__query_builder::Col::new(table_name, "ruleset_version"), + difficulty_preset: __sdk::__query_builder::Col::new(table_name, "difficulty_preset"), + title: __sdk::__query_builder::Col::new(table_name, "title"), + description: __sdk::__query_builder::Col::new(table_name, "description"), + theme_description: __sdk::__query_builder::Col::new(table_name, "theme_description"), + player_image_description: __sdk::__query_builder::Col::new( + table_name, + "player_image_description", + ), + opponent_image_description: __sdk::__query_builder::Col::new( + table_name, + "opponent_image_description", + ), + onomatopoeia: __sdk::__query_builder::Col::new(table_name, "onomatopoeia"), + player_character_image_src: __sdk::__query_builder::Col::new( + table_name, + "player_character_image_src", + ), + opponent_character_image_src: __sdk::__query_builder::Col::new( + table_name, + "opponent_character_image_src", + ), + ui_background_image_src: __sdk::__query_builder::Col::new( + table_name, + "ui_background_image_src", + ), + play_count: __sdk::__query_builder::Col::new(table_name, "play_count"), + finish_count: __sdk::__query_builder::Col::new(table_name, "finish_count"), + updated_at_micros: __sdk::__query_builder::Col::new(table_name, "updated_at_micros"), + published_at_micros: __sdk::__query_builder::Col::new( + table_name, + "published_at_micros", + ), + } + } +} diff --git a/server-rs/crates/spacetime-client/src/module_bindings/bark_battle_gallery_view_table.rs b/server-rs/crates/spacetime-client/src/module_bindings/bark_battle_gallery_view_table.rs new file mode 100644 index 000000000..7cd139c03 --- /dev/null +++ b/server-rs/crates/spacetime-client/src/module_bindings/bark_battle_gallery_view_table.rs @@ -0,0 +1,114 @@ +// THIS FILE IS AUTOMATICALLY GENERATED BY SPACETIMEDB. EDITS TO THIS FILE +// WILL NOT BE SAVED. MODIFY TABLES IN YOUR MODULE SOURCE CODE INSTEAD. + +#![allow(unused, clippy::all)] +use super::bark_battle_gallery_view_row_type::BarkBattleGalleryViewRow; +use spacetimedb_sdk::__codegen::{self as __sdk, __lib, __sats, __ws}; + +/// Table handle for the table `bark_battle_gallery_view`. +/// +/// Obtain a handle from the [`BarkBattleGalleryViewTableAccess::bark_battle_gallery_view`] method on [`super::RemoteTables`], +/// like `ctx.db.bark_battle_gallery_view()`. +/// +/// Users are encouraged not to explicitly reference this type, +/// but to directly chain method calls, +/// like `ctx.db.bark_battle_gallery_view().on_insert(...)`. +pub struct BarkBattleGalleryViewTableHandle<'ctx> { + imp: __sdk::TableHandle, + ctx: std::marker::PhantomData<&'ctx super::RemoteTables>, +} + +#[allow(non_camel_case_types)] +/// Extension trait for access to the table `bark_battle_gallery_view`. +/// +/// Implemented for [`super::RemoteTables`]. +pub trait BarkBattleGalleryViewTableAccess { + #[allow(non_snake_case)] + /// Obtain a [`BarkBattleGalleryViewTableHandle`], which mediates access to the table `bark_battle_gallery_view`. + fn bark_battle_gallery_view(&self) -> BarkBattleGalleryViewTableHandle<'_>; +} + +impl BarkBattleGalleryViewTableAccess for super::RemoteTables { + fn bark_battle_gallery_view(&self) -> BarkBattleGalleryViewTableHandle<'_> { + BarkBattleGalleryViewTableHandle { + imp: self + .imp + .get_table::("bark_battle_gallery_view"), + ctx: std::marker::PhantomData, + } + } +} + +pub struct BarkBattleGalleryViewInsertCallbackId(__sdk::CallbackId); +pub struct BarkBattleGalleryViewDeleteCallbackId(__sdk::CallbackId); + +impl<'ctx> __sdk::Table for BarkBattleGalleryViewTableHandle<'ctx> { + type Row = BarkBattleGalleryViewRow; + type EventContext = super::EventContext; + + fn count(&self) -> u64 { + self.imp.count() + } + fn iter(&self) -> impl Iterator + '_ { + self.imp.iter() + } + + type InsertCallbackId = BarkBattleGalleryViewInsertCallbackId; + + fn on_insert( + &self, + callback: impl FnMut(&Self::EventContext, &Self::Row) + Send + 'static, + ) -> BarkBattleGalleryViewInsertCallbackId { + BarkBattleGalleryViewInsertCallbackId(self.imp.on_insert(Box::new(callback))) + } + + fn remove_on_insert(&self, callback: BarkBattleGalleryViewInsertCallbackId) { + self.imp.remove_on_insert(callback.0) + } + + type DeleteCallbackId = BarkBattleGalleryViewDeleteCallbackId; + + fn on_delete( + &self, + callback: impl FnMut(&Self::EventContext, &Self::Row) + Send + 'static, + ) -> BarkBattleGalleryViewDeleteCallbackId { + BarkBattleGalleryViewDeleteCallbackId(self.imp.on_delete(Box::new(callback))) + } + + fn remove_on_delete(&self, callback: BarkBattleGalleryViewDeleteCallbackId) { + self.imp.remove_on_delete(callback.0) + } +} + +#[doc(hidden)] +pub(super) fn register_table(client_cache: &mut __sdk::ClientCache) { + let _table = + client_cache.get_or_make_table::("bark_battle_gallery_view"); +} + +#[doc(hidden)] +pub(super) fn parse_table_update( + raw_updates: __ws::v2::TableUpdate, +) -> __sdk::Result<__sdk::TableUpdate> { + __sdk::TableUpdate::parse_table_update(raw_updates).map_err(|e| { + __sdk::InternalError::failed_parse("TableUpdate", "TableUpdate") + .with_cause(e) + .into() + }) +} + +#[allow(non_camel_case_types)] +/// Extension trait for query builder access to the table `BarkBattleGalleryViewRow`. +/// +/// Implemented for [`__sdk::QueryTableAccessor`]. +pub trait bark_battle_gallery_viewQueryTableAccess { + #[allow(non_snake_case)] + /// Get a query builder for the table `BarkBattleGalleryViewRow`. + fn bark_battle_gallery_view(&self) -> __sdk::__query_builder::Table; +} + +impl bark_battle_gallery_viewQueryTableAccess for __sdk::QueryTableAccessor { + fn bark_battle_gallery_view(&self) -> __sdk::__query_builder::Table { + __sdk::__query_builder::Table::new("bark_battle_gallery_view") + } +} diff --git a/server-rs/crates/spacetime-client/src/module_bindings/bark_battle_run_snapshot_type.rs b/server-rs/crates/spacetime-client/src/module_bindings/bark_battle_run_snapshot_type.rs index 474af775f..71bb833db 100644 --- a/server-rs/crates/spacetime-client/src/module_bindings/bark_battle_run_snapshot_type.rs +++ b/server-rs/crates/spacetime-client/src/module_bindings/bark_battle_run_snapshot_type.rs @@ -13,7 +13,6 @@ pub struct BarkBattleRunSnapshot { pub config_version: u64, pub ruleset_version: String, pub difficulty_preset: String, - pub leaderboard_enabled: bool, pub status: String, pub client_started_at_micros: i64, pub server_started_at_micros: i64, diff --git a/server-rs/crates/spacetime-client/src/module_bindings/bark_battle_runtime_config_snapshot_type.rs b/server-rs/crates/spacetime-client/src/module_bindings/bark_battle_runtime_config_snapshot_type.rs index e176ca632..a707ad158 100644 --- a/server-rs/crates/spacetime-client/src/module_bindings/bark_battle_runtime_config_snapshot_type.rs +++ b/server-rs/crates/spacetime-client/src/module_bindings/bark_battle_runtime_config_snapshot_type.rs @@ -13,7 +13,6 @@ pub struct BarkBattleRuntimeConfigSnapshot { pub config_version: u64, pub ruleset_version: String, pub difficulty_preset: String, - pub leaderboard_enabled: bool, pub config_json: String, pub published_snapshot_json: String, pub published_at_micros: i64, diff --git a/server-rs/crates/spacetime-module/src/bark_battle.rs b/server-rs/crates/spacetime-module/src/bark_battle.rs index 2d104b7e9..da9817762 100644 --- a/server-rs/crates/spacetime-module/src/bark_battle.rs +++ b/server-rs/crates/spacetime-module/src/bark_battle.rs @@ -1,6 +1,7 @@ use crate::*; use serde::{Serialize, de::DeserializeOwned}; use sha2::{Digest, Sha256}; +use spacetimedb::AnonymousViewContext; pub(crate) mod tables; mod types; @@ -8,6 +9,38 @@ mod types; pub use tables::*; pub use types::*; +/// Bark Battle 公开广场列表投影。 +/// +/// HTTP gallery 订阅该 public view 后读取本地 cache;view 只从已发布配置和统计投影 +/// 组装 v1 公开字段,避免每个公开列表请求重新调用 procedure 热路径。 +#[spacetimedb::view(accessor = bark_battle_gallery_view, public)] +pub fn bark_battle_gallery_view(ctx: &AnonymousViewContext) -> Vec { + let mut items = ctx + .db + .bark_battle_published_config() + .by_bark_battle_published_owner_user_id() + .filter(""..) + .filter_map(|row| match build_bark_battle_gallery_view_row(ctx, &row) { + Ok(item) => Some(item), + Err(error) => { + log::warn!( + "汪汪声浪公开广场 view 跳过损坏的作品投影 work_id={}: {}", + row.work_id, + error + ); + None + } + }) + .collect::>(); + items.sort_by(|left, right| { + right + .updated_at_micros + .cmp(&left.updated_at_micros) + .then_with(|| left.work_id.cmp(&right.work_id)) + }); + items +} + #[spacetimedb::procedure] pub fn create_bark_battle_draft( ctx: &mut ProcedureContext, @@ -106,17 +139,23 @@ fn create_bark_battle_draft_tx( let config = BarkBattleEditorConfigSnapshot { title: normalize_title(input.title.as_deref())?, description: normalize_optional_text(input.description.as_deref()), - theme_preset: normalize_required_preset(&input.theme_preset, "theme_preset")?, - player_dog_skin_preset: normalize_required_preset( - &input.player_dog_skin_preset, - "player_dog_skin_preset", + theme_description: normalize_required_description( + &input.theme_description, + "theme_description", )?, - opponent_dog_skin_preset: normalize_required_preset( - &input.opponent_dog_skin_preset, - "opponent_dog_skin_preset", + player_image_description: normalize_required_description( + &input.player_image_description, + "player_image_description", )?, + opponent_image_description: normalize_required_description( + &input.opponent_image_description, + "opponent_image_description", + )?, + onomatopoeia: Vec::new(), + player_character_image_src: None, + opponent_character_image_src: None, + ui_background_image_src: None, difficulty_preset: normalize_difficulty(input.difficulty_preset.as_deref())?, - leaderboard_enabled: input.leaderboard_enabled.unwrap_or(true), }; let row = BarkBattleDraftConfigRow { draft_id: input.draft_id.clone(), @@ -125,7 +164,7 @@ fn create_bark_battle_draft_tx( config_version: 1, ruleset_version: BARK_BATTLE_DEFAULT_RULESET_VERSION.to_string(), difficulty_preset: config.difficulty_preset.clone(), - leaderboard_enabled: config.leaderboard_enabled, + leaderboard_enabled: true, config_json: to_json_string(&config), editor_state_json: normalize_json_string( input.editor_state_json.as_deref(), @@ -145,12 +184,10 @@ fn update_bark_battle_draft_config_tx( require_non_empty(&input.draft_id, "bark_battle draft_id")?; require_non_empty(&input.owner_user_id, "bark_battle owner_user_id")?; require_non_empty(&input.work_id, "bark_battle work_id")?; - let editor_config = parse_editor_config(&input.config_json)?; - validate_editor_config_snapshot(&editor_config)?; - if editor_config.difficulty_preset != input.difficulty_preset - || editor_config.leaderboard_enabled != input.leaderboard_enabled - { - return Err("bark_battle config_json 与行字段不一致".to_string()); + let mut editor_config = parse_editor_config(&input.config_json)?; + normalize_editor_config_snapshot(&mut editor_config)?; + if editor_config.difficulty_preset != input.difficulty_preset { + return Err("bark_battle config_json 与 difficulty_preset 不匹配".to_string()); } let updated_at = Timestamp::from_micros_since_unix_epoch(input.updated_at_micros); let existing = ctx @@ -162,15 +199,15 @@ fn update_bark_battle_draft_config_tx( if existing.owner_user_id != input.owner_user_id || existing.work_id != input.work_id { return Err("bark_battle draft owner/work 不匹配".to_string()); } - if input.config_version <= existing.config_version { - return Err("bark_battle draft config_version 必须递增".to_string()); - } let mut row = existing; - row.config_version = input.config_version; + // 中文注释:HTTP BFF 会先读缓存再发更新,订阅缓存可能短暂落后; + // 这里按“至少递增 1”兜底,避免前端重复保存素材时被版本号误伤。 + row.config_version = input + .config_version + .max(row.config_version.saturating_add(1)); row.ruleset_version = normalize_ruleset_version(&input.ruleset_version)?; row.difficulty_preset = normalize_difficulty(Some(&input.difficulty_preset))?; - row.leaderboard_enabled = input.leaderboard_enabled; - row.config_json = input.config_json; + row.config_json = to_json_string(&editor_config); row.updated_at = updated_at; ctx.db .bark_battle_draft_config() @@ -196,6 +233,20 @@ fn publish_bark_battle_work_tx( if draft.owner_user_id != input.owner_user_id || draft.work_id != input.work_id { return Err("bark_battle draft owner/work 不匹配".to_string()); } + let published_snapshot_json = match input.published_snapshot_json.as_deref() { + Some(value) => { + let mut editor_config = parse_editor_config(value)?; + normalize_editor_config_snapshot(&mut editor_config)?; + if editor_config.difficulty_preset != draft.difficulty_preset { + return Err( + "bark_battle published_snapshot_json 与草稿 difficulty_preset 不匹配" + .to_string(), + ); + } + to_json_string(&editor_config) + } + None => draft.config_json.clone(), + }; let published = BarkBattlePublishedConfigRow { work_id: draft.work_id.clone(), owner_user_id: draft.owner_user_id.clone(), @@ -203,12 +254,9 @@ fn publish_bark_battle_work_tx( config_version: draft.config_version, ruleset_version: normalize_ruleset_version(&draft.ruleset_version)?, difficulty_preset: normalize_difficulty(Some(&draft.difficulty_preset))?, - leaderboard_enabled: draft.leaderboard_enabled, - config_json: draft.config_json.clone(), - published_snapshot_json: match input.published_snapshot_json.as_deref() { - Some(value) => normalize_json_string(Some(value), "published_snapshot_json")?, - None => draft.config_json.clone(), - }, + leaderboard_enabled: true, + config_json: published_snapshot_json.clone(), + published_snapshot_json, created_at: published_at, updated_at: published_at, published_at, @@ -293,7 +341,7 @@ fn start_bark_battle_run_tx( config_version: input.config_version, ruleset_version: input.ruleset_version, difficulty_preset: input.difficulty_preset, - leaderboard_enabled: published.leaderboard_enabled, + leaderboard_enabled: true, status: BARK_BATTLE_RUN_RUNNING.to_string(), client_started_at_micros: input.client_started_at_micros, server_started_at: started_at, @@ -479,7 +527,6 @@ fn draft_snapshot(row: &BarkBattleDraftConfigRow) -> BarkBattleDraftConfigSnapsh config_version: row.config_version, ruleset_version: row.ruleset_version.clone(), difficulty_preset: row.difficulty_preset.clone(), - leaderboard_enabled: row.leaderboard_enabled, config_json: row.config_json.clone(), editor_state_json: row.editor_state_json.clone(), created_at_micros: row.created_at.to_micros_since_unix_epoch(), @@ -495,7 +542,6 @@ fn runtime_config_snapshot(row: &BarkBattlePublishedConfigRow) -> BarkBattleRunt config_version: row.config_version, ruleset_version: row.ruleset_version.clone(), difficulty_preset: row.difficulty_preset.clone(), - leaderboard_enabled: row.leaderboard_enabled, config_json: row.config_json.clone(), published_snapshot_json: row.published_snapshot_json.clone(), published_at_micros: row.published_at.to_micros_since_unix_epoch(), @@ -503,6 +549,43 @@ fn runtime_config_snapshot(row: &BarkBattlePublishedConfigRow) -> BarkBattleRunt } } +fn build_bark_battle_gallery_view_row( + ctx: &AnonymousViewContext, + row: &BarkBattlePublishedConfigRow, +) -> Result { + let mut editor_config = parse_editor_config(&row.config_json)?; + normalize_editor_config_snapshot(&mut editor_config)?; + let stats = ctx + .db + .bark_battle_work_stats_projection() + .work_id() + .find(&row.work_id); + Ok(BarkBattleGalleryViewRow { + work_id: row.work_id.clone(), + owner_user_id: row.owner_user_id.clone(), + source_draft_id: row.source_draft_id.clone(), + config_version: row.config_version, + ruleset_version: row.ruleset_version.clone(), + difficulty_preset: row.difficulty_preset.clone(), + title: editor_config.title, + description: editor_config.description, + theme_description: editor_config.theme_description, + player_image_description: editor_config.player_image_description, + opponent_image_description: editor_config.opponent_image_description, + onomatopoeia: editor_config.onomatopoeia, + player_character_image_src: editor_config.player_character_image_src, + opponent_character_image_src: editor_config.opponent_character_image_src, + ui_background_image_src: editor_config.ui_background_image_src, + play_count: stats.as_ref().map(|stats| stats.play_count).unwrap_or(0), + finish_count: stats + .as_ref() + .map(|stats| stats.finished_count) + .unwrap_or(0), + updated_at_micros: row.updated_at.to_micros_since_unix_epoch(), + published_at_micros: row.published_at.to_micros_since_unix_epoch(), + }) +} + fn hash_run_token(token: &str) -> String { let digest = Sha256::digest(token.as_bytes()); digest.iter().map(|byte| format!("{byte:02x}")).collect() @@ -522,12 +605,34 @@ fn require_non_empty(value: &str, label: &str) -> Result<(), String> { } } -fn validate_editor_config_snapshot(config: &BarkBattleEditorConfigSnapshot) -> Result<(), String> { - normalize_title(Some(&config.title))?; - normalize_required_preset(&config.theme_preset, "theme_preset")?; - normalize_required_preset(&config.player_dog_skin_preset, "player_dog_skin_preset")?; - normalize_required_preset(&config.opponent_dog_skin_preset, "opponent_dog_skin_preset")?; - normalize_difficulty(Some(&config.difficulty_preset))?; +fn normalize_editor_config_snapshot( + config: &mut BarkBattleEditorConfigSnapshot, +) -> Result<(), String> { + config.title = normalize_title(Some(&config.title))?; + config.theme_description = + normalize_required_description(&config.theme_description, "theme_description")?; + config.player_image_description = normalize_required_description( + &config.player_image_description, + "player_image_description", + )?; + config.opponent_image_description = normalize_required_description( + &config.opponent_image_description, + "opponent_image_description", + )?; + config.onomatopoeia = normalize_onomatopoeia(std::mem::take(&mut config.onomatopoeia)); + config.player_character_image_src = normalize_optional_asset_source( + config.player_character_image_src.as_deref(), + "player_character_image_src", + )?; + config.opponent_character_image_src = normalize_optional_asset_source( + config.opponent_character_image_src.as_deref(), + "opponent_character_image_src", + )?; + config.ui_background_image_src = normalize_optional_asset_source( + config.ui_background_image_src.as_deref(), + "ui_background_image_src", + )?; + config.difficulty_preset = normalize_difficulty(Some(&config.difficulty_preset))?; Ok(()) } @@ -546,12 +651,37 @@ fn normalize_optional_text(value: Option<&str>) -> String { value.unwrap_or_default().trim().chars().take(120).collect() } -fn normalize_required_preset(value: &str, field_name: &str) -> Result { - let preset = value.trim(); - if preset.is_empty() { +fn normalize_required_description(value: &str, field_name: &str) -> Result { + let description = value.trim(); + if description.is_empty() { return Err(format!("bark_battle {field_name} 不能为空")); } - Ok(preset.to_string()) + if description.chars().count() > 240 { + return Err(format!("bark_battle {field_name} 不能超过 240 个字符")); + } + Ok(description.to_string()) +} + +fn normalize_onomatopoeia(words: Vec) -> Vec { + words + .into_iter() + .map(|word| word.trim().chars().take(12).collect::()) + .filter(|word| !word.is_empty()) + .take(24) + .collect() +} + +fn normalize_optional_asset_source( + value: Option<&str>, + field_name: &str, +) -> Result, String> { + let Some(value) = value.map(str::trim).filter(|value| !value.is_empty()) else { + return Ok(None); + }; + if value.chars().count() > 512 { + return Err(format!("bark_battle {field_name} 不能超过 512 个字符")); + } + Ok(Some(value.to_string())) } fn normalize_ruleset_version(value: &str) -> Result { @@ -639,7 +769,6 @@ fn run_snapshot(row: &BarkBattleRuntimeRunRow) -> BarkBattleRunSnapshot { config_version: row.config_version, ruleset_version: row.ruleset_version.clone(), difficulty_preset: row.difficulty_preset.clone(), - leaderboard_enabled: row.leaderboard_enabled, status: row.status.clone(), client_started_at_micros: row.client_started_at_micros, server_started_at_micros: row.server_started_at.to_micros_since_unix_epoch(), @@ -870,7 +999,6 @@ mod tests { config_version: 1, ruleset_version: BARK_BATTLE_DEFAULT_RULESET_VERSION.to_string(), difficulty_preset: BARK_BATTLE_DIFFICULTY_NORMAL.to_string(), - leaderboard_enabled: true, config_json: "{}".to_string(), updated_at_micros: 1_700_000, }; @@ -884,7 +1012,6 @@ mod tests { config_version: input.config_version, ruleset_version: input.ruleset_version.clone(), difficulty_preset: input.difficulty_preset.clone(), - leaderboard_enabled: input.leaderboard_enabled, config_json: input.config_json.clone(), editor_state_json: "{}".to_string(), created_at_micros: 1_700_000, @@ -910,4 +1037,84 @@ mod tests { assert!(normalize_title(Some(" 标题 ")).is_ok()); assert!(normalize_title(Some(" ")).is_err()); } + + #[test] + fn published_snapshot_is_normalized_as_runtime_config() { + let mut editor_config = parse_editor_config( + &serde_json::json!({ + "title": " 汪汪测试杯 ", + "description": "", + "themeDescription": " 阳光草坪 ", + "playerImageDescription": " 主角柴犬 ", + "opponentImageDescription": " 对手哈士奇 ", + "onomatopoeia": [" 轰汪! ", "冲啊冲啊冲啊冲啊冲啊!", ""], + "playerCharacterImageSrc": "/generated-bark-battle-assets/player.png", + "opponentCharacterImageSrc": "/generated-bark-battle-assets/opponent.png", + "uiBackgroundImageSrc": "/generated-bark-battle-assets/ui.png", + "difficultyPreset": "normal" + }) + .to_string(), + ) + .expect("published snapshot should parse"); + + normalize_editor_config_snapshot(&mut editor_config) + .expect("published snapshot should normalize"); + let config_json = to_json_string(&editor_config); + + assert!(config_json.contains("/generated-bark-battle-assets/player.png")); + assert!(config_json.contains("/generated-bark-battle-assets/opponent.png")); + assert!(config_json.contains("/generated-bark-battle-assets/ui.png")); + assert!(config_json.contains("阳光草坪")); + assert!(config_json.contains("轰汪!")); + assert!(config_json.contains("冲啊冲啊冲啊冲啊")); + assert!(!config_json.contains("冲啊冲啊冲啊冲啊冲啊!")); + assert!(!config_json.contains("\"title\":\" 汪汪测试杯 \"")); + assert!(!config_json.contains("themePreset")); + assert!(!config_json.contains("playerDogSkinPreset")); + assert!(!config_json.contains("opponentDogSkinPreset")); + } + + #[test] + fn bark_battle_gallery_view_row_exposes_custom_onomatopoeia() { + let mut editor_config = parse_editor_config( + &serde_json::json!({ + "title": "声浪公开赛", + "description": "画廊映射测试", + "themeDescription": "霓虹竞技场", + "playerImageDescription": "星际猫骑士", + "opponentImageDescription": "机器人拳手", + "onomatopoeia": [" 轰! ", "炸场!", ""], + "difficultyPreset": "normal" + }) + .to_string(), + ) + .expect("gallery config should parse"); + + normalize_editor_config_snapshot(&mut editor_config) + .expect("gallery config should normalize"); + + let row = BarkBattleGalleryViewRow { + work_id: "BB-33333333".to_string(), + owner_user_id: "user-3".to_string(), + source_draft_id: Some("bark-battle-draft-3".to_string()), + config_version: 1, + ruleset_version: BARK_BATTLE_DEFAULT_RULESET_VERSION.to_string(), + difficulty_preset: editor_config.difficulty_preset.clone(), + title: editor_config.title, + description: editor_config.description, + theme_description: editor_config.theme_description, + player_image_description: editor_config.player_image_description, + opponent_image_description: editor_config.opponent_image_description, + onomatopoeia: editor_config.onomatopoeia, + player_character_image_src: editor_config.player_character_image_src, + opponent_character_image_src: editor_config.opponent_character_image_src, + ui_background_image_src: editor_config.ui_background_image_src, + play_count: 8, + finish_count: 5, + updated_at_micros: 1_713_686_401_234_567, + published_at_micros: 1_713_686_401_234_000, + }; + + assert_eq!(row.onomatopoeia, vec!["轰!".to_string(), "炸场!".to_string()]); + } } diff --git a/server-rs/crates/spacetime-module/src/bark_battle/types.rs b/server-rs/crates/spacetime-module/src/bark_battle/types.rs index a1652d786..380d9b1f3 100644 --- a/server-rs/crates/spacetime-module/src/bark_battle/types.rs +++ b/server-rs/crates/spacetime-module/src/bark_battle/types.rs @@ -24,11 +24,10 @@ pub struct BarkBattleDraftCreateInput { pub work_id: String, pub title: Option, pub description: Option, - pub theme_preset: String, - pub player_dog_skin_preset: String, - pub opponent_dog_skin_preset: String, + pub theme_description: String, + pub player_image_description: String, + pub opponent_image_description: String, pub difficulty_preset: Option, - pub leaderboard_enabled: Option, pub editor_state_json: Option, pub created_at_micros: i64, } @@ -41,7 +40,6 @@ pub struct BarkBattleDraftConfigUpsertInput { pub config_version: u64, pub ruleset_version: String, pub difficulty_preset: String, - pub leaderboard_enabled: bool, pub config_json: String, pub updated_at_micros: i64, } @@ -116,11 +114,18 @@ pub struct BarkBattleProcedureResult { pub struct BarkBattleEditorConfigSnapshot { pub title: String, pub description: String, - pub theme_preset: String, - pub player_dog_skin_preset: String, - pub opponent_dog_skin_preset: String, + pub theme_description: String, + pub player_image_description: String, + pub opponent_image_description: String, + #[serde(default, skip_serializing_if = "Vec::is_empty")] + pub onomatopoeia: Vec, + #[serde(default, skip_serializing_if = "Option::is_none")] + pub player_character_image_src: Option, + #[serde(default, skip_serializing_if = "Option::is_none")] + pub opponent_character_image_src: Option, + #[serde(default, skip_serializing_if = "Option::is_none")] + pub ui_background_image_src: Option, pub difficulty_preset: String, - pub leaderboard_enabled: bool, } #[derive(Clone, Debug, PartialEq, Eq, Serialize, Deserialize, SpacetimeType)] @@ -132,7 +137,6 @@ pub struct BarkBattleDraftConfigSnapshot { pub config_version: u64, pub ruleset_version: String, pub difficulty_preset: String, - pub leaderboard_enabled: bool, pub config_json: String, pub editor_state_json: String, pub created_at_micros: i64, @@ -148,7 +152,6 @@ pub struct BarkBattleRuntimeConfigSnapshot { pub config_version: u64, pub ruleset_version: String, pub difficulty_preset: String, - pub leaderboard_enabled: bool, pub config_json: String, pub published_snapshot_json: String, pub published_at_micros: i64, @@ -164,7 +167,6 @@ pub struct BarkBattleRunSnapshot { pub config_version: u64, pub ruleset_version: String, pub difficulty_preset: String, - pub leaderboard_enabled: bool, pub status: String, pub client_started_at_micros: i64, pub server_started_at_micros: i64, @@ -177,3 +179,31 @@ pub struct BarkBattleRunSnapshot { pub leaderboard_score: Option, pub score_id: Option, } + +/// Bark Battle 公开广场只读投影行。 +/// +/// 该结构只暴露 v1 公共卡片需要的配置和基础统计,不把内部排行榜开关或旧皮肤 / +/// 音效预设重新带回公开语义。 +#[derive(Clone, Debug, PartialEq, Serialize, Deserialize, SpacetimeType)] +#[serde(rename_all = "camelCase")] +pub struct BarkBattleGalleryViewRow { + pub work_id: String, + pub owner_user_id: String, + pub source_draft_id: Option, + pub config_version: u64, + pub ruleset_version: String, + pub difficulty_preset: String, + pub title: String, + pub description: String, + pub theme_description: String, + pub player_image_description: String, + pub opponent_image_description: String, + pub onomatopoeia: Vec, + pub player_character_image_src: Option, + pub opponent_character_image_src: Option, + pub ui_background_image_src: Option, + pub play_count: u64, + pub finish_count: u64, + pub updated_at_micros: i64, + pub published_at_micros: i64, +} diff --git a/server-rs/crates/spacetime-module/src/runtime/creation_entry_config.rs b/server-rs/crates/spacetime-module/src/runtime/creation_entry_config.rs index c0b47b2ec..71384fc28 100644 --- a/server-rs/crates/spacetime-module/src/runtime/creation_entry_config.rs +++ b/server-rs/crates/spacetime-module/src/runtime/creation_entry_config.rs @@ -181,17 +181,7 @@ fn seed_creation_entry_config_if_missing(ctx: &ReducerContext) { migrate_rpg_entry_from_old_hidden_default(ctx, now); migrate_visual_novel_entry_from_old_visible_default(ctx, now); - migrate_coming_soon_entry_from_old_open_default( - ctx, - now, - ComingSoonEntryDefault { - id: "bark-battle", - title: "汪汪声浪", - subtitle: "声控对战挑战", - image_src: "/creation-type-references/creative-agent.webp", - sort_order: 85, - }, - ); + migrate_bark_battle_entry_to_open_default(ctx, now); migrate_coming_soon_entry_from_old_open_default( ctx, now, @@ -205,21 +195,21 @@ fn seed_creation_entry_config_if_missing(ctx: &ReducerContext) { ); } -fn migrate_rpg_entry_from_old_hidden_default(ctx: &ReducerContext, now: Timestamp) { - let id = "rpg".to_string(); +fn migrate_bark_battle_entry_to_open_default(ctx: &ReducerContext, now: Timestamp) { + let id = "bark-battle".to_string(); let Some(row) = ctx.db.creation_entry_type_config().id().find(&id) else { return; }; - // 中文注释:只开放历史默认隐藏的 RPG 入口,不覆盖后台入口开关后续手动配置。 - let still_old_hidden_default = row.title == "文字冒险" - && row.subtitle == "经典 RPG 体验" - && row.badge == "内测" - && row.image_src == "/creation-type-references/rpg.webp" - && !row.visible - && row.open - && row.sort_order == 10; - if !still_old_hidden_default { + // 中文注释:只纠偏系统默认汪汪声浪入口,不覆盖后台手动改过标题、排序或可见性的配置。 + let still_system_default = row.title == "汪汪声浪" + && row.subtitle == "声控对战挑战" + && row.visible + && row.sort_order == 85 + && (row.image_src == "/creation-type-references/creative-agent.webp" + || row.image_src == "/creation-type-references/bark-battle.webp") + && ((row.badge == "敬请期待" && !row.open) || (row.badge == "可创建" && row.open)); + if !still_system_default { return; } @@ -228,7 +218,7 @@ fn migrate_rpg_entry_from_old_hidden_default(ctx: &ReducerContext, now: Timestam .id() .update(CreationEntryTypeConfig { badge: "可创建".to_string(), - visible: true, + image_src: "/creation-type-references/bark-battle.webp".to_string(), open: true, updated_at: now, ..row diff --git a/src/components/bark-battle-creation/BarkBattleConfigEditor.test.tsx b/src/components/bark-battle-creation/BarkBattleConfigEditor.test.tsx index ac43de6a8..cc6619fbb 100644 --- a/src/components/bark-battle-creation/BarkBattleConfigEditor.test.tsx +++ b/src/components/bark-battle-creation/BarkBattleConfigEditor.test.tsx @@ -7,54 +7,118 @@ import { describe, expect, it, vi } from 'vitest'; import { BarkBattleConfigEditor } from './BarkBattleConfigEditor'; describe('BarkBattleConfigEditor', () => { - it('allows creators to edit lightweight config and publish a Bark Battle work', async () => { - const onPublish = vi.fn(); - render(); + it('allows creators to edit v1 descriptions and compile a Bark Battle draft', async () => { + const onPreview = vi.fn(); + render(); expect(screen.getByRole('heading', { name: '汪汪声浪大作战' })).toBeTruthy(); expect(screen.getByText('轻配置')).toBeTruthy(); expect((screen.getByLabelText('作品标题') as HTMLInputElement).value).toBe('我的声浪竞技场'); expect((screen.getByLabelText('难度预设') as HTMLSelectElement).value).toBe('normal'); - expect((screen.getByLabelText('开启排行榜') as HTMLInputElement).checked).toBe(true); + expect(screen.queryByLabelText('资源 URL')).toBeNull(); + expect(screen.queryByLabelText('玩家图片 URL')).toBeNull(); + expect(screen.queryByLabelText('对手图片 URL')).toBeNull(); + expect(screen.queryByLabelText('UI背景 URL')).toBeNull(); + expect(screen.queryByLabelText('排行榜开关')).toBeNull(); + expect( + (screen.getByLabelText('拟声词') as HTMLTextAreaElement).value, + ).toContain('轰汪!'); await userEvent.clear(screen.getByLabelText('作品标题')); - await userEvent.type(screen.getByLabelText('作品标题'), '周末狗狗杯'); - await userEvent.selectOptions(screen.getByLabelText('主题背景'), 'neon-park'); - await userEvent.selectOptions(screen.getByLabelText('玩家狗狗'), 'shiba'); - await userEvent.selectOptions(screen.getByLabelText('对手狗狗'), 'husky'); + await userEvent.type(screen.getByLabelText('作品标题'), '狗狗冠军杯'); + await userEvent.clear(screen.getByLabelText('主题/场景描述')); + await userEvent.type(screen.getByLabelText('主题/场景描述'), '霓虹公园声浪擂台'); + await userEvent.clear(screen.getByLabelText('玩家形象描述')); + await userEvent.type(screen.getByLabelText('玩家形象描述'), '红围巾柴犬'); + await userEvent.clear(screen.getByLabelText('对手形象描述')); + await userEvent.type(screen.getByLabelText('对手形象描述'), '蓝头带哈士奇'); + await userEvent.clear(screen.getByLabelText('拟声词')); + await userEvent.type( + screen.getByLabelText('拟声词'), + '炸场!\n冲啊! / 破阵!、Boom!', + ); await userEvent.selectOptions(screen.getByLabelText('难度预设'), 'hard'); - await userEvent.click(screen.getByLabelText('开启排行榜')); - await userEvent.click(screen.getByRole('button', { name: '发布并试玩' })); + await userEvent.click(screen.getByRole('button', { name: '生成草稿' })); - expect(onPublish).toHaveBeenCalledWith({ - title: '周末狗狗杯', + expect(onPreview).toHaveBeenCalledWith({ + title: '狗狗冠军杯', description: '', - themePreset: 'neon-park', - playerDogSkinPreset: 'shiba', - opponentDogSkinPreset: 'husky', + themeDescription: '霓虹公园声浪擂台', + playerImageDescription: '红围巾柴犬', + opponentImageDescription: '蓝头带哈士奇', + onomatopoeia: ['炸场!', '冲啊!', '破阵!', 'Boom!'], difficultyPreset: 'hard', - leaderboardEnabled: false, }); }); - it('requires a non-empty title before publishing', async () => { - const onPublish = vi.fn(); - render(); + it('uses a louder theme-aware default onomatopoeia pool without locking to dogs', async () => { + const onPreview = vi.fn(); + render(); + + const defaultWords = (screen.getByLabelText('拟声词') as HTMLTextAreaElement) + .value.split(/\n+/u) + .map((word) => word.trim()) + .filter(Boolean); + + expect(defaultWords.length).toBeGreaterThanOrEqual(10); + expect(defaultWords).toEqual( + expect.arrayContaining(['轰汪!', '炸场!', '破阵!', '燃起来!']), + ); + expect(defaultWords.some((word) => word.includes('喵'))).toBe(false); + expect(defaultWords.some((word) => word.includes('汪'))).toBe(true); + + await userEvent.clear(screen.getByLabelText('主题/场景描述')); + await userEvent.type( + screen.getByLabelText('主题/场景描述'), + '星舰机甲擂台,等离子音浪爆发', + ); + await userEvent.clear(screen.getByLabelText('玩家形象描述')); + await userEvent.type(screen.getByLabelText('玩家形象描述'), '星际猫骑士'); + await userEvent.clear(screen.getByLabelText('对手形象描述')); + await userEvent.type(screen.getByLabelText('对手形象描述'), '机器人拳手'); + + const updatedWords = (screen.getByLabelText('拟声词') as HTMLTextAreaElement) + .value.split(/\n+/u) + .map((word) => word.trim()) + .filter(Boolean); + expect(updatedWords).toEqual( + expect.arrayContaining(['能量爆裂!', '超频!', '电光轰鸣!']), + ); + expect(updatedWords.some((word) => word.includes('汪'))).toBe(false); + }); + + it('keeps creator-edited onomatopoeia when descriptions change', async () => { + const onPreview = vi.fn(); + render(); + + await userEvent.clear(screen.getByLabelText('拟声词')); + await userEvent.type(screen.getByLabelText('拟声词'), '轰!\n破阵!'); + await userEvent.clear(screen.getByLabelText('主题/场景描述')); + await userEvent.type(screen.getByLabelText('主题/场景描述'), '星舰机甲擂台'); + + expect((screen.getByLabelText('拟声词') as HTMLTextAreaElement).value).toBe( + '轰!\n破阵!', + ); + }); + + it('requires a non-empty title before compiling a draft', async () => { + const onPreview = vi.fn(); + render(); await userEvent.clear(screen.getByLabelText('作品标题')); - await userEvent.click(screen.getByRole('button', { name: '发布并试玩' })); + await userEvent.click(screen.getByRole('button', { name: '生成草稿' })); - expect(onPublish).not.toHaveBeenCalled(); + expect(onPreview).not.toHaveBeenCalled(); expect(screen.getByText('请先填写作品标题')).toBeTruthy(); }); it('can render as an embedded creation form without a local page header', () => { - const onPublish = vi.fn(); + const onPreview = vi.fn(); render( , @@ -63,6 +127,32 @@ describe('BarkBattleConfigEditor', () => { expect(screen.queryByRole('heading', { name: '汪汪声浪大作战' })).toBeNull(); expect(screen.queryByRole('button', { name: '返回' })).toBeNull(); expect(screen.getByLabelText('汪汪声浪轻配置编辑器')).toBeTruthy(); - expect(screen.getByText('发布失败')).toBeTruthy(); + expect(screen.getByText('外部错误')).toBeTruthy(); + }); + + it('keeps the mobile form in the parent scroll flow with a safe submit footer', () => { + const onPreview = vi.fn(); + render( + , + ); + + const editor = screen.getByLabelText('汪汪声浪轻配置编辑器'); + expect(editor.className).toContain('overflow-visible'); + expect(editor.className).toContain('lg:overflow-y-auto'); + expect(editor.className).not.toContain('overflow-y-auto overscroll-y-contain pr-0.5'); + + const themeLabel = screen.getByText('主题/场景描述'); + expect(themeLabel.className).toContain('bg-rose-50'); + + const submitFooter = screen + .getByRole('button', { name: '生成草稿' }) + .closest('div'); + expect(submitFooter?.className).toContain('shrink-0'); + expect(submitFooter?.className).toContain('safe-area-inset-bottom'); }); }); diff --git a/src/components/bark-battle-creation/BarkBattleConfigEditor.tsx b/src/components/bark-battle-creation/BarkBattleConfigEditor.tsx index aed592390..42246dcd4 100644 --- a/src/components/bark-battle-creation/BarkBattleConfigEditor.tsx +++ b/src/components/bark-battle-creation/BarkBattleConfigEditor.tsx @@ -1,88 +1,144 @@ -import { ArrowLeft, Loader2, Trophy, WandSparkles } from 'lucide-react'; -import { useMemo, useState } from 'react'; +import { ArrowLeft, Loader2, Play } from 'lucide-react'; +import { useEffect, useMemo, useState } from 'react'; import type { BarkBattleConfigEditorPayload } from '../../../packages/shared/src/contracts/barkBattle'; import type { BarkBattleDifficultyPreset } from '../../../packages/shared/src/contracts/barkBattle'; +import { buildBarkBattleDefaultOnomatopoeia } from '../../games/bark-battle/application/BarkBattleConfig'; import { BarkBattlePreviewCard } from './BarkBattlePreviewCard'; export type BarkBattleConfigEditorProps = { isBusy?: boolean; error?: string | null; - onPublish: (payload: BarkBattleConfigEditorPayload) => void | Promise; + onPreview: (payload: BarkBattleConfigEditorPayload) => void | Promise; onBack?: () => void; showBackButton?: boolean; title?: string | null; }; -const THEME_OPTIONS = [ - { value: 'sunny-yard', label: '阳光院子' }, - { value: 'neon-park', label: '霓虹公园' }, - { value: 'moonlight-rooftop', label: '月光天台' }, -]; - -const DOG_SKIN_OPTIONS = [ - { value: 'corgi', label: '柯基' }, - { value: 'shiba', label: '柴犬' }, - { value: 'husky', label: '哈士奇' }, -]; - const DIFFICULTY_OPTIONS: Array<{ value: BarkBattleDifficultyPreset; label: string }> = [ { value: 'easy', label: '轻松' }, { value: 'normal', label: '标准' }, { value: 'hard', label: '硬核' }, ]; +const FIELD_LABEL_CLASS = + 'mb-2 inline-flex rounded-full px-2 py-0.5 text-sm font-black text-[var(--platform-text-strong)]'; +const ACCENT_FIELD_LABEL_CLASS = + 'mb-2 inline-flex rounded-full border border-rose-200/70 bg-rose-50/88 px-2.5 py-1 text-sm font-black text-rose-700 shadow-sm'; +const DEFAULT_THEME_DESCRIPTION = '阳光草坪上的圆形声浪擂台'; +const DEFAULT_PLAYER_IMAGE_DESCRIPTION = '戴红色围巾的勇敢小狗'; +const DEFAULT_OPPONENT_IMAGE_DESCRIPTION = '戴蓝色头带的活力小狗'; + +function buildDefaultOnomatopoeiaText(params: { + themeDescription: string; + playerImageDescription: string; + opponentImageDescription: string; +}) { + return buildBarkBattleDefaultOnomatopoeia(params).join('\n'); +} export function BarkBattleConfigEditor({ isBusy = false, error: externalError = null, - onPublish, + onPreview, onBack, showBackButton = true, title: headingTitle = '汪汪声浪大作战', }: BarkBattleConfigEditorProps) { const [title, setTitle] = useState('我的声浪竞技场'); const [description, setDescription] = useState(''); - const [themePreset, setThemePreset] = useState('sunny-yard'); - const [playerDogSkinPreset, setPlayerDogSkinPreset] = useState('corgi'); - const [opponentDogSkinPreset, setOpponentDogSkinPreset] = useState('husky'); + const [themeDescription, setThemeDescription] = useState( + DEFAULT_THEME_DESCRIPTION, + ); + const [playerImageDescription, setPlayerImageDescription] = useState( + DEFAULT_PLAYER_IMAGE_DESCRIPTION, + ); + const [opponentImageDescription, setOpponentImageDescription] = useState( + DEFAULT_OPPONENT_IMAGE_DESCRIPTION, + ); + const [isOnomatopoeiaCustomized, setIsOnomatopoeiaCustomized] = + useState(false); + const [onomatopoeiaText, setOnomatopoeiaText] = useState(() => + buildDefaultOnomatopoeiaText({ + themeDescription: DEFAULT_THEME_DESCRIPTION, + playerImageDescription: DEFAULT_PLAYER_IMAGE_DESCRIPTION, + opponentImageDescription: DEFAULT_OPPONENT_IMAGE_DESCRIPTION, + }), + ); const [difficultyPreset, setDifficultyPreset] = useState('normal'); - const [leaderboardEnabled, setLeaderboardEnabled] = useState(true); const [localError, setLocalError] = useState(null); + useEffect(() => { + if (isOnomatopoeiaCustomized) { + return; + } + setOnomatopoeiaText( + buildDefaultOnomatopoeiaText({ + themeDescription, + playerImageDescription, + opponentImageDescription, + }), + ); + }, [ + isOnomatopoeiaCustomized, + themeDescription, + playerImageDescription, + opponentImageDescription, + ]); + + const onomatopoeia = useMemo( + () => + onomatopoeiaText + .split(/[\n,,、/|]+/u) + .map((word) => word.trim()) + .filter(Boolean) + .slice(0, 24), + [onomatopoeiaText], + ); + const payload = useMemo( () => ({ title: title.trim(), description: description.trim(), - themePreset, - playerDogSkinPreset, - opponentDogSkinPreset, + themeDescription: themeDescription.trim(), + playerImageDescription: playerImageDescription.trim(), + opponentImageDescription: opponentImageDescription.trim(), + onomatopoeia, difficultyPreset, - leaderboardEnabled, }), [ title, description, - themePreset, - playerDogSkinPreset, - opponentDogSkinPreset, + themeDescription, + playerImageDescription, + opponentImageDescription, + onomatopoeia, difficultyPreset, - leaderboardEnabled, ], ); - const handlePublish = () => { + const runValidatedAction = ( + action: (payload: BarkBattleConfigEditorPayload) => void | Promise, + ) => { if (!payload.title) { setLocalError('请先填写作品标题'); return; } + if (!payload.themeDescription) { + setLocalError('请先填写主题/场景描述'); + return; + } + if (!payload.playerImageDescription || !payload.opponentImageDescription) { + setLocalError('请先填写双方形象描述'); + return; + } setLocalError(null); - void onPublish(payload); + void action(payload); }; const visibleError = localError ?? externalError; return (
{showBackButton && onBack ? ( @@ -101,7 +157,7 @@ export function BarkBattleConfigEditor({ ) : null} -
+
{headingTitle ? (
@@ -116,13 +172,11 @@ export function BarkBattleConfigEditor({ ) : null}
-
+