From 269f35cecfc13e0e1fd3e9e83f532d7a0967e1ea Mon Sep 17 00:00:00 2001 From: kdletters <61648117+kdletters@users.noreply.github.com> Date: Mon, 18 May 2026 19:40:14 +0800 Subject: [PATCH] refactor: split large modules and normalize rust layout --- .hermes/shared-memory/decision-log.md | 21 +- .hermes/shared-memory/pitfalls.md | 6 +- ...】server-rs与SpacetimeDB数据契约-2026-05-15.md | 60 +- ...玩法创作】平台入口与玩法链路-2026-05-15.md | 2 + scripts/check-spacetime-runtime-access.mjs | 6 +- scripts/generate-spacetime-bindings.mjs | 26 + .../mod.rs => generated_image_assets.rs} | 0 server-rs/crates/api-server/src/match3d.rs | 8588 +--------------- .../crates/api-server/src/match3d/draft.rs | 881 ++ .../crates/api-server/src/match3d/handlers.rs | 1406 +++ .../api-server/src/match3d/item_assets.rs | 2631 +++++ .../crates/api-server/src/match3d/runtime.rs | 37 + .../crates/api-server/src/match3d/tests.rs | 1875 ++++ .../src/match3d/vector_engine_gemini.rs | 483 + .../crates/api-server/src/match3d/works.rs | 1254 +++ .../src/{modules/mod.rs => modules.rs} | 0 .../src/{prompt/mod.rs => prompt.rs} | 0 .../src/prompt/{puzzle/mod.rs => puzzle.rs} | 0 .../src/prompt/{rpg/mod.rs => rpg.rs} | 0 .../src/{puzzle/mod.rs => puzzle.rs} | 0 .../crates/spacetime-client/src/mapper.rs | 8642 +---------------- .../crates/spacetime-client/src/mapper/ai.rs | 306 + .../spacetime-client/src/mapper/assets.rs | 382 + .../spacetime-client/src/mapper/auth.rs | 42 + .../src/mapper/bark_battle.rs | 94 + .../spacetime-client/src/mapper/big_fish.rs | 616 ++ .../spacetime-client/src/mapper/combat.rs | 124 + .../spacetime-client/src/mapper/common.rs | 706 ++ .../src/mapper/custom_world.rs | 957 ++ .../spacetime-client/src/mapper/inventory.rs | 200 + .../spacetime-client/src/mapper/match3d.rs | 606 ++ .../crates/spacetime-client/src/mapper/npc.rs | 624 ++ .../spacetime-client/src/mapper/puzzle.rs | 1084 +++ .../spacetime-client/src/mapper/runtime.rs | 440 + .../src/mapper/runtime_profile.rs | 1326 +++ .../src/mapper/square_hole.rs | 417 + .../spacetime-client/src/mapper/story.rs | 291 + .../src/mapper/visual_novel.rs | 252 + .../mod.rs => module_bindings.rs} | 0 .../spacetime-module/src/{ai/mod.rs => ai.rs} | 0 .../mod.rs => asset_metadata.rs} | 0 .../src/{auth/mod.rs => auth.rs} | 0 .../{bark_battle/mod.rs => bark_battle.rs} | 0 .../src/{big_fish/mod.rs => big_fish.rs} | 0 .../{custom_world/mod.rs => custom_world.rs} | 0 .../src/{gameplay/mod.rs => gameplay.rs} | 0 .../src/{match3d/mod.rs => match3d.rs} | 0 .../src/{runtime/mod.rs => runtime.rs} | 0 .../{square_hole/mod.rs => square_hole.rs} | 0 .../PlatformEntryFlowShellImpl.tsx | 139 +- .../PuzzleOnboardingView.tsx | 137 + 51 files changed, 17492 insertions(+), 17169 deletions(-) rename server-rs/crates/api-server/src/{generated_image_assets/mod.rs => generated_image_assets.rs} (100%) create mode 100644 server-rs/crates/api-server/src/match3d/draft.rs create mode 100644 server-rs/crates/api-server/src/match3d/handlers.rs create mode 100644 server-rs/crates/api-server/src/match3d/item_assets.rs create mode 100644 server-rs/crates/api-server/src/match3d/runtime.rs create mode 100644 server-rs/crates/api-server/src/match3d/tests.rs create mode 100644 server-rs/crates/api-server/src/match3d/vector_engine_gemini.rs create mode 100644 server-rs/crates/api-server/src/match3d/works.rs rename server-rs/crates/api-server/src/{modules/mod.rs => modules.rs} (100%) rename server-rs/crates/api-server/src/{prompt/mod.rs => prompt.rs} (100%) rename server-rs/crates/api-server/src/prompt/{puzzle/mod.rs => puzzle.rs} (100%) rename server-rs/crates/api-server/src/prompt/{rpg/mod.rs => rpg.rs} (100%) rename server-rs/crates/api-server/src/{puzzle/mod.rs => puzzle.rs} (100%) create mode 100644 server-rs/crates/spacetime-client/src/mapper/ai.rs create mode 100644 server-rs/crates/spacetime-client/src/mapper/assets.rs create mode 100644 server-rs/crates/spacetime-client/src/mapper/auth.rs create mode 100644 server-rs/crates/spacetime-client/src/mapper/bark_battle.rs create mode 100644 server-rs/crates/spacetime-client/src/mapper/big_fish.rs create mode 100644 server-rs/crates/spacetime-client/src/mapper/combat.rs create mode 100644 server-rs/crates/spacetime-client/src/mapper/common.rs create mode 100644 server-rs/crates/spacetime-client/src/mapper/custom_world.rs create mode 100644 server-rs/crates/spacetime-client/src/mapper/inventory.rs create mode 100644 server-rs/crates/spacetime-client/src/mapper/match3d.rs create mode 100644 server-rs/crates/spacetime-client/src/mapper/npc.rs create mode 100644 server-rs/crates/spacetime-client/src/mapper/puzzle.rs create mode 100644 server-rs/crates/spacetime-client/src/mapper/runtime.rs create mode 100644 server-rs/crates/spacetime-client/src/mapper/runtime_profile.rs create mode 100644 server-rs/crates/spacetime-client/src/mapper/square_hole.rs create mode 100644 server-rs/crates/spacetime-client/src/mapper/story.rs create mode 100644 server-rs/crates/spacetime-client/src/mapper/visual_novel.rs rename server-rs/crates/spacetime-client/src/{module_bindings/mod.rs => module_bindings.rs} (100%) rename server-rs/crates/spacetime-module/src/{ai/mod.rs => ai.rs} (100%) rename server-rs/crates/spacetime-module/src/{asset_metadata/mod.rs => asset_metadata.rs} (100%) rename server-rs/crates/spacetime-module/src/{auth/mod.rs => auth.rs} (100%) rename server-rs/crates/spacetime-module/src/{bark_battle/mod.rs => bark_battle.rs} (100%) rename server-rs/crates/spacetime-module/src/{big_fish/mod.rs => big_fish.rs} (100%) rename server-rs/crates/spacetime-module/src/{custom_world/mod.rs => custom_world.rs} (100%) rename server-rs/crates/spacetime-module/src/{gameplay/mod.rs => gameplay.rs} (100%) rename server-rs/crates/spacetime-module/src/{match3d/mod.rs => match3d.rs} (100%) rename server-rs/crates/spacetime-module/src/{runtime/mod.rs => runtime.rs} (100%) rename server-rs/crates/spacetime-module/src/{square_hole/mod.rs => square_hole.rs} (100%) create mode 100644 src/components/platform-entry/PlatformEntryFlowShellImpl/PuzzleOnboardingView.tsx diff --git a/.hermes/shared-memory/decision-log.md b/.hermes/shared-memory/decision-log.md index 9dad9456..6468f9d3 100644 --- a/.hermes/shared-memory/decision-log.md +++ b/.hermes/shared-memory/decision-log.md @@ -16,10 +16,29 @@ --- + +## 2026-05-18 Rust 手写模块入口统一不用 mod.rs + +- 背景:Rust 目录模块同时存在 `mod.rs` 与同名 `.rs` 两种入口形式,前次拆分已让 `spacetime-client/src/mapper.rs` 采用同名入口;继续新增 `mod.rs` 会让文件定位和评审口径不一致。 +- 决策:手写 Rust 模块统一使用同名入口文件,例如 `puzzle.rs`、`match3d.rs`、`gameplay.rs`,子模块继续放在同名目录下;不要再为手写模块新增 `mod.rs`。SpacetimeDB CLI 生成的 bindings 也由生成脚本同步为 `module_bindings.rs` 加 `module_bindings/` 子目录,避免仓库里继续出现 `mod.rs`。 +- 边界:本决策只规范文件布局,不改变 module path、HTTP route、DTO、SpacetimeDB schema、生成绑定内容或运行时行为。 +- 影响范围:`server-rs/crates/api-server/src/`、`server-rs/crates/spacetime-module/src/`、`server-rs/crates/spacetime-client/src/module_bindings.rs`、`scripts/generate-spacetime-bindings.mjs`。 +- 验证方式:执行 `Get-ChildItem server-rs -Recurse -Filter mod.rs` 应无结果;再执行对应 `cargo check` / 定向测试 / 编码检查。 +- 关联文档:`docs/【后端架构】server-rs与SpacetimeDB数据契约-2026-05-15.md`。 + +## 2026-05-18 大文件拆分继续按聚合入口加领域子模块推进 + +- 背景:完成拼图 `api-server` 拆分后,`match3d.rs`、`spacetime-client/src/mapper.rs` 与 `PlatformEntryFlowShellImpl.tsx` 仍是后续迭代和评审的高噪音大文件。 +- 决策:抓大鹅 Match3D 的 `api-server` 单文件改为同名入口 `src/match3d.rs` 加 `src/match3d/` 子模块目录,`handlers.rs`、`draft.rs`、`works.rs`、`item_assets.rs`、`runtime.rs`、`vector_engine_gemini.rs`、`mappers.rs`、`tags.rs`、`tests.rs` 分担原实现;`spacetime-client/src/mapper.rs` 改为聚合入口,具体 mapper 按领域落到 `src/mapper/*.rs`;平台入口继续以 `PlatformEntryFlowShellImpl.tsx` 为编排壳,独立 UI 片段优先拆到 `PlatformEntryFlowShellImpl/` 子目录,本次已抽出 `PuzzleOnboardingView.tsx`。 +- 边界:这些拆分只改变文件组织,不改变 HTTP route、DTO、error envelope、SpacetimeDB schema、生成绑定、procedure result、入口配置事实源、前端行为、VectorEngine / OSS 副作用或计费语义。后续要下沉领域规则时另行讨论并更新设计。 +- 影响范围:`server-rs/crates/api-server/src/match3d/`、`server-rs/crates/spacetime-client/src/mapper/`、`src/components/platform-entry/PlatformEntryFlowShellImpl/`、后端架构文档和玩法链路文档。 +- 验证方式:执行 `cargo check -p api-server --manifest-path server-rs\Cargo.toml`、`cargo test -p api-server match3d --manifest-path server-rs\Cargo.toml --no-run`、`cargo check -p spacetime-client --manifest-path server-rs\Cargo.toml`、前端 typecheck 或定向 tsc、`git diff --check` 与 `npm run check:encoding`。 +- 关联文档:`docs/【后端架构】server-rs与SpacetimeDB数据契约-2026-05-15.md`、`docs/【玩法创作】平台入口与玩法链路-2026-05-15.md`。 + ## 2026-05-18 api-server 拼图能力按 HTTP/BFF 子模块拆分 - 背景:`server-rs/crates/api-server/src/puzzle.rs` 已膨胀为数千行大文件,混合 Axum handler、草稿编译、图片生成、VectorEngine / OSS 持久化、DTO mapper、标签生成和测试;继续在单文件内迭代会降低定位和评审效率。 -- 决策:删除单文件 `puzzle.rs`,改为 `server-rs/crates/api-server/src/puzzle/` 目录模块。`mod.rs` 只保留聚合入口和 handler re-export;`handlers.rs` 放 HTTP handler;`draft.rs` 放表单草稿 / 编译 / snapshot helper;`generation.rs` 放图片与 UI 背景生成编排;`vector_engine.rs` 放 VectorEngine、下载、OSS、asset object / binding 和错误归一;`mappers.rs` / `tags.rs` 保留映射和标签 / 错误 helper;`tests.rs` 承接原 puzzle 单测。 +- 决策:原超大 `puzzle.rs` 改为同名入口 `server-rs/crates/api-server/src/puzzle.rs` 加 `server-rs/crates/api-server/src/puzzle/` 子模块目录。`puzzle.rs` 只保留聚合入口和 handler re-export;`handlers.rs` 放 HTTP handler;`draft.rs` 放表单草稿 / 编译 / snapshot helper;`generation.rs` 放图片与 UI 背景生成编排;`vector_engine.rs` 放 VectorEngine、下载、OSS、asset object / binding 和错误归一;`mappers.rs` / `tags.rs` 保留映射和标签 / 错误 helper;`tests.rs` 承接原 puzzle 单测。 - 边界:本次只改变 `api-server` 内部文件组织,不改变 `/api/runtime/puzzle/*` 路由、DTO、error envelope、SpacetimeDB schema、公开 gallery cache 语义或计费语义。领域规则后续仍应逐步沉到 `module-puzzle`,SpacetimeDB 表、reducer、procedure 和 row shape 仍留在 `spacetime-module`。 - 影响范围:`server-rs/crates/api-server/src/puzzle/`、`server-rs/crates/api-server/src/modules/puzzle.rs` 的 handler 引用、后端架构文档。 - 验证方式:执行 `cargo check -p api-server --manifest-path server-rs\Cargo.toml`;后续若改动 puzzle API 行为,再按对应路由补充定向测试和 `npm run dev:api-server` `/healthz` smoke。 diff --git a/.hermes/shared-memory/pitfalls.md b/.hermes/shared-memory/pitfalls.md index 2b8ccc05..a24a32b8 100644 --- a/.hermes/shared-memory/pitfalls.md +++ b/.hermes/shared-memory/pitfalls.md @@ -44,7 +44,7 @@ - 原因:封面生成属于定向图片槽位更新;若后端复用草稿编译写回,可能按 session config 重算作品行。即使后端已修正,前端若直接把封面接口返回的整份 `item` 当成最新 profile,也可能用旧回包里的空 `generatedItemAssets` 覆盖当前页面素材。 - 处理:`POST /api/creation/match3d/works/{profileId}/cover-image` 只保存 `coverImageSrc` / `coverAssetId` 等封面字段,保留当前 `generated_item_assets_json`、难度、消除次数、题材和描述;前端收到回包后只合并 `coverImageSrc`,继续保留当前可见 `generatedItemAssets`、`clearCount` 和 `difficulty`。 - 验证:`npm run test -- src\components\match3d-result\Match3DResultView.test.tsx` 覆盖旧回包不覆盖物品素材和配置;`cargo test -p api-server match3d_cover --manifest-path server-rs\Cargo.toml` 覆盖封面提示词与参考图链路。 -- 关联:`src/components/match3d-result/Match3DResultView.tsx`、`server-rs/crates/api-server/src/match3d.rs`、`server-rs/crates/spacetime-module/src/match3d/mod.rs`、`docs/technical/MATCH3D_DRAFT_ASSET_GENERATION_PIPELINE_2026-05-10.md`。 +- 关联:`src/components/match3d-result/Match3DResultView.tsx`、`server-rs/crates/api-server/src/match3d.rs`、`server-rs/crates/spacetime-module/src/match3d.rs`、`docs/technical/MATCH3D_DRAFT_ASSET_GENERATION_PIPELINE_2026-05-10.md`。 ## OSS V4 签名时间和 bucket/object_key 兼容 @@ -113,7 +113,7 @@ - 原因:个人作品列表和公开广场列表复用了同一套 procedure 输入,导致公开列表为了通过 owner 校验传固定占位 owner,并把可长期同步的公开读模型当成请求期查询。 - 处理:每个公开广场新增或复用专用 public view / public read model:`match_3_d_gallery_view`、`square_hole_gallery_view`、`visual_novel_gallery_view`、`big_fish_gallery_view`。`spacetime-client` 建连接后订阅这些 view 和对应 `public_work_play_daily_stat` source_type 桶,HTTP gallery 只读本地 cache。个人作品列表、详情、发布、点赞、游玩记录和 Remix 仍走原有 procedure / reducer。 - 验证:搜索 `server-rs/crates/spacetime-client/src/{match3d,square_hole,visual_novel,big_fish}.rs`,公开 gallery 主路径应读取 `connection.db().*_gallery_view()`,不应调用 `list_*_works_with_input`;执行 `npm run spacetime:generate`、`cargo check -p spacetime-client --manifest-path server-rs/Cargo.toml`、`cargo check -p api-server --manifest-path server-rs/Cargo.toml`、`npm run check:spacetime-schema`。 -- 关联:`server-rs/crates/spacetime-module/src/match3d/mod.rs`、`server-rs/crates/spacetime-module/src/square_hole/mod.rs`、`server-rs/crates/spacetime-module/src/visual_novel.rs`、`server-rs/crates/spacetime-module/src/big_fish/session.rs`、`docs/【后端架构】server-rs与SpacetimeDB数据契约-2026-05-15.md`。 +- 关联:`server-rs/crates/spacetime-module/src/match3d.rs`、`server-rs/crates/spacetime-module/src/square_hole.rs`、`server-rs/crates/spacetime-module/src/visual_novel.rs`、`server-rs/crates/spacetime-module/src/big_fish/session.rs`、`docs/【后端架构】server-rs与SpacetimeDB数据契约-2026-05-15.md`。 ## 自定义世界广场和创作入口配置不要每次 HTTP 请求调用只读 procedure @@ -846,7 +846,7 @@ - 原因:旧运行态把消除次数和类型数量绑在一起,结果页文案又同时展示“素材图片 / 局内类型”,导致前端、发布校验和 run start 口径不一致。 - 处理:统一使用 `物品种类` 口径:轻松 3、标准 9、进阶 15、硬核 21;历史 `clearCount=20` 且难度为硬核的运行态按新硬核升为 21 组三消,避免 20 组却要求 21 种素材。发布前按 `image_ready` 且有 `imageViews[]` 或 `imageSrc/imageObjectKey` 的生成素材数量阻断不足难度;试玩不阻断,但通过 `itemTypeCountOverride` 自动降到已生成 2D 素材数量。重启从已有 run 快照反推实际物品种类,保持同一局重开不变。 - 验证:`npm run test -- src\components\match3d-result\Match3DResultView.test.tsx`、`cargo test -p module-match3d --manifest-path server-rs\Cargo.toml`,涉及发布 reducer 时补跑 `cargo test -p spacetime-module match3d --manifest-path server-rs\Cargo.toml`。 -- 关联:`src/components/match3d-result/Match3DResultView.tsx`、`src/services/match3d-runtime/match3dRuntimeClient.ts`、`server-rs/crates/module-match3d/src/application.rs`、`server-rs/crates/spacetime-module/src/match3d/mod.rs`、`docs/technical/MATCH3D_DRAFT_ASSET_GENERATION_PIPELINE_2026-05-10.md`。 +- 关联:`src/components/match3d-result/Match3DResultView.tsx`、`src/services/match3d-runtime/match3dRuntimeClient.ts`、`server-rs/crates/module-match3d/src/application.rs`、`server-rs/crates/spacetime-module/src/match3d.rs`、`docs/technical/MATCH3D_DRAFT_ASSET_GENERATION_PIPELINE_2026-05-10.md`。 ## 抓大鹅标签清洗不要把 `3D素材` 当编号剥掉 diff --git a/docs/【后端架构】server-rs与SpacetimeDB数据契约-2026-05-15.md b/docs/【后端架构】server-rs与SpacetimeDB数据契约-2026-05-15.md index ca8ff0cc..cb2ce61f 100644 --- a/docs/【后端架构】server-rs与SpacetimeDB数据契约-2026-05-15.md +++ b/docs/【后端架构】server-rs与SpacetimeDB数据契约-2026-05-15.md @@ -40,6 +40,12 @@ server-rs + Axum + SpacetimeDB npm run check:server-rs-ddd ``` +## `spacetime-client` mapper 组织 + +`server-rs/crates/spacetime-client/src/mapper.rs` 只作为聚合入口,负责声明 `src/mapper/` 下的领域子模块并 re-export 原有 record / mapper 能力;不要在该文件继续堆叠大段映射实现。 + +当前子模块按调用领域拆分:`assets.rs`、`auth.rs`、`runtime.rs`、`runtime_profile.rs`、`custom_world.rs`、`puzzle.rs`、`match3d.rs`、`square_hole.rs`、`visual_novel.rs`、`big_fish.rs`、`story.rs`、`ai.rs`、`bark_battle.rs`、`combat.rs`、`inventory.rs`、`npc.rs`,跨领域轻量 helper 和共享 record 统一放在 `common.rs`。该拆分只改变 `spacetime-client` 文件组织,不改变 SpacetimeDB schema、生成绑定、procedure result 契约或外部 DTO;后续新增 mapper 时优先落到对应领域子模块,不得重新引入跨层 JSON 字符串兼容结构。 + ## API 路由分组 路由树由 `server-rs/crates/api-server/src/app.rs` 统一构造。当前主要分组: @@ -73,11 +79,12 @@ npm run check:server-rs-ddd 2. `app.rs` 只保留全局 middleware、TraceLayer、request context、tracking middleware、入口开关和少量顶层 glue。 3. 路由迁移和业务重构分阶段处理;先移动路由装配,再拆 handler 内部实现。 4. 大 handler 拆分时优先按 `router.rs`、`handlers.rs`、`application.rs`、`assets.rs`、`mapper.rs`、`errors.rs` 分层。`handlers.rs` 只做 Axum extract、鉴权和 request/response,业务规则继续下沉到 `module-*`。 +5. 手写 Rust 模块入口统一使用同名 `.rs` 文件,例如 `puzzle.rs` + `puzzle/*.rs`、`match3d.rs` + `match3d/*.rs`;不要再新增 `mod.rs` 入口。生成的 SpacetimeDB Rust bindings 也由生成脚本同步为 `module_bindings.rs` + `module_bindings/*.rs` 布局。 拼图 `api-server` 内部拆分: - `server-rs/crates/api-server/src/modules/puzzle.rs` 只负责路由装配、鉴权层和参考图 body limit;对外继续引用同一批 handler 名称。 -- `server-rs/crates/api-server/src/puzzle/mod.rs` 只作为聚合入口,保留共享 import / 常量、内部模块声明和 handler re-export,不继续承载大段实现。 +- `server-rs/crates/api-server/src/puzzle.rs` 只作为聚合入口,保留共享 import / 常量、内部模块声明和 handler re-export,不继续承载大段实现。 - `server-rs/crates/api-server/src/puzzle/handlers.rs` 承接 Axum handler,负责 extract、鉴权上下文、调用 SpacetimeDB facade / 编排 helper,并返回 HTTP/SSE 响应。 - `server-rs/crates/api-server/src/puzzle/draft.rs` 承接表单草稿保存、草稿编译、首关命名、UI 背景 prompt、降级 snapshot 和初始资产就绪校验。 - `server-rs/crates/api-server/src/puzzle/generation.rs` 承接拼图图片与 UI 背景的生成编排、计费包裹和 reference image 路径选择。 @@ -87,6 +94,19 @@ npm run check:server-rs-ddd 该拆分只改变 `api-server` 文件组织,不改变 `/api/runtime/puzzle/*` route、DTO、error envelope、SpacetimeDB schema、公开 gallery cache 语义或计费语义;后续继续细分时也必须先保持行为不变,再单独讨论领域规则下沉。 +抓大鹅 Match3D `api-server` 内部拆分: + +- `server-rs/crates/api-server/src/modules/match3d.rs` 继续负责路由装配和 body limit;对外 handler 名称保持不变。 +- `server-rs/crates/api-server/src/match3d.rs` 只作为聚合入口,保留共享 import / 常量 / 内部类型、模块声明和 handler re-export。 +- `server-rs/crates/api-server/src/match3d/handlers.rs` 承接 Axum handler,负责 extract、鉴权上下文、调用 SpacetimeDB facade / 编排 helper,并返回 HTTP 响应。 +- `server-rs/crates/api-server/src/match3d/draft.rs` 承接 Agent session、草稿编译、题材 / 难度 / 物品计划和草稿持久化编排。 +- `server-rs/crates/api-server/src/match3d/works.rs` 承接作品 CRUD、封面 / 背景 / 容器资产生成入口、发布 / Remix / 点赞 / 游玩记录和作品级 helper。 +- `server-rs/crates/api-server/src/match3d/item_assets.rs` 承接物品 sheet 生成、绿幕 / 近白底透明化、切图、append / replace / delete / sort / merge 和素材持久化。 +- `server-rs/crates/api-server/src/match3d/vector_engine_gemini.rs` 承接 VectorEngine Gemini 请求体、响应解析、base64 图片下载和上游错误归一。 +- `server-rs/crates/api-server/src/match3d/runtime.rs` 保留运行态轻量归一 helper;`mappers.rs` / `tags.rs` / `tests.rs` 分别承接 DTO 映射、标签 / 通用错误 helper 和原有单测。 + +该拆分只改变 `api-server` 文件组织,不改变 `/api/creation/match3d/*`、`/api/runtime/match3d/*` route、DTO、error envelope、SpacetimeDB schema、公开 gallery cache 语义、VectorEngine / OSS 副作用边界或计费语义;后续继续细分时也必须先保持行为不变,再单独讨论领域规则下沉到 `module-match3d`。 + 生成资产 Adapter 规则: 1. 稳定单图链路可收敛到 `api-server` 内部生成资产 Adapter:provider 生成、下载/base64 解码、MIME/extension 归一、OSS private upload、HEAD、asset object confirm、entity binding。 @@ -240,7 +260,7 @@ npm run check:server-rs-ddd ### `battle_state` - Rust 结构体:`BattleState` -- 源码:`server-rs/crates/spacetime-module/src/gameplay/mod.rs` +- 源码:`server-rs/crates/spacetime-module/src/gameplay.rs` ### `big_fish_agent_message` @@ -278,7 +298,7 @@ npm run check:server-rs-ddd ### `chapter_progression` - Rust 结构体:`ChapterProgression` -- 源码:`server-rs/crates/spacetime-module/src/gameplay/mod.rs` +- 源码:`server-rs/crates/spacetime-module/src/gameplay.rs` ### `creation_entry_config` @@ -293,38 +313,38 @@ npm run check:server-rs-ddd ### `custom_world_agent_message` - Rust 结构体:`CustomWorldAgentMessage` -- 源码:`server-rs/crates/spacetime-module/src/custom_world/mod.rs` +- 源码:`server-rs/crates/spacetime-module/src/custom_world.rs` ### `custom_world_agent_operation` - Rust 结构体:`CustomWorldAgentOperation` -- 源码:`server-rs/crates/spacetime-module/src/custom_world/mod.rs` +- 源码:`server-rs/crates/spacetime-module/src/custom_world.rs` ### `custom_world_agent_session` - Rust 结构体:`CustomWorldAgentSession` -- 源码:`server-rs/crates/spacetime-module/src/custom_world/mod.rs` +- 源码:`server-rs/crates/spacetime-module/src/custom_world.rs` ### `custom_world_draft_card` - Rust 结构体:`CustomWorldDraftCard` -- 源码:`server-rs/crates/spacetime-module/src/custom_world/mod.rs` +- 源码:`server-rs/crates/spacetime-module/src/custom_world.rs` ### `custom_world_gallery_entry` - Rust 结构体:`CustomWorldGalleryEntry` -- 源码:`server-rs/crates/spacetime-module/src/custom_world/mod.rs` +- 源码:`server-rs/crates/spacetime-module/src/custom_world.rs` - 作用:自定义世界公开作品列表读模型。`api-server` 的 `spacetime-client` 长期订阅 `SELECT * FROM custom_world_gallery_entry` 与 `SELECT * FROM public_work_play_daily_stat WHERE source_type = 'custom-world'`,`/api/runtime/custom-world-gallery` 从本地 cache 排序并聚合 `recentPlayCount7d`,不再每个 HTTP 请求调用 `list_custom_world_gallery_entries` procedure。旧 procedure 只用于兼容旧库缺少 gallery 读模型行时的一次性同步兜底。 ### `custom_world_profile` - Rust 结构体:`CustomWorldProfile` -- 源码:`server-rs/crates/spacetime-module/src/custom_world/mod.rs` +- 源码:`server-rs/crates/spacetime-module/src/custom_world.rs` ### `custom_world_session` - Rust 结构体:`CustomWorldSession` -- 源码:`server-rs/crates/spacetime-module/src/custom_world/mod.rs` +- 源码:`server-rs/crates/spacetime-module/src/custom_world.rs` ### `database_migration_import_chunk` @@ -339,7 +359,7 @@ npm run check:server-rs-ddd ### `inventory_slot` - Rust 结构体:`InventorySlot` -- 源码:`server-rs/crates/spacetime-module/src/gameplay/mod.rs` +- 源码:`server-rs/crates/spacetime-module/src/gameplay.rs` ### `match3d_agent_message` @@ -365,18 +385,18 @@ npm run check:server-rs-ddd - Rust view:`match3d_gallery_view` - 返回类型:`Vec` -- 源码:`server-rs/crates/spacetime-module/src/match3d/mod.rs` +- 源码:`server-rs/crates/spacetime-module/src/match3d.rs` - 说明:抓大鹅公开广场列表投影,只暴露 `publication_status = published` 的作品卡片字段;`api-server` 的 `spacetime-client` 长期订阅 `SELECT * FROM match_3_d_gallery_view` 与 `SELECT * FROM public_work_play_daily_stat WHERE source_type = 'match3d'` 后,从本地 cache 构造 `/api/runtime/match3d/gallery` 响应。公开列表不再每个 HTTP 请求调用 `list_match3d_works` procedure;个人作品列表、详情、发布、点赞、游玩记录和 Remix 仍按原有 procedure / reducer 路径处理。 ### `npc_state` - Rust 结构体:`NpcState` -- 源码:`server-rs/crates/spacetime-module/src/gameplay/mod.rs` +- 源码:`server-rs/crates/spacetime-module/src/gameplay.rs` ### `player_progression` - Rust 结构体:`PlayerProgression` -- 源码:`server-rs/crates/spacetime-module/src/gameplay/mod.rs` +- 源码:`server-rs/crates/spacetime-module/src/gameplay.rs` ### `profile_dashboard_state` @@ -546,12 +566,12 @@ npm run check:server-rs-ddd ### `quest_log` - Rust 结构体:`QuestLog` -- 源码:`server-rs/crates/spacetime-module/src/gameplay/mod.rs` +- 源码:`server-rs/crates/spacetime-module/src/gameplay.rs` ### `quest_record` - Rust 结构体:`QuestRecord` -- 源码:`server-rs/crates/spacetime-module/src/gameplay/mod.rs` +- 源码:`server-rs/crates/spacetime-module/src/gameplay.rs` ### `refresh_session` @@ -592,18 +612,18 @@ npm run check:server-rs-ddd - Rust view:`square_hole_gallery_view` - 返回类型:`Vec` -- 源码:`server-rs/crates/spacetime-module/src/square_hole/mod.rs` +- 源码:`server-rs/crates/spacetime-module/src/square_hole.rs` - 说明:方洞挑战公开广场列表投影,只暴露 `publication_status = published` 的作品卡片字段;`api-server` 的 `spacetime-client` 长期订阅 `SELECT * FROM square_hole_gallery_view` 与 `SELECT * FROM public_work_play_daily_stat WHERE source_type = 'square-hole'` 后,从本地 cache 构造 `/api/runtime/square-hole/gallery` 响应。公开列表不再每个 HTTP 请求调用 `list_square_hole_works` procedure;个人作品列表、详情、发布、点赞、游玩记录和 Remix 仍按原有 procedure / reducer 路径处理。 ### `story_event` - Rust 结构体:`StoryEvent` -- 源码:`server-rs/crates/spacetime-module/src/gameplay/mod.rs` +- 源码:`server-rs/crates/spacetime-module/src/gameplay.rs` ### `story_session` - Rust 结构体:`StorySession` -- 源码:`server-rs/crates/spacetime-module/src/gameplay/mod.rs` +- 源码:`server-rs/crates/spacetime-module/src/gameplay.rs` ### `tracking_daily_stat` @@ -618,7 +638,7 @@ npm run check:server-rs-ddd ### `treasure_record` - Rust 结构体:`TreasureRecord` -- 源码:`server-rs/crates/spacetime-module/src/gameplay/mod.rs` +- 源码:`server-rs/crates/spacetime-module/src/gameplay.rs` ### `user_account` diff --git a/docs/【玩法创作】平台入口与玩法链路-2026-05-15.md b/docs/【玩法创作】平台入口与玩法链路-2026-05-15.md index d323e87d..3bdad79f 100644 --- a/docs/【玩法创作】平台入口与玩法链路-2026-05-15.md +++ b/docs/【玩法创作】平台入口与玩法链路-2026-05-15.md @@ -8,6 +8,8 @@ 当前创作 Tab 固定为智能创作首页与模板入口,草稿 Tab 承接作品架。点击独立入口后应切换到对应内嵌创作表单或生成页;不要额外做一张平行配置页,除非玩法本身需要完整独立工作台。 +`PlatformEntryFlowShellImpl.tsx` 仍是平台入口编排壳,后续维护时应优先把独立 UI 片段、公开作品映射、草稿生成 notice 和运行态状态 helper 拆到 `src/components/platform-entry/PlatformEntryFlowShellImpl/` 或同目录紧邻 helper 文件。拆分只允许改变文件组织,不改变入口配置事实源、默认导出、props、页面阶段、UI 文案或现有交互;其中拼图首访 onboarding 已拆为 `PlatformEntryFlowShellImpl/PuzzleOnboardingView.tsx`。 + ## 草稿与作品架 1. 草稿页作品卡对齐发现页列表卡风格:左侧信息,右侧封面图,移动端单列,桌面两到三列。 diff --git a/scripts/check-spacetime-runtime-access.mjs b/scripts/check-spacetime-runtime-access.mjs index 7ca05f89..0931ef21 100644 --- a/scripts/check-spacetime-runtime-access.mjs +++ b/scripts/check-spacetime-runtime-access.mjs @@ -29,7 +29,7 @@ const forbiddenSnippets = [ reason: 'puzzle_leaderboard_entry 已有 by_puzzle_leaderboard_profile_grid 索引', }, { - file: 'server-rs/crates/spacetime-module/src/match3d/mod.rs', + file: 'server-rs/crates/spacetime-module/src/match3d.rs', snippet: '.match3d_work_profile()\n .iter()\n .filter(|row| {', reason: 'match3d_work_profile 已有 owner/status 索引,列表不应整表过滤', }, @@ -99,12 +99,12 @@ const forbiddenSnippets = [ reason: 'tracking_daily_stat 已有 by_tracking_daily_stat_scope_day / event_day 索引,analytics 查询不应整表过滤', }, { - file: 'server-rs/crates/spacetime-module/src/custom_world/mod.rs', + file: 'server-rs/crates/spacetime-module/src/custom_world.rs', snippet: '.custom_world_profile()\n .iter()\n .find(|row| {', reason: 'custom_world_profile owner 维度已有 by_custom_world_profile_owner_user_id 索引', }, { - file: 'server-rs/crates/spacetime-module/src/custom_world/mod.rs', + file: 'server-rs/crates/spacetime-module/src/custom_world.rs', snippet: '.custom_world_profile()\n .iter()\n .filter(|profile| {', reason: 'custom_world_profile Published 同步已有 by_custom_world_profile_publication_status 索引', }, diff --git a/scripts/generate-spacetime-bindings.mjs b/scripts/generate-spacetime-bindings.mjs index 27f391b4..6aaf5cef 100644 --- a/scripts/generate-spacetime-bindings.mjs +++ b/scripts/generate-spacetime-bindings.mjs @@ -21,6 +21,14 @@ const TARGETS = [ 'src', 'module_bindings', ), + entryFile: path.join( + REPO_ROOT, + 'server-rs', + 'crates', + 'spacetime-client', + 'src', + 'module_bindings.rs', + ), }, ]; @@ -64,6 +72,7 @@ for (const target of selectedTargets) { console.log(`[spacetime:generate] 同步 ${fileCount} 个文件到 ${target.outDir}`); await replaceGeneratedDir(tempOutDir, target.outDir); + await moveGeneratedEntryFile(target); } await rm(tempRoot, {recursive: true, force: true}); @@ -111,6 +120,23 @@ async function replaceGeneratedDir(fromDir, toDir) { } } +async function moveGeneratedEntryFile(target) { + if (!target.entryFile) { + return; + } + + assertInside(target.entryFile, REPO_ROOT, '生成入口文件'); + const generatedModFile = path.join(target.outDir, 'mod.rs'); + + if (!existsSync(generatedModFile)) { + throw new Error(`${target.name} bindings 缺少入口文件: ${generatedModFile}`); + } + + await rm(target.entryFile, {force: true}); + await cp(generatedModFile, target.entryFile, {force: true}); + await rm(generatedModFile, {force: true}); +} + function assertInside(candidate, parent, label) { const relative = path.relative(path.resolve(parent), path.resolve(candidate)); if (relative === '' || relative.startsWith('..') || path.isAbsolute(relative)) { diff --git a/server-rs/crates/api-server/src/generated_image_assets/mod.rs b/server-rs/crates/api-server/src/generated_image_assets.rs similarity index 100% rename from server-rs/crates/api-server/src/generated_image_assets/mod.rs rename to server-rs/crates/api-server/src/generated_image_assets.rs diff --git a/server-rs/crates/api-server/src/match3d.rs b/server-rs/crates/api-server/src/match3d.rs index cd4058a6..405393cd 100644 --- a/server-rs/crates/api-server/src/match3d.rs +++ b/server-rs/crates/api-server/src/match3d.rs @@ -343,2277 +343,6 @@ impl Match3DItemAssetsGenerationPlan { } } -pub async fn create_match3d_agent_session( - State(state): State, - Extension(request_context): Extension, - Extension(authenticated): Extension, - payload: Result, JsonRejection>, -) -> Result, Response> { - let Json(payload) = match3d_json(payload, &request_context, MATCH3D_AGENT_PROVIDER)?; - let config = build_config_from_create_request(&payload); - let seed_text = build_seed_text(&payload, &config); - let welcome_message_text = MATCH3D_QUESTION_THEME.to_string(); - - let session = state - .spacetime_client() - .create_match3d_agent_session(Match3DAgentSessionCreateRecordInput { - session_id: build_prefixed_uuid_id(MATCH3D_SESSION_ID_PREFIX), - owner_user_id: authenticated.claims().user_id().to_string(), - seed_text, - welcome_message_id: build_prefixed_uuid_id(MATCH3D_MESSAGE_ID_PREFIX), - welcome_message_text, - config_json: serialize_match3d_config(&config), - created_at_micros: current_utc_micros(), - }) - .await - .map_err(|error| { - match3d_error_response( - &request_context, - MATCH3D_AGENT_PROVIDER, - map_match3d_client_error(error), - ) - })?; - - Ok(json_success_body( - Some(&request_context), - Match3DAgentSessionResponse { - session: load_match3d_agent_session_response_with_persisted_assets( - &state, - authenticated.claims().user_id(), - session, - ) - .await, - }, - )) -} - -pub async fn get_match3d_agent_session( - State(state): State, - Path(session_id): Path, - Extension(request_context): Extension, - Extension(authenticated): Extension, -) -> Result, Response> { - ensure_non_empty( - &request_context, - MATCH3D_AGENT_PROVIDER, - &session_id, - "sessionId", - )?; - - let session = state - .spacetime_client() - .get_match3d_agent_session(session_id, authenticated.claims().user_id().to_string()) - .await - .map_err(|error| { - match3d_error_response( - &request_context, - MATCH3D_AGENT_PROVIDER, - map_match3d_client_error(error), - ) - })?; - - Ok(json_success_body( - Some(&request_context), - Match3DAgentSessionResponse { - session: load_match3d_agent_session_response_with_persisted_assets( - &state, - authenticated.claims().user_id(), - session, - ) - .await, - }, - )) -} - -pub async fn submit_match3d_agent_message( - State(state): State, - Path(session_id): Path, - Extension(request_context): Extension, - Extension(authenticated): Extension, - payload: Result, JsonRejection>, -) -> Result, Response> { - let Json(payload) = match3d_json(payload, &request_context, MATCH3D_AGENT_PROVIDER)?; - let session = submit_and_finalize_match3d_message( - &state, - &request_context, - authenticated.claims().user_id(), - session_id, - payload, - ) - .await?; - - Ok(json_success_body( - Some(&request_context), - Match3DAgentSessionResponse { - session: load_match3d_agent_session_response_with_persisted_assets( - &state, - authenticated.claims().user_id(), - session, - ) - .await, - }, - )) -} - -pub async fn stream_match3d_agent_message( - State(state): State, - Path(session_id): Path, - Extension(request_context): Extension, - Extension(authenticated): Extension, - payload: Result, JsonRejection>, -) -> Result { - let Json(payload) = match3d_json(payload, &request_context, MATCH3D_AGENT_PROVIDER)?; - ensure_non_empty( - &request_context, - MATCH3D_AGENT_PROVIDER, - &session_id, - "sessionId", - )?; - - let owner_user_id = authenticated.claims().user_id().to_string(); - let request_context_for_stream = request_context.clone(); - let stream = async_stream::stream! { - let result = submit_and_finalize_match3d_message( - &state, - &request_context_for_stream, - owner_user_id.as_str(), - session_id, - payload, - ) - .await; - - match result { - Ok(session) => { - let session_response = load_match3d_agent_session_response_with_persisted_assets( - &state, - owner_user_id.as_str(), - session, - ) - .await; - if let Some(reply) = session_response.last_assistant_reply.clone() { - yield Ok::(match3d_sse_json_event_or_error( - "reply_delta", - json!({ "text": reply }), - )); - } - yield Ok::(match3d_sse_json_event_or_error( - "session", - json!({ "session": session_response }), - )); - yield Ok::(match3d_sse_json_event_or_error( - "done", - json!({ "ok": true }), - )); - } - Err(response) => { - yield Ok::(match3d_sse_json_event_or_error( - "error", - json!({ "message": response.status().to_string() }), - )); - } - } - }; - - Ok(Sse::new(stream).into_response()) -} - -pub async fn execute_match3d_agent_action( - State(state): State, - Path(session_id): Path, - Extension(request_context): Extension, - Extension(authenticated): Extension, - payload: Result, JsonRejection>, -) -> Result, Response> { - let Json(payload) = match3d_json(payload, &request_context, MATCH3D_AGENT_PROVIDER)?; - ensure_non_empty( - &request_context, - MATCH3D_AGENT_PROVIDER, - &session_id, - "sessionId", - )?; - - if payload.action.trim() != "match3d_compile_draft" { - return Err(match3d_bad_request( - &request_context, - MATCH3D_AGENT_PROVIDER, - "unknown match3d action", - )); - } - - let (session, generated_item_assets) = compile_match3d_draft_for_session( - &state, - &request_context, - &authenticated, - session_id, - payload.game_name, - payload.summary, - payload.tags, - payload.cover_image_src, - payload.generate_click_sound, - ) - .await?; - - Ok(json_success_body( - Some(&request_context), - Match3DAgentActionResponse { - session: map_match3d_agent_session_response_with_assets( - session, - &generated_item_assets, - ), - }, - )) -} - -pub async fn compile_match3d_agent_draft( - State(state): State, - Path(session_id): Path, - Extension(request_context): Extension, - Extension(authenticated): Extension, - payload: Result, JsonRejection>, -) -> Result, Response> { - let payload = payload - .map(|Json(payload)| payload) - .unwrap_or(CompileMatch3DDraftRequest { - game_name: None, - summary: None, - tags: None, - cover_image_src: None, - generate_click_sound: None, - }); - ensure_non_empty( - &request_context, - MATCH3D_AGENT_PROVIDER, - &session_id, - "sessionId", - )?; - - let (session, generated_item_assets) = compile_match3d_draft_for_session( - &state, - &request_context, - &authenticated, - session_id, - payload.game_name, - payload.summary, - payload.tags, - payload.cover_image_src, - payload.generate_click_sound, - ) - .await?; - - Ok(json_success_body( - Some(&request_context), - Match3DAgentActionResponse { - session: map_match3d_agent_session_response_with_assets( - session, - &generated_item_assets, - ), - }, - )) -} - -pub async fn get_match3d_works( - State(state): State, - Extension(request_context): Extension, - Extension(authenticated): Extension, -) -> Result, Response> { - let items = state - .spacetime_client() - .list_match3d_works(authenticated.claims().user_id().to_string()) - .await - .map_err(|error| { - match3d_error_response( - &request_context, - MATCH3D_WORKS_PROVIDER, - map_match3d_client_error(error), - ) - })?; - - Ok(json_success_body( - Some(&request_context), - Match3DWorksResponse { - items: items - .into_iter() - .map(map_match3d_work_summary_response) - .collect(), - }, - )) -} - -pub async fn list_match3d_gallery( - State(state): State, - Extension(request_context): Extension, -) -> Result, Response> { - let items = state - .spacetime_client() - .list_match3d_gallery() - .await - .map_err(|error| { - match3d_error_response( - &request_context, - MATCH3D_WORKS_PROVIDER, - map_match3d_client_error(error), - ) - })?; - - Ok(json_success_body( - Some(&request_context), - Match3DWorksResponse { - items: items - .into_iter() - .map(map_match3d_work_summary_response) - .collect(), - }, - )) -} - -pub async fn get_match3d_work_detail( - State(state): State, - Path(profile_id): Path, - Extension(request_context): Extension, - Extension(authenticated): Extension, -) -> Result, Response> { - ensure_non_empty( - &request_context, - MATCH3D_WORKS_PROVIDER, - &profile_id, - "profileId", - )?; - - let item = state - .spacetime_client() - .get_match3d_work_detail(profile_id, authenticated.claims().user_id().to_string()) - .await - .map_err(|error| { - match3d_error_response( - &request_context, - MATCH3D_WORKS_PROVIDER, - map_match3d_client_error(error), - ) - })?; - - Ok(json_success_body( - Some(&request_context), - Match3DWorkDetailResponse { - item: map_match3d_work_profile_response(item), - }, - )) -} - -pub async fn put_match3d_work( - State(state): State, - Path(profile_id): Path, - Extension(request_context): Extension, - Extension(authenticated): Extension, - payload: Result, JsonRejection>, -) -> Result, Response> { - let Json(payload) = match3d_json(payload, &request_context, MATCH3D_WORKS_PROVIDER)?; - ensure_non_empty( - &request_context, - MATCH3D_WORKS_PROVIDER, - &profile_id, - "profileId", - )?; - - let existing = state - .spacetime_client() - .get_match3d_work_detail( - profile_id.clone(), - authenticated.claims().user_id().to_string(), - ) - .await - .map_err(|error| { - match3d_error_response( - &request_context, - MATCH3D_WORKS_PROVIDER, - map_match3d_client_error(error), - ) - })?; - let theme_text = payload - .theme_text - .clone() - .filter(|value| !value.trim().is_empty()) - .unwrap_or(existing.theme_text); - let item = state - .spacetime_client() - .update_match3d_work(Match3DWorkUpdateRecordInput { - profile_id, - owner_user_id: authenticated.claims().user_id().to_string(), - game_name: payload.game_name, - theme_text, - summary_text: payload.summary, - tags_json: serde_json::to_string(&normalize_tags(payload.tags)).unwrap_or_default(), - cover_image_src: payload.cover_image_src.unwrap_or_default(), - cover_asset_id: String::new(), - clear_count: payload.clear_count, - difficulty: payload.difficulty, - updated_at_micros: current_utc_micros(), - }) - .await - .map_err(|error| { - match3d_error_response( - &request_context, - MATCH3D_WORKS_PROVIDER, - map_match3d_client_error(error), - ) - })?; - - Ok(json_success_body( - Some(&request_context), - Match3DWorkMutationResponse { - item: map_match3d_work_profile_response(item), - }, - )) -} - -pub async fn put_match3d_audio_assets( - State(state): State, - Path(profile_id): Path, - Extension(request_context): Extension, - Extension(authenticated): Extension, - payload: Result, JsonRejection>, -) -> Result, Response> { - let Json(payload) = match3d_json(payload, &request_context, MATCH3D_WORKS_PROVIDER)?; - ensure_non_empty( - &request_context, - MATCH3D_WORKS_PROVIDER, - &profile_id, - "profileId", - )?; - - let owner_user_id = authenticated.claims().user_id().to_string(); - let existing = state - .spacetime_client() - .get_match3d_work_detail(profile_id.clone(), owner_user_id.clone()) - .await - .map_err(|error| { - match3d_error_response( - &request_context, - MATCH3D_WORKS_PROVIDER, - map_match3d_client_error(error), - ) - })?; - let session_id = existing.source_session_id.clone().ok_or_else(|| { - match3d_error_response( - &request_context, - MATCH3D_WORKS_PROVIDER, - AppError::from_status(StatusCode::BAD_REQUEST).with_details(json!({ - "provider": MATCH3D_WORKS_PROVIDER, - "message": "抓大鹅作品缺少来源 session,无法写回音频素材", - })), - ) - })?; - let assets = payload - .generated_item_assets - .into_iter() - .map(Match3DGeneratedItemAsset::from) - .collect::>(); - let session = upsert_match3d_draft_snapshot( - &state, - &request_context, - &authenticated, - session_id, - owner_user_id.clone(), - profile_id.clone(), - Some(existing.game_name), - Some(existing.summary), - Some(serde_json::to_string(&existing.tags).unwrap_or_default()), - existing.cover_image_src, - None, - serialize_match3d_generated_item_assets(&assets), - ) - .await?; - - let item = state - .spacetime_client() - .get_match3d_work_detail(profile_id, owner_user_id) - .await - .map_err(|error| { - match3d_error_response( - &request_context, - MATCH3D_WORKS_PROVIDER, - map_match3d_client_error(error), - ) - })?; - let _ = session; - Ok(json_success_body( - Some(&request_context), - Match3DWorkMutationResponse { - item: map_match3d_work_profile_response(item), - }, - )) -} - -pub async fn persist_match3d_generated_model( - State(state): State, - Path(profile_id): Path, - Extension(request_context): Extension, - Extension(authenticated): Extension, - payload: Result, JsonRejection>, -) -> Result, Response> { - let Json(payload) = match3d_json(payload, &request_context, MATCH3D_WORKS_PROVIDER)?; - ensure_non_empty( - &request_context, - MATCH3D_WORKS_PROVIDER, - &profile_id, - "profileId", - )?; - ensure_non_empty( - &request_context, - MATCH3D_WORKS_PROVIDER, - &payload.item_id, - "itemId", - )?; - ensure_non_empty( - &request_context, - MATCH3D_WORKS_PROVIDER, - &payload.item_name, - "itemName", - )?; - ensure_non_empty( - &request_context, - MATCH3D_WORKS_PROVIDER, - &payload.source_url, - "sourceUrl", - )?; - - let owner_user_id = authenticated.claims().user_id().to_string(); - let existing = state - .spacetime_client() - .get_match3d_work_detail(profile_id.clone(), owner_user_id.clone()) - .await - .map_err(|error| { - match3d_error_response( - &request_context, - MATCH3D_WORKS_PROVIDER, - map_match3d_client_error(error), - ) - })?; - let session_id = existing.source_session_id.clone().ok_or_else(|| { - match3d_error_response( - &request_context, - MATCH3D_WORKS_PROVIDER, - AppError::from_status(StatusCode::BAD_REQUEST).with_details(json!({ - "provider": MATCH3D_WORKS_PROVIDER, - "message": "抓大鹅作品缺少来源 session,无法保存历史模型", - })), - ) - })?; - - let mut assets = - parse_match3d_generated_item_assets(existing.generated_item_assets_json.as_deref()) - .into_iter() - .map(Match3DGeneratedItemAsset::from) - .collect::>(); - let current_asset = assets - .iter() - .find(|asset| asset.item_id == payload.item_id) - .cloned(); - let item_name = normalize_match3d_item_name(payload.item_name.as_str()); - let item_name = if item_name.is_empty() { - current_asset - .as_ref() - .map(|asset| asset.item_name.clone()) - .unwrap_or_else(|| payload.item_name.trim().to_string()) - } else { - item_name - }; - let model_file = hyper3d_contract::Hyper3dDownloadFilePayload { - name: normalize_optional_text(payload.file_name.as_deref()) - .unwrap_or_else(|| "model.glb".to_string()), - url: payload.source_url.trim().to_string(), - }; - let downloaded_model = download_match3d_legacy_model(&model_file) - .await - .map_err(|error| match3d_error_response(&request_context, MATCH3D_WORKS_PROVIDER, error))?; - let task_uuid = normalize_optional_text(payload.task_uuid.as_deref()); - let item_slug = build_match3d_item_slug(payload.item_id.as_str(), item_name.as_str()); - let generated_at_micros = current_utc_micros(); - let uploaded_model = persist_match3d_generated_bytes( - &state, - owner_user_id.as_str(), - session_id.as_str(), - profile_id.as_str(), - &[ - "items", - item_slug.as_str(), - "model", - task_uuid.as_deref().unwrap_or("manual"), - ], - downloaded_model.file_name.as_str(), - downloaded_model.content_type.as_str(), - downloaded_model.bytes, - "match3d_item_model", - task_uuid.as_deref(), - generated_at_micros, - ) - .await - .map_err(|error| match3d_error_response(&request_context, MATCH3D_WORKS_PROVIDER, error))?; - let next_asset = Match3DGeneratedItemAsset { - item_id: payload.item_id, - item_name, - item_size: current_asset - .as_ref() - .and_then(|asset| asset.item_size.clone()) - .or_else(|| Some(MATCH3D_ITEM_SIZE_LARGE.to_string())), - image_src: current_asset - .as_ref() - .and_then(|asset| asset.image_src.clone()), - image_object_key: current_asset - .as_ref() - .and_then(|asset| asset.image_object_key.clone()), - image_views: current_asset - .as_ref() - .map(|asset| asset.image_views.clone()) - .unwrap_or_default(), - model_src: Some(uploaded_model.src), - model_object_key: Some(uploaded_model.object_key), - model_file_name: Some(downloaded_model.file_name), - task_uuid, - subscription_key: normalize_optional_text(payload.subscription_key.as_deref()).or_else( - || { - current_asset - .as_ref() - .and_then(|asset| asset.subscription_key.clone()) - }, - ), - sound_prompt: current_asset - .as_ref() - .and_then(|asset| asset.sound_prompt.clone()), - background_music_title: current_asset - .as_ref() - .and_then(|asset| asset.background_music_title.clone()), - background_music_style: current_asset - .as_ref() - .and_then(|asset| asset.background_music_style.clone()), - background_music_prompt: current_asset - .as_ref() - .and_then(|asset| asset.background_music_prompt.clone()), - background_music: current_asset - .as_ref() - .and_then(|asset| asset.background_music.clone()), - click_sound: current_asset - .as_ref() - .and_then(|asset| asset.click_sound.clone()), - background_asset: current_asset - .as_ref() - .and_then(|asset| asset.background_asset.clone()), - status: "model_ready".to_string(), - error: None, - }; - upsert_match3d_generated_item_asset(&mut assets, next_asset.clone()); - persist_match3d_generated_item_assets_snapshot( - &state, - &request_context, - &authenticated, - session_id.as_str(), - owner_user_id.as_str(), - profile_id.as_str(), - &assets, - ) - .await?; - - Ok(json_success_body( - Some(&request_context), - PersistMatch3DGeneratedModelResponse { - asset: map_match3d_generated_item_asset_for_work(Match3DGeneratedItemAssetJson::from( - next_asset, - )), - }, - )) -} - -pub async fn generate_match3d_cover_image( - State(state): State, - Path(profile_id): Path, - Extension(request_context): Extension, - Extension(authenticated): Extension, - payload: Result, JsonRejection>, -) -> Result, Response> { - let Json(payload) = match3d_json(payload, &request_context, MATCH3D_WORKS_PROVIDER)?; - ensure_non_empty( - &request_context, - MATCH3D_WORKS_PROVIDER, - &profile_id, - "profileId", - )?; - let prompt = normalize_match3d_cover_prompt(payload.prompt.as_str()); - ensure_non_empty(&request_context, MATCH3D_WORKS_PROVIDER, &prompt, "prompt")?; - - let context = - load_match3d_work_asset_context(&state, &request_context, &authenticated, &profile_id) - .await?; - let generated_cover = generate_match3d_cover_image_asset( - &state, - &context.owner_user_id, - context.session_id.as_str(), - profile_id.as_str(), - &context.config, - prompt.as_str(), - payload.uploaded_image_src, - collect_match3d_cover_reference_image_sources( - payload.reference_image_src, - payload.reference_image_srcs, - ), - ) - .await - .map_err(|error| match3d_error_response(&request_context, MATCH3D_WORKS_PROVIDER, error))?; - - let item = update_match3d_work_cover_only( - &state, - &request_context, - context.owner_user_id.as_str(), - context.profile, - generated_cover.src.as_str(), - ) - .await?; - - Ok(json_success_body( - Some(&request_context), - GenerateMatch3DCoverImageResponse { - item: map_match3d_work_profile_response(item), - cover_image_src: generated_cover.src, - cover_image_object_key: generated_cover.object_key, - prompt, - }, - )) -} - -async fn update_match3d_work_cover_only( - state: &AppState, - request_context: &RequestContext, - owner_user_id: &str, - profile: Match3DWorkProfileRecord, - cover_image_src: &str, -) -> Result { - // 中文注释:封面生成是定向图片槽位更新,不能复用草稿编译路径重算题材、难度或素材 JSON。 - state - .spacetime_client() - .update_match3d_work(Match3DWorkUpdateRecordInput { - profile_id: profile.profile_id, - owner_user_id: owner_user_id.to_string(), - game_name: profile.game_name, - theme_text: profile.theme_text, - summary_text: profile.summary, - tags_json: serde_json::to_string(&normalize_tags(profile.tags)).unwrap_or_default(), - cover_image_src: cover_image_src.to_string(), - cover_asset_id: profile.cover_asset_id.unwrap_or_default(), - clear_count: profile.clear_count, - difficulty: profile.difficulty, - updated_at_micros: current_utc_micros(), - }) - .await - .map_err(|error| { - match3d_error_response( - request_context, - MATCH3D_WORKS_PROVIDER, - map_match3d_client_error(error), - ) - }) -} - -pub async fn generate_match3d_background_image_for_work( - State(state): State, - Path(profile_id): Path, - Extension(request_context): Extension, - Extension(authenticated): Extension, - payload: Result, JsonRejection>, -) -> Result, Response> { - let Json(payload) = match3d_json(payload, &request_context, MATCH3D_WORKS_PROVIDER)?; - ensure_non_empty( - &request_context, - MATCH3D_WORKS_PROVIDER, - &profile_id, - "profileId", - )?; - let prompt = normalize_match3d_background_prompt(payload.prompt.as_str()); - ensure_non_empty(&request_context, MATCH3D_WORKS_PROVIDER, &prompt, "prompt")?; - let prompt_fingerprint = build_match3d_prompt_fingerprint(prompt.as_str()); - - let context = - load_match3d_work_asset_context(&state, &request_context, &authenticated, &profile_id) - .await?; - let Match3DWorkAssetContext { - owner_user_id, - session_id, - profile, - config, - assets, - } = context; - let billing_asset_id = format!("{}:{}:{}", session_id, profile_id, prompt_fingerprint); - let (generated_background, generated_assets) = execute_billable_asset_operation_with_cost( - &state, - owner_user_id.as_str(), - "match3d_ui_background_image", - billing_asset_id.as_str(), - MATCH3D_BACKGROUND_IMAGE_POINTS_COST, - async { - let generated_background = generate_match3d_background_image( - &state, - owner_user_id.as_str(), - session_id.as_str(), - profile_id.as_str(), - &config, - prompt.as_str(), - ) - .await?; - let mut assets = assets; - attach_match3d_background_asset_to_assets(&mut assets, generated_background.clone()); - let save_result = persist_match3d_generated_item_assets_snapshot( - &state, - &request_context, - &authenticated, - session_id.as_str(), - owner_user_id.as_str(), - profile_id.as_str(), - &assets, - ) - .await; - if let Err(response) = save_result { - tracing::warn!( - provider = MATCH3D_WORKS_PROVIDER, - profile_id, - owner_user_id = %owner_user_id, - status = %response.status(), - "抓大鹅 UI 背景图已生成但 SpacetimeDB 草稿写回不可用,降级返回本次生成资产" - ); - } - Ok((generated_background, assets)) - }, - ) - .await - .map_err(|error| match3d_error_response(&request_context, MATCH3D_WORKS_PROVIDER, error))?; - - let item = state - .spacetime_client() - .get_match3d_work_detail(profile_id.clone(), owner_user_id.clone()) - .await - .map(|item| map_match3d_work_profile_response(item)) - .unwrap_or_else(|error| { - tracing::warn!( - provider = MATCH3D_WORKS_PROVIDER, - profile_id, - owner_user_id = %owner_user_id, - error = %error, - "抓大鹅 UI 背景图生成后读取作品详情失败,降级使用写回前快照" - ); - map_match3d_work_profile_response(build_match3d_work_profile_record_with_assets( - profile, - &generated_assets, - )) - }); - let background_image_src = generated_background.image_src.clone().unwrap_or_default(); - let background_image_object_key = generated_background - .image_object_key - .clone() - .unwrap_or_default(); - - Ok(json_success_body( - Some(&request_context), - GenerateMatch3DBackgroundImageResponse { - item, - background_image_src, - background_image_object_key, - generated_background_asset: map_match3d_background_asset_for_work(generated_background), - prompt, - }, - )) -} - -pub async fn generate_match3d_container_image_for_work( - State(state): State, - Path(profile_id): Path, - Extension(request_context): Extension, - Extension(authenticated): Extension, - payload: Result, JsonRejection>, -) -> Result, Response> { - let Json(payload) = match3d_json(payload, &request_context, MATCH3D_WORKS_PROVIDER)?; - ensure_non_empty( - &request_context, - MATCH3D_WORKS_PROVIDER, - &profile_id, - "profileId", - )?; - let prompt = normalize_match3d_background_prompt(payload.prompt.as_str()); - ensure_non_empty(&request_context, MATCH3D_WORKS_PROVIDER, &prompt, "prompt")?; - let prompt_fingerprint = build_match3d_prompt_fingerprint(prompt.as_str()); - - let context = - load_match3d_work_asset_context(&state, &request_context, &authenticated, &profile_id) - .await?; - let Match3DWorkAssetContext { - owner_user_id, - session_id, - profile, - config, - assets, - } = context; - let billing_asset_id = format!( - "{}:{}:{}:container", - session_id, profile_id, prompt_fingerprint - ); - let (generated_background, generated_assets) = execute_billable_asset_operation_with_cost( - &state, - owner_user_id.as_str(), - "match3d_ui_container_image", - billing_asset_id.as_str(), - MATCH3D_BACKGROUND_IMAGE_POINTS_COST, - async { - let generated_container = generate_match3d_container_image( - &state, - owner_user_id.as_str(), - session_id.as_str(), - profile_id.as_str(), - &config, - prompt.as_str(), - ) - .await?; - let mut assets = assets; - let generated_background = - merge_match3d_container_image_into_background_asset(&assets, generated_container); - attach_match3d_background_asset_to_assets(&mut assets, generated_background.clone()); - let save_result = persist_match3d_generated_item_assets_snapshot( - &state, - &request_context, - &authenticated, - session_id.as_str(), - owner_user_id.as_str(), - profile_id.as_str(), - &assets, - ) - .await; - if let Err(response) = save_result { - tracing::warn!( - provider = MATCH3D_WORKS_PROVIDER, - profile_id, - owner_user_id = %owner_user_id, - status = %response.status(), - "抓大鹅容器形象已生成但 SpacetimeDB 草稿写回不可用,降级返回本次生成资产" - ); - } - Ok((generated_background, assets)) - }, - ) - .await - .map_err(|error| match3d_error_response(&request_context, MATCH3D_WORKS_PROVIDER, error))?; - - let item = state - .spacetime_client() - .get_match3d_work_detail(profile_id.clone(), owner_user_id.clone()) - .await - .map(|item| map_match3d_work_profile_response(item)) - .unwrap_or_else(|error| { - tracing::warn!( - provider = MATCH3D_WORKS_PROVIDER, - profile_id, - owner_user_id = %owner_user_id, - error = %error, - "抓大鹅容器形象生成后读取作品详情失败,降级使用写回前快照" - ); - map_match3d_work_profile_response(build_match3d_work_profile_record_with_assets( - profile, - &generated_assets, - )) - }); - let container_image_src = generated_background - .container_image_src - .clone() - .unwrap_or_default(); - let container_image_object_key = generated_background - .container_image_object_key - .clone() - .unwrap_or_default(); - - Ok(json_success_body( - Some(&request_context), - GenerateMatch3DContainerImageResponse { - item, - container_image_src, - container_image_object_key, - generated_background_asset: map_match3d_background_asset_for_work(generated_background), - prompt, - }, - )) -} - -pub async fn generate_match3d_item_assets_for_work( - State(state): State, - Path(profile_id): Path, - Extension(request_context): Extension, - Extension(authenticated): Extension, - payload: Result, JsonRejection>, -) -> Result, Response> { - let Json(payload) = match3d_json(payload, &request_context, MATCH3D_WORKS_PROVIDER)?; - ensure_non_empty( - &request_context, - MATCH3D_WORKS_PROVIDER, - &profile_id, - "profileId", - )?; - let item_names = normalize_match3d_batch_item_names(payload.item_names); - if item_names.is_empty() { - return Err(match3d_bad_request( - &request_context, - MATCH3D_WORKS_PROVIDER, - "请填写至少一个物品名称", - )); - } - let generation_mode = normalize_match3d_item_assets_generation_mode(payload.mode.as_deref()); - - let context = - load_match3d_work_asset_context(&state, &request_context, &authenticated, &profile_id) - .await?; - let Match3DWorkAssetContext { - owner_user_id, - session_id, - profile, - config, - assets, - } = context; - let generation_plan = - build_match3d_item_assets_generation_plan(generation_mode, item_names, &assets); - if generation_plan.billed_item_count() == 0 { - return Ok(json_success_body( - Some(&request_context), - GenerateMatch3DItemAssetsResponse { - item: map_match3d_work_profile_response(profile), - generated_item_assets: sort_match3d_generated_assets(assets) - .into_iter() - .map(Match3DGeneratedItemAssetJson::from) - .map(map_match3d_generated_item_asset_for_work) - .collect(), - }, - )); - } - let billed_item_count = generation_plan.billed_item_count(); - let points_cost = calculate_match3d_item_assets_points_cost(billed_item_count); - let billing_asset_id = format!( - "{}:{}:{}:{}", - session_id, - profile_id, - billed_item_count, - build_match3d_prompt_fingerprint(generation_plan.billing_fingerprint_source().as_str()) - ); - let generated_assets = execute_billable_asset_operation_with_cost( - &state, - owner_user_id.as_str(), - "match3d_item_assets", - billing_asset_id.as_str(), - points_cost, - async { - append_match3d_item_assets( - &state, - &request_context, - &authenticated, - owner_user_id.as_str(), - session_id.as_str(), - profile_id.as_str(), - &config, - generation_plan, - assets, - ) - .await - .map_err(|response| { - AppError::from_status(response.status()).with_details(json!({ - "provider": MATCH3D_WORKS_PROVIDER, - "message": "抓大鹅批量新增物品素材失败", - })) - }) - }, - ) - .await - .map_err(|error| match3d_error_response(&request_context, MATCH3D_WORKS_PROVIDER, error))?; - - let item = state - .spacetime_client() - .get_match3d_work_detail(profile_id, owner_user_id) - .await - .map_err(|error| { - match3d_error_response( - &request_context, - MATCH3D_WORKS_PROVIDER, - map_match3d_client_error(error), - ) - })?; - Ok(json_success_body( - Some(&request_context), - GenerateMatch3DItemAssetsResponse { - item: map_match3d_work_profile_response(item), - generated_item_assets: generated_assets - .into_iter() - .map(Match3DGeneratedItemAssetJson::from) - .map(map_match3d_generated_item_asset_for_work) - .collect(), - }, - )) -} - -pub async fn generate_match3d_work_tags( - State(state): State, - Extension(request_context): Extension, - Extension(_authenticated): Extension, - payload: Result, JsonRejection>, -) -> Result, Response> { - let Json(payload) = match3d_json(payload, &request_context, MATCH3D_WORKS_PROVIDER)?; - let tags = generate_match3d_work_tags_for_profile( - &state, - payload.game_name.as_str(), - payload.theme_text.as_str(), - payload.summary.as_deref(), - ) - .await; - - Ok(json_success_body( - Some(&request_context), - GenerateMatch3DWorkTagsResponse { tags }, - )) -} - -pub async fn publish_match3d_work( - State(state): State, - Path(profile_id): Path, - Extension(request_context): Extension, - Extension(authenticated): Extension, -) -> Result, Response> { - ensure_non_empty( - &request_context, - MATCH3D_WORKS_PROVIDER, - &profile_id, - "profileId", - )?; - - let item = state - .spacetime_client() - .publish_match3d_work( - profile_id, - authenticated.claims().user_id().to_string(), - current_utc_micros(), - ) - .await - .map_err(|error| { - match3d_error_response( - &request_context, - MATCH3D_WORKS_PROVIDER, - map_match3d_client_error(error), - ) - })?; - - Ok(json_success_body( - Some(&request_context), - Match3DWorkMutationResponse { - item: map_match3d_work_profile_response(item), - }, - )) -} - -pub async fn delete_match3d_work( - State(state): State, - Path(profile_id): Path, - Extension(request_context): Extension, - Extension(authenticated): Extension, -) -> Result, Response> { - ensure_non_empty( - &request_context, - MATCH3D_WORKS_PROVIDER, - &profile_id, - "profileId", - )?; - - let items = state - .spacetime_client() - .delete_match3d_work(profile_id, authenticated.claims().user_id().to_string()) - .await - .map_err(|error| { - match3d_error_response( - &request_context, - MATCH3D_WORKS_PROVIDER, - map_match3d_client_error(error), - ) - })?; - - Ok(json_success_body( - Some(&request_context), - Match3DWorksResponse { - items: items - .into_iter() - .map(map_match3d_work_summary_response) - .collect(), - }, - )) -} - -pub async fn start_match3d_run( - State(state): State, - Path(profile_id): Path, - Extension(request_context): Extension, - Extension(authenticated): Extension, - payload: Result, JsonRejection>, -) -> Result, Response> { - let maybe_payload = payload.ok().map(|Json(payload)| payload); - let profile_id = maybe_payload - .as_ref() - .map(|payload| payload.profile_id.clone()) - .filter(|value| !value.trim().is_empty()) - .unwrap_or(profile_id); - ensure_non_empty( - &request_context, - MATCH3D_RUNTIME_PROVIDER, - &profile_id, - "profileId", - )?; - - let run = state - .spacetime_client() - .start_match3d_run(Match3DRunStartRecordInput { - run_id: build_prefixed_uuid_id(MATCH3D_RUN_ID_PREFIX), - owner_user_id: authenticated.claims().user_id().to_string(), - profile_id: profile_id.clone(), - started_at_ms: current_utc_ms(), - item_type_count_override: maybe_payload - .as_ref() - .and_then(|payload| payload.item_type_count_override) - .unwrap_or(0), - }) - .await - .map_err(|error| { - match3d_error_response( - &request_context, - MATCH3D_RUNTIME_PROVIDER, - map_match3d_client_error(error), - ) - })?; - - record_work_play_start_after_success( - &state, - &request_context, - WorkPlayTrackingDraft::new( - "match3d", - profile_id.clone(), - &authenticated, - "/api/runtime/match3d/...", - ) - .profile_id(profile_id.clone()) - .extra(json!({ - "runId": run.run_id, - })), - ) - .await; - - Ok(json_success_body( - Some(&request_context), - Match3DRunResponse { - run: map_match3d_run_response(run), - }, - )) -} - -pub async fn get_match3d_run( - State(state): State, - Path(run_id): Path, - Extension(request_context): Extension, - Extension(authenticated): Extension, -) -> Result, Response> { - ensure_non_empty(&request_context, MATCH3D_RUNTIME_PROVIDER, &run_id, "runId")?; - - let run = state - .spacetime_client() - .get_match3d_run(run_id, authenticated.claims().user_id().to_string()) - .await - .map_err(|error| { - match3d_error_response( - &request_context, - MATCH3D_RUNTIME_PROVIDER, - map_match3d_client_error(error), - ) - })?; - - Ok(json_success_body( - Some(&request_context), - Match3DRunResponse { - run: map_match3d_run_response(run), - }, - )) -} - -pub async fn click_match3d_item( - State(state): State, - Path(run_id): Path, - Extension(request_context): Extension, - Extension(authenticated): Extension, - payload: Result, JsonRejection>, -) -> Result, Response> { - let Json(payload) = match3d_json(payload, &request_context, MATCH3D_RUNTIME_PROVIDER)?; - ensure_non_empty(&request_context, MATCH3D_RUNTIME_PROVIDER, &run_id, "runId")?; - ensure_non_empty( - &request_context, - MATCH3D_RUNTIME_PROVIDER, - &payload.item_instance_id, - "itemInstanceId", - )?; - ensure_non_empty( - &request_context, - MATCH3D_RUNTIME_PROVIDER, - &payload.client_event_id, - "clientEventId", - )?; - - let confirmation = state - .spacetime_client() - .click_match3d_item(Match3DRunClickRecordInput { - run_id: payload.run_id.unwrap_or(run_id), - owner_user_id: authenticated.claims().user_id().to_string(), - item_instance_id: payload.item_instance_id, - client_snapshot_version: payload.client_snapshot_version.min(u32::MAX as u64) as u32, - client_event_id: payload.client_event_id, - clicked_at_ms: payload.clicked_at_ms.min(i64::MAX as u64) as i64, - }) - .await - .map_err(|error| { - match3d_error_response( - &request_context, - MATCH3D_RUNTIME_PROVIDER, - map_match3d_client_error(error), - ) - })?; - - Ok(json_success_body( - Some(&request_context), - Match3DClickResponse { - confirmation: map_match3d_click_confirmation_response(confirmation), - }, - )) -} - -pub async fn stop_match3d_run( - State(state): State, - Path(run_id): Path, - Extension(request_context): Extension, - Extension(authenticated): Extension, - payload: Result, JsonRejection>, -) -> Result, Response> { - let _ = payload.ok(); - ensure_non_empty(&request_context, MATCH3D_RUNTIME_PROVIDER, &run_id, "runId")?; - - let run = state - .spacetime_client() - .stop_match3d_run(Match3DRunStopRecordInput { - run_id, - owner_user_id: authenticated.claims().user_id().to_string(), - stopped_at_ms: current_utc_ms(), - }) - .await - .map_err(|error| { - match3d_error_response( - &request_context, - MATCH3D_RUNTIME_PROVIDER, - map_match3d_client_error(error), - ) - })?; - - Ok(json_success_body( - Some(&request_context), - Match3DRunResponse { - run: map_match3d_run_response(run), - }, - )) -} - -pub async fn restart_match3d_run( - State(state): State, - Path(run_id): Path, - Extension(request_context): Extension, - Extension(authenticated): Extension, -) -> Result, Response> { - ensure_non_empty(&request_context, MATCH3D_RUNTIME_PROVIDER, &run_id, "runId")?; - - let run = state - .spacetime_client() - .restart_match3d_run(Match3DRunRestartRecordInput { - source_run_id: run_id, - next_run_id: build_prefixed_uuid_id(MATCH3D_RUN_ID_PREFIX), - owner_user_id: authenticated.claims().user_id().to_string(), - restarted_at_ms: current_utc_ms(), - }) - .await - .map_err(|error| { - match3d_error_response( - &request_context, - MATCH3D_RUNTIME_PROVIDER, - map_match3d_client_error(error), - ) - })?; - - Ok(json_success_body( - Some(&request_context), - Match3DRunResponse { - run: map_match3d_run_response(run), - }, - )) -} - -pub async fn finish_match3d_time_up( - State(state): State, - Path(run_id): Path, - Extension(request_context): Extension, - Extension(authenticated): Extension, -) -> Result, Response> { - ensure_non_empty(&request_context, MATCH3D_RUNTIME_PROVIDER, &run_id, "runId")?; - - let run = state - .spacetime_client() - .finish_match3d_time_up(Match3DRunTimeUpRecordInput { - run_id, - owner_user_id: authenticated.claims().user_id().to_string(), - finished_at_ms: current_utc_ms(), - }) - .await - .map_err(|error| { - match3d_error_response( - &request_context, - MATCH3D_RUNTIME_PROVIDER, - map_match3d_client_error(error), - ) - })?; - - Ok(json_success_body( - Some(&request_context), - Match3DRunResponse { - run: map_match3d_run_response(run), - }, - )) -} - -async fn submit_and_finalize_match3d_message( - state: &AppState, - request_context: &RequestContext, - owner_user_id: &str, - session_id: String, - payload: SendMatch3DAgentMessageRequest, -) -> Result { - ensure_non_empty( - request_context, - MATCH3D_AGENT_PROVIDER, - &session_id, - "sessionId", - )?; - ensure_non_empty( - request_context, - MATCH3D_AGENT_PROVIDER, - &payload.client_message_id, - "clientMessageId", - )?; - ensure_non_empty( - request_context, - MATCH3D_AGENT_PROVIDER, - &payload.text, - "text", - )?; - - let submitted = state - .spacetime_client() - .submit_match3d_agent_message(Match3DAgentMessageSubmitRecordInput { - session_id: session_id.clone(), - owner_user_id: owner_user_id.to_string(), - user_message_id: payload.client_message_id.clone(), - user_message_text: payload.text.clone(), - submitted_at_micros: current_utc_micros(), - }) - .await - .map_err(|error| { - match3d_error_response( - request_context, - MATCH3D_AGENT_PROVIDER, - map_match3d_client_error(error), - ) - })?; - let next_turn = submitted.current_turn.saturating_add(1); - let next_config = build_config_from_message(&submitted, &payload); - let assistant_reply = build_match3d_assistant_reply_for_turn(&next_config, next_turn); - let progress_percent = resolve_progress_percent_for_turn(next_turn); - let stage = if progress_percent >= 100 { - "ReadyToCompile" - } else { - "Collecting" - } - .to_string(); - - state - .spacetime_client() - .finalize_match3d_agent_message(Match3DAgentMessageFinalizeRecordInput { - session_id, - owner_user_id: owner_user_id.to_string(), - assistant_message_id: Some(build_prefixed_uuid_id(MATCH3D_MESSAGE_ID_PREFIX)), - assistant_reply_text: Some(assistant_reply), - config_json: serialize_match3d_config(&next_config), - progress_percent, - stage, - updated_at_micros: current_utc_micros(), - error_message: None, - }) - .await - .map_err(|error| { - match3d_error_response( - request_context, - MATCH3D_AGENT_PROVIDER, - map_match3d_client_error(error), - ) - }) -} - -async fn load_match3d_agent_session_response_with_persisted_assets( - state: &AppState, - owner_user_id: &str, - session: Match3DAgentSessionRecord, -) -> Match3DAgentSessionSnapshotResponse { - let Some(profile_id) = resolve_match3d_session_existing_profile_id(&session) else { - return map_match3d_agent_session_response(session); - }; - let assets = - get_match3d_existing_generated_item_assets(state, owner_user_id, profile_id.as_str()).await; - map_match3d_agent_session_response_with_assets(session, &assets) -} - -fn resolve_match3d_session_existing_profile_id( - session: &Match3DAgentSessionRecord, -) -> Option { - session - .draft - .as_ref() - .map(|draft| draft.profile_id.trim()) - .filter(|profile_id| !profile_id.is_empty()) - .or_else(|| { - session - .published_profile_id - .as_deref() - .map(str::trim) - .filter(|profile_id| !profile_id.is_empty()) - }) - .map(str::to_string) -} - -async fn compile_match3d_draft_for_session( - state: &AppState, - request_context: &RequestContext, - authenticated: &AuthenticatedAccessToken, - session_id: String, - game_name: Option, - summary: Option, - tags: Option>, - cover_image_src: Option, - generate_click_sound: Option, -) -> Result<(Match3DAgentSessionRecord, Vec), Response> { - let owner_user_id = authenticated.claims().user_id().to_string(); - let initial_session = state - .spacetime_client() - .get_match3d_agent_session(session_id.clone(), owner_user_id.clone()) - .await - .map_err(|error| { - match3d_error_response( - request_context, - MATCH3D_AGENT_PROVIDER, - map_match3d_client_error(error), - ) - })?; - let mut config = resolve_config_or_default(initial_session.config.as_ref()); - if let Some(generate_click_sound) = generate_click_sound { - config.generate_click_sound = generate_click_sound; - } - // 中文注释:抓大鹅入口已支持表单直创;完整表单创建的 session - // 不需要再伪造三轮聊天,只要配置字段完整即可进入草稿编译。 - let has_complete_form_config = !config.theme_text.trim().is_empty() - && config.clear_count > 0 - && (1..=10).contains(&config.difficulty); - if !has_complete_form_config - && (initial_session.current_turn < 3 || initial_session.progress_percent < 100) - { - return Err(match3d_bad_request( - request_context, - MATCH3D_AGENT_PROVIDER, - "match3d 创作配置尚未确认完成", - )); - } - - let requested_game_name = normalize_optional_match3d_text(game_name); - let requested_summary = normalize_optional_match3d_text(summary); - let requested_tags = tags.map(normalize_tags).filter(|items| !items.is_empty()); - let requested_cover_image_src = normalize_optional_match3d_text(cover_image_src); - let fallback_work_metadata = fallback_match3d_work_metadata(config.theme_text.as_str()); - let profile_id = resolve_match3d_draft_profile_id(&initial_session); - let initial_game_name = requested_game_name - .clone() - .unwrap_or_else(|| fallback_work_metadata.game_name.clone()); - let initial_tags = requested_tags - .clone() - .unwrap_or_else(|| fallback_work_metadata.tags.clone()); - let billing_asset_id = format!("{}:{}:{}", session_id, profile_id, current_utc_micros()); - execute_billable_match3d_draft_generation( - state, - request_context, - owner_user_id.as_str(), - billing_asset_id.as_str(), - async { - let mut session = upsert_match3d_draft_snapshot( - state, - request_context, - authenticated, - session_id.clone(), - owner_user_id.clone(), - profile_id.clone(), - Some(initial_game_name), - requested_summary.clone().or_else(|| Some(String::new())), - Some(serde_json::to_string(&initial_tags).unwrap_or_default()), - requested_cover_image_src.clone(), - None, - None, - ) - .await?; - - if session.draft.is_none() { - return Err(match3d_error_response( - request_context, - MATCH3D_AGENT_PROVIDER, - match3d_bad_gateway("抓大鹅草稿创建失败,请稍后重试"), - )); - } - - let mut generated_work_metadata = generate_match3d_draft_plan(state, &config).await; - let resolved_game_name = requested_game_name - .unwrap_or_else(|| generated_work_metadata.metadata.game_name.clone()); - let resolved_summary = requested_summary - .clone() - .unwrap_or_else(|| generated_work_metadata.metadata.summary.clone()); - let resolved_tags = match requested_tags { - Some(tags) => tags, - None => { - generate_match3d_work_tags_for_plan( - state, - resolved_game_name.as_str(), - config.theme_text.as_str(), - resolved_summary.as_str(), - &generated_work_metadata.metadata.tags, - ) - .await - } - }; - generated_work_metadata.metadata.tags = resolved_tags.clone(); - session = upsert_match3d_draft_snapshot( - state, - request_context, - authenticated, - session_id, - owner_user_id.clone(), - profile_id.clone(), - Some(resolved_game_name), - Some(resolved_summary), - Some(serde_json::to_string(&resolved_tags).unwrap_or_default()), - requested_cover_image_src.clone(), - None, - None, - ) - .await?; - - let existing_assets = get_match3d_existing_generated_item_assets( - state, - owner_user_id.as_str(), - profile_id.as_str(), - ) - .await; - let generated_item_assets = generate_match3d_item_assets( - state, - request_context, - authenticated, - owner_user_id.as_str(), - session.session_id.as_str(), - profile_id.as_str(), - &config, - generated_work_metadata.items, - existing_assets, - ) - .await?; - let generated_item_assets = ensure_match3d_background_asset( - state, - request_context, - authenticated, - owner_user_id.as_str(), - session.session_id.as_str(), - profile_id.as_str(), - &config, - generated_work_metadata.background_prompt.as_str(), - generated_item_assets, - ) - .await?; - let existing_cover_image_src = get_match3d_existing_cover_image_src( - state, - owner_user_id.as_str(), - profile_id.as_str(), - ) - .await; - let default_cover_image_src = requested_cover_image_src - .clone() - .or(existing_cover_image_src) - .or_else(|| resolve_match3d_default_cover_image_src(&generated_item_assets)); - let next_session = upsert_match3d_draft_snapshot( - state, - request_context, - authenticated, - session.session_id.clone(), - owner_user_id.clone(), - profile_id, - None, - None, - None, - default_cover_image_src, - None, - serialize_match3d_generated_item_assets(&generated_item_assets), - ) - .await?; - - Ok((next_session, generated_item_assets)) - }, - ) - .await -} - -/// 中文注释:抓大鹅草稿生成是一次完整外部生成动作,按 session/profile 幂等预扣 10 泥点。 -async fn execute_billable_match3d_draft_generation( - state: &AppState, - request_context: &RequestContext, - owner_user_id: &str, - billing_asset_id: &str, - operation: Fut, -) -> Result -where - Fut: Future>, -{ - let points_consumed = consume_match3d_draft_generation_points( - state, - request_context, - owner_user_id, - billing_asset_id, - ) - .await?; - - match operation.await { - Ok(value) => Ok(value), - Err(response) => { - if points_consumed { - refund_match3d_draft_generation_points(state, owner_user_id, billing_asset_id) - .await; - } - Err(response) - } - } -} - -async fn consume_match3d_draft_generation_points( - state: &AppState, - request_context: &RequestContext, - owner_user_id: &str, - billing_asset_id: &str, -) -> Result { - let ledger_id = format!( - "asset_operation_consume:{}:match3d_draft_generation:{}", - owner_user_id, billing_asset_id - ); - match state - .spacetime_client() - .consume_profile_wallet_points( - owner_user_id.to_string(), - MATCH3D_DRAFT_GENERATION_POINTS_COST, - ledger_id, - current_utc_micros(), - ) - .await - { - Ok(_) => Ok(true), - Err(error) if should_skip_asset_operation_billing_for_connectivity(&error) => { - tracing::warn!( - owner_user_id, - billing_asset_id, - error = %error, - "抓大鹅草稿泥点预扣因 SpacetimeDB 连接不可用而降级跳过" - ); - Ok(false) - } - Err(error) => Err(match3d_error_response( - request_context, - MATCH3D_AGENT_PROVIDER, - map_asset_operation_wallet_error(error), - )), - } -} - -async fn refund_match3d_draft_generation_points( - state: &AppState, - owner_user_id: &str, - billing_asset_id: &str, -) { - let ledger_id = format!( - "asset_operation_refund:{}:match3d_draft_generation:{}", - owner_user_id, billing_asset_id - ); - if let Err(error) = state - .spacetime_client() - .refund_profile_wallet_points( - owner_user_id.to_string(), - MATCH3D_DRAFT_GENERATION_POINTS_COST, - ledger_id, - current_utc_micros(), - ) - .await - { - tracing::error!( - owner_user_id, - billing_asset_id, - error = %error, - "抓大鹅草稿生成失败后的泥点退款失败" - ); - } -} - -fn resolve_match3d_draft_profile_id(session: &Match3DAgentSessionRecord) -> String { - session - .draft - .as_ref() - .map(|draft| draft.profile_id.trim()) - .filter(|profile_id| !profile_id.is_empty()) - .or_else(|| { - session - .published_profile_id - .as_deref() - .map(str::trim) - .filter(|profile_id| !profile_id.is_empty()) - }) - .map(str::to_string) - .unwrap_or_else(|| build_prefixed_uuid_id(MATCH3D_PROFILE_ID_PREFIX)) -} - -#[allow(clippy::too_many_arguments)] -async fn upsert_match3d_draft_snapshot( - state: &AppState, - request_context: &RequestContext, - authenticated: &AuthenticatedAccessToken, - session_id: String, - owner_user_id: String, - profile_id: String, - game_name: Option, - summary_text: Option, - tags_json: Option, - cover_image_src: Option, - cover_asset_id: Option, - generated_item_assets_json: Option, -) -> Result { - state - .spacetime_client() - .compile_match3d_draft(Match3DCompileDraftRecordInput { - session_id, - owner_user_id, - profile_id, - author_display_name: resolve_author_display_name(state, authenticated), - game_name, - summary_text, - tags_json, - cover_image_src, - cover_asset_id, - compiled_at_micros: current_utc_micros(), - generated_item_assets_json, - }) - .await - .map_err(|error| { - match3d_error_response( - request_context, - MATCH3D_AGENT_PROVIDER, - map_match3d_client_error(error), - ) - }) -} - -async fn get_match3d_existing_generated_item_assets( - state: &AppState, - owner_user_id: &str, - profile_id: &str, -) -> Vec { - match state - .spacetime_client() - .get_match3d_work_detail(profile_id.to_string(), owner_user_id.to_string()) - .await - { - Ok(profile) => { - parse_match3d_generated_item_assets(profile.generated_item_assets_json.as_deref()) - .into_iter() - .map(Match3DGeneratedItemAsset::from) - .collect() - } - Err(error) => { - tracing::debug!( - provider = MATCH3D_AGENT_PROVIDER, - profile_id, - error = %error, - "读取抓大鹅已有素材失败,按空素材继续生成" - ); - Vec::new() - } - } -} - -async fn get_match3d_existing_cover_image_src( - state: &AppState, - owner_user_id: &str, - profile_id: &str, -) -> Option { - state - .spacetime_client() - .get_match3d_work_detail(profile_id.to_string(), owner_user_id.to_string()) - .await - .ok() - .and_then(|profile| profile.cover_image_src) - .map(|value| value.trim().to_string()) - .filter(|value| !value.is_empty()) -} - -async fn load_match3d_work_asset_context( - state: &AppState, - request_context: &RequestContext, - authenticated: &AuthenticatedAccessToken, - profile_id: &str, -) -> Result { - let owner_user_id = authenticated.claims().user_id().to_string(); - let profile = state - .spacetime_client() - .get_match3d_work_detail(profile_id.to_string(), owner_user_id.clone()) - .await - .map_err(|error| { - match3d_error_response( - request_context, - MATCH3D_WORKS_PROVIDER, - map_match3d_client_error(error), - ) - })?; - let session_id = profile.source_session_id.clone().ok_or_else(|| { - match3d_error_response( - request_context, - MATCH3D_WORKS_PROVIDER, - AppError::from_status(StatusCode::BAD_REQUEST).with_details(json!({ - "provider": MATCH3D_WORKS_PROVIDER, - "message": "抓大鹅作品缺少来源 session,无法生成素材", - })), - ) - })?; - let config = match state - .spacetime_client() - .get_match3d_agent_session(session_id.clone(), owner_user_id.clone()) - .await - { - Ok(session) => { - let mut config = resolve_config_or_default(session.config.as_ref()); - if config.theme_text.trim().is_empty() { - config.theme_text = profile.theme_text.clone(); - } - config - } - Err(error) => { - tracing::debug!( - provider = MATCH3D_WORKS_PROVIDER, - profile_id, - session_id = session_id.as_str(), - error = %error, - "读取抓大鹅 session 配置失败,使用作品 profile 派生素材配置" - ); - Match3DConfigJson { - theme_text: profile.theme_text.clone(), - reference_image_src: profile.reference_image_src.clone(), - clear_count: profile.clear_count, - difficulty: profile.difficulty, - asset_style_id: None, - asset_style_label: None, - asset_style_prompt: None, - generate_click_sound: false, - } - } - }; - let assets = parse_match3d_generated_item_assets(profile.generated_item_assets_json.as_deref()) - .into_iter() - .map(Match3DGeneratedItemAsset::from) - .collect::>(); - Ok(Match3DWorkAssetContext { - owner_user_id, - session_id, - profile, - config, - assets, - }) -} - -#[allow(clippy::too_many_arguments)] -async fn persist_match3d_generated_item_assets_snapshot( - state: &AppState, - request_context: &RequestContext, - authenticated: &AuthenticatedAccessToken, - session_id: &str, - owner_user_id: &str, - profile_id: &str, - assets: &[Match3DGeneratedItemAsset], -) -> Result<(), Response> { - upsert_match3d_draft_snapshot( - state, - request_context, - authenticated, - session_id.to_string(), - owner_user_id.to_string(), - profile_id.to_string(), - None, - None, - None, - None, - None, - serialize_match3d_generated_item_assets(assets), - ) - .await - .map(|_| ()) -} - -mod mappers; -use mappers::*; - -fn build_config_from_create_request( - payload: &CreateMatch3DAgentSessionRequest, -) -> Match3DConfigJson { - Match3DConfigJson { - theme_text: payload - .theme_text - .as_deref() - .or(payload.seed_text.as_deref()) - .map(str::trim) - .filter(|value| !value.is_empty()) - .unwrap_or(MATCH3D_DEFAULT_THEME) - .to_string(), - reference_image_src: payload.reference_image_src.clone(), - clear_count: payload.clear_count.unwrap_or(MATCH3D_DEFAULT_CLEAR_COUNT), - difficulty: payload - .difficulty - .unwrap_or(MATCH3D_DEFAULT_DIFFICULTY) - .clamp(1, 10), - asset_style_id: normalize_optional_text(payload.asset_style_id.as_deref()), - asset_style_label: normalize_optional_text(payload.asset_style_label.as_deref()), - asset_style_prompt: normalize_optional_text(payload.asset_style_prompt.as_deref()), - generate_click_sound: payload.generate_click_sound.unwrap_or(false), - } -} - -fn build_config_from_message( - session: &Match3DAgentSessionRecord, - payload: &SendMatch3DAgentMessageRequest, -) -> Match3DConfigJson { - let current = resolve_config_or_default(session.config.as_ref()); - let text = payload.text.trim(); - let reference_image_src = payload - .reference_image_src - .as_deref() - .map(str::trim) - .filter(|value| !value.is_empty()) - .map(str::to_string) - .or(current.reference_image_src); - let quick_fill_requested = - payload.quick_fill_requested.unwrap_or(false) || text.contains("自动配置"); - - let mut theme_text = current.theme_text; - let mut clear_count = current.clear_count.max(1); - let mut difficulty = current.difficulty.clamp(1, 10); - let asset_style_id = current.asset_style_id; - let asset_style_label = current.asset_style_label; - let asset_style_prompt = current.asset_style_prompt; - let generate_click_sound = current.generate_click_sound; - - match session.current_turn { - 0 => { - theme_text = if quick_fill_requested { - MATCH3D_DEFAULT_THEME.to_string() - } else { - parse_theme_answer(text).unwrap_or(theme_text) - }; - } - 1 => { - clear_count = if quick_fill_requested { - clear_count - } else { - parse_number_after_keywords(text, &["消除", "次数", "clearCount"]) - .unwrap_or(clear_count) - } - .max(1); - } - _ => { - difficulty = if quick_fill_requested { - difficulty - } else { - parse_number_after_keywords(text, &["难度", "difficulty"]).unwrap_or(difficulty) - } - .clamp(1, 10); - } - } - - Match3DConfigJson { - theme_text, - reference_image_src, - clear_count, - difficulty, - asset_style_id, - asset_style_label, - asset_style_prompt, - generate_click_sound, - } -} - -fn resolve_config_or_default(config: Option<&Match3DCreatorConfigRecord>) -> Match3DConfigJson { - config - .map(|config| Match3DConfigJson { - theme_text: config.theme_text.clone(), - reference_image_src: config.reference_image_src.clone(), - clear_count: config.clear_count.max(1), - difficulty: config.difficulty.clamp(1, 10), - asset_style_id: config.asset_style_id.clone(), - asset_style_label: config.asset_style_label.clone(), - asset_style_prompt: config.asset_style_prompt.clone(), - generate_click_sound: config.generate_click_sound, - }) - .unwrap_or_else(|| Match3DConfigJson { - theme_text: MATCH3D_DEFAULT_THEME.to_string(), - reference_image_src: None, - clear_count: MATCH3D_DEFAULT_CLEAR_COUNT, - difficulty: MATCH3D_DEFAULT_DIFFICULTY, - asset_style_id: None, - asset_style_label: None, - asset_style_prompt: None, - generate_click_sound: false, - }) -} - -fn normalize_optional_text(value: Option<&str>) -> Option { - value - .map(str::trim) - .filter(|value| !value.is_empty()) - .map(str::to_string) -} - -fn serialize_match3d_config(config: &Match3DConfigJson) -> Option { - serde_json::to_string(config).ok() -} - -fn build_seed_text( - payload: &CreateMatch3DAgentSessionRequest, - config: &Match3DConfigJson, -) -> String { - payload - .seed_text - .as_deref() - .map(str::trim) - .filter(|value| !value.is_empty()) - .map(str::to_string) - .unwrap_or_else(|| { - format!( - "{}题材,消除{}次,难度{}", - config.theme_text, config.clear_count, config.difficulty - ) - }) -} - -fn build_match3d_assistant_reply(config: &Match3DConfigJson) -> String { - format!( - "已确认:{}题材,需要消除 {} 次,共 {} 件物品,难度 {}。", - config.theme_text, - config.clear_count, - config.clear_count.saturating_mul(3), - config.difficulty - ) -} - -fn build_match3d_assistant_reply_for_turn(config: &Match3DConfigJson, current_turn: u32) -> String { - match current_turn { - 0 => MATCH3D_QUESTION_THEME.to_string(), - 1 => MATCH3D_QUESTION_CLEAR_COUNT.to_string(), - 2 => MATCH3D_QUESTION_DIFFICULTY.to_string(), - _ => build_match3d_assistant_reply(config), - } -} - -fn resolve_progress_percent_for_turn(current_turn: u32) -> u32 { - match current_turn { - 0 => 0, - 1 => 33, - 2 => 66, - _ => 100, - } -} - -fn parse_theme_answer(text: &str) -> Option { - for marker in ["题材", "主题"] { - if let Some((_, value)) = text.split_once(marker) { - let normalized = value - .trim_matches(|ch: char| ch == ':' || ch == ':' || ch.is_whitespace()) - .split_whitespace() - .next() - .unwrap_or_default() - .trim_matches(['。', ',', ',', ';', ';']) - .to_string(); - if !normalized.is_empty() { - return Some(normalized); - } - } - } - let trimmed = text.trim(); - if (2..=24).contains(&trimmed.chars().count()) && !trimmed.chars().any(|ch| ch.is_ascii_digit()) - { - return Some(trimmed.to_string()); - } - None -} - -fn parse_number_after_keywords(text: &str, keywords: &[&str]) -> Option { - for keyword in keywords { - if let Some(index) = text.find(keyword) { - let suffix = &text[index + keyword.len()..]; - if let Some(value) = first_positive_integer(suffix) { - return Some(value); - } - } - } - first_positive_integer(text) -} - -fn first_positive_integer(text: &str) -> Option { - let mut digits = String::new(); - for ch in text.chars() { - if ch.is_ascii_digit() { - digits.push(ch); - } else if !digits.is_empty() { - break; - } - } - digits.parse::().ok().filter(|value| *value > 0) -} - -fn normalize_tags(tags: Vec) -> Vec { - let mut result: Vec = Vec::new(); - for tag in tags { - let trimmed = normalize_match3d_tag(tag.as_str()); - if !trimmed.is_empty() && !result.iter().any(|value| value == &trimmed) { - result.push(trimmed); - } - if result.len() >= 6 { - break; - } - } - result -} - -fn normalize_optional_match3d_text(value: Option) -> Option { - value - .map(|value| value.trim().to_string()) - .filter(|value| !value.is_empty()) -} - -mod tags; - -use tags::*; - fn serialize_match3d_generated_item_assets(assets: &[Match3DGeneratedItemAsset]) -> Option { if assets.is_empty() { return None; @@ -2734,4438 +463,29 @@ impl From } } -fn resolve_author_display_name( - state: &AppState, - authenticated: &AuthenticatedAccessToken, -) -> String { - state - .auth_user_service() - .get_user_by_id(authenticated.claims().user_id()) - .ok() - .flatten() - .map(|user| user.display_name) - .filter(|value| !value.trim().is_empty()) - .unwrap_or_else(|| "玩家".to_string()) -} +mod handlers; +pub(crate) use self::handlers::*; -async fn generate_match3d_item_assets( - state: &AppState, - request_context: &RequestContext, - authenticated: &AuthenticatedAccessToken, - owner_user_id: &str, - session_id: &str, - profile_id: &str, - config: &Match3DConfigJson, - item_plan: Vec, - existing_assets: Vec, -) -> Result, Response> { - // 中文注释:抓大鹅音频生成当前关闭;自动草稿只补齐 2D 物品图片和可选点击音效。 - let target_item_count = resolve_match3d_generated_item_count(config); - let mut assets = normalize_match3d_generated_item_assets_for_resume(existing_assets); - if has_match3d_required_generated_assets(&assets, target_item_count, config) { - return Ok(assets.into_iter().take(target_item_count).collect()); - } +mod mappers; +use self::mappers::*; - if !has_match3d_required_item_images(&assets, target_item_count) { - assets = ensure_match3d_item_image_assets( - state, - request_context, - authenticated, - owner_user_id, - session_id, - profile_id, - config, - item_plan, - assets, - ) - .await?; - } - assets = ensure_match3d_click_sound_assets( - state, - request_context, - authenticated, - owner_user_id, - session_id, - profile_id, - config, - assets, - ) - .await?; - persist_match3d_generated_item_assets_snapshot( - state, - request_context, - authenticated, - session_id, - owner_user_id, - profile_id, - &assets, - ) - .await?; +mod tags; +use self::tags::*; - Ok(assets.into_iter().take(target_item_count).collect()) -} +mod draft; +use self::draft::*; -#[allow(clippy::too_many_arguments)] -async fn ensure_match3d_item_image_assets( - state: &AppState, - request_context: &RequestContext, - authenticated: &AuthenticatedAccessToken, - owner_user_id: &str, - session_id: &str, - profile_id: &str, - config: &Match3DConfigJson, - item_plan: Vec, - existing_assets: Vec, -) -> Result, Response> { - let mut assets = normalize_match3d_generated_item_assets_for_resume(existing_assets); - let target_item_count = resolve_match3d_generated_item_count(config); - let item_plan = normalize_match3d_item_plan(config, item_plan); - let missing_items = item_plan - .iter() - .take(target_item_count) - .enumerate() - .filter_map(|(index, item)| { - let item_id = format!("match3d-item-{}", index + 1); - if assets.iter().any(|asset| { - asset.item_id == item_id && is_match3d_generated_asset_image_ready(asset) - }) { - return None; - } - Some(Match3DItemImageGenerationSeed { - item_id, - item_name: item.name.clone(), - item_size: item.item_size.clone(), - sound_prompt: item.sound_prompt.clone(), - persist_asset: true, - background_music_title: None, - background_music_style: None, - background_music_prompt: None, - background_asset: if index == 0 { - assets - .first() - .and_then(|asset| asset.background_asset.clone()) - } else { - None - }, - }) - }) - .collect::>(); +mod works; +use self::works::*; - let generated_assets = generate_match3d_item_image_assets_in_batches( - state, - request_context, - MATCH3D_AGENT_PROVIDER, - owner_user_id, - session_id, - profile_id, - config, - missing_items, - ) - .await?; +mod runtime; +use self::runtime::*; - for generated_asset in generated_assets - .into_iter() - .filter(|generated| generated.persist_asset) - .map(|generated| generated.asset) - { - upsert_match3d_generated_item_asset(&mut assets, generated_asset); - persist_match3d_generated_item_assets_snapshot( - state, - request_context, - authenticated, - session_id, - owner_user_id, - profile_id, - &assets, - ) - .await?; - } +mod item_assets; +use self::item_assets::*; - Ok(assets) -} - -#[derive(Clone)] -struct Match3DItemImageGenerationSeed { - item_id: String, - item_name: String, - item_size: String, - sound_prompt: String, - persist_asset: bool, - background_music_title: Option, - background_music_style: Option, - background_music_prompt: Option, - background_asset: Option, -} - -struct Match3DMaterialBatchOutput { - task_id: String, - generated_at_micros: i64, - items: Vec<(Match3DItemImageGenerationSeed, Vec)>, -} - -struct Match3DGeneratedItemImageAssetOutput { - asset: Match3DGeneratedItemAsset, - persist_asset: bool, -} - -#[allow(clippy::too_many_arguments)] -async fn generate_match3d_item_image_assets_in_batches( - state: &AppState, - request_context: &RequestContext, - provider: &str, - owner_user_id: &str, - session_id: &str, - profile_id: &str, - config: &Match3DConfigJson, - item_seeds: Vec, -) -> Result, Response> { - if item_seeds.is_empty() { - return Ok(Vec::new()); - } - require_match3d_oss_client(state) - .map_err(|error| match3d_error_response(request_context, provider, error))?; - - let mut batch_tasks = item_seeds - .chunks(MATCH3D_MATERIAL_ITEM_BATCH_SIZE) - .map(|chunk| { - let chunk_seeds = chunk.to_vec(); - async move { - let item_names = chunk_seeds - .iter() - .map(|item| item.item_name.clone()) - .collect::>(); - let material_sheet = - generate_match3d_material_sheet(state, config, &item_names).await?; - let generated_at_micros = current_utc_micros(); - let persisted_seed_count = chunk_seeds - .iter() - .position(|seed| !seed.persist_asset) - .unwrap_or(chunk_seeds.len()); - debug_assert!( - chunk_seeds[persisted_seed_count..] - .iter() - .all(|seed| !seed.persist_asset) - ); - let persisted_seeds = chunk_seeds - .into_iter() - .take(persisted_seed_count) - .collect::>(); - let persisted_item_names = persisted_seeds - .iter() - .map(|item| item.item_name.clone()) - .collect::>(); - let item_images = - slice_match3d_material_sheet(&material_sheet.image, &persisted_item_names)?; - Ok::<_, AppError>(Match3DMaterialBatchOutput { - task_id: material_sheet.task_id, - generated_at_micros, - items: persisted_seeds - .into_iter() - .zip(item_images.into_iter()) - .collect::>(), - }) - } - }) - .collect::>(); - - let mut batches = Vec::new(); - while let Some(batch_result) = batch_tasks.next().await { - batches.push( - batch_result - .map_err(|error| match3d_error_response(request_context, provider, error))?, - ); - } - - let mut generated_assets = Vec::new(); - for batch in batches { - let sheet_task_id = batch.task_id; - let generated_at_micros = batch.generated_at_micros; - for (item_index, (seed, item_images)) in batch.items.into_iter().enumerate() { - let item_slug = build_match3d_item_slug(seed.item_id.as_str(), seed.item_name.as_str()); - let mut image_views = Vec::with_capacity(item_images.len()); - for (view_index, item_image) in item_images.into_iter().enumerate() { - let view_number = view_index + 1; - let view_upload = persist_match3d_generated_bytes( - state, - owner_user_id, - session_id, - profile_id, - &["items", item_slug.as_str(), "views"], - format!("view-{view_number:02}.png").as_str(), - "image/png", - item_image.bytes, - "match3d_item_image_view", - Some(sheet_task_id.as_str()), - generated_at_micros.saturating_add( - (item_index * MATCH3D_ITEM_VIEW_COUNT + view_index) as i64 + 1, - ), - ) - .await - .map_err(|error| match3d_error_response(request_context, provider, error))?; - image_views.push(Match3DGeneratedItemImageView { - view_id: format!("view-{view_number:02}"), - view_index: view_number as u32, - image_src: Some(view_upload.src), - image_object_key: Some(view_upload.object_key), - }); - } - let primary_view = image_views.first().cloned(); - generated_assets.push(Match3DGeneratedItemImageAssetOutput { - persist_asset: seed.persist_asset, - asset: Match3DGeneratedItemAsset { - item_id: seed.item_id, - item_name: seed.item_name, - item_size: Some(normalize_match3d_item_size(seed.item_size.as_str())) - .filter(|value| !value.is_empty()) - .or_else(|| Some(MATCH3D_ITEM_SIZE_LARGE.to_string())), - image_src: primary_view - .as_ref() - .and_then(|view| view.image_src.clone()), - image_object_key: primary_view - .as_ref() - .and_then(|view| view.image_object_key.clone()), - image_views, - model_src: None, - model_object_key: None, - model_file_name: None, - task_uuid: None, - subscription_key: None, - sound_prompt: Some(seed.sound_prompt), - background_music_title: seed.background_music_title, - background_music_style: seed.background_music_style, - background_music_prompt: seed.background_music_prompt, - background_music: None, - click_sound: None, - background_asset: seed.background_asset, - status: "image_ready".to_string(), - error: None, - }, - }); - } - } - - generated_assets.sort_by(|left, right| { - match3d_item_sort_index(left.asset.item_id.as_str()) - .cmp(&match3d_item_sort_index(right.asset.item_id.as_str())) - .then_with(|| left.asset.item_id.cmp(&right.asset.item_id)) - }); - Ok(generated_assets) -} - -#[allow(clippy::too_many_arguments)] -async fn append_match3d_item_assets( - state: &AppState, - request_context: &RequestContext, - authenticated: &AuthenticatedAccessToken, - owner_user_id: &str, - session_id: &str, - profile_id: &str, - config: &Match3DConfigJson, - generation_plan: Match3DItemAssetsGenerationPlan, - existing_assets: Vec, -) -> Result, Response> { - match generation_plan { - Match3DItemAssetsGenerationPlan::Append(append_plan) => { - append_match3d_new_item_assets( - state, - request_context, - authenticated, - owner_user_id, - session_id, - profile_id, - config, - append_plan, - existing_assets, - ) - .await - } - Match3DItemAssetsGenerationPlan::Replace(replace_plan) => { - replace_match3d_item_assets( - state, - request_context, - authenticated, - owner_user_id, - session_id, - profile_id, - config, - replace_plan, - existing_assets, - ) - .await - } - } -} - -#[allow(clippy::too_many_arguments)] -async fn ensure_match3d_click_sound_assets( - state: &AppState, - request_context: &RequestContext, - authenticated: &AuthenticatedAccessToken, - owner_user_id: &str, - session_id: &str, - profile_id: &str, - config: &Match3DConfigJson, - assets: Vec, -) -> Result, Response> { - if !config.generate_click_sound { - return Ok(assets); - } - - let mut assets = normalize_match3d_generated_item_assets_for_resume(assets); - let seeds = assets - .iter() - .filter(|asset| is_match3d_generated_asset_image_ready(asset)) - .filter(|asset| asset.click_sound.is_none()) - .cloned() - .collect::>(); - if seeds.is_empty() { - return Ok(assets); - } - - let mut sound_tasks = seeds - .into_iter() - .map(|asset| async move { - let prompt = asset - .sound_prompt - .clone() - .filter(|value| !value.trim().is_empty()) - .unwrap_or_else(|| { - build_fallback_match3d_item_sound_prompt(config, asset.item_name.as_str()) - }); - let result = generate_match3d_click_sound_asset( - state, - owner_user_id, - profile_id, - asset.item_id.as_str(), - asset.item_name.as_str(), - prompt.as_str(), - ) - .await; - (asset, prompt, result) - }) - .collect::>(); - - while let Some((mut asset, prompt, result)) = sound_tasks.next().await { - match result { - Ok(click_sound) => { - asset.sound_prompt = Some(prompt); - asset.click_sound = Some(click_sound); - asset.error = None; - } - Err(error) => { - tracing::warn!( - provider = MATCH3D_AGENT_PROVIDER, - session_id, - profile_id, - item_id = asset.item_id.as_str(), - error = %error, - "抓大鹅入口内联点击音效生成失败,保留草稿并允许结果页重试" - ); - } - } - upsert_match3d_generated_item_asset(&mut assets, asset); - persist_match3d_generated_item_assets_snapshot( - state, - request_context, - authenticated, - session_id, - owner_user_id, - profile_id, - &assets, - ) - .await?; - } - - Ok(assets) -} - -async fn generate_match3d_click_sound_asset( - state: &AppState, - owner_user_id: &str, - profile_id: &str, - item_id: &str, - item_name: &str, - prompt: &str, -) -> Result { - let mut asset = generate_sound_effect_asset_for_creation( - state, - owner_user_id, - prompt.to_string(), - Some(3), - None, - GeneratedCreationAudioTarget { - entity_kind: "match3d_item".to_string(), - entity_id: item_id.to_string(), - slot: "click_sound".to_string(), - asset_kind: MATCH3D_CLICK_SOUND_ASSET_KIND.to_string(), - profile_id: Some(profile_id.to_string()), - storage_prefix: LegacyAssetPrefix::Match3DAssets, - }, - ) - .await?; - asset.title = Some(format!("{item_name}点击音效")); - Ok(asset) -} - -#[allow(clippy::too_many_arguments)] -async fn append_match3d_new_item_assets( - state: &AppState, - request_context: &RequestContext, - authenticated: &AuthenticatedAccessToken, - owner_user_id: &str, - session_id: &str, - profile_id: &str, - config: &Match3DConfigJson, - append_plan: Match3DItemAssetAppendPlan, - existing_assets: Vec, -) -> Result, Response> { - let mut assets = sort_match3d_generated_assets(existing_assets); - let existing_item_count = assets.len(); - let requested_item_count = append_plan.requested_item_names.len(); - if requested_item_count == 0 { - return Ok(assets); - } - let mut next_item_index = next_match3d_generated_item_index(&assets); - let item_seeds = append_plan - .padded_item_names - .into_iter() - .enumerate() - .map(|(index, item_name)| { - let item_id = allocate_match3d_generated_item_id(&assets, &mut next_item_index); - Match3DItemImageGenerationSeed { - item_id, - item_size: infer_match3d_item_size(item_name.as_str()), - sound_prompt: build_fallback_match3d_item_sound_prompt(config, item_name.as_str()), - item_name, - persist_asset: index < requested_item_count, - background_music_title: None, - background_music_style: None, - background_music_prompt: None, - background_asset: None, - } - }) - .collect::>(); - let generated_assets = generate_match3d_item_image_assets_in_batches( - state, - request_context, - MATCH3D_WORKS_PROVIDER, - owner_user_id, - session_id, - profile_id, - config, - item_seeds, - ) - .await?; - for generated_asset in generated_assets - .into_iter() - .filter(|generated| generated.persist_asset) - .map(|generated| generated.asset) - { - upsert_match3d_generated_item_asset(&mut assets, generated_asset); - persist_match3d_generated_item_assets_snapshot( - state, - request_context, - authenticated, - session_id, - owner_user_id, - profile_id, - &assets, - ) - .await?; - } - ensure_match3d_click_sound_assets( - state, - request_context, - authenticated, - owner_user_id, - session_id, - profile_id, - config, - assets, - ) - .await - .map(|assets| { - sort_match3d_generated_assets(assets) - .into_iter() - .take(existing_item_count + requested_item_count) - .collect() - }) -} - -#[allow(clippy::too_many_arguments)] -async fn replace_match3d_item_assets( - state: &AppState, - request_context: &RequestContext, - authenticated: &AuthenticatedAccessToken, - owner_user_id: &str, - session_id: &str, - profile_id: &str, - config: &Match3DConfigJson, - replace_plan: Match3DItemAssetReplacePlan, - existing_assets: Vec, -) -> Result, Response> { - let mut assets = sort_match3d_generated_assets(existing_assets); - if replace_plan.target_assets.is_empty() { - return Ok(assets); - } - let target_by_name = replace_plan - .target_assets - .iter() - .map(|asset| (asset.item_name.trim().to_string(), asset.clone())) - .collect::>(); - let mut next_item_index = next_match3d_generated_item_index(&assets); - let requested_item_count = replace_plan.requested_item_names.len(); - let item_seeds = replace_plan - .padded_item_names - .into_iter() - .enumerate() - .map(|(index, item_name)| { - let matched_asset = target_by_name.get(item_name.trim()).cloned(); - let item_id = matched_asset - .as_ref() - .map(|asset| asset.item_id.clone()) - .unwrap_or_else(|| { - allocate_match3d_generated_item_id(&assets, &mut next_item_index) - }); - Match3DItemImageGenerationSeed { - item_id, - item_size: matched_asset - .as_ref() - .and_then(|asset| asset.item_size.clone()) - .map(|value| normalize_match3d_item_size(value.as_str())) - .filter(|value| !value.is_empty()) - .unwrap_or_else(|| infer_match3d_item_size(item_name.as_str())), - sound_prompt: matched_asset - .as_ref() - .and_then(|asset| asset.sound_prompt.clone()) - .filter(|value| !value.trim().is_empty()) - .unwrap_or_else(|| { - build_fallback_match3d_item_sound_prompt(config, item_name.as_str()) - }), - item_name, - persist_asset: index < requested_item_count, - background_music_title: matched_asset - .as_ref() - .and_then(|asset| asset.background_music_title.clone()), - background_music_style: matched_asset - .as_ref() - .and_then(|asset| asset.background_music_style.clone()), - background_music_prompt: matched_asset - .as_ref() - .and_then(|asset| asset.background_music_prompt.clone()), - background_asset: matched_asset - .as_ref() - .and_then(|asset| asset.background_asset.clone()), - } - }) - .collect::>(); - let generated_assets = generate_match3d_item_image_assets_in_batches( - state, - request_context, - MATCH3D_WORKS_PROVIDER, - owner_user_id, - session_id, - profile_id, - config, - item_seeds, - ) - .await?; - for generated_asset in generated_assets - .into_iter() - .filter(|generated| generated.persist_asset) - .map(|generated| generated.asset) - { - let current_asset = assets - .iter() - .find(|candidate| candidate.item_id == generated_asset.item_id) - .cloned(); - upsert_match3d_generated_item_asset( - &mut assets, - merge_regenerated_match3d_item_asset(current_asset, generated_asset), - ); - persist_match3d_generated_item_assets_snapshot( - state, - request_context, - authenticated, - session_id, - owner_user_id, - profile_id, - &assets, - ) - .await?; - } - ensure_match3d_click_sound_assets( - state, - request_context, - authenticated, - owner_user_id, - session_id, - profile_id, - config, - assets, - ) - .await - .map(sort_match3d_generated_assets) -} - -struct Match3DMaterialSheet { - task_id: String, - image: DownloadedOpenAiImage, -} - -struct Match3DVectorEngineGeminiImageSettings { - base_url: String, - api_key: String, - request_timeout_ms: u64, -} - -struct Match3DSlicedItemImage { - bytes: Vec, -} - -async fn generate_match3d_draft_plan( - state: &AppState, - config: &Match3DConfigJson, -) -> Match3DGeneratedDraftPlan { - let Some(llm_client) = state - .creative_agent_gpt5_client() - .or_else(|| state.llm_client()) - else { - return fallback_match3d_draft_plan(config); - }; - let system_prompt = "你是抓大鹅游戏的草稿生成编辑,只返回 JSON。"; - let gameplay_item_count = resolve_match3d_gameplay_item_count(config); - let generated_item_count = resolve_match3d_generated_item_count(config); - let user_prompt = format!( - "题材设定:{}\n请生成抓大鹅游戏草稿生成计划。要求:只返回 JSON 对象,字段为 gameName、summary、tags、backgroundPrompt、items。gameName 为 4 到 12 个中文字符,不要包含“作品”“游戏”;summary 为 18 到 48 个中文字符的作品描述,说明题材氛围和核心体验,不要写规则说明;tags 为 3 到 6 个中文短标签,每个 2 到 6 个汉字,后续会用同一作品信息再次生成作品标签;backgroundPrompt 是用于生成局内纯背景图的中文提示词,只描述竖屏移动端抓大鹅题材氛围、色彩和环境,不得描述锅、圆盘、托盘、拼图槽、物品槽、HUD、UI、文字、按钮、倒计时、分数或物品;当前玩法需要 {} 种物品,但素材图固定每 5 个物品一批,因此 items 必须向上补齐并正好返回 {} 项,每项包含 name、itemSize 和 soundPrompt。name 为 2 到 6 个汉字;itemSize 只能是“大”“中”“小”之一,按物品真实相对尺寸判断,例如西瓜/大箱子偏大,苹果/杯子偏中,糖果/钥匙偏小;soundPrompt 只作为历史字段保留,可返回空字符串。", - config.theme_text, gameplay_item_count, generated_item_count - ); - let response = llm_client - .request_text( - LlmTextRequest::new(vec![ - LlmMessage::system(system_prompt), - LlmMessage::user(user_prompt), - ]) - .with_model(MATCH3D_WORK_METADATA_LLM_MODEL) - .with_responses_api(), - ) - .await; - - match response { - Ok(response) => parse_match3d_draft_plan(response.content.as_str(), config) - .unwrap_or_else(|| fallback_match3d_draft_plan(config)), - Err(error) => { - tracing::warn!( - provider = MATCH3D_AGENT_PROVIDER, - theme_text = config.theme_text.as_str(), - error = %error, - "抓大鹅草稿生成计划失败,降级使用本地生成计划" - ); - fallback_match3d_draft_plan(config) - } - } -} - -fn parse_match3d_draft_plan( - raw: &str, - config: &Match3DConfigJson, -) -> Option { - let raw = raw.trim(); - let json_text = if let Some(start) = raw.find('{') - && let Some(end) = raw.rfind('}') - && end > start - { - &raw[start..=end] - } else { - raw - }; - let value = serde_json::from_str::(json_text).ok()?; - let game_name = normalize_match3d_game_name(value.get("gameName")?.as_str()?); - if game_name.is_empty() { - return None; - } - let tags = value - .get("tags") - .and_then(Value::as_array) - .map(|items| normalize_match3d_tag_candidates(items.iter().filter_map(Value::as_str))) - .unwrap_or_default(); - let fallback = fallback_match3d_draft_plan(config); - let summary = value - .get("summary") - .or_else(|| value.get("description")) - .or_else(|| value.get("workSummary")) - .or_else(|| value.get("work_summary")) - .and_then(Value::as_str) - .map(normalize_match3d_work_summary) - .filter(|value| !value.is_empty()) - .unwrap_or(fallback.metadata.summary); - let items = value - .get("items") - .and_then(Value::as_array) - .map(|items| { - items - .iter() - .filter_map(|item| { - let name = - normalize_match3d_item_name(item.get("name").and_then(Value::as_str)?); - if name.is_empty() { - return None; - } - let item_size = item - .get("itemSize") - .or_else(|| item.get("item_size")) - .or_else(|| item.get("size")) - .and_then(Value::as_str) - .map(normalize_match3d_item_size) - .filter(|value| !value.is_empty()) - .unwrap_or_else(|| infer_match3d_item_size(&name)); - let sound_prompt = item - .get("soundPrompt") - .or_else(|| item.get("sound_prompt")) - .and_then(Value::as_str) - .map(normalize_match3d_audio_prompt) - .filter(|value| !value.is_empty()) - .unwrap_or_else(|| build_fallback_match3d_item_sound_prompt(config, &name)); - Some(Match3DGeneratedItemPlan { - name, - item_size, - sound_prompt, - }) - }) - .collect::>() - }) - .unwrap_or_default(); - let background_prompt = value - .get("backgroundPrompt") - .or_else(|| value.get("background_prompt")) - .and_then(Value::as_str) - .map(normalize_match3d_background_prompt) - .filter(|value| !value.is_empty()) - .unwrap_or(fallback.background_prompt); - - Some(Match3DGeneratedDraftPlan { - metadata: Match3DGeneratedWorkMetadata { - game_name, - summary, - tags: normalize_match3d_tag_candidates(tags), - }, - items: normalize_match3d_item_plan(config, items), - background_prompt, - }) -} - -#[cfg(test)] -fn parse_match3d_work_metadata(raw: &str) -> Option { - let config = Match3DConfigJson { - theme_text: MATCH3D_DEFAULT_THEME.to_string(), - reference_image_src: None, - clear_count: MATCH3D_DEFAULT_CLEAR_COUNT, - difficulty: MATCH3D_DEFAULT_DIFFICULTY, - asset_style_id: None, - asset_style_label: None, - asset_style_prompt: None, - generate_click_sound: false, - }; - parse_match3d_draft_plan(raw, &config).map(|plan| plan.metadata) -} - -fn normalize_match3d_game_name(raw: &str) -> String { - raw.trim() - .trim_matches(['"', '\'', '“', '”', '。', ',', ',', '、']) - .chars() - .filter(|character| !character.is_control()) - .take(16) - .collect::() - .trim() - .to_string() -} - -fn normalize_match3d_work_summary(raw: &str) -> String { - raw.trim() - .trim_matches(['"', '\'', '“', '”']) - .split_whitespace() - .collect::>() - .join("") - .chars() - .filter(|character| !character.is_control()) - .take(80) - .collect::() - .trim() - .to_string() -} - -fn fallback_match3d_work_metadata(theme_text: &str) -> Match3DGeneratedWorkMetadata { - let theme = theme_text.trim(); - let normalized_theme = if theme.is_empty() { "主题" } else { theme }; - Match3DGeneratedWorkMetadata { - game_name: format!("{normalized_theme}抓大鹅"), - summary: normalize_match3d_work_summary( - format!("{normalized_theme}主题的轻量抓取消除作品,适合快速体验。").as_str(), - ), - tags: normalize_match3d_tag_candidates([normalized_theme, "抓大鹅", "经典消除", "2D素材"]), - } -} - -fn fallback_match3d_draft_plan(config: &Match3DConfigJson) -> Match3DGeneratedDraftPlan { - let metadata = fallback_match3d_work_metadata(config.theme_text.as_str()); - let items = fallback_match3d_item_names(config.theme_text.as_str()) - .into_iter() - .take(resolve_match3d_generated_item_count(config)) - .map(|name| Match3DGeneratedItemPlan { - item_size: infer_match3d_item_size(&name), - sound_prompt: build_fallback_match3d_item_sound_prompt(config, &name), - name, - }) - .collect::>(); - Match3DGeneratedDraftPlan { - background_prompt: build_fallback_match3d_background_prompt(config), - metadata, - items, - } -} - -fn normalize_match3d_item_name(raw: &str) -> String { - raw.trim() - .trim_matches(['"', '\'', '“', '”', '。', ',', ',', '、']) - .chars() - .filter(|character| !character.is_control()) - .take(12) - .collect::() - .trim() - .to_string() -} - -fn normalize_match3d_item_size(raw: &str) -> String { - let normalized = raw - .trim() - .trim_matches(['"', '\'', '“', '”', '。', ',', ',', '、']); - match normalized { - "大" | "大型" | "偏大" | "large" | "Large" | "L" | "l" => { - MATCH3D_ITEM_SIZE_LARGE.to_string() - } - "中" | "中型" | "中等" | "medium" | "Medium" | "M" | "m" => { - MATCH3D_ITEM_SIZE_MEDIUM.to_string() - } - "小" | "小型" | "偏小" | "small" | "Small" | "S" | "s" => { - MATCH3D_ITEM_SIZE_SMALL.to_string() - } - _ => String::new(), - } -} - -fn infer_match3d_item_size(item_name: &str) -> String { - let name = item_name.trim(); - let large_keywords = [ - "西瓜", "南瓜", "椰子", "箱", "盒", "桶", "盆", "锅", "坛", "瓶子", "大瓶", "包", "书包", - "枕", "抱枕", "玩偶", "球", "圆球", "足球", "篮球", "鼓", - ]; - if large_keywords.iter().any(|keyword| name.contains(keyword)) { - return MATCH3D_ITEM_SIZE_LARGE.to_string(); - } - let small_keywords = [ - "草莓", "蓝莓", "葡萄", "樱桃", "莓", "糖", "糖果", "钥匙", "硬币", "纽扣", "徽章", "戒指", - "耳环", "铃铛", "星星", "宝石", "叶片", "花瓣", "蘑菇", "贝壳", "印章", "彩蛋", "棋子", - "骰子", "挂件", - ]; - if small_keywords.iter().any(|keyword| name.contains(keyword)) { - return MATCH3D_ITEM_SIZE_SMALL.to_string(); - } - MATCH3D_ITEM_SIZE_MEDIUM.to_string() -} - -fn fallback_match3d_item_names(theme_text: &str) -> Vec { - let theme = theme_text.trim(); - let normalized_theme = if theme.is_empty() { "主题" } else { theme }; - [ - "小物件", - "徽章", - "摆件", - "挂件", - "圆球", - "方块", - "钥匙", - "杯子", - "糖果", - "星星", - "宝石", - "铃铛", - "叶片", - "蘑菇", - "花朵", - "果冻", - "小瓶", - "帽子", - "贝壳", - "纽扣", - "积木", - "印章", - "彩蛋", - "小鼓", - "风车", - ] - .into_iter() - .map(|suffix| format!("{normalized_theme}{suffix}")) - .take(MATCH3D_MAX_GENERATED_ITEM_COUNT) - .collect() -} - -fn normalize_match3d_item_plan( - config: &Match3DConfigJson, - items: Vec, -) -> Vec { - let target_item_count = resolve_match3d_generated_item_count(config); - let mut normalized = Vec::new(); - for item in items { - let name = normalize_match3d_item_name(item.name.as_str()); - if name.is_empty() - || normalized - .iter() - .any(|candidate: &Match3DGeneratedItemPlan| candidate.name == name) - { - continue; - } - let sound_prompt = normalize_match3d_audio_prompt(item.sound_prompt.as_str()); - let item_size = normalize_match3d_item_size(item.item_size.as_str()); - normalized.push(Match3DGeneratedItemPlan { - item_size: if item_size.is_empty() { - infer_match3d_item_size(&name) - } else { - item_size - }, - sound_prompt: if sound_prompt.is_empty() { - build_fallback_match3d_item_sound_prompt(config, &name) - } else { - sound_prompt - }, - name, - }); - if normalized.len() >= target_item_count { - break; - } - } - - if normalized.len() < target_item_count { - for name in fallback_match3d_item_names(config.theme_text.as_str()) { - if normalized.iter().any(|candidate| candidate.name == name) { - continue; - } - normalized.push(Match3DGeneratedItemPlan { - item_size: infer_match3d_item_size(&name), - sound_prompt: build_fallback_match3d_item_sound_prompt(config, &name), - name, - }); - if normalized.len() >= target_item_count { - break; - } - } - } - - if normalized.len() < target_item_count { - fill_match3d_item_plan_to_count(config, &mut normalized, target_item_count); - } - - normalized -} - -fn fill_match3d_item_plan_to_count( - config: &Match3DConfigJson, - normalized: &mut Vec, - target_item_count: usize, -) { - let normalized_theme = config.theme_text.trim(); - let fallback_prefix = if normalized_theme.is_empty() { - "补充物品".to_string() - } else { - format!("{normalized_theme}补充") - }; - let mut index = 1usize; - while normalized.len() < target_item_count { - let name = normalize_match3d_item_name(format!("{fallback_prefix}{index}").as_str()); - if !name.is_empty() - && !normalized - .iter() - .any(|candidate: &Match3DGeneratedItemPlan| candidate.name == name) - { - normalized.push(Match3DGeneratedItemPlan { - item_size: infer_match3d_item_size(&name), - sound_prompt: build_fallback_match3d_item_sound_prompt(config, &name), - name, - }); - } - index += 1; - } -} - -fn normalize_match3d_batch_item_names(items: Vec) -> Vec { - let mut normalized: Vec = Vec::new(); - for item in items { - let name = normalize_match3d_item_name(item.as_str()); - if name.is_empty() || normalized.iter().any(|candidate| candidate == &name) { - continue; - } - normalized.push(name); - if normalized.len() >= MATCH3D_MAX_GENERATED_ITEM_COUNT { - break; - } - } - normalized -} - -fn normalize_match3d_item_assets_generation_mode( - mode: Option<&str>, -) -> Match3DItemAssetsGenerationMode { - match mode - .unwrap_or_default() - .trim() - .to_ascii_lowercase() - .as_str() - { - "replace" | "regenerate" => Match3DItemAssetsGenerationMode::Replace, - _ => Match3DItemAssetsGenerationMode::Append, - } -} - -fn build_match3d_item_assets_generation_plan( - mode: Match3DItemAssetsGenerationMode, - item_names: Vec, - existing_assets: &[Match3DGeneratedItemAsset], -) -> Match3DItemAssetsGenerationPlan { - match mode { - Match3DItemAssetsGenerationMode::Append => Match3DItemAssetsGenerationPlan::Append( - build_match3d_item_asset_append_plan(item_names, existing_assets), - ), - Match3DItemAssetsGenerationMode::Replace => Match3DItemAssetsGenerationPlan::Replace( - build_match3d_item_asset_replace_plan(item_names, existing_assets), - ), - } -} - -fn build_match3d_item_asset_append_plan( - item_names: Vec, - existing_assets: &[Match3DGeneratedItemAsset], -) -> Match3DItemAssetAppendPlan { - let available_capacity = MATCH3D_MAX_GENERATED_ITEM_COUNT.saturating_sub(existing_assets.len()); - let mut requested_item_names = item_names - .into_iter() - .filter(|name| { - !existing_assets - .iter() - .any(|asset| asset.item_name.trim() == name.trim()) - }) - .take(available_capacity) - .collect::>(); - requested_item_names.truncate(available_capacity); - let padded_item_names = build_match3d_padded_item_names_for_generation( - &requested_item_names, - existing_assets, - available_capacity, - ); - - Match3DItemAssetAppendPlan { - requested_item_names, - padded_item_names, - } -} - -fn build_match3d_padded_item_names_for_generation( - item_names: &[String], - existing_assets: &[Match3DGeneratedItemAsset], - available_capacity: usize, -) -> Vec { - let mut padded = item_names - .iter() - .take(available_capacity) - .cloned() - .collect::>(); - let target_item_count = round_match3d_item_count_to_full_sheet(padded.len()); - let mut fallback_index = 1usize; - while padded.len() < target_item_count { - let candidate = normalize_match3d_item_name(format!("追加物品{fallback_index}").as_str()); - fallback_index += 1; - if candidate.is_empty() - || padded.iter().any(|name| name == &candidate) - || existing_assets - .iter() - .any(|asset| asset.item_name.trim() == candidate.as_str()) - { - continue; - } - padded.push(candidate); - } - padded -} - -fn build_match3d_item_asset_replace_plan( - item_names: Vec, - existing_assets: &[Match3DGeneratedItemAsset], -) -> Match3DItemAssetReplacePlan { - let mut requested_item_names = Vec::new(); - let mut target_assets = Vec::new(); - for item_name in item_names { - let Some(asset) = existing_assets - .iter() - .find(|asset| asset.item_name.trim() == item_name.trim()) - else { - continue; - }; - if target_assets - .iter() - .any(|candidate: &Match3DGeneratedItemAsset| candidate.item_id == asset.item_id) - { - continue; - } - requested_item_names.push(asset.item_name.clone()); - target_assets.push(asset.clone()); - if requested_item_names.len() >= MATCH3D_MAX_GENERATED_ITEM_COUNT { - break; - } - } - let padded_item_names = build_match3d_padded_item_names_for_generation( - &requested_item_names, - existing_assets, - MATCH3D_MAX_GENERATED_ITEM_COUNT, - ); - - Match3DItemAssetReplacePlan { - requested_item_names, - padded_item_names, - target_assets, - } -} - -fn calculate_match3d_item_assets_points_cost(item_count: usize) -> u64 { - if item_count == 0 { - return 0; - } - item_count.div_ceil(MATCH3D_MATERIAL_ITEM_BATCH_SIZE) as u64 - * MATCH3D_ITEM_ASSETS_POINTS_PER_BATCH -} - -fn normalize_match3d_cover_prompt(raw: &str) -> String { - raw.trim() - .chars() - .filter(|character| !character.is_control()) - .take(900) - .collect::() - .trim() - .to_string() -} - -fn normalize_match3d_audio_prompt(raw: &str) -> String { - raw.trim() - .chars() - .filter(|character| !character.is_control()) - .take(500) - .collect::() - .trim() - .to_string() -} - -fn normalize_match3d_background_prompt(raw: &str) -> String { - raw.trim() - .chars() - .filter(|character| !character.is_control()) - .take(900) - .collect::() - .trim() - .to_string() -} - -fn build_match3d_prompt_fingerprint(value: &str) -> String { - let mut hash = 0u32; - for character in value.chars() { - hash = hash.wrapping_mul(31).wrapping_add(character as u32); - } - format!("{hash:08x}") -} - -fn build_fallback_match3d_background_prompt(config: &Match3DConfigJson) -> String { - let theme = config.theme_text.trim(); - let normalized_theme = if theme.is_empty() { "抓大鹅" } else { theme }; - normalize_match3d_background_prompt( - format!( - "{normalized_theme}题材抓大鹅游戏竖屏纯背景图,表现题材环境、绿色纵向渐变和轻快休闲氛围,中央区域保持干净通透,方便运行态叠加默认交互容器。无锅、无圆盘、无托盘、无拼图槽、无物品槽、无文字、无水印、无 UI、无按钮、无倒计时、无物品、无角色、无手。" - ) - .as_str(), - ) -} - -fn build_fallback_match3d_item_sound_prompt(config: &Match3DConfigJson, item_name: &str) -> String { - let theme = config.theme_text.trim(); - let normalized_theme = if theme.is_empty() { "抓大鹅" } else { theme }; - normalize_match3d_audio_prompt( - format!( - "{normalized_theme}题材抓大鹅中“{item_name}”被点击并消除时的短促反馈音效,清脆、可爱、有轻微弹跳感,适合移动端休闲游戏。" - ) - .as_str(), - ) -} - -fn normalize_match3d_generated_item_assets_for_resume( - assets: Vec, -) -> Vec { - let mut normalized = Vec::new(); - for asset in sort_match3d_generated_assets(assets) { - if asset.item_id.trim().is_empty() - || normalized - .iter() - .any(|candidate: &Match3DGeneratedItemAsset| candidate.item_id == asset.item_id) - { - continue; - } - normalized.push(asset); - if normalized.len() >= MATCH3D_MAX_GENERATED_ITEM_COUNT { - break; - } - } - normalized -} - -fn resolve_match3d_gameplay_item_count(config: &Match3DConfigJson) -> usize { - match config.clear_count { - 8 => 3, - 12 => 9, - 16 => 15, - 20 | 21 => 21, - _ => match config.difficulty { - 0..=2 => 3, - 3..=4 => 9, - 5..=6 => 15, - _ => 21, - }, - } - .min(MATCH3D_MAX_GENERATED_ITEM_COUNT) -} - -fn resolve_match3d_generated_item_count(config: &Match3DConfigJson) -> usize { - round_match3d_item_count_to_full_sheet(resolve_match3d_gameplay_item_count(config)) - .min(MATCH3D_MAX_GENERATED_ITEM_COUNT) -} - -fn round_match3d_item_count_to_full_sheet(item_count: usize) -> usize { - if item_count == 0 { - return 0; - } - item_count.div_ceil(MATCH3D_MATERIAL_ITEM_BATCH_SIZE) * MATCH3D_MATERIAL_ITEM_BATCH_SIZE -} - -fn sort_match3d_generated_assets( - mut assets: Vec, -) -> Vec { - assets.sort_by(|left, right| { - match3d_item_sort_index(left.item_id.as_str()) - .cmp(&match3d_item_sort_index(right.item_id.as_str())) - .then_with(|| left.item_id.cmp(&right.item_id)) - }); - assets -} - -fn match3d_item_sort_index(item_id: &str) -> u32 { - item_id - .rsplit('-') - .next() - .and_then(|value| value.parse::().ok()) - .unwrap_or(u32::MAX) -} - -fn is_match3d_generated_asset_image_ready(asset: &Match3DGeneratedItemAsset) -> bool { - let view_count = asset - .image_views - .iter() - .filter(|view| { - view.image_object_key - .as_deref() - .map(str::trim) - .filter(|value| !value.is_empty()) - .is_some() - || view - .image_src - .as_deref() - .map(str::trim) - .filter(|value| !value.is_empty()) - .is_some() - }) - .count(); - view_count >= MATCH3D_ITEM_VIEW_COUNT -} - -fn has_match3d_required_item_images( - assets: &[Match3DGeneratedItemAsset], - required_item_count: usize, -) -> bool { - assets.len() >= required_item_count - && assets - .iter() - .take(required_item_count) - .all(is_match3d_generated_asset_image_ready) -} - -fn has_match3d_required_generated_assets( - assets: &[Match3DGeneratedItemAsset], - required_item_count: usize, - config: &Match3DConfigJson, -) -> bool { - has_match3d_required_item_images(assets, required_item_count) - && (!config.generate_click_sound - || assets - .iter() - .take(required_item_count) - .all(|asset| asset.click_sound.is_some())) -} - -fn upsert_match3d_generated_item_asset( - assets: &mut Vec, - asset: Match3DGeneratedItemAsset, -) { - if let Some(current) = assets - .iter_mut() - .find(|candidate| candidate.item_id == asset.item_id) - { - *current = asset; - *assets = sort_match3d_generated_assets(std::mem::take(assets)); - return; - } - assets.push(asset); - *assets = sort_match3d_generated_assets(std::mem::take(assets)); -} - -fn merge_regenerated_match3d_item_asset( - current_asset: Option, - generated_asset: Match3DGeneratedItemAsset, -) -> Match3DGeneratedItemAsset { - let Some(current_asset) = current_asset else { - return generated_asset; - }; - - Match3DGeneratedItemAsset { - item_id: current_asset.item_id, - item_name: current_asset.item_name, - item_size: current_asset - .item_size - .or(generated_asset.item_size) - .or_else(|| Some(MATCH3D_ITEM_SIZE_LARGE.to_string())), - image_src: generated_asset.image_src, - image_object_key: generated_asset.image_object_key, - image_views: generated_asset.image_views, - model_src: current_asset.model_src, - model_object_key: current_asset.model_object_key, - model_file_name: current_asset.model_file_name, - task_uuid: generated_asset.task_uuid.or(current_asset.task_uuid), - subscription_key: generated_asset - .subscription_key - .or(current_asset.subscription_key), - sound_prompt: generated_asset.sound_prompt.or(current_asset.sound_prompt), - background_music_title: current_asset.background_music_title, - background_music_style: current_asset.background_music_style, - background_music_prompt: current_asset.background_music_prompt, - background_music: current_asset.background_music, - click_sound: current_asset.click_sound, - background_asset: current_asset.background_asset, - status: generated_asset.status, - error: generated_asset.error, - } -} - -fn next_match3d_generated_item_index(assets: &[Match3DGeneratedItemAsset]) -> u32 { - assets - .iter() - .filter_map(|asset| { - let value = match3d_item_sort_index(asset.item_id.as_str()); - if value == u32::MAX { None } else { Some(value) } - }) - .max() - .unwrap_or(0) - .saturating_add(1) -} - -fn allocate_match3d_generated_item_id( - assets: &[Match3DGeneratedItemAsset], - next_item_index: &mut u32, -) -> String { - loop { - let candidate = format!("match3d-item-{}", *next_item_index); - *next_item_index = next_item_index.saturating_add(1); - if !assets.iter().any(|asset| asset.item_id == candidate) { - return candidate; - } - } -} - -fn is_match3d_background_asset_ready(asset: &Match3DGeneratedBackgroundAsset) -> bool { - asset.status == "image_ready" - && (asset - .image_object_key - .as_deref() - .map(str::trim) - .filter(|value| !value.is_empty()) - .is_some() - || asset - .image_src - .as_deref() - .map(str::trim) - .filter(|value| !value.is_empty()) - .is_some()) - && (asset - .container_image_object_key - .as_deref() - .map(str::trim) - .filter(|value| !value.is_empty()) - .is_some() - || asset - .container_image_src - .as_deref() - .map(str::trim) - .filter(|value| !value.is_empty()) - .is_some()) -} - -#[allow(clippy::too_many_arguments)] -async fn ensure_match3d_background_asset( - state: &AppState, - request_context: &RequestContext, - authenticated: &AuthenticatedAccessToken, - owner_user_id: &str, - session_id: &str, - profile_id: &str, - config: &Match3DConfigJson, - background_prompt: &str, - mut assets: Vec, -) -> Result, Response> { - let normalized_prompt = normalize_match3d_background_prompt(background_prompt); - let resolved_prompt = if normalized_prompt.is_empty() { - build_fallback_match3d_background_prompt(config) - } else { - normalized_prompt - }; - if let Some(existing_background) = find_match3d_generated_background_asset(&assets) { - if is_match3d_background_asset_ready(&existing_background) { - return Ok(assets); - } - } - - let generated_background = generate_match3d_background_image( - state, - owner_user_id, - session_id, - profile_id, - config, - &resolved_prompt, - ) - .await - .map_err(|error| match3d_error_response(request_context, MATCH3D_AGENT_PROVIDER, error))?; - attach_match3d_background_asset_to_assets(&mut assets, generated_background); - persist_match3d_generated_item_assets_snapshot( - state, - request_context, - authenticated, - session_id, - owner_user_id, - profile_id, - &assets, - ) - .await?; - Ok(assets) -} - -fn attach_match3d_background_asset_to_assets( - assets: &mut Vec, - background_asset: Match3DGeneratedBackgroundAsset, -) { - if let Some(first_asset) = assets - .iter_mut() - .min_by_key(|asset| match3d_item_sort_index(asset.item_id.as_str())) - { - first_asset.background_asset = Some(background_asset); - } -} - -fn build_match3d_item_slug(item_id: &str, item_name: &str) -> String { - format!( - "{}-{}", - sanitize_match3d_asset_segment(item_id, "match3d-item"), - sanitize_match3d_asset_segment(item_name, "item") - ) -} - -async fn generate_match3d_cover_image_asset( - state: &AppState, - owner_user_id: &str, - session_id: &str, - profile_id: &str, - config: &Match3DConfigJson, - prompt: &str, - uploaded_image_src: Option, - reference_image_srcs: Vec, -) -> Result { - require_match3d_oss_client(state)?; - let settings = require_openai_image_settings(state)?; - let http_client = build_openai_image_http_client(&settings)?; - let cover_prompt = build_match3d_cover_generation_prompt(config, prompt); - let generated = if let Some(uploaded_image) = resolve_match3d_reference_image_for_edit( - state, - uploaded_image_src.as_deref(), - MATCH3D_ITEM_IMAGE_MAX_BYTES, - "match3d-cover-upload", - ) - .await? - { - create_openai_image_edit( - &http_client, - &settings, - build_match3d_cover_edit_prompt(cover_prompt.as_str()).as_str(), - Some("文字、水印、UI、按钮、倒计时、分数、教程浮层、菜单、边框"), - "1:1", - &uploaded_image, - "抓大鹅封面图重绘失败", - ) - .await? - } else { - let reference_images = resolve_match3d_cover_reference_image_data_urls( - state, - reference_image_srcs, - MATCH3D_ITEM_IMAGE_MAX_BYTES, - ) - .await?; - create_openai_image_generation( - &http_client, - &settings, - build_match3d_cover_reference_generation_prompt( - cover_prompt.as_str(), - !reference_images.is_empty(), - ) - .as_str(), - Some("文字、水印、UI、按钮、倒计时、分数、教程浮层、菜单、边框"), - "1:1", - 1, - reference_images.as_slice(), - "抓大鹅封面图生成失败", - ) - .await? - }; - let image = generated.images.into_iter().next().ok_or_else(|| { - AppError::from_status(StatusCode::BAD_GATEWAY).with_details(json!({ - "provider": "vector-engine", - "message": "抓大鹅封面图生成失败:未返回图片", - })) - })?; - - let file_name = format!("cover.{}", image.extension); - persist_match3d_generated_bytes( - state, - owner_user_id, - session_id, - profile_id, - &["cover", generated.task_id.as_str()], - file_name.as_str(), - image.mime_type.as_str(), - image.bytes, - "match3d_cover_image", - Some(generated.task_id.as_str()), - current_utc_micros(), - ) - .await -} - -fn build_match3d_cover_generation_prompt(config: &Match3DConfigJson, prompt: &str) -> String { - let style_clause = resolve_match3d_asset_style_prompt(config) - .map(|style| format!("整体美术风格遵循:{style}。")) - .unwrap_or_default(); - format!( - "{theme}题材抓大鹅作品封面图。{style_clause}{prompt}。画面为1:1封面,主体清晰、色彩明亮、适合移动端作品卡片展示;可以包含生成物品或主题元素,但不要出现任何文字、按钮、倒计时、分数或 UI。", - theme = config.theme_text, - style_clause = style_clause, - prompt = prompt, - ) -} - -fn build_match3d_cover_edit_prompt(prompt: &str) -> String { - format!( - concat!( - "请以随请求上传的封面图作为第一优先级重绘依据,保留主图的主体、构图、视角和主要配色;", - "允许按文字要求提升美术质量、统一风格和补充细节,但不要改成与主图无关的新画面。\n", - "{prompt}" - ), - prompt = prompt.trim() - ) -} - -fn build_match3d_cover_reference_generation_prompt( - prompt: &str, - has_reference_images: bool, -) -> String { - if !has_reference_images { - return prompt.trim().to_string(); - } - format!( - concat!( - "请参考随请求提供的一张或多张图片作为题材、物体和美术风格参考,融合为一张新的抓大鹅作品封面;", - "参考图只用于主体元素、材质、配色和风格启发,不要拼贴成素材墙或多图排版。\n", - "{prompt}" - ), - prompt = prompt.trim() - ) -} - -async fn generate_match3d_background_image( - state: &AppState, - owner_user_id: &str, - session_id: &str, - profile_id: &str, - config: &Match3DConfigJson, - prompt: &str, -) -> Result { - require_match3d_oss_client(state)?; - let settings = require_openai_image_settings(state)?; - let http_client = build_openai_image_http_client(&settings)?; - let reference_image = load_match3d_container_reference_image().await?; - let generated_background = create_openai_image_generation( - &http_client, - &settings, - build_match3d_background_generation_prompt(config, prompt).as_str(), - Some( - "文字、水印、UI、按钮、倒计时、分数、物品、角色、手、边框、教程浮层、菜单、透明区域、透明 alpha、镂空、棋盘格透明底", - ), - "9:16", - 1, - &[], - "抓大鹅背景图生成失败", - ) - .await?; - let background_image = generated_background - .images - .into_iter() - .next() - .ok_or_else(|| { - AppError::from_status(StatusCode::BAD_GATEWAY).with_details(json!({ - "provider": "vector-engine", - "message": "抓大鹅背景图生成失败:未返回图片", - })) - })?; - let background_image = make_match3d_background_image_opaque(background_image)?; - let background_upload = persist_match3d_generated_bytes( - state, - owner_user_id, - session_id, - profile_id, - &["background", generated_background.task_id.as_str()], - "background.png", - background_image.mime_type.as_str(), - background_image.bytes, - "match3d_background_image", - Some(generated_background.task_id.as_str()), - current_utc_micros(), - ) - .await?; - - let container_prompt = build_match3d_container_generation_prompt(config, prompt); - let generated_container = create_openai_image_edit( - &http_client, - &settings, - container_prompt.as_str(), - Some("文字、水印、按钮、倒计时、分数、物品、角色、手、教程浮层、菜单、整页背景、小容器、正俯视圆盘、侧视碗、餐盘、托盘、画布大留白"), - "1:1", - &reference_image, - "抓大鹅容器 UI 图生成失败", - ) - .await?; - let container_image = generated_container - .images - .into_iter() - .next() - .ok_or_else(|| { - AppError::from_status(StatusCode::BAD_GATEWAY).with_details(json!({ - "provider": "vector-engine", - "message": "抓大鹅容器 UI 图生成失败:未返回图片", - })) - })?; - let container_image = make_match3d_container_image_transparent(container_image)?; - let container_upload = persist_match3d_generated_bytes( - state, - owner_user_id, - session_id, - profile_id, - &["ui-container", generated_container.task_id.as_str()], - "container.png", - container_image.mime_type.as_str(), - container_image.bytes, - "match3d_ui_container_image", - Some(generated_container.task_id.as_str()), - current_utc_micros(), - ) - .await?; - - Ok(Match3DGeneratedBackgroundAsset { - prompt: prompt.to_string(), - image_src: Some(background_upload.src), - image_object_key: Some(background_upload.object_key), - container_prompt: Some(container_prompt), - container_image_src: Some(container_upload.src), - container_image_object_key: Some(container_upload.object_key), - status: "image_ready".to_string(), - error: None, - }) -} - -async fn generate_match3d_container_image( - state: &AppState, - owner_user_id: &str, - session_id: &str, - profile_id: &str, - config: &Match3DConfigJson, - prompt: &str, -) -> Result { - require_match3d_oss_client(state)?; - let settings = require_openai_image_settings(state)?; - let http_client = build_openai_image_http_client(&settings)?; - let reference_image = load_match3d_container_reference_image().await?; - let container_prompt = build_match3d_container_generation_prompt(config, prompt); - let generated_container = create_openai_image_edit( - &http_client, - &settings, - container_prompt.as_str(), - Some("文字、水印、按钮、倒计时、分数、物品、角色、手、教程浮层、菜单、整页背景、小容器、正俯视圆盘、侧视碗、餐盘、托盘、画布大留白"), - "1:1", - &reference_image, - "抓大鹅容器 UI 图生成失败", - ) - .await?; - let container_image = generated_container - .images - .into_iter() - .next() - .ok_or_else(|| { - AppError::from_status(StatusCode::BAD_GATEWAY).with_details(json!({ - "provider": "vector-engine", - "message": "抓大鹅容器 UI 图生成失败:未返回图片", - })) - })?; - let container_image = make_match3d_container_image_transparent(container_image)?; - let container_upload = persist_match3d_generated_bytes( - state, - owner_user_id, - session_id, - profile_id, - &["ui-container", generated_container.task_id.as_str()], - "container.png", - container_image.mime_type.as_str(), - container_image.bytes, - "match3d_ui_container_image", - Some(generated_container.task_id.as_str()), - current_utc_micros(), - ) - .await?; - - Ok(Match3DGeneratedBackgroundAsset { - prompt: prompt.to_string(), - image_src: None, - image_object_key: None, - container_prompt: Some(container_prompt), - container_image_src: Some(container_upload.src), - container_image_object_key: Some(container_upload.object_key), - status: "image_ready".to_string(), - error: None, - }) -} - -fn merge_match3d_container_image_into_background_asset( - assets: &[Match3DGeneratedItemAsset], - container_asset: Match3DGeneratedBackgroundAsset, -) -> Match3DGeneratedBackgroundAsset { - let existing_background = find_match3d_generated_background_asset(assets); - let prompt = existing_background - .as_ref() - .map(|asset| asset.prompt.trim()) - .filter(|value| !value.is_empty()) - .map(str::to_string) - .unwrap_or_else(|| container_asset.prompt.clone()); - Match3DGeneratedBackgroundAsset { - prompt, - image_src: existing_background - .as_ref() - .and_then(|asset| asset.image_src.clone()), - image_object_key: existing_background - .as_ref() - .and_then(|asset| asset.image_object_key.clone()), - container_prompt: container_asset.container_prompt, - container_image_src: container_asset.container_image_src, - container_image_object_key: container_asset.container_image_object_key, - status: "image_ready".to_string(), - error: container_asset.error, - } -} - -async fn load_match3d_container_reference_image() -> Result { - let bytes = tokio::fs::read(MATCH3D_CONTAINER_REFERENCE_IMAGE_PATH) - .await - .map_err(|error| { - AppError::from_status(StatusCode::INTERNAL_SERVER_ERROR).with_details(json!({ - "provider": MATCH3D_AGENT_PROVIDER, - "message": format!("读取抓大鹅容器参考图失败:{error}"), - })) - })?; - if bytes.is_empty() { - return Err( - AppError::from_status(StatusCode::INTERNAL_SERVER_ERROR).with_details(json!({ - "provider": MATCH3D_AGENT_PROVIDER, - "message": "抓大鹅容器参考图为空", - })), - ); - } - Ok(OpenAiReferenceImage { - bytes, - mime_type: "image/png".to_string(), - file_name: "match3d-container-reference.png".to_string(), - }) -} - -fn build_match3d_background_generation_prompt(config: &Match3DConfigJson, prompt: &str) -> String { - let style_clause = resolve_match3d_asset_style_prompt(config) - .map(|style| format!("整体美术风格参考:{style}。")) - .unwrap_or_default(); - format!( - "{prompt}\n{style_clause}生成一张 9:16 竖屏抓大鹅游戏纯背景图,只表现题材氛围、色彩层次和场景环境。必须全画幅不透明,四边和角落都要有完整环境像素,不得出现透明 alpha、透明底、镂空或棋盘格透明区域。画面不得出现锅、圆盘、托盘、拼图槽、物品槽、棋盘、容器边框、HUD、文字、按钮、倒计时、分数、物品、角色或手。中央区域保持干净通透,方便运行态后续叠加默认交互容器和物品素材。" - ) -} - -fn build_match3d_container_generation_prompt(config: &Match3DConfigJson, prompt: &str) -> String { - let style_clause = resolve_match3d_asset_style_prompt(config) - .map(|style| format!("整体美术风格参考:{style}。")) - .unwrap_or_default(); - format!( - "{prompt}\n{style_clause}生成一张 1:1 抓大鹅中心容器 UI 图,只绘制一个贴合题材设定的圆形或浅盘状竞技容器。严格参考输入参考图的容器范围和视图角度:容器外轮廓必须接近画布四边,占画布宽度约 86%-92%、高度约 82%-90%,中心在画布中心略偏下,只保留少量透明留白;视角为轻俯视 3/4 上方视角,能看到圆形碗体外壁、厚实前沿和横向椭圆形内口,不能画成正俯视扁圆盘、侧视碗、小托盘或居中的小容器。容器需要有清晰外沿、内侧可放置 2D 物品的干净空间、轻微阴影和高辨识边界;背景必须是透明 alpha,不得出现白底、纯色底、渐变底、场景底或整页背景。禁止文字、水印、按钮、倒计时、分数、物品、角色、手、教程浮层和菜单。" - ) -} - -// 中文注释:9:16 运行背景是整屏底图,必须和中心容器透明素材分层处理,避免局内露出透明底。 -fn make_match3d_background_image_opaque( - image: DownloadedOpenAiImage, -) -> Result { - 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}"), - })) - })?; - let mut rgba = source.to_rgba8(); - let matte = sample_match3d_background_opaque_matte(&rgba).unwrap_or([246, 243, 236]); - let mut changed = false; - - for pixel in rgba.pixels_mut() { - let alpha = pixel.0[3]; - if alpha == 255 { - continue; - } - pixel.0 = blend_match3d_background_pixel_over_matte(pixel.0, matte); - changed = true; - } - - if !changed { - return Ok(image); - } - - let mut encoded = std::io::Cursor::new(Vec::new()); - image::DynamicImage::ImageRgba8(rgba) - .write_to(&mut encoded, ImageFormat::Png) - .map_err(|error| { - AppError::from_status(StatusCode::BAD_GATEWAY).with_details(json!({ - "provider": "match3d-assets", - "message": format!("抓大鹅背景图不透明化失败:{error}"), - })) - })?; - - Ok(DownloadedOpenAiImage { - bytes: encoded.into_inner(), - mime_type: "image/png".to_string(), - extension: "png".to_string(), - }) -} - -fn sample_match3d_background_opaque_matte(image: &image::RgbaImage) -> Option<[u8; 3]> { - sample_match3d_background_matte_from_edges(image) - .or_else(|| sample_match3d_background_matte_from_pixels(image)) -} - -fn sample_match3d_background_matte_from_edges(image: &image::RgbaImage) -> Option<[u8; 3]> { - let (width, height) = image.dimensions(); - if width == 0 || height == 0 { - return None; - } - - let mut sampler = Match3DBackgroundMatteSampler::default(); - for x in 0..width { - sampler.push(image.get_pixel(x, 0).0); - sampler.push(image.get_pixel(x, height - 1).0); - } - for y in 1..height.saturating_sub(1) { - sampler.push(image.get_pixel(0, y).0); - sampler.push(image.get_pixel(width - 1, y).0); - } - sampler.finish() -} - -fn sample_match3d_background_matte_from_pixels(image: &image::RgbaImage) -> Option<[u8; 3]> { - let mut sampler = Match3DBackgroundMatteSampler::default(); - for pixel in image.pixels() { - sampler.push(pixel.0); - } - sampler.finish() -} - -#[derive(Default)] -struct Match3DBackgroundMatteSampler { - red: u64, - green: u64, - blue: u64, - weight: u64, -} - -impl Match3DBackgroundMatteSampler { - fn push(&mut self, pixel: [u8; 4]) { - let alpha = pixel[3] as u64; - if alpha < 32 { - return; - } - self.red = self.red.saturating_add(pixel[0] as u64 * alpha); - self.green = self.green.saturating_add(pixel[1] as u64 * alpha); - self.blue = self.blue.saturating_add(pixel[2] as u64 * alpha); - self.weight = self.weight.saturating_add(alpha); - } - - fn finish(self) -> Option<[u8; 3]> { - (self.weight > 0).then(|| { - [ - (self.red / self.weight) as u8, - (self.green / self.weight) as u8, - (self.blue / self.weight) as u8, - ] - }) - } -} - -fn blend_match3d_background_pixel_over_matte(pixel: [u8; 4], matte: [u8; 3]) -> [u8; 4] { - let alpha = pixel[3] as u16; - let inverse_alpha = 255u16.saturating_sub(alpha); - [ - blend_match3d_background_channel(pixel[0], matte[0], alpha, inverse_alpha), - blend_match3d_background_channel(pixel[1], matte[1], alpha, inverse_alpha), - blend_match3d_background_channel(pixel[2], matte[2], alpha, inverse_alpha), - 255, - ] -} - -fn blend_match3d_background_channel( - foreground: u8, - matte: u8, - alpha: u16, - inverse_alpha: u16, -) -> u8 { - ((foreground as u16 * alpha + matte as u16 * inverse_alpha + 127) / 255) as u8 -} - -fn make_match3d_container_image_transparent( - image: DownloadedOpenAiImage, -) -> Result { - 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}"), - })) - })?; - let mut rgba = source.to_rgba8(); - let (width, height) = rgba.dimensions(); - remove_match3d_container_plain_background(rgba.as_mut(), width as usize, height as usize); - - let mut encoded = std::io::Cursor::new(Vec::new()); - image::DynamicImage::ImageRgba8(rgba) - .write_to(&mut encoded, ImageFormat::Png) - .map_err(|error| { - AppError::from_status(StatusCode::BAD_GATEWAY).with_details(json!({ - "provider": "match3d-assets", - "message": format!("抓大鹅容器图透明化失败:{error}"), - })) - })?; - - Ok(DownloadedOpenAiImage { - bytes: encoded.into_inner(), - mime_type: "image/png".to_string(), - extension: "png".to_string(), - }) -} - -async fn generate_match3d_material_sheet( - state: &AppState, - config: &Match3DConfigJson, - item_names: &[String], -) -> Result { - let settings = require_match3d_vector_engine_gemini_image_settings(state)?; - let http_client = build_match3d_vector_engine_gemini_image_http_client(&settings)?; - let prompt = build_match3d_material_sheet_prompt(config, item_names); - let negative_prompt = build_match3d_material_sheet_negative_prompt(config); - let generated = create_match3d_vector_engine_gemini_image_generation( - &http_client, - &settings, - prompt.as_str(), - negative_prompt.as_str(), - "抓大鹅素材图生成失败", - ) - .await?; - let image = generated.images.into_iter().next().ok_or_else(|| { - AppError::from_status(StatusCode::BAD_GATEWAY).with_details(json!({ - "provider": "vector-engine-gemini", - "message": "抓大鹅素材图生成失败:未返回图片", - })) - })?; - - Ok(Match3DMaterialSheet { - task_id: generated.task_id, - image, - }) -} - -fn require_match3d_vector_engine_gemini_image_settings( - state: &AppState, -) -> Result { - let base_url = state - .config - .vector_engine_base_url - .trim() - .trim_end_matches('/'); - if base_url.is_empty() { - return Err( - AppError::from_status(StatusCode::SERVICE_UNAVAILABLE).with_details(json!({ - "provider": "vector-engine-gemini", - "reason": "VECTOR_ENGINE_BASE_URL 未配置", - })), - ); - } - - let api_key = state - .config - .vector_engine_api_key - .as_deref() - .map(str::trim) - .filter(|value| !value.is_empty()) - .ok_or_else(|| { - AppError::from_status(StatusCode::SERVICE_UNAVAILABLE).with_details(json!({ - "provider": "vector-engine-gemini", - "reason": "VECTOR_ENGINE_API_KEY 未配置", - })) - })?; - - Ok(Match3DVectorEngineGeminiImageSettings { - base_url: base_url.to_string(), - api_key: api_key.to_string(), - request_timeout_ms: state.config.vector_engine_image_request_timeout_ms.max(1), - }) -} - -fn build_match3d_vector_engine_gemini_image_http_client( - settings: &Match3DVectorEngineGeminiImageSettings, -) -> Result { - reqwest::Client::builder() - .timeout(Duration::from_millis(settings.request_timeout_ms)) - .build() - .map_err(|error| { - AppError::from_status(StatusCode::INTERNAL_SERVER_ERROR).with_details(json!({ - "provider": "vector-engine-gemini", - "message": format!("构造抓大鹅 VectorEngine Gemini 图片生成 HTTP 客户端失败:{error}"), - })) - }) -} - -async fn create_match3d_vector_engine_gemini_image_generation( - http_client: &reqwest::Client, - settings: &Match3DVectorEngineGeminiImageSettings, - prompt: &str, - negative_prompt: &str, - failure_context: &str, -) -> Result { - let request_body = build_match3d_vector_engine_gemini_image_request_body( - prompt, - negative_prompt, - MATCH3D_MATERIAL_VECTOR_ENGINE_GEMINI_ASPECT_RATIO, - ); - let response = http_client - .post(build_match3d_vector_engine_gemini_generate_content_url( - settings, - )) - .query(&[("key", settings.api_key.as_str())]) - .header(header::ACCEPT, "application/json") - .header(header::CONTENT_TYPE, "application/json") - .json(&request_body) - .send() - .await - .map_err(|error| { - map_match3d_vector_engine_gemini_image_request_error(format!( - "{failure_context}:调用 VectorEngine Gemini 图片生成失败:{error}" - )) - })?; - let status = response.status(); - let response_text = response.text().await.map_err(|error| { - map_match3d_vector_engine_gemini_image_request_error(format!( - "{failure_context}:读取 VectorEngine Gemini 图片生成响应失败:{error}" - )) - })?; - if !status.is_success() { - return Err(map_match3d_vector_engine_gemini_image_upstream_error( - status, - response_text.as_str(), - failure_context, - )); - } - - let payload = parse_match3d_json_payload( - response_text.as_str(), - "解析抓大鹅 VectorEngine Gemini 图片生成响应失败", - "vector-engine-gemini", - )?; - let image_urls = extract_match3d_image_urls(&payload); - if !image_urls.is_empty() { - return download_match3d_images_from_urls( - http_client, - format!("vector-engine-gemini-{}", current_utc_micros()), - image_urls, - 1, - "vector-engine-gemini", - ) - .await; - } - - let b64_images = extract_match3d_b64_images(&payload); - if !b64_images.is_empty() { - return Ok(match3d_images_from_base64( - format!("vector-engine-gemini-{}", current_utc_micros()), - b64_images, - 1, - )); - } - - Err( - AppError::from_status(StatusCode::BAD_GATEWAY).with_details(json!({ - "provider": "vector-engine-gemini", - "message": "抓大鹅 VectorEngine Gemini 图片生成未返回图片", - "rawExcerpt": trim_match3d_upstream_excerpt(response_text.as_str(), 800), - })), - ) -} - -fn build_match3d_vector_engine_gemini_image_request_body( - prompt: &str, - negative_prompt: &str, - aspect_ratio: &str, -) -> Value { - json!({ - "contents": [{ - "role": "user", - "parts": [{ - "text": build_match3d_vector_engine_gemini_prompt(prompt, negative_prompt), - }], - }], - "generationConfig": { - "responseModalities": ["TEXT", "IMAGE"], - "imageConfig": { - "aspectRatio": aspect_ratio, - }, - }, - }) -} - -fn build_match3d_vector_engine_gemini_generate_content_url( - settings: &Match3DVectorEngineGeminiImageSettings, -) -> String { - let base_url = settings.base_url.trim_end_matches("/v1"); - format!( - "{}/v1beta/models/{}:generateContent", - base_url, MATCH3D_MATERIAL_VECTOR_ENGINE_GEMINI_MODEL - ) -} - -fn build_match3d_vector_engine_gemini_prompt(prompt: &str, negative_prompt: &str) -> String { - let prompt = prompt.trim(); - let negative_prompt = negative_prompt.trim(); - if negative_prompt.is_empty() { - return prompt.to_string(); - } - - format!("{prompt}\n避免:{negative_prompt}") -} - -async fn download_match3d_images_from_urls( - http_client: &reqwest::Client, - task_id: String, - image_urls: Vec, - candidate_count: u32, - provider: &str, -) -> Result { - let mut images = Vec::with_capacity(candidate_count.clamp(1, 4) as usize); - for image_url in image_urls - .into_iter() - .take(candidate_count.clamp(1, 4) as usize) - { - images - .push(download_match3d_remote_image(http_client, image_url.as_str(), provider).await?); - } - Ok(OpenAiGeneratedImages { - task_id, - actual_prompt: None, - images, - }) -} - -async fn download_match3d_remote_image( - http_client: &reqwest::Client, - image_url: &str, - provider: &str, -) -> Result { - let response = http_client.get(image_url).send().await.map_err(|error| { - AppError::from_status(StatusCode::BAD_GATEWAY).with_details(json!({ - "provider": provider, - "message": format!("下载抓大鹅生成图片失败:{error}"), - })) - })?; - let status = response.status(); - let content_type = response - .headers() - .get(header::CONTENT_TYPE) - .and_then(|value| value.to_str().ok()) - .unwrap_or("image/png") - .to_string(); - let body = response.bytes().await.map_err(|error| { - AppError::from_status(StatusCode::BAD_GATEWAY).with_details(json!({ - "provider": provider, - "message": format!("读取抓大鹅生成图片内容失败:{error}"), - })) - })?; - if !status.is_success() { - return Err( - AppError::from_status(StatusCode::BAD_GATEWAY).with_details(json!({ - "provider": provider, - "message": "下载抓大鹅生成图片失败", - "status": status.as_u16(), - })), - ); - } - - let mime_type = normalize_match3d_downloaded_image_mime_type(content_type.as_str()); - Ok(DownloadedOpenAiImage { - extension: match3d_mime_to_extension(mime_type.as_str()).to_string(), - mime_type, - bytes: body.to_vec(), - }) -} - -fn match3d_images_from_base64( - task_id: String, - b64_images: Vec, - candidate_count: u32, -) -> OpenAiGeneratedImages { - let images = b64_images - .into_iter() - .take(candidate_count.clamp(1, 4) as usize) - .filter_map(|raw| decode_match3d_base64_image(raw.as_str())) - .collect(); - OpenAiGeneratedImages { - task_id, - actual_prompt: None, - images, - } -} - -fn decode_match3d_base64_image(raw: &str) -> Option { - let bytes = BASE64_STANDARD.decode(raw.trim()).ok()?; - let mime_type = infer_match3d_image_mime_type(bytes.as_slice()).to_string(); - Some(DownloadedOpenAiImage { - extension: match3d_mime_to_extension(mime_type.as_str()).to_string(), - mime_type, - bytes, - }) -} - -fn parse_match3d_json_payload( - raw_text: &str, - failure_context: &str, - provider: &str, -) -> Result { - serde_json::from_str::(raw_text).map_err(|error| { - AppError::from_status(StatusCode::BAD_GATEWAY).with_details(json!({ - "provider": provider, - "message": format!("{failure_context}:{error}"), - "rawExcerpt": trim_match3d_upstream_excerpt(raw_text, 800), - })) - }) -} - -fn extract_match3d_image_urls(payload: &Value) -> Vec { - let mut urls = Vec::new(); - collect_match3d_strings_by_key(payload, "url", &mut urls); - collect_match3d_strings_by_key(payload, "image", &mut urls); - collect_match3d_strings_by_key(payload, "image_url", &mut urls); - let mut deduped = Vec::new(); - for url in urls { - if (url.starts_with("http://") || url.starts_with("https://")) && !deduped.contains(&url) { - deduped.push(url); - } - } - deduped -} - -fn extract_match3d_b64_images(payload: &Value) -> Vec { - let mut values = Vec::new(); - collect_match3d_strings_by_key(payload, "b64_json", &mut values); - collect_match3d_inline_image_data(payload, &mut values); - values -} - -fn collect_match3d_inline_image_data(payload: &Value, results: &mut Vec) { - match payload { - Value::Array(entries) => { - for entry in entries { - collect_match3d_inline_image_data(entry, results); - } - } - Value::Object(object) => { - for key in ["inlineData", "inline_data"] { - if let Some(Value::Object(inline_data)) = object.get(key) { - let mime_type = inline_data - .get("mimeType") - .or_else(|| inline_data.get("mime_type")) - .and_then(Value::as_str) - .map(str::trim) - .unwrap_or("image/png") - .to_ascii_lowercase(); - if !mime_type.is_empty() && !mime_type.starts_with("image/") { - continue; - } - if let Some(data) = inline_data - .get("data") - .and_then(Value::as_str) - .map(str::trim) - .filter(|value| !value.is_empty()) - { - results.push(data.to_string()); - } - } - } - for nested_value in object.values() { - collect_match3d_inline_image_data(nested_value, results); - } - } - _ => {} - } -} - -fn find_first_match3d_string_by_key(payload: &Value, target_key: &str) -> Option { - let mut results = Vec::new(); - collect_match3d_strings_by_key(payload, target_key, &mut results); - results.into_iter().next() -} - -fn collect_match3d_strings_by_key(payload: &Value, target_key: &str, results: &mut Vec) { - match payload { - Value::Array(entries) => { - for entry in entries { - collect_match3d_strings_by_key(entry, target_key, results); - } - } - Value::Object(object) => { - for (key, nested_value) in object { - if key == target_key { - match nested_value { - Value::String(text) => { - let text = text.trim(); - if !text.is_empty() { - results.push(text.to_string()); - } - } - Value::Array(entries) => { - for entry in entries { - if let Some(text) = entry - .as_str() - .map(str::trim) - .filter(|value| !value.is_empty()) - { - results.push(text.to_string()); - } - } - } - _ => {} - } - } - collect_match3d_strings_by_key(nested_value, target_key, results); - } - } - _ => {} - } -} - -fn map_match3d_vector_engine_gemini_image_request_error(message: String) -> AppError { - AppError::from_status(StatusCode::BAD_GATEWAY).with_details(json!({ - "provider": "vector-engine-gemini", - "message": message, - })) -} - -fn map_match3d_vector_engine_gemini_image_upstream_error( - upstream_status: reqwest::StatusCode, - raw_text: &str, - fallback_message: &str, -) -> AppError { - let message = parse_match3d_api_error_message(raw_text, fallback_message); - let raw_excerpt = trim_match3d_upstream_excerpt(raw_text, 800); - tracing::warn!( - provider = "vector-engine-gemini", - upstream_status = upstream_status.as_u16(), - message = %message, - raw_excerpt = %raw_excerpt, - "抓大鹅 VectorEngine Gemini 图片生成上游请求失败" - ); - AppError::from_status(StatusCode::BAD_GATEWAY).with_details(json!({ - "provider": "vector-engine-gemini", - "upstreamStatus": upstream_status.as_u16(), - "message": message, - "rawExcerpt": raw_excerpt, - })) -} - -fn parse_match3d_api_error_message(raw_text: &str, fallback_message: &str) -> String { - let trimmed = raw_text.trim(); - if trimmed.is_empty() { - return fallback_message.to_string(); - } - if let Ok(payload) = serde_json::from_str::(trimmed) { - for key in ["message", "code"] { - if let Some(value) = find_first_match3d_string_by_key(&payload, key) { - return if key == "message" { - value - } else { - format!("{fallback_message}({value})") - }; - } - } - } - trimmed.to_string() -} - -fn trim_match3d_upstream_excerpt(raw_text: &str, max_chars: usize) -> String { - raw_text.chars().take(max_chars).collect() -} - -fn normalize_match3d_downloaded_image_mime_type(content_type: &str) -> String { - let mime_type = content_type - .split(';') - .next() - .map(str::trim) - .unwrap_or("image/png"); - match mime_type { - "image/png" | "image/webp" | "image/jpeg" | "image/jpg" | "image/gif" => { - mime_type.to_string() - } - _ => "image/png".to_string(), - } -} - -fn match3d_mime_to_extension(mime_type: &str) -> &str { - match mime_type { - "image/png" => "png", - "image/webp" => "webp", - "image/gif" => "gif", - "image/jpeg" | "image/jpg" => "jpg", - _ => "png", - } -} - -async fn download_match3d_legacy_model( - file: &hyper3d_contract::Hyper3dDownloadFilePayload, -) -> Result { - let http_client = reqwest::Client::builder() - .timeout(Duration::from_millis( - MATCH3D_LEGACY_MODEL_DOWNLOAD_TIMEOUT_MS, - )) - .build() - .map_err(|error| match3d_bad_gateway(format!("构造历史模型下载客户端失败:{error}")))?; - tracing::info!( - provider = MATCH3D_AGENT_PROVIDER, - file_name = file.name.as_str(), - "抓大鹅历史 GLB 下载开始" - ); - let response = http_client - .get(file.url.as_str()) - .send() - .await - .map_err(|error| match3d_bad_gateway(format!("下载历史模型失败:{error}")))?; - let status = response.status(); - let content_type = response - .headers() - .get(header::CONTENT_TYPE) - .and_then(|value| value.to_str().ok()) - .unwrap_or("model/gltf-binary") - .to_string(); - let bytes = response - .bytes() - .await - .map_err(|error| match3d_bad_gateway(format!("读取历史模型内容失败:{error}")))?; - if !status.is_success() { - return Err(match3d_bad_gateway(format!( - "下载历史模型失败:HTTP {}", - status.as_u16() - ))); - } - if !is_match3d_downloaded_model_payload(file.name.as_str(), content_type.as_str()) { - return Err(match3d_bad_gateway("历史模型下载结果不是 GLB 模型文件")); - } - if bytes.is_empty() || bytes.len() > MATCH3D_LEGACY_MODEL_MAX_BYTES { - return Err(match3d_bad_gateway("历史模型内容为空或超过大小上限")); - } - if !is_match3d_glb_binary_payload(&bytes) { - return Err(match3d_bad_gateway("历史模型下载结果不是有效 GLB 模型文件")); - } - - Ok(Match3DDownloadedModel { - bytes: bytes.to_vec(), - file_name: normalize_match3d_model_file_name(file.name.as_str()), - content_type: normalize_match3d_model_content_type(content_type.as_str()), - }) -} - -fn is_match3d_downloaded_model_payload(file_name: &str, content_type: &str) -> bool { - let normalized_file_name = file_name.to_ascii_lowercase(); - let normalized_content_type = content_type - .split(';') - .next() - .unwrap_or(content_type) - .trim() - .to_ascii_lowercase(); - normalized_file_name.ends_with(".glb") - || matches!( - normalized_content_type.as_str(), - "model/gltf-binary" | "application/octet-stream" - ) -} - -fn normalize_match3d_model_file_name(raw: &str) -> String { - let trimmed = raw.trim().rsplit('/').next().unwrap_or(raw).trim(); - let without_query = trimmed.split('?').next().unwrap_or(trimmed).trim(); - let normalized = without_query.to_ascii_lowercase(); - let stem = without_query - .strip_suffix(".glb") - .or_else(|| { - normalized - .strip_suffix(".glb") - .map(|_| &without_query[..without_query.len().saturating_sub(4)]) - }) - .unwrap_or(without_query); - let sanitized_stem = sanitize_match3d_asset_segment(stem, "model"); - format!("{sanitized_stem}.glb") -} - -fn normalize_match3d_model_content_type(raw: &str) -> String { - let normalized = raw.split(';').next().unwrap_or(raw).trim().to_lowercase(); - if normalized == "model/gltf-binary" { - return normalized; - } - "model/gltf-binary".to_string() -} - -fn is_match3d_glb_binary_payload(bytes: &[u8]) -> bool { - if bytes.len() < 12 { - return false; - } - - let magic = u32::from_le_bytes([bytes[0], bytes[1], bytes[2], bytes[3]]); - let version = u32::from_le_bytes([bytes[4], bytes[5], bytes[6], bytes[7]]); - let declared_length = u32::from_le_bytes([bytes[8], bytes[9], bytes[10], bytes[11]]) as usize; - magic == 0x4654_6c67 && version == 2 && declared_length == bytes.len() -} - -async fn read_match3d_generated_object_bytes( - state: &AppState, - object_key: &str, - message_prefix: &str, - max_size_bytes: usize, -) -> Result, AppError> { - let object_key = object_key.trim().trim_start_matches('/'); - if object_key.is_empty() { - return Err( - AppError::from_status(StatusCode::BAD_REQUEST).with_details(json!({ - "provider": "match3d-assets", - "message": format!("{message_prefix}:objectKey 不能为空"), - })), - ); - } - let oss_client = state.oss_client().ok_or_else(|| { - AppError::from_status(StatusCode::SERVICE_UNAVAILABLE).with_details(json!({ - "provider": "aliyun-oss", - "reason": "OSS 未完成环境变量配置", - })) - })?; - let signed = oss_client - .sign_get_object_url(platform_oss::OssSignedGetObjectUrlRequest { - object_key: object_key.to_string(), - expire_seconds: Some(300), - }) - .map_err(|error| map_oss_error(error, "aliyun-oss"))?; - let response = reqwest::Client::new() - .get(signed.signed_url.as_str()) - .send() - .await - .map_err(|error| match3d_bad_gateway(format!("{message_prefix}:{error}")))?; - let status = response.status(); - if !status.is_success() { - return Err(match3d_bad_gateway(format!( - "{message_prefix}:HTTP {}", - status.as_u16() - ))); - } - let bytes = response - .bytes() - .await - .map_err(|error| match3d_bad_gateway(format!("{message_prefix}:{error}")))?; - if bytes.is_empty() || bytes.len() > max_size_bytes { - return Err(match3d_bad_gateway(format!( - "{message_prefix}:内容为空或超过大小上限" - ))); - } - Ok(bytes.to_vec()) -} - -async fn resolve_match3d_reference_image_data_url( - state: &AppState, - source: Option<&str>, - max_size_bytes: usize, -) -> Result, AppError> { - let Some(source) = source.map(str::trim).filter(|value| !value.is_empty()) else { - return Ok(None); - }; - if source.starts_with("data:image/") { - return Ok(Some(source.to_string())); - } - if let Some(public_path) = normalize_match3d_public_reference_image_path(source) { - let bytes = tokio::fs::read(public_path.as_str()) - .await - .map_err(|error| { - AppError::from_status(StatusCode::INTERNAL_SERVER_ERROR).with_details(json!({ - "provider": MATCH3D_WORKS_PROVIDER, - "message": format!("读取抓大鹅本地参考图失败:{error}"), - "path": public_path, - })) - })?; - if bytes.is_empty() || bytes.len() > max_size_bytes { - return Err( - AppError::from_status(StatusCode::BAD_REQUEST).with_details(json!({ - "provider": MATCH3D_WORKS_PROVIDER, - "field": "referenceImageSrcs", - "message": "封面参考图过大,请压缩后重试。", - "maxBytes": max_size_bytes, - "actualBytes": bytes.len(), - })), - ); - } - return Ok(Some(format!( - "data:{};base64,{}", - infer_match3d_image_mime_type(bytes.as_slice()), - BASE64_STANDARD.encode(bytes) - ))); - } - if !source.trim_start_matches('/').starts_with("generated-") { - return Ok(Some(source.to_string())); - } - let bytes = - read_match3d_generated_object_bytes(state, source, "读取抓大鹅参考图失败", max_size_bytes) - .await?; - Ok(Some(format!( - "data:{};base64,{}", - infer_match3d_image_mime_type(bytes.as_slice()), - BASE64_STANDARD.encode(bytes) - ))) -} - -fn normalize_match3d_public_reference_image_path(source: &str) -> Option { - let source = source - .trim() - .split('?') - .next() - .unwrap_or_default() - .trim() - .trim_start_matches('/'); - if !source.starts_with("match3d-background-references/") { - return None; - } - if source.contains("..") || source.contains('\\') { - return None; - } - let lower = source.to_ascii_lowercase(); - if !matches!( - lower.rsplit('.').next(), - Some("png" | "jpg" | "jpeg" | "webp") - ) { - return None; - } - Some(format!("public/{source}")) -} - -fn collect_match3d_cover_reference_image_sources( - legacy_reference_image_src: Option, - reference_image_srcs: Vec, -) -> Vec { - let mut sources = Vec::new(); - for source in legacy_reference_image_src - .into_iter() - .chain(reference_image_srcs) - { - let normalized = source.trim(); - if normalized.is_empty() { - continue; - } - if !sources - .iter() - .any(|existing: &String| existing == normalized) - { - sources.push(normalized.to_string()); - } - if sources.len() >= 6 { - break; - } - } - sources -} - -async fn resolve_match3d_cover_reference_image_data_urls( - state: &AppState, - sources: Vec, - max_size_bytes: usize, -) -> Result, AppError> { - let mut resolved = Vec::new(); - for source in sources { - if let Some(data_url) = - resolve_match3d_reference_image_data_url(state, Some(source.as_str()), max_size_bytes) - .await? - { - resolved.push(data_url); - } - } - Ok(resolved) -} - -async fn resolve_match3d_reference_image_for_edit( - state: &AppState, - source: Option<&str>, - max_size_bytes: usize, - file_name_prefix: &str, -) -> Result, AppError> { - let Some(source) = source.map(str::trim).filter(|value| !value.is_empty()) else { - return Ok(None); - }; - let bytes = if source.starts_with("data:image/") { - decode_match3d_data_url_bytes(source)? - } else if source.trim_start_matches('/').starts_with("generated-") { - read_match3d_generated_object_bytes( - state, - source, - "读取抓大鹅封面上传图失败", - max_size_bytes, - ) - .await? - } else { - return Err( - AppError::from_status(StatusCode::BAD_REQUEST).with_details(json!({ - "provider": MATCH3D_WORKS_PROVIDER, - "field": "uploadedImageSrc", - "message": "封面上传图必须是图片 Data URL 或 /generated-* 路径。", - })), - ); - }; - if bytes.is_empty() || bytes.len() > max_size_bytes { - return Err( - AppError::from_status(StatusCode::BAD_REQUEST).with_details(json!({ - "provider": MATCH3D_WORKS_PROVIDER, - "field": "uploadedImageSrc", - "message": "封面上传图过大,请压缩后重试。", - "maxBytes": max_size_bytes, - "actualBytes": bytes.len(), - })), - ); - } - let mime_type = infer_match3d_image_mime_type(bytes.as_slice()).to_string(); - Ok(Some(OpenAiReferenceImage { - file_name: format!( - "{}.{}", - file_name_prefix, - match3d_mime_to_extension(mime_type.as_str()) - ), - mime_type, - bytes, - })) -} - -fn decode_match3d_data_url_bytes(source: &str) -> Result, AppError> { - let Some((header, data)) = source.split_once(',') else { - return Err( - AppError::from_status(StatusCode::BAD_REQUEST).with_details(json!({ - "provider": MATCH3D_WORKS_PROVIDER, - "field": "uploadedImageSrc", - "message": "图片 Data URL 格式不正确。", - })), - ); - }; - if !header.starts_with("data:image/") || !header.contains(";base64") { - return Err( - AppError::from_status(StatusCode::BAD_REQUEST).with_details(json!({ - "provider": MATCH3D_WORKS_PROVIDER, - "field": "uploadedImageSrc", - "message": "图片 Data URL 必须是 base64 图片。", - })), - ); - } - BASE64_STANDARD.decode(data.trim()).map_err(|error| { - AppError::from_status(StatusCode::BAD_REQUEST).with_details(json!({ - "provider": MATCH3D_WORKS_PROVIDER, - "field": "uploadedImageSrc", - "message": format!("图片 Data URL 解码失败:{error}"), - })) - }) -} - -fn infer_match3d_image_mime_type(bytes: &[u8]) -> &'static str { - if bytes.starts_with(b"\x89PNG\r\n\x1a\n") { - return "image/png"; - } - if bytes.starts_with(&[0xff, 0xd8, 0xff]) { - return "image/jpeg"; - } - if bytes.starts_with(b"RIFF") && bytes.get(8..12) == Some(b"WEBP") { - return "image/webp"; - } - "image/png" -} - -fn build_match3d_material_sheet_prompt( - config: &Match3DConfigJson, - item_names: &[String], -) -> String { - let asset_style_prompt = resolve_match3d_asset_style_prompt(config); - let style_clause = asset_style_prompt - .as_ref() - .map(|prompt| format!("整体画风遵循:{prompt}。")) - .unwrap_or_default(); - let item_rows = item_names - .iter() - .enumerate() - .map(|(index, name)| format!("第{}行:{name} 的 5 个不同视角", index + 1)) - .collect::>() - .join(";"); - format!( - "生成一张1024x1024的1:1图片。固定生成5行*5列网格素材图,画面是{theme}题材的抓大鹅游戏2D物品素材。{style_clause}严格5*5均匀排布,严格按行组织:{item_rows}。同一行五格必须是同一物品的五个不同视角,依次为正面、左前、右前、俯视、背面;每个格子一个独立居中的完整物体,每格背景必须是统一纯绿色绿幕背景(高饱和亮绿色,接近 #00FF00),背景平整无纹理、无渐变、无阴影、无道具,方便后续抠成透明。物体本身不得使用与绿幕相同的纯绿色;若物品天然含绿色,必须使用更深、更黄或更蓝的绿色并用清晰描边与绿幕区分。统一柔和光照,清晰轮廓,适合直接切割成游戏2D图标。请让每个物体完整落在自己的格子中央,四周保留留白,相邻物体主体之间必须至少保留单个素材格宽度的1/4空白间距(约25%单格宽度),包含左右相邻格和上下相邻行,物体主体不得占满格子。禁止主体跨格、贴边或越界,禁止任何内容进入相邻格子影响裁剪后的效果。不要出现文字、水印、UI、边框、网格线、标签、底座、场景或其他物体。", - theme = config.theme_text, - style_clause = style_clause, - item_rows = item_rows, - ) -} - -fn build_match3d_material_sheet_negative_prompt(config: &Match3DConfigJson) -> String { - let base = "文字、水印、UI、边框、网格线、标签、人物手部、复杂背景、非绿幕背景、白色背景、灰色背景、渐变背景、纹理背景"; - if !is_match3d_pixel_retro_style(config) { - return base.to_string(); - } - - format!( - "{base}、抗锯齿、平滑插画、柔焦、软边渐变、矢量扁平插画、真实 3D 渲染、PBR 材质、摄影棚光照" - ) -} - -fn resolve_match3d_asset_style_prompt(config: &Match3DConfigJson) -> Option { - let prompt = config - .asset_style_prompt - .as_deref() - .or(config.asset_style_label.as_deref()) - .map(str::trim) - .filter(|value| !value.is_empty()) - .map(str::to_string); - if !is_match3d_pixel_retro_style(config) { - return prompt; - } - Some(match prompt { - Some(prompt) if prompt.contains("禁止抗锯齿") && prompt.contains("64x64") => prompt, - Some(prompt) => format!("{prompt};{MATCH3D_PIXEL_RETRO_STYLE_PROMPT}"), - None => MATCH3D_PIXEL_RETRO_STYLE_PROMPT.to_string(), - }) -} - -fn is_match3d_pixel_retro_style(config: &Match3DConfigJson) -> bool { - config - .asset_style_id - .as_deref() - .map(str::trim) - .is_some_and(|value| value.eq_ignore_ascii_case("pixel-retro")) - || config - .asset_style_label - .as_deref() - .map(str::trim) - .is_some_and(|value| value.contains("像素复古")) -} - -fn slice_match3d_material_sheet( - image: &DownloadedOpenAiImage, - item_names: &[String], -) -> Result>, AppError> { - // 中文注释:素材图提示词固定要求 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() -} - -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(), - ) -} - -#[derive(Clone, Copy, Debug)] -struct Match3DMaterialCellBounds { - x0: u32, - y0: u32, - x1: u32, - y1: u32, -} - -impl Match3DMaterialCellBounds { - fn width(self) -> u32 { - self.x1.saturating_sub(self.x0).max(1) - } - - fn height(self) -> u32 { - self.y1.saturating_sub(self.y0).max(1) - } - - fn area(self) -> u32 { - self.width().saturating_mul(self.height()) - } - - fn to_crop_tuple(self) -> (u32, u32, u32, u32) { - (self.x0, self.y0, self.width(), self.height()) - } -} - -fn resolve_match3d_material_cell_bounds( - image_width: u32, - image_height: u32, - row_count: u32, - row: u32, - col: u32, -) -> Match3DMaterialCellBounds { - let normalized_rows = row_count.clamp(1, MATCH3D_MATERIAL_GRID_SIZE); - let cell_x0 = col.saturating_mul(image_width) / MATCH3D_MATERIAL_GRID_SIZE; - let cell_x1 = (col.saturating_add(1)).saturating_mul(image_width) / MATCH3D_MATERIAL_GRID_SIZE; - let cell_y0 = row.saturating_mul(image_height) / normalized_rows; - let cell_y1 = (row.saturating_add(1)).saturating_mul(image_height) / normalized_rows; - - Match3DMaterialCellBounds { - x0: cell_x0.min(image_width.saturating_sub(1)), - y0: cell_y0.min(image_height.saturating_sub(1)), - x1: cell_x1.clamp(cell_x0.saturating_add(1), image_width), - y1: cell_y1.clamp(cell_y0.saturating_add(1), image_height), - } -} - -fn detect_match3d_material_foreground_bounds( - source: &image::DynamicImage, - cell: Match3DMaterialCellBounds, -) -> Option { - let background = sample_match3d_material_cell_background(source, cell); - let mut foreground: Option = None; - let mut foreground_pixels = 0u32; - - for y in cell.y0..cell.y1 { - for x in cell.x0..cell.x1 { - if !is_match3d_material_foreground_pixel(source.get_pixel(x, y).0, background) { - continue; - } - foreground_pixels = foreground_pixels.saturating_add(1); - foreground = Some(match foreground { - Some(bounds) => Match3DMaterialCellBounds { - x0: bounds.x0.min(x), - y0: bounds.y0.min(y), - x1: bounds.x1.max(x.saturating_add(1)), - y1: bounds.y1.max(y.saturating_add(1)), - }, - None => Match3DMaterialCellBounds { - x0: x, - y0: y, - x1: x.saturating_add(1), - y1: y.saturating_add(1), - }, - }); - } - } - - let min_foreground_pixels = (cell.area() / 320).clamp(12, 220); - foreground.filter(|bounds| { - foreground_pixels >= min_foreground_pixels && bounds.width() > 2 && bounds.height() > 2 - }) -} - -fn detect_match3d_material_visible_bounds( - image: &image::RgbaImage, -) -> Option { - let (width, height) = image.dimensions(); - let mut bounds: Option = None; - let mut visible_pixels = 0u32; - - for y in 0..height { - for x in 0..width { - let pixel = image.get_pixel(x, y).0; - if !is_match3d_material_visible_pixel(pixel) { - continue; - } - visible_pixels = visible_pixels.saturating_add(1); - bounds = Some(match bounds { - Some(current) => Match3DMaterialCellBounds { - x0: current.x0.min(x), - y0: current.y0.min(y), - x1: current.x1.max(x.saturating_add(1)), - y1: current.y1.max(y.saturating_add(1)), - }, - None => Match3DMaterialCellBounds { - x0: x, - y0: y, - x1: x.saturating_add(1), - y1: y.saturating_add(1), - }, - }); - } - } - - let min_visible_pixels = ((width.saturating_mul(height)) / 540).clamp(10, 120); - bounds.filter(|visible_bounds| { - visible_pixels >= min_visible_pixels - && visible_bounds.width() > 2 - && visible_bounds.height() > 2 - }) -} - -fn sample_match3d_material_cell_background( - source: &image::DynamicImage, - cell: Match3DMaterialCellBounds, -) -> [u8; 4] { - let sample_size = (cell.width().min(cell.height()) / 12).clamp(2, 8); - let sample_points = [ - (cell.x0, cell.y0), - (cell.x1.saturating_sub(sample_size), cell.y0), - (cell.x0, cell.y1.saturating_sub(sample_size)), - ( - cell.x1.saturating_sub(sample_size), - cell.y1.saturating_sub(sample_size), - ), - ]; - let mut samples = Vec::new(); - for (start_x, start_y) in sample_points { - let mut totals = [0u32; 4]; - let mut count = 0u32; - for y in start_y..start_y.saturating_add(sample_size).min(cell.y1) { - for x in start_x..start_x.saturating_add(sample_size).min(cell.x1) { - let pixel = source.get_pixel(x, y).0; - totals[0] = totals[0].saturating_add(pixel[0] as u32); - totals[1] = totals[1].saturating_add(pixel[1] as u32); - totals[2] = totals[2].saturating_add(pixel[2] as u32); - totals[3] = totals[3].saturating_add(pixel[3] as u32); - count = count.saturating_add(1); - } - } - if count > 0 { - samples.push([ - (totals[0] / count) as u8, - (totals[1] / count) as u8, - (totals[2] / count) as u8, - (totals[3] / count) as u8, - ]); - } - } - - samples - .into_iter() - .min_by_key(|sample| { - let luminance = sample[0] as u16 + sample[1] as u16 + sample[2] as u16; - (sample[3] as u16, u16::MAX.saturating_sub(luminance)) - }) - .unwrap_or([255, 255, 255, 255]) -} - -fn clamp_match3d_material_unit(value: f32) -> f32 { - value.clamp(0.0, 1.0) -} - -fn lerp_match3d_material_channel(from: f32, to: f32, t: f32) -> f32 { - from + (to - from) * clamp_match3d_material_unit(t) -} - -fn is_match3d_material_foreground_pixel(pixel: [u8; 4], background: [u8; 4]) -> bool { - let alpha_diff = pixel[3] as i32 - background[3] as i32; - if alpha_diff.abs() >= MATCH3D_MATERIAL_FOREGROUND_ALPHA_THRESHOLD && pixel[3] > 24 { - return true; - } - if pixel[3] <= 24 { - return false; - } - - let color_diff = (pixel[0] as i32 - background[0] as i32).abs() - + (pixel[1] as i32 - background[1] as i32).abs() - + (pixel[2] as i32 - background[2] as i32).abs(); - color_diff >= MATCH3D_MATERIAL_FOREGROUND_DIFF_THRESHOLD -} - -fn remove_match3d_material_view_edge_matte(pixels: &mut [u8], width: usize, height: usize) -> bool { - let pixel_count = width.saturating_mul(height); - if pixel_count == 0 || pixels.len() < pixel_count.saturating_mul(4) { - return false; - } - - let mut changed = false; - let mut background_mask = vec![0u8; pixel_count]; - let mut queue = Vec::::new(); - let mut queue_index = 0usize; - let mut transparent_pixel_count = 0usize; - for pixel_index in 0..pixel_count { - let offset = pixel_index * 4; - if pixels[offset + 3] == 0 { - background_mask[pixel_index] = 1; - queue.push(pixel_index); - transparent_pixel_count = transparent_pixel_count.saturating_add(1); - } - } - let has_transparent_background = transparent_pixel_count > pixel_count / 200; - - // 中文注释:单图被前景边界收紧后,浅绿框可能正好贴在 PNG 外缘; - // 把外缘一段宽度作为去背种子,但只清理绿幕 / 近白 matte,避免误伤贴边主体。 - let edge_width = resolve_match3d_material_view_edge_cleanup_width(width, height); - for y in 0..height { - for x in 0..width { - if x >= edge_width - && y >= edge_width - && x.saturating_add(edge_width) < width - && y.saturating_add(edge_width) < height - { - continue; - } - let pixel_index = y * width + x; - if background_mask[pixel_index] != 0 { - continue; - } - let offset = pixel_index * 4; - let pixel = [ - pixels[offset], - pixels[offset + 1], - pixels[offset + 2], - pixels[offset + 3], - ]; - if !is_match3d_material_view_background_pixel(pixel) { - continue; - } - background_mask[pixel_index] = 1; - queue.push(pixel_index); - } - } - - while queue_index < queue.len() { - let pixel_index = queue[queue_index]; - queue_index += 1; - let x = pixel_index % width; - let y = pixel_index / width; - let neighbors = [ - (x > 0).then(|| pixel_index - 1), - (x + 1 < width).then_some(pixel_index + 1), - (y > 0).then(|| pixel_index - width), - (y + 1 < height).then_some(pixel_index + width), - ]; - - for next_pixel_index in neighbors.into_iter().flatten() { - if background_mask[next_pixel_index] != 0 { - continue; - } - let offset = next_pixel_index * 4; - let pixel = [ - pixels[offset], - pixels[offset + 1], - pixels[offset + 2], - pixels[offset + 3], - ]; - if !is_match3d_material_view_background_pixel(pixel) { - continue; - } - background_mask[next_pixel_index] = 1; - queue.push(next_pixel_index); - } - } - - for _ in 0..edge_width { - let mut expanded_mask = background_mask.clone(); - let mut changed_this_round = false; - for y in 0..height { - for x in 0..width { - let pixel_index = y * width + x; - if background_mask[pixel_index] != 0 { - continue; - } - let offset = pixel_index * 4; - if !is_match3d_material_view_background_pixel([ - pixels[offset], - pixels[offset + 1], - pixels[offset + 2], - pixels[offset + 3], - ]) { - continue; - } - - if touches_match3d_material_background_mask(x, y, width, height, &background_mask) { - expanded_mask[pixel_index] = 1; - changed_this_round = true; - } - } - } - background_mask = expanded_mask; - if !changed_this_round { - break; - } - } - - // 中文注释:边缘抗锯齿圈要直接从可见像素里剔除,再按剩余主体重新收紧裁边。 - for pixel_index in 0..pixel_count { - if background_mask[pixel_index] == 0 { - continue; - } - let offset = pixel_index * 4; - if pixels[offset + 3] != 0 - || pixels[offset] != 0 - || pixels[offset + 1] != 0 - || pixels[offset + 2] != 0 - { - pixels[offset] = 0; - pixels[offset + 1] = 0; - pixels[offset + 2] = 0; - pixels[offset + 3] = 0; - changed = true; - } - } - - if has_transparent_background { - let mut visible_mask = vec![0u8; pixel_count]; - for pixel_index in 0..pixel_count { - let offset = pixel_index * 4; - if is_match3d_material_visible_pixel([ - pixels[offset], - pixels[offset + 1], - pixels[offset + 2], - pixels[offset + 3], - ]) { - visible_mask[pixel_index] = 1; - } - } - - for _ in 0..2 { - let mut changed_this_round = false; - for y in 0..height { - for x in 0..width { - let pixel_index = y * width + x; - if visible_mask[pixel_index] == 0 { - continue; - } - let offset = pixel_index * 4; - let pixel = [ - pixels[offset], - pixels[offset + 1], - pixels[offset + 2], - pixels[offset + 3], - ]; - if !is_match3d_material_green_contaminated_edge_pixel(pixel) { - continue; - } - if !touches_match3d_material_background_mask( - x, - y, - width, - height, - &background_mask, - ) { - continue; - } - - if is_match3d_material_strong_green_contamination(pixel) { - pixels[offset] = 0; - pixels[offset + 1] = 0; - pixels[offset + 2] = 0; - pixels[offset + 3] = 0; - visible_mask[pixel_index] = 0; - background_mask[pixel_index] = 1; - changed = true; - changed_this_round = true; - continue; - } - - let replacement = collect_match3d_material_visible_neighbor_color( - pixels, - width, - height, - x, - y, - &background_mask, - &visible_mask, - ) - .unwrap_or(( - pixels[offset], - pixels[offset + 1], - pixels[offset + 2], - )); - let next_red = replacement.0.max(pixels[offset]); - let next_blue = replacement.2.max(pixels[offset + 2]); - let next_green = replacement - .1 - .min(next_red.max(next_blue).saturating_add(12)); - if next_red != pixels[offset] - || next_green != pixels[offset + 1] - || next_blue != pixels[offset + 2] - { - pixels[offset] = next_red; - pixels[offset + 1] = next_green; - pixels[offset + 2] = next_blue; - changed = true; - changed_this_round = true; - } - background_mask[pixel_index] = 1; - } - } - if !changed_this_round { - break; - } - } - } - - changed -} - -fn resolve_match3d_material_view_edge_cleanup_width(width: usize, height: usize) -> usize { - let min_side = width.min(height).max(1); - (min_side / 24).clamp(4, 12).min(min_side) -} - -fn is_match3d_material_view_background_pixel(pixel: [u8; 4]) -> bool { - pixel[3] < 16 - || is_match3d_material_soft_edge_pixel(pixel) - || compute_match3d_material_white_screen_score(pixel) > 0.18 -} - -fn is_match3d_material_visible_pixel(pixel: [u8; 4]) -> bool { - pixel[3] > 0 && (pixel[0] > 8 || pixel[1] > 8 || pixel[2] > 8) -} - -fn is_match3d_material_soft_edge_pixel(pixel: [u8; 4]) -> bool { - if pixel[3] == 0 { - return false; - } - - let red = pixel[0]; - let green = pixel[1]; - let blue = pixel[2]; - green >= 188 - && green.saturating_sub(red.max(blue)) >= 42 - && (red >= 48 || blue >= 96 || pixel[3] < 236) -} - -fn is_match3d_material_green_contaminated_edge_pixel(pixel: [u8; 4]) -> bool { - if pixel[3] == 0 { - return false; - } - - let red = pixel[0]; - let green = pixel[1]; - let blue = pixel[2]; - green >= 72 && green.saturating_sub(red.max(blue)) >= 18 -} - -fn is_match3d_material_strong_green_contamination(pixel: [u8; 4]) -> bool { - let red = pixel[0]; - let green = pixel[1]; - let blue = pixel[2]; - green >= 148 && green.saturating_sub(red.max(blue)) >= 34 -} - -fn collect_match3d_material_visible_neighbor_color( - pixels: &[u8], - width: usize, - height: usize, - x: usize, - y: usize, - background_mask: &[u8], - visible_mask: &[u8], -) -> Option<(u8, u8, u8)> { - let mut total_weight = 0.0f32; - let mut total_red = 0.0f32; - let mut total_green = 0.0f32; - let mut total_blue = 0.0f32; - - for offset_y in -3i32..=3 { - for offset_x in -3i32..=3 { - if offset_x == 0 && offset_y == 0 { - continue; - } - let next_x = x as i32 + offset_x; - let next_y = y as i32 + offset_y; - if next_x < 0 || next_x >= width as i32 || next_y < 0 || next_y >= height as i32 { - continue; - } - - let next_pixel_index = next_y as usize * width + next_x as usize; - if background_mask[next_pixel_index] != 0 || visible_mask[next_pixel_index] == 0 { - continue; - } - - let next_offset = next_pixel_index * 4; - let next_alpha = pixels[next_offset + 3]; - if next_alpha < 96 { - continue; - } - let pixel = [ - pixels[next_offset], - pixels[next_offset + 1], - pixels[next_offset + 2], - next_alpha, - ]; - if is_match3d_material_green_contaminated_edge_pixel(pixel) - || is_match3d_material_soft_edge_pixel(pixel) - { - continue; - } - - let distance = offset_x.unsigned_abs() + offset_y.unsigned_abs(); - let weight = (next_alpha as f32 / 255.0) - * if distance <= 1 { - 2.0 - } else if distance <= 3 { - 1.2 - } else { - 0.7 - }; - total_weight += weight; - total_red += pixels[next_offset] as f32 * weight; - total_green += pixels[next_offset + 1] as f32 * weight; - total_blue += pixels[next_offset + 2] as f32 * weight; - } - } - - if total_weight <= 0.0 { - return None; - } - - Some(( - (total_red / total_weight).round() as u8, - (total_green / total_weight).round() as u8, - (total_blue / total_weight).round() as u8, - )) -} - -fn apply_match3d_material_green_screen_alpha(source: image::DynamicImage) -> image::DynamicImage { - let mut image = source.to_rgba8(); - let (width, height) = image.dimensions(); - remove_match3d_material_green_screen_background( - image.as_mut(), - width as usize, - height as usize, - ); - image::DynamicImage::ImageRgba8(image) -} - -fn remove_match3d_material_green_screen_background( - pixels: &mut [u8], - width: usize, - height: usize, -) -> bool { - let pixel_count = width.saturating_mul(height); - if pixel_count == 0 || pixels.len() < pixel_count.saturating_mul(4) { - return false; - } - - let mut green_scores = vec![0.0f32; pixel_count]; - let mut white_scores = vec![0.0f32; pixel_count]; - let mut background_hints = vec![0.0f32; pixel_count]; - let mut background_mask = vec![0u8; pixel_count]; - let mut queue = Vec::::new(); - let mut queue_index = 0usize; - - for pixel_index in 0..pixel_count { - let offset = pixel_index * 4; - let red = pixels[offset]; - let green = pixels[offset + 1]; - let blue = pixels[offset + 2]; - let alpha = pixels[offset + 3]; - let green_score = compute_match3d_material_green_screen_score([red, green, blue, alpha]); - let white_score = compute_match3d_material_white_screen_score([red, green, blue, alpha]); - let transparency_hint = clamp_match3d_material_unit((56.0 - alpha as f32) / 56.0) * 0.75; - - green_scores[pixel_index] = green_score; - white_scores[pixel_index] = white_score; - background_hints[pixel_index] = green_score.max(white_score).max(transparency_hint); - } - - let seed_background_pixel = |pixel_index: usize, - background_mask: &mut [u8], - queue: &mut Vec| { - if background_mask[pixel_index] != 0 { - return; - } - let alpha = pixels[pixel_index * 4 + 3]; - let strong_candidate = alpha < 40 - || green_scores[pixel_index] >= MATCH3D_MATERIAL_HARD_GREEN_SCREEN_SCORE - || (alpha < 224 && green_scores[pixel_index] > MATCH3D_MATERIAL_GREEN_SCREEN_MIN_SCORE) - || white_scores[pixel_index] > 0.32; - if !strong_candidate { - return; - } - background_mask[pixel_index] = 1; - queue.push(pixel_index); - }; - - for x in 0..width { - seed_background_pixel(x, &mut background_mask, &mut queue); - seed_background_pixel((height - 1) * width + x, &mut background_mask, &mut queue); - } - for y in 1..height.saturating_sub(1) { - seed_background_pixel(y * width, &mut background_mask, &mut queue); - seed_background_pixel(y * width + width - 1, &mut background_mask, &mut queue); - } - - while queue_index < queue.len() { - let pixel_index = queue[queue_index]; - queue_index += 1; - - let x = pixel_index % width; - let y = pixel_index / width; - let neighbor_indexes = [ - if x > 0 { Some(pixel_index - 1) } else { None }, - if x + 1 < width { - Some(pixel_index + 1) - } else { - None - }, - if y > 0 { - Some(pixel_index - width) - } else { - None - }, - if y + 1 < height { - Some(pixel_index + width) - } else { - None - }, - ]; - - for next_pixel_index in neighbor_indexes.into_iter().flatten() { - if background_mask[next_pixel_index] != 0 { - continue; - } - let next_offset = next_pixel_index * 4; - let alpha = pixels[next_offset + 3]; - let green_score = green_scores[next_pixel_index]; - let white_score = white_scores[next_pixel_index]; - let hint = background_hints[next_pixel_index]; - let reachable_soft_edge = hint > 0.08 - && alpha < 224 - && (green_score > 0.04 || white_score > 0.08 || alpha < 180); - let green_background = green_score >= MATCH3D_MATERIAL_HARD_GREEN_SCREEN_SCORE - || (alpha < 224 && green_score > MATCH3D_MATERIAL_GREEN_SCREEN_MIN_SCORE); - if alpha < 40 || green_background || white_score > 0.32 || reachable_soft_edge { - background_mask[next_pixel_index] = 1; - queue.push(next_pixel_index); - } - } - } - - // 中文注释:Gemini 有时把每个素材格生成成独立绿幕块,块外又是近白背景; - // 这类绿幕不一定和整张 sheet 外边缘连通,必须用高置信绿幕直接补进背景层。 - for pixel_index in 0..pixel_count { - if background_mask[pixel_index] == 0 - && green_scores[pixel_index] >= MATCH3D_MATERIAL_HARD_GREEN_SCREEN_SCORE - { - background_mask[pixel_index] = 1; - } - } - - // 中文注释:较厚的抗锯齿绿边可能低于 hard 阈值;先沿整张 sheet 的透明背景向内吃掉 - // 软绿边,再进入格子裁剪,避免每张切图自带绿色描边。 - let soft_green_cleanup_rounds = (width.min(height) / 40).clamp(4, 14); - for _ in 0..soft_green_cleanup_rounds { - let mut expanded_mask = background_mask.clone(); - let mut changed_this_round = false; - for y in 0..height { - for x in 0..width { - let pixel_index = y * width + x; - if background_mask[pixel_index] != 0 { - continue; - } - let offset = pixel_index * 4; - let pixel = [ - pixels[offset], - pixels[offset + 1], - pixels[offset + 2], - pixels[offset + 3], - ]; - let green_score = green_scores[pixel_index]; - let white_score = white_scores[pixel_index]; - if !is_match3d_material_soft_green_matte_pixel(pixel, green_score, white_score) { - continue; - } - if !touches_match3d_material_background_mask(x, y, width, height, &background_mask) - { - continue; - } - - expanded_mask[pixel_index] = 1; - changed_this_round = true; - } - } - background_mask = expanded_mask; - if !changed_this_round { - break; - } - } - - // 中文注释:主体边缘常带一圈绿幕或白底抗锯齿,扩一层软边,避免切割后残留毛边。 - for _ in 0..2 { - let mut expanded_mask = background_mask.clone(); - for y in 0..height { - for x in 0..width { - let pixel_index = y * width + x; - if background_mask[pixel_index] != 0 { - continue; - } - let alpha = pixels[pixel_index * 4 + 3]; - let green_score = green_scores[pixel_index]; - let white_score = white_scores[pixel_index]; - let hint = background_hints[pixel_index]; - let soft_matte_candidate = alpha < 224 - || white_score > 0.10 - || green_score >= MATCH3D_MATERIAL_HARD_GREEN_SCREEN_SCORE; - if hint < MATCH3D_MATERIAL_GREEN_SCREEN_SOFT_SCORE || !soft_matte_candidate { - continue; - } - - let mut adjacent_background_count = 0usize; - for offset_y in -1i32..=1 { - for offset_x in -1i32..=1 { - if offset_x == 0 && offset_y == 0 { - continue; - } - let next_x = x as i32 + offset_x; - let next_y = y as i32 + offset_y; - if next_x < 0 - || next_x >= width as i32 - || next_y < 0 - || next_y >= height as i32 - { - adjacent_background_count += 1; - continue; - } - if background_mask[next_y as usize * width + next_x as usize] != 0 { - adjacent_background_count += 1; - } - } - } - - if adjacent_background_count >= 2 - || (adjacent_background_count >= 1 - && hint >= MATCH3D_MATERIAL_GREEN_SCREEN_MIN_SCORE) - { - expanded_mask[pixel_index] = 1; - } - } - } - background_mask = expanded_mask; - } - - let mut changed = false; - for pixel_index in 0..pixel_count { - if background_mask[pixel_index] == 0 { - continue; - } - let alpha_offset = pixel_index * 4 + 3; - if pixels[alpha_offset] != 0 { - pixels[alpha_offset] = 0; - changed = true; - } - } - - for y in 0..height { - for x in 0..width { - let pixel_index = y * width + x; - let offset = pixel_index * 4; - let alpha = pixels[offset + 3]; - if alpha == 0 { - continue; - } - - let mut touches_transparent_edge = false; - for offset_y in -1i32..=1 { - for offset_x in -1i32..=1 { - if offset_x == 0 && offset_y == 0 { - continue; - } - let next_x = x as i32 + offset_x; - let next_y = y as i32 + offset_y; - if next_x < 0 || next_x >= width as i32 || next_y < 0 || next_y >= height as i32 - { - touches_transparent_edge = true; - continue; - } - let next_pixel_index = next_y as usize * width + next_x as usize; - if background_mask[next_pixel_index] != 0 - || pixels[next_pixel_index * 4 + 3] < 16 - { - touches_transparent_edge = true; - } - } - } - - if !touches_transparent_edge { - continue; - } - - let green_score = green_scores[pixel_index]; - let white_score = white_scores[pixel_index]; - let contamination = green_score.max(white_score).max(if alpha < 220 { - ((220 - alpha) as f32 / 220.0) * 0.25 - } else { - 0.0 - }); - if contamination < 0.06 { - continue; - } - - let sample = collect_match3d_material_foreground_neighbor_color( - pixels, - width, - height, - x, - y, - &background_mask, - &background_hints, - ); - let mut red = pixels[offset] as f32; - let mut green = pixels[offset + 1] as f32; - let mut blue = pixels[offset + 2] as f32; - let blend = clamp_match3d_material_unit(contamination.max(0.22)); - - if let Some((sample_red, sample_green, sample_blue)) = sample { - red = lerp_match3d_material_channel(red, sample_red as f32, blend); - green = lerp_match3d_material_channel(green, sample_green as f32, blend); - blue = lerp_match3d_material_channel(blue, sample_blue as f32, blend); - - if green_score > 0.04 { - green = green.min(sample_green as f32 + 18.0); - } - if white_score > 0.1 { - red = red.min(sample_red as f32 + 26.0); - green = green.min(sample_green as f32 + 26.0); - blue = blue.min(sample_blue as f32 + 26.0); - } - } else { - if green_score > 0.04 { - let toned_green = (green - (green - red.max(blue)) * 0.78) - .round() - .max(red.max(blue)); - green = green.min(toned_green).min(red.max(blue) + 18.0); - } - - if white_score > 0.12 { - let spread = red.max(green).max(blue) - red.min(green).min(blue); - if spread < 20.0 { - let toned_value = ((red + green + blue) / 3.0 * 0.88).round(); - red = red.min(toned_value); - green = green.min(toned_value); - blue = blue.min(toned_value); - } - } - } - - let mut next_alpha = alpha; - let edge_fade = (green_score * 0.35).max(white_score * 0.28); - if edge_fade > 0.08 { - next_alpha = ((alpha as f32) * (1.0 - edge_fade)).round() as u8; - if next_alpha < 10 { - next_alpha = 0; - } - } - - let next_red = red.round().clamp(0.0, 255.0) as u8; - let next_green = green.round().clamp(0.0, 255.0) as u8; - let next_blue = blue.round().clamp(0.0, 255.0) as u8; - if next_red != pixels[offset] - || next_green != pixels[offset + 1] - || next_blue != pixels[offset + 2] - || next_alpha != alpha - { - pixels[offset] = next_red; - pixels[offset + 1] = next_green; - pixels[offset + 2] = next_blue; - pixels[offset + 3] = next_alpha; - changed = true; - } - } - } - - changed -} - -fn touches_match3d_material_background_mask( - x: usize, - y: usize, - width: usize, - height: usize, - background_mask: &[u8], -) -> bool { - for offset_y in -1i32..=1 { - for offset_x in -1i32..=1 { - if offset_x == 0 && offset_y == 0 { - continue; - } - let next_x = x as i32 + offset_x; - let next_y = y as i32 + offset_y; - if next_x < 0 || next_x >= width as i32 || next_y < 0 || next_y >= height as i32 { - return true; - } - if background_mask[next_y as usize * width + next_x as usize] != 0 { - return true; - } - } - } - false -} - -fn is_match3d_material_soft_green_matte_pixel( - pixel: [u8; 4], - green_score: f32, - white_score: f32, -) -> bool { - if pixel[3] == 0 || green_score < MATCH3D_MATERIAL_GREEN_SCREEN_MIN_SCORE { - return false; - } - - let red = pixel[0]; - let green = pixel[1]; - let blue = pixel[2]; - let foreground_mix = red.max(blue); - green >= 188 - && white_score < 0.34 - && green.saturating_sub(foreground_mix) >= 42 - && (red >= 48 || blue >= 96 || pixel[3] < 236) -} - -fn compute_match3d_material_green_screen_score(pixel: [u8; 4]) -> f32 { - if pixel[3] == 0 { - return 1.0; - } - - let red = pixel[0] as f32; - let green = pixel[1] as f32; - let blue = pixel[2] as f32; - let green_lead = green - red.max(blue); - if green < 96.0 || green_lead <= 18.0 { - return 0.0; - } - - let green_ratio = green / (red + blue).max(1.0); - if green_ratio <= 0.9 { - return 0.0; - } - - (((green - 96.0) / 128.0).clamp(0.0, 1.0) * 0.34 - + ((green_lead - 18.0) / 120.0).clamp(0.0, 1.0) * 0.46 - + ((green_ratio - 0.9) / 2.4).clamp(0.0, 1.0) * 0.20) - .clamp(0.0, 1.0) -} - -fn compute_match3d_material_white_screen_score(pixel: [u8; 4]) -> f32 { - if pixel[3] == 0 { - return 1.0; - } - - let red = pixel[0] as f32; - let green = pixel[1] as f32; - let blue = pixel[2] as f32; - let max_channel = red.max(green).max(blue); - let min_channel = red.min(green).min(blue); - let average = (red + green + blue) / 3.0; - if average < 188.0 || min_channel < 168.0 { - return 0.0; - } - - let spread = max_channel - min_channel; - let neutrality = 1.0 - clamp_match3d_material_unit((spread - 6.0) / 34.0); - let brightness = clamp_match3d_material_unit((average - 188.0) / 55.0); - let floor = clamp_match3d_material_unit((min_channel - 168.0) / 60.0); - clamp_match3d_material_unit(neutrality * (brightness * 0.85 + floor * 0.15)) -} - -fn remove_match3d_container_plain_background( - pixels: &mut [u8], - width: usize, - height: usize, -) -> bool { - let pixel_count = width.saturating_mul(height); - if pixel_count == 0 || pixels.len() < pixel_count.saturating_mul(4) { - return false; - } - - let mut background_mask = vec![0u8; pixel_count]; - let mut queue = Vec::::new(); - let mut queue_index = 0usize; - - let seed_pixel = |pixel_index: usize, background_mask: &mut [u8], queue: &mut Vec| { - if background_mask[pixel_index] != 0 { - return; - } - let offset = pixel_index * 4; - if is_match3d_container_background_pixel([ - pixels[offset], - pixels[offset + 1], - pixels[offset + 2], - pixels[offset + 3], - ]) { - background_mask[pixel_index] = 1; - queue.push(pixel_index); - } - }; - - for x in 0..width { - seed_pixel(x, &mut background_mask, &mut queue); - seed_pixel((height - 1) * width + x, &mut background_mask, &mut queue); - } - for y in 1..height.saturating_sub(1) { - seed_pixel(y * width, &mut background_mask, &mut queue); - seed_pixel(y * width + width - 1, &mut background_mask, &mut queue); - } - - while queue_index < queue.len() { - let pixel_index = queue[queue_index]; - queue_index += 1; - let x = pixel_index % width; - let y = pixel_index / width; - let neighbors = [ - (x > 0).then(|| pixel_index - 1), - (x + 1 < width).then_some(pixel_index + 1), - (y > 0).then(|| pixel_index - width), - (y + 1 < height).then_some(pixel_index + width), - ]; - - for next_pixel_index in neighbors.into_iter().flatten() { - if background_mask[next_pixel_index] != 0 { - continue; - } - let offset = next_pixel_index * 4; - if is_match3d_container_background_pixel([ - pixels[offset], - pixels[offset + 1], - pixels[offset + 2], - pixels[offset + 3], - ]) { - background_mask[next_pixel_index] = 1; - queue.push(next_pixel_index); - } - } - } - - // 中文注释:图生图偶尔会在容器边缘留下白底抗锯齿,扩一层只清理连到背景的浅色边。 - for _ in 0..2 { - let mut expanded_mask = background_mask.clone(); - for y in 0..height { - for x in 0..width { - let pixel_index = y * width + x; - if background_mask[pixel_index] != 0 { - continue; - } - let offset = pixel_index * 4; - let pixel = [ - pixels[offset], - pixels[offset + 1], - pixels[offset + 2], - pixels[offset + 3], - ]; - if !is_match3d_container_soft_background_pixel(pixel) { - continue; - } - - let mut adjacent_background_count = 0usize; - for offset_y in -1i32..=1 { - for offset_x in -1i32..=1 { - if offset_x == 0 && offset_y == 0 { - continue; - } - let next_x = x as i32 + offset_x; - let next_y = y as i32 + offset_y; - if next_x < 0 - || next_x >= width as i32 - || next_y < 0 - || next_y >= height as i32 - { - adjacent_background_count += 1; - continue; - } - if background_mask[next_y as usize * width + next_x as usize] != 0 { - adjacent_background_count += 1; - } - } - } - - if adjacent_background_count >= 3 { - expanded_mask[pixel_index] = 1; - } - } - } - background_mask = expanded_mask; - } - - let mut changed = false; - for pixel_index in 0..pixel_count { - if background_mask[pixel_index] == 0 { - continue; - } - let offset = pixel_index * 4; - if pixels[offset + 3] != 0 { - pixels[offset + 3] = 0; - changed = true; - } - } - changed -} - -fn is_match3d_container_background_pixel(pixel: [u8; 4]) -> bool { - pixel[3] < 16 || compute_match3d_material_white_screen_score(pixel) > 0.34 -} - -fn is_match3d_container_soft_background_pixel(pixel: [u8; 4]) -> bool { - pixel[3] < 80 || compute_match3d_material_white_screen_score(pixel) > 0.18 -} - -fn collect_match3d_material_foreground_neighbor_color( - pixels: &[u8], - width: usize, - height: usize, - x: usize, - y: usize, - background_mask: &[u8], - background_hints: &[f32], -) -> Option<(u8, u8, u8)> { - let mut total_weight = 0.0f32; - let mut total_red = 0.0f32; - let mut total_green = 0.0f32; - let mut total_blue = 0.0f32; - - for offset_y in -2i32..=2 { - for offset_x in -2i32..=2 { - if offset_x == 0 && offset_y == 0 { - continue; - } - let next_x = x as i32 + offset_x; - let next_y = y as i32 + offset_y; - if next_x < 0 || next_x >= width as i32 || next_y < 0 || next_y >= height as i32 { - continue; - } - - let next_pixel_index = next_y as usize * width + next_x as usize; - if background_mask[next_pixel_index] != 0 || background_hints[next_pixel_index] >= 0.18 - { - continue; - } - - let next_offset = next_pixel_index * 4; - let next_alpha = pixels[next_offset + 3]; - if next_alpha < 96 { - continue; - } - let distance = offset_x.unsigned_abs() + offset_y.unsigned_abs(); - let weight = (next_alpha as f32 / 255.0) - * if distance <= 1 { - 1.8 - } else if distance == 2 { - 1.2 - } else { - 0.7 - }; - - total_weight += weight; - total_red += pixels[next_offset] as f32 * weight; - total_green += pixels[next_offset + 1] as f32 * weight; - total_blue += pixels[next_offset + 2] as f32 * weight; - } - } - - if total_weight <= 0.0 { - return None; - } - - Some(( - (total_red / total_weight).round() as u8, - (total_green / total_weight).round() as u8, - (total_blue / total_weight).round() as u8, - )) -} - -#[allow(clippy::too_many_arguments)] -async fn persist_match3d_generated_bytes( - state: &AppState, - owner_user_id: &str, - session_id: &str, - profile_id: &str, - path_segments: &[&str], - file_name: &str, - content_type: &str, - bytes: Vec, - asset_kind: &str, - source_job_id: Option<&str>, - generated_at_micros: i64, -) -> Result { - let oss_client = require_match3d_oss_client(state)?; - let mut metadata = BTreeMap::new(); - metadata.insert("x-oss-meta-asset-kind".to_string(), asset_kind.to_string()); - metadata.insert( - "x-oss-meta-owner-user-id".to_string(), - owner_user_id.to_string(), - ); - metadata.insert("x-oss-meta-profile-id".to_string(), profile_id.to_string()); - if let Some(source_job_id) = source_job_id.filter(|value| !value.trim().is_empty()) { - metadata.insert( - "x-oss-meta-source-job-id".to_string(), - source_job_id.to_string(), - ); - } - - let oss_http_client = reqwest::Client::builder() - .timeout(Duration::from_millis(MATCH3D_OSS_PUT_TIMEOUT_MS)) - .build() - .map_err(|error| match3d_bad_gateway(format!("构造抓大鹅 OSS 上传客户端失败:{error}")))?; - let put_result = oss_client - .put_object( - &oss_http_client, - OssPutObjectRequest { - prefix: LegacyAssetPrefix::Match3DAssets, - path_segments: std::iter::once(session_id) - .chain(std::iter::once(profile_id)) - .chain(path_segments.iter().copied()) - .map(|segment| sanitize_match3d_asset_segment(segment, "asset")) - .collect(), - file_name: file_name.to_string(), - content_type: Some(content_type.to_string()), - access: OssObjectAccess::Private, - metadata, - body: bytes, - }, - ) - .await - .map_err(|error| map_oss_error(error, "aliyun-oss"))?; - - let _ = generated_at_micros; - Ok(Match3DAssetUpload { - src: put_result.legacy_public_path, - object_key: put_result.object_key, - }) -} - -fn require_match3d_oss_client(state: &AppState) -> Result<&platform_oss::OssClient, AppError> { - state - .oss_client() - .ok_or_else(|| match3d_oss_config_error(&state.config)) -} - -fn match3d_oss_config_error(config: &AppConfig) -> AppError { - let missing = missing_match3d_oss_env_keys(config); - let reason = match3d_oss_missing_reason(&missing); - - AppError::from_status(StatusCode::SERVICE_UNAVAILABLE).with_details(json!({ - "provider": "aliyun-oss", - "reason": reason, - "missingEnv": missing, - })) -} - -fn match3d_oss_missing_reason(missing: &[&str]) -> String { - if missing.is_empty() { - "OSS 未完成环境变量配置".to_string() - } else { - format!("OSS 未完成环境变量配置,缺少:{}", missing.join(", ")) - } -} - -fn missing_match3d_oss_env_keys(config: &AppConfig) -> Vec<&'static str> { - [ - ("ALIYUN_OSS_BUCKET", config.oss_bucket.as_deref()), - ("ALIYUN_OSS_ENDPOINT", config.oss_endpoint.as_deref()), - ( - "ALIYUN_OSS_ACCESS_KEY_ID", - config.oss_access_key_id.as_deref(), - ), - ( - "ALIYUN_OSS_ACCESS_KEY_SECRET", - config.oss_access_key_secret.as_deref(), - ), - ] - .into_iter() - .filter_map(|(name, value)| match value { - Some(value) if !value.trim().is_empty() => None, - _ => Some(name), - }) - .collect() -} - -fn sanitize_match3d_asset_segment(raw: &str, fallback: &str) -> String { - let normalized = raw - .trim() - .chars() - .map(|ch| { - if ch.is_ascii_alphanumeric() || ch == '-' || ch == '_' { - ch.to_ascii_lowercase() - } else { - '-' - } - }) - .collect::(); - let collapsed = normalized - .split('-') - .filter(|part| !part.is_empty()) - .collect::>() - .join("-"); - if collapsed.is_empty() { - fallback.to_string() - } else { - collapsed.chars().take(64).collect() - } -} - -fn normalize_match3d_run_status(value: &str) -> &str { - match value { - "Running" => "running", - "Won" => "won", - "Failed" => "failed", - "Stopped" => "stopped", - _ => value, - } -} - -fn normalize_match3d_item_state(value: &str) -> &str { - match value { - "InBoard" => "in_board", - "InTray" => "in_tray", - "Cleared" => "cleared", - _ => value, - } -} - -fn normalize_match3d_failure_reason(value: &str) -> &str { - match value { - "TimeUp" => "time_up", - "TrayFull" => "tray_full", - _ => value, - } -} - -fn normalize_match3d_click_reject_reason(value: &str) -> &str { - match value { - "RejectedNotClickable" => "item_not_clickable", - "RejectedAlreadyMoved" => "item_not_in_board", - "RejectedTrayFull" => "tray_full", - "VersionConflict" => "snapshot_version_mismatch", - "RunFinished" => "run_not_active", - _ => value, - } -} +mod vector_engine_gemini; +use self::vector_engine_gemini::*; fn ensure_non_empty( request_context: &RequestContext, @@ -7286,1878 +606,4 @@ fn current_utc_ms() -> i64 { } #[cfg(test)] -mod tests { - use super::*; - - fn test_match3d_generated_item_asset(index: u32, name: &str) -> Match3DGeneratedItemAsset { - Match3DGeneratedItemAsset { - item_id: format!("match3d-item-{index}"), - item_name: name.to_string(), - item_size: Some(infer_match3d_item_size(name)), - 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: Some(format!( - "/generated-match3d-assets/s/p/items/i{index}/model/model.glb" - )), - model_object_key: Some(format!( - "generated-match3d-assets/s/p/items/i{index}/model/model.glb" - )), - model_file_name: Some("model.glb".to_string()), - task_uuid: Some(format!("task-{index}")), - subscription_key: Some(format!("sub-{index}")), - sound_prompt: Some(format!("{name}点击音效")), - 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, - } - } - - fn config(theme_text: &str, clear_count: u32, difficulty: u32) -> Match3DConfigJson { - Match3DConfigJson { - theme_text: theme_text.to_string(), - reference_image_src: None, - clear_count, - difficulty, - asset_style_id: None, - asset_style_label: None, - asset_style_prompt: None, - generate_click_sound: false, - } - } - - #[test] - fn match3d_agent_reply_asks_three_questions_before_confirmation() { - let current = config("水果", 4, 6); - - assert_eq!( - build_match3d_assistant_reply_for_turn(¤t, 0), - MATCH3D_QUESTION_THEME - ); - assert_eq!( - build_match3d_assistant_reply_for_turn(¤t, 1), - MATCH3D_QUESTION_CLEAR_COUNT - ); - assert_eq!( - build_match3d_assistant_reply_for_turn(¤t, 2), - MATCH3D_QUESTION_DIFFICULTY - ); - assert_eq!( - build_match3d_assistant_reply_for_turn(¤t, 3), - "已确认:水果题材,需要消除 4 次,共 12 件物品,难度 6。" - ); - } - - #[test] - fn match3d_agent_progress_follows_question_turns() { - assert_eq!(resolve_progress_percent_for_turn(0), 0); - assert_eq!(resolve_progress_percent_for_turn(1), 33); - assert_eq!(resolve_progress_percent_for_turn(2), 66); - assert_eq!(resolve_progress_percent_for_turn(3), 100); - assert_eq!(resolve_progress_percent_for_turn(8), 100); - } - - #[test] - fn match3d_anchor_pack_masks_uncollected_default_values() { - let pack = Match3DAnchorPackRecord { - theme: Match3DAnchorItemRecord { - key: "theme".to_string(), - label: "题材主题".to_string(), - value: "缤纷玩具".to_string(), - status: "confirmed".to_string(), - }, - clear_count: Match3DAnchorItemRecord { - key: "clearCount".to_string(), - label: "需要消除次数".to_string(), - value: "12".to_string(), - status: "confirmed".to_string(), - }, - difficulty: Match3DAnchorItemRecord { - key: "difficulty".to_string(), - label: "难度".to_string(), - value: "4".to_string(), - status: "confirmed".to_string(), - }, - }; - - let response = map_match3d_anchor_pack_response_for_turn(pack, 0, "Collecting"); - - assert_eq!(response.theme.value, ""); - assert_eq!(response.theme.status, "missing"); - assert_eq!(response.clear_count.value, ""); - assert_eq!(response.clear_count.status, "missing"); - assert_eq!(response.difficulty.value, ""); - assert_eq!(response.difficulty.status, "missing"); - } - - #[test] - fn match3d_item_image_path_segments_stay_unique_for_chinese_names() { - let item_names = ["草莓", "苹果", "香蕉"]; - let slugs = item_names - .iter() - .enumerate() - .map(|(index, item_name)| { - let item_id = format!("match3d-item-{}", index + 1); - format!( - "{item_id}-{}", - sanitize_match3d_asset_segment(item_name, "item") - ) - }) - .collect::>(); - - assert_eq!( - slugs, - vec![ - "match3d-item-1-item", - "match3d-item-2-item", - "match3d-item-3-item", - ] - ); - } - - #[test] - 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..5 { - for col in 0..5 { - let color = image::Rgba([ - 32 + row as u8 * 40, - 24 + col as u8 * 36, - 210 - row as u8 * 30, - 255, - ]); - for y in row * 100..(row + 1) * 100 { - for x in col * 100..(col + 1) * 100 { - sheet.put_pixel(x, y, color); - } - } - } - } - let mut encoded = std::io::Cursor::new(Vec::new()); - image::DynamicImage::ImageRgba8(sheet) - .write_to(&mut encoded, ImageFormat::Png) - .expect("sheet should encode"); - let image = DownloadedOpenAiImage { - bytes: encoded.into_inner(), - mime_type: "image/png".to_string(), - extension: "png".to_string(), - }; - - let slices = slice_match3d_material_sheet(&image, &item_names).expect("sheet should slice"); - - assert_eq!(slices.len(), 3); - for (row, views) in slices.iter().enumerate() { - assert_eq!(views.len(), MATCH3D_ITEM_VIEW_COUNT); - for (col, view) in views.iter().enumerate() { - let decoded = image::load_from_memory(view.bytes.as_slice()) - .expect("view should decode") - .to_rgba8(); - let pixel = decoded.get_pixel(decoded.width() / 2, decoded.height() / 2); - assert_eq!( - pixel.0, - [ - 32 + row as u8 * 40, - 24 + col as u8 * 36, - 210 - row as u8 * 30, - 255, - ], - "row {row} col {col} should be cut from the fixed 5*5 grid row" - ); - } - } - } - - #[test] - fn match3d_material_sheet_slicing_keeps_near_edge_foreground_pixels() { - 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 { - for x in 18..82 { - sheet.put_pixel(x, y, image::Rgba([20, 80, 240, 255])); - } - } - for y in 5..96 { - for x in 18..82 { - sheet.put_pixel(x, y, image::Rgba([220, 32, 48, 255])); - } - } - for y in 96..99 { - for x in 18..82 { - sheet.put_pixel(x, y, image::Rgba([20, 180, 64, 255])); - } - } - let mut encoded = std::io::Cursor::new(Vec::new()); - image::DynamicImage::ImageRgba8(sheet) - .write_to(&mut encoded, ImageFormat::Png) - .expect("sheet should encode"); - let image = DownloadedOpenAiImage { - bytes: encoded.into_inner(), - mime_type: "image/png".to_string(), - extension: "png".to_string(), - }; - - let slices = slice_match3d_material_sheet(&image, &item_names).expect("sheet should slice"); - let decoded = image::load_from_memory(slices[0][0].bytes.as_slice()) - .expect("view should decode") - .to_rgba8(); - - let pixels = decoded.pixels().map(|pixel| pixel.0).collect::>(); - assert!( - pixels.iter().any(|pixel| *pixel == [20, 80, 240, 255]), - "贴近顶部的前景像素不能被固定内缩切掉" - ); - assert!( - pixels.iter().any(|pixel| *pixel == [20, 180, 64, 255]), - "贴近底部的前景像素不能被固定内缩切掉" - ); - } - - #[test] - fn match3d_material_sheet_slicing_makes_green_screen_transparent_before_crop() { - let width = 500; - let height = 500; - let item_names = vec!["草莓".to_string()]; - let mut sheet = image::RgbaImage::from_pixel(width, height, image::Rgba([0, 255, 0, 255])); - for y in 35..65 { - for x in 35..65 { - sheet.put_pixel(x, y, image::Rgba([220, 32, 48, 255])); - } - } - - let mut encoded = std::io::Cursor::new(Vec::new()); - image::DynamicImage::ImageRgba8(sheet) - .write_to(&mut encoded, ImageFormat::Png) - .expect("sheet should encode"); - let image = DownloadedOpenAiImage { - bytes: encoded.into_inner(), - mime_type: "image/png".to_string(), - extension: "png".to_string(), - }; - - let slices = slice_match3d_material_sheet(&image, &item_names).expect("sheet should slice"); - let decoded = image::load_from_memory(slices[0][0].bytes.as_slice()) - .expect("view should decode") - .to_rgba8(); - - assert!( - decoded.pixels().all(|pixel| { - let [red, green, blue, alpha] = pixel.0; - alpha == 0 || !(green > red.saturating_add(32) && green > blue.saturating_add(32)) - }), - "绿幕背景必须在切割输出中变成透明或被单素材二次裁边移除" - ); - assert!( - decoded.pixels().any(|pixel| pixel.0 == [220, 32, 48, 255]), - "物品主体不能被绿幕去背误删" - ); - } - - #[test] - fn match3d_material_sheet_slicing_removes_isolated_green_cell_background() { - let width = 500; - let height = 500; - let item_names = vec!["葡萄".to_string()]; - let mut sheet = - image::RgbaImage::from_pixel(width, height, image::Rgba([245, 245, 245, 255])); - for y in 8..92 { - for x in 8..92 { - sheet.put_pixel(x, y, image::Rgba([0, 236, 18, 255])); - } - } - for y in 35..65 { - for x in 35..65 { - sheet.put_pixel(x, y, image::Rgba([136, 64, 210, 255])); - } - } - - let mut encoded = std::io::Cursor::new(Vec::new()); - image::DynamicImage::ImageRgba8(sheet) - .write_to(&mut encoded, ImageFormat::Png) - .expect("sheet should encode"); - let image = DownloadedOpenAiImage { - bytes: encoded.into_inner(), - mime_type: "image/png".to_string(), - extension: "png".to_string(), - }; - - let slices = slice_match3d_material_sheet(&image, &item_names).expect("sheet should slice"); - let decoded = image::load_from_memory(slices[0][0].bytes.as_slice()) - .expect("view should decode") - .to_rgba8(); - - assert!( - decoded - .pixels() - .all(|pixel| pixel.0[3] == 0 || pixel.0[1] < 180), - "没有连到整张 sheet 外边缘的绿幕块也必须被转成透明" - ); - assert!( - decoded.pixels().any(|pixel| pixel.0 == [136, 64, 210, 255]), - "绿幕清理不能误删物品主体" - ); - } - - #[test] - fn match3d_material_sheet_slicing_removes_soft_green_matte_before_crop() { - let width = 500; - let height = 500; - let item_names = vec!["草莓".to_string()]; - let mut sheet = image::RgbaImage::from_pixel(width, height, image::Rgba([0, 255, 0, 255])); - for y in 28..72 { - for x in 28..72 { - sheet.put_pixel(x, y, image::Rgba([64, 198, 112, 255])); - } - } - for y in 36..64 { - for x in 36..64 { - sheet.put_pixel(x, y, image::Rgba([220, 32, 48, 255])); - } - } - - let mut encoded = std::io::Cursor::new(Vec::new()); - image::DynamicImage::ImageRgba8(sheet) - .write_to(&mut encoded, ImageFormat::Png) - .expect("sheet should encode"); - let image = DownloadedOpenAiImage { - bytes: encoded.into_inner(), - mime_type: "image/png".to_string(), - extension: "png".to_string(), - }; - - let slices = slice_match3d_material_sheet(&image, &item_names).expect("sheet should slice"); - let decoded = image::load_from_memory(slices[0][0].bytes.as_slice()) - .expect("view should decode") - .to_rgba8(); - - assert!( - decoded.pixels().all(|pixel| { - let [red, green, blue, alpha] = pixel.0; - alpha == 0 || green <= red.max(blue).saturating_add(32) - }), - "整张 sheet 去绿后再裁剪,输出 PNG 不能保留可见软绿边" - ); - assert!( - decoded.pixels().any(|pixel| pixel.0 == [220, 32, 48, 255]), - "软绿边清理不能误删物品主体" - ); - } - - #[test] - fn match3d_material_sheet_slicing_crops_single_view_green_antialias_border() { - let width = 500; - let height = 500; - let item_names = vec!["丸子".to_string()]; - let mut sheet = image::RgbaImage::from_pixel(width, height, image::Rgba([0, 255, 0, 255])); - for y in 22..78 { - for x in 22..78 { - if x <= 24 || x >= 75 || y <= 24 || y >= 75 { - sheet.put_pixel(x, y, image::Rgba([168, 246, 176, 255])); - } - } - } - for y in 40..60 { - for x in 40..60 { - sheet.put_pixel(x, y, image::Rgba([174, 92, 72, 255])); - } - } - - let mut encoded = std::io::Cursor::new(Vec::new()); - image::DynamicImage::ImageRgba8(sheet) - .write_to(&mut encoded, ImageFormat::Png) - .expect("sheet should encode"); - let image = DownloadedOpenAiImage { - bytes: encoded.into_inner(), - mime_type: "image/png".to_string(), - extension: "png".to_string(), - }; - - let slices = slice_match3d_material_sheet(&image, &item_names).expect("sheet should slice"); - let decoded = image::load_from_memory(slices[0][0].bytes.as_slice()) - .expect("view should decode") - .to_rgba8(); - - assert!( - decoded.width() <= 24 && decoded.height() <= 24, - "单素材裁剪后必须再吃掉浅绿抗锯齿边,不能把素材自带绿边算进输出尺寸;got {}x{}", - decoded.width(), - decoded.height() - ); - assert!( - decoded - .pixels() - .all(|pixel| pixel.0[3] == 0 || pixel.0 != [168, 246, 176, 255]), - "单素材输出 PNG 不能保留浅绿抗锯齿边像素" - ); - assert!( - decoded.pixels().any(|pixel| pixel.0 == [174, 92, 72, 255]), - "单素材二次裁边不能误删物品主体" - ); - } - - #[test] - fn match3d_material_view_edge_matte_removes_green_border_touching_png_edge() { - let width = 72; - let height = 72; - let mut view = - image::RgbaImage::from_pixel(width, height, image::Rgba([168, 246, 176, 255])); - for y in 10..62 { - for x in 10..62 { - view.put_pixel(x, y, image::Rgba([0, 0, 0, 0])); - } - } - for y in 24..48 { - for x in 24..48 { - view.put_pixel(x, y, image::Rgba([174, 92, 72, 255])); - } - } - - let cleaned = - crop_match3d_material_view_edge_matte(image::DynamicImage::ImageRgba8(view)).to_rgba8(); - - assert!( - cleaned.width() <= 28 && cleaned.height() <= 28, - "单图外缘浅绿框即使贴住 PNG 边界,也必须被透明化并从可见边界中移除;got {}x{}", - cleaned.width(), - cleaned.height() - ); - assert!( - cleaned - .pixels() - .all(|pixel| pixel.0[3] == 0 || pixel.0 != [168, 246, 176, 255]), - "单图外缘浅绿框不能残留为可见像素" - ); - assert!( - cleaned.pixels().any(|pixel| pixel.0 == [174, 92, 72, 255]), - "扩大边缘清理宽度不能误删物品主体" - ); - } - - #[test] - fn match3d_material_view_edge_matte_neutralizes_dark_green_contour_pixels() { - let width = 64; - let height = 64; - let mut view = image::RgbaImage::from_pixel(width, height, image::Rgba([0, 0, 0, 0])); - for y in 16..48 { - for x in 16..48 { - if x <= 18 || x >= 45 || y <= 18 || y >= 45 { - view.put_pixel(x, y, image::Rgba([42, 118, 36, 255])); - } else { - view.put_pixel(x, y, image::Rgba([174, 92, 72, 255])); - } - } - } - - let cleaned = - crop_match3d_material_view_edge_matte(image::DynamicImage::ImageRgba8(view)).to_rgba8(); - - assert!( - cleaned.pixels().all(|pixel| { - let [red, green, blue, alpha] = pixel.0; - alpha == 0 || green <= red.max(blue).saturating_add(18) - }), - "暗绿轮廓污染也必须被透明化或去绿,不能残留可见绿边" - ); - assert!( - cleaned.pixels().any(|pixel| pixel.0 == [174, 92, 72, 255]), - "暗绿轮廓清理不能误删物品主体" - ); - } - - #[test] - fn match3d_material_sheet_slicing_cleans_white_matte_edge() { - let width = 500; - let height = 500; - let item_names = vec!["羽毛".to_string()]; - let mut sheet = image::RgbaImage::from_pixel(width, height, image::Rgba([0, 255, 0, 255])); - for y in 32..68 { - for x in 32..68 { - sheet.put_pixel(x, y, image::Rgba([248, 248, 244, 255])); - } - } - for y in 36..64 { - for x in 36..64 { - sheet.put_pixel(x, y, image::Rgba([225, 174, 58, 255])); - } - } - - let mut encoded = std::io::Cursor::new(Vec::new()); - image::DynamicImage::ImageRgba8(sheet) - .write_to(&mut encoded, ImageFormat::Png) - .expect("sheet should encode"); - let image = DownloadedOpenAiImage { - bytes: encoded.into_inner(), - mime_type: "image/png".to_string(), - extension: "png".to_string(), - }; - - let slices = slice_match3d_material_sheet(&image, &item_names).expect("sheet should slice"); - let decoded = image::load_from_memory(slices[0][0].bytes.as_slice()) - .expect("view should decode") - .to_rgba8(); - - assert!( - decoded.pixels().all(|pixel| { - let [red, green, blue, alpha] = pixel.0; - alpha == 0 || !(red >= 238 && green >= 238 && blue >= 232) - }), - "近白抠图边必须被清成透明或去污染,不能在输出 PNG 中形成白边" - ); - assert!( - decoded.pixels().any(|pixel| pixel.0 == [225, 174, 58, 255]), - "白边清理不能误删物品主体" - ); - } - - #[test] - fn match3d_container_image_postprocess_removes_plain_background() { - let width = 256; - let height = 256; - let mut image = - image::RgbaImage::from_pixel(width, height, image::Rgba([248, 248, 246, 255])); - for y in 68..190 { - for x in 38..218 { - image.put_pixel(x, y, image::Rgba([160, 104, 54, 255])); - } - } - - let mut encoded = std::io::Cursor::new(Vec::new()); - image::DynamicImage::ImageRgba8(image) - .write_to(&mut encoded, ImageFormat::Png) - .expect("container should encode"); - let processed = make_match3d_container_image_transparent(DownloadedOpenAiImage { - bytes: encoded.into_inner(), - mime_type: "image/png".to_string(), - extension: "png".to_string(), - }) - .expect("container should postprocess"); - let decoded = image::load_from_memory(processed.bytes.as_slice()) - .expect("processed container 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, - "容器图四周白底必须在入库前转成透明 alpha" - ); - assert_eq!( - decoded.get_pixel(width / 2, height / 2).0[3], - 255, - "容器主体不能被透明化误删" - ); - } - - #[test] - fn match3d_background_image_postprocess_removes_transparent_pixels() { - let width = 16; - let height = 16; - let mut image = - image::RgbaImage::from_pixel(width, height, image::Rgba([80, 140, 190, 255])); - image.put_pixel(0, 0, image::Rgba([0, 0, 0, 0])); - image.put_pixel(8, 8, image::Rgba([240, 120, 40, 128])); - - let mut encoded = std::io::Cursor::new(Vec::new()); - image::DynamicImage::ImageRgba8(image) - .write_to(&mut encoded, ImageFormat::Png) - .expect("background should encode"); - let processed = make_match3d_background_image_opaque(DownloadedOpenAiImage { - bytes: encoded.into_inner(), - mime_type: "image/png".to_string(), - extension: "png".to_string(), - }) - .expect("background should postprocess"); - let decoded = image::load_from_memory(processed.bytes.as_slice()) - .expect("processed background should decode") - .to_rgba8(); - - assert_eq!(processed.mime_type, "image/png"); - assert_eq!(processed.extension, "png"); - assert!( - decoded.pixels().all(|pixel| pixel.0[3] == 255), - "抓大鹅 9:16 背景图入库前必须移除所有透明 alpha" - ); - assert_ne!( - decoded.get_pixel(0, 0).0, - [0, 0, 0, 0], - "原透明角落必须被合成到不透明背景色上" - ); - } - - #[test] - fn match3d_work_metadata_parses_gpt4o_json() { - let metadata = parse_match3d_work_metadata( - r#"{"gameName":"果园大鹅宴","summary":"在明亮果园里收集水果小物件,节奏轻快适合随手游玩。","tags":["水果","抓大鹅","经典消除","轻量休闲"],"backgroundMusic":{"title":"果园轻舞","style":"轻快, 休闲","prompt":"果园主题循环背景音乐"},"backgroundPrompt":"果园主题绿色果园竖屏纯背景图","items":[{"name":"草莓","soundPrompt":"草莓点击音效"},{"name":"苹果","soundPrompt":"苹果点击音效"},{"name":"香蕉","soundPrompt":"香蕉点击音效"}]}"#, - ) - .expect("metadata should parse"); - - assert_eq!(metadata.game_name, "果园大鹅宴"); - assert_eq!( - metadata.summary, - "在明亮果园里收集水果小物件,节奏轻快适合随手游玩。" - ); - assert_eq!( - metadata.tags, - vec!["水果", "抓大鹅", "经典消除", "轻量休闲", "2D素材", "收集"] - ); - } - - #[test] - fn match3d_work_metadata_fallback_keeps_empty_description_boundary() { - let metadata = fallback_match3d_work_metadata("水果"); - - assert_eq!(metadata.game_name, "水果抓大鹅"); - assert!(metadata.summary.contains("水果主题")); - assert!(metadata.tags.contains(&"水果".to_string())); - assert!(metadata.tags.contains(&"抓大鹅".to_string())); - } - - #[test] - fn match3d_draft_plan_parses_audio_prompts() { - let plan = parse_match3d_draft_plan( - r#"{"gameName":"果园大鹅宴","summary":"明亮果园里堆满水果小物,轻快收集感突出。","tags":["水果","抓大鹅"],"backgroundMusic":{"title":"果园轻舞","style":"轻快, 休闲","prompt":""},"backgroundPrompt":"果园主题抓大鹅竖屏纯背景,绿色渐变和明亮果园氛围","items":[{"name":"草莓","soundPrompt":"草莓点击消除的清脆音效"},{"name":"苹果","soundPrompt":"苹果落入托盘的弹跳音"},{"name":"香蕉","soundPrompt":"香蕉消除时的轻快提示音"}]}"#, - &config("水果", 3, 3), - ) - .expect("draft plan should parse"); - - assert_eq!(plan.metadata.game_name, "果园大鹅宴"); - assert_eq!( - plan.metadata.summary, - "明亮果园里堆满水果小物,轻快收集感突出。" - ); - assert!(plan.background_prompt.contains("纯背景")); - assert_eq!(plan.items[0].name, "草莓"); - assert_eq!(plan.items[0].item_size, MATCH3D_ITEM_SIZE_SMALL); - assert!(plan.items[0].sound_prompt.contains("草莓")); - } - - #[test] - fn match3d_draft_plan_parses_relative_item_sizes() { - let plan = parse_match3d_draft_plan( - r#"{"gameName":"果园大鹅宴","summary":"果园小物堆满浅盘,轻快明亮适合随手消除。","tags":["水果","抓大鹅"],"backgroundPrompt":"果园主题竖屏纯背景","items":[{"name":"西瓜","itemSize":"大","soundPrompt":""},{"name":"苹果","itemSize":"中","soundPrompt":""},{"name":"糖果","itemSize":"小","soundPrompt":""}]}"#, - &config("水果", 3, 3), - ) - .expect("draft plan should parse"); - - assert_eq!(plan.items[0].item_size, MATCH3D_ITEM_SIZE_LARGE); - assert_eq!(plan.items[1].item_size, MATCH3D_ITEM_SIZE_MEDIUM); - assert_eq!(plan.items[2].item_size, MATCH3D_ITEM_SIZE_SMALL); - } - - #[test] - fn match3d_legacy_item_asset_without_size_defaults_to_large() { - let assets = parse_match3d_generated_item_assets(Some( - r#"[{"itemId":"match3d-item-1","itemName":"草莓","status":"image_ready"}]"#, - )); - let asset = Match3DGeneratedItemAsset::from(assets[0].clone()); - - assert_eq!(asset.item_size.as_deref(), Some(MATCH3D_ITEM_SIZE_LARGE)); - } - - #[test] - 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(), 10); - assert_eq!(plan.items[8].name, "蓝莓"); - assert_ne!(plan.items[9].name, "蓝莓"); - } - - #[test] - fn match3d_generated_item_count_rounds_up_to_five_multiples() { - assert_eq!( - resolve_match3d_generated_item_count(&config("水果", 8, 2)), - 5 - ); - assert_eq!( - resolve_match3d_generated_item_count(&config("水果", 12, 4)), - 10 - ); - assert_eq!( - resolve_match3d_generated_item_count(&config("水果", 16, 6)), - 15 - ); - assert_eq!( - resolve_match3d_generated_item_count(&config("水果", 21, 8)), - 25 - ); - } - - #[test] - fn match3d_generated_assets_require_only_images_when_click_sound_is_closed() { - let assets = vec![test_match3d_generated_item_asset(1, "草莓")]; - - assert!(has_match3d_required_generated_assets( - &assets, - 1, - &config("水果", 3, 3) - )); - } - - #[test] - 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(5), 2); - assert_eq!(calculate_match3d_item_assets_points_cost(6), 4); - assert_eq!(calculate_match3d_item_assets_points_cost(10), 4); - } - - #[test] - fn match3d_item_asset_append_plan_pads_generation_without_persisting_padding() { - let existing_assets = vec![Match3DGeneratedItemAsset { - item_id: "match3d-item-1".to_string(), - item_name: "草莓".to_string(), - item_size: Some(MATCH3D_ITEM_SIZE_SMALL.to_string()), - image_src: None, - image_object_key: None, - image_views: Vec::new(), - 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, - }]; - - let plan = build_match3d_item_asset_append_plan( - vec![ - "草莓".to_string(), - "苹果".to_string(), - "香蕉".to_string(), - "梨子".to_string(), - ], - &existing_assets, - ); - - assert_eq!(plan.requested_item_names, vec!["苹果", "香蕉", "梨子"]); - 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()), - 2 - ); - } - - #[test] - fn match3d_item_asset_append_plan_still_generates_full_sheet_when_capacity_is_low() { - let existing_assets = (1..MATCH3D_MAX_GENERATED_ITEM_COUNT) - .map(|index| Match3DGeneratedItemAsset { - item_id: format!("match3d-item-{index}"), - item_name: format!("已有物品{index}"), - item_size: Some(MATCH3D_ITEM_SIZE_LARGE.to_string()), - image_src: None, - image_object_key: None, - image_views: Vec::new(), - 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::>(); - - let plan = - build_match3d_item_asset_append_plan(vec!["新物品".to_string()], &existing_assets); - - assert_eq!(plan.requested_item_names, vec!["新物品"]); - assert_eq!( - plan.padded_item_names.len(), - MATCH3D_MATERIAL_ITEM_BATCH_SIZE - ); - assert_eq!(plan.padded_item_names[0], "新物品"); - } - - #[test] - fn match3d_item_asset_replace_plan_only_targets_existing_names() { - let existing_assets = vec![ - test_match3d_generated_item_asset(1, "草莓"), - test_match3d_generated_item_asset(2, "苹果"), - ]; - let plan = build_match3d_item_asset_replace_plan( - vec!["苹果".to_string(), "不存在".to_string(), "苹果".to_string()], - &existing_assets, - ); - - assert_eq!(plan.requested_item_names, vec!["苹果"]); - assert_eq!(plan.target_assets.len(), 1); - assert_eq!(plan.target_assets[0].item_id, "match3d-item-2"); - assert_eq!( - plan.padded_item_names.len(), - MATCH3D_MATERIAL_ITEM_BATCH_SIZE - ); - assert_eq!(plan.padded_item_names[0], "苹果"); - } - - #[test] - fn match3d_item_assets_generation_mode_defaults_to_append() { - assert!(matches!( - normalize_match3d_item_assets_generation_mode(None), - Match3DItemAssetsGenerationMode::Append - )); - assert!(matches!( - normalize_match3d_item_assets_generation_mode(Some("replace")), - Match3DItemAssetsGenerationMode::Replace - )); - assert!(matches!( - normalize_match3d_item_assets_generation_mode(Some("regenerate")), - Match3DItemAssetsGenerationMode::Replace - )); - } - - #[test] - fn match3d_regenerated_asset_keeps_stable_identity_and_side_assets() { - let mut current_asset = test_match3d_generated_item_asset(1, "草莓"); - current_asset.background_music_title = Some("果园轻舞".to_string()); - current_asset.background_asset = Some(Match3DGeneratedBackgroundAsset { - prompt: "果园背景".to_string(), - image_src: Some("/generated-match3d-assets/s/p/background/bg.png".to_string()), - image_object_key: None, - container_prompt: Some("果园容器".to_string()), - container_image_src: Some( - "/generated-match3d-assets/s/p/ui-container/container.png".to_string(), - ), - container_image_object_key: None, - status: "image_ready".to_string(), - error: None, - }); - let mut generated_asset = test_match3d_generated_item_asset(99, "新草莓"); - generated_asset.image_src = - Some("/generated-match3d-assets/s/p/items/new/views/view-01.png".to_string()); - generated_asset.model_src = None; - generated_asset.model_object_key = None; - - let merged = - merge_regenerated_match3d_item_asset(Some(current_asset.clone()), generated_asset); - - assert_eq!(merged.item_id, "match3d-item-1"); - assert_eq!(merged.item_name, "草莓"); - assert_eq!( - merged.image_src.as_deref(), - Some("/generated-match3d-assets/s/p/items/new/views/view-01.png") - ); - assert_eq!( - merged.model_src.as_deref(), - current_asset.model_src.as_deref() - ); - assert_eq!(merged.background_music_title.as_deref(), Some("果园轻舞")); - assert!(merged.background_asset.is_some()); - assert_eq!(merged.status, "image_ready"); - } - - #[test] - 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("5行*5列")); - assert!(prompt.contains("严格5*5均匀排布")); - assert!(prompt.contains("绿幕背景")); - assert!(prompt.contains("#00FF00")); - assert!(prompt.contains("单个素材格宽度的1/4空白间距")); - assert!(prompt.contains("约25%单格宽度")); - assert!(prompt.contains("禁止主体跨格")); - assert!(prompt.contains("贴边或越界")); - } - - #[test] - fn match3d_material_sheet_prompt_hardens_pixel_retro_style() { - let mut config = config("水果", 12, 4); - config.asset_style_id = Some("pixel-retro".to_string()); - config.asset_style_label = Some("像素复古".to_string()); - let prompt = build_match3d_material_sheet_prompt(&config, &["草莓".to_string()]); - let negative_prompt = build_match3d_material_sheet_negative_prompt(&config); - - 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_material_sheet_request_uses_vector_engine_gemini_contract() { - let body = build_match3d_vector_engine_gemini_image_request_body( - "生成水果素材图", - "文字、水印", - MATCH3D_MATERIAL_VECTOR_ENGINE_GEMINI_ASPECT_RATIO, - ); - - assert_eq!(body["generationConfig"]["responseModalities"][0], "TEXT"); - assert_eq!(body["generationConfig"]["responseModalities"][1], "IMAGE"); - assert_eq!( - body["generationConfig"]["imageConfig"]["aspectRatio"], - MATCH3D_MATERIAL_VECTOR_ENGINE_GEMINI_ASPECT_RATIO - ); - assert!(body.get("model").is_none()); - assert!(body.get("n").is_none()); - assert!(body.get("official_fallback").is_none()); - assert!(body.get("image").is_none()); - assert!(body.get("image_urls").is_none()); - assert!( - body["contents"][0]["parts"][0]["text"] - .as_str() - .unwrap_or_default() - .contains("文字、水印") - ); - } - - #[test] - fn match3d_extracts_vector_engine_gemini_inline_image_data() { - let payload = json!({ - "candidates": [{ - "content": { - "parts": [ - { "text": "已生成" }, - { - "inlineData": { - "mimeType": "image/png", - "data": "iVBORw0KGgo=" - } - }, - { - "inline_data": { - "mime_type": "image/webp", - "data": "UklGRg==" - } - }, - { - "inlineData": { - "mimeType": "text/plain", - "data": "not-image-data" - } - }, - { - "data": "not-inline-image-data" - } - ] - } - }] - }); - - assert_eq!( - extract_match3d_b64_images(&payload), - vec!["iVBORw0KGgo=", "UklGRg=="] - ); - } - - #[test] - fn match3d_vector_engine_gemini_url_accepts_root_or_v1_base() { - let root_settings = Match3DVectorEngineGeminiImageSettings { - base_url: "https://api.vectorengine.cn".to_string(), - api_key: "test-key".to_string(), - request_timeout_ms: 1_000_000, - }; - let v1_settings = Match3DVectorEngineGeminiImageSettings { - base_url: "https://api.vectorengine.cn/v1".to_string(), - api_key: "test-key".to_string(), - request_timeout_ms: 1_000_000, - }; - - assert_eq!( - build_match3d_vector_engine_gemini_generate_content_url(&root_settings), - "https://api.vectorengine.cn/v1beta/models/gemini-3-pro-image-preview:generateContent" - ); - assert_eq!( - build_match3d_vector_engine_gemini_generate_content_url(&v1_settings), - "https://api.vectorengine.cn/v1beta/models/gemini-3-pro-image-preview:generateContent" - ); - } - - #[test] - fn match3d_background_and_container_prompts_keep_ui_layers_split() { - let config = config("水果", 3, 3); - let background_prompt = - build_match3d_background_generation_prompt(&config, "果园绿色竖屏纯背景"); - let container_prompt = - build_match3d_container_generation_prompt(&config, "果园绿色竖屏纯背景"); - - assert!(background_prompt.contains("9:16")); - assert!(background_prompt.contains("纯背景图")); - assert!(background_prompt.contains("不得出现锅")); - assert!(background_prompt.contains("拼图槽")); - assert!(background_prompt.contains("物品槽")); - assert!(background_prompt.contains("全画幅不透明")); - assert!(background_prompt.contains("透明 alpha")); - assert!(background_prompt.contains("默认交互容器")); - - assert!(container_prompt.contains("1:1")); - assert!(container_prompt.contains("中心容器 UI 图")); - assert!(container_prompt.contains("贴合题材设定")); - assert!(container_prompt.contains("占画布宽度约 86%-92%")); - assert!(container_prompt.contains("轻俯视 3/4")); - assert!(container_prompt.contains("横向椭圆形内口")); - assert!(container_prompt.contains("不能画成正俯视扁圆盘")); - assert!(container_prompt.contains("透明 alpha")); - assert!(container_prompt.contains("白底")); - assert!(container_prompt.contains("整页背景")); - assert!(container_prompt.contains("禁止文字")); - } - - #[test] - fn match3d_background_asset_requires_background_and_container_images() { - let background_only = Match3DGeneratedBackgroundAsset { - prompt: "果园背景".to_string(), - image_src: Some( - "/generated-match3d-assets/session/profile/background/bg.png".to_string(), - ), - image_object_key: None, - container_prompt: None, - container_image_src: None, - container_image_object_key: None, - status: "image_ready".to_string(), - error: None, - }; - let with_container = Match3DGeneratedBackgroundAsset { - container_prompt: Some("果园容器".to_string()), - container_image_src: Some( - "/generated-match3d-assets/session/profile/ui-container/container.png".to_string(), - ), - ..background_only.clone() - }; - - assert!(!is_match3d_background_asset_ready(&background_only)); - assert!(is_match3d_background_asset_ready(&with_container)); - } - - #[test] - fn match3d_default_cover_prefers_generated_container_ui_image() { - let assets = vec![Match3DGeneratedItemAsset { - item_id: "match3d-item-1".to_string(), - item_name: "草莓".to_string(), - item_size: Some(MATCH3D_ITEM_SIZE_SMALL.to_string()), - image_src: None, - image_object_key: None, - image_views: Vec::new(), - 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: Some(Match3DGeneratedBackgroundAsset { - prompt: "果园背景".to_string(), - image_src: Some( - "/generated-match3d-assets/session/profile/background/background.png" - .to_string(), - ), - image_object_key: None, - container_prompt: Some("果园容器".to_string()), - container_image_src: Some( - "/generated-match3d-assets/session/profile/ui-container/container.png" - .to_string(), - ), - container_image_object_key: None, - status: "image_ready".to_string(), - error: None, - }), - status: "image_ready".to_string(), - error: None, - }]; - - assert_eq!( - resolve_match3d_default_cover_image_src(&assets).as_deref(), - Some("/generated-match3d-assets/session/profile/ui-container/container.png") - ); - } - - #[test] - fn match3d_cover_reference_sources_are_deduped_and_limited() { - let sources = collect_match3d_cover_reference_image_sources( - Some("/generated-match3d-assets/a.png".to_string()), - vec![ - "/generated-match3d-assets/a.png".to_string(), - "data:image/png;base64,b".to_string(), - "/generated-match3d-assets/c.png".to_string(), - "/generated-match3d-assets/d.png".to_string(), - "/generated-match3d-assets/e.png".to_string(), - "/generated-match3d-assets/f.png".to_string(), - "/generated-match3d-assets/g.png".to_string(), - ], - ); - - assert_eq!(sources.len(), 6); - assert_eq!(sources[0], "/generated-match3d-assets/a.png"); - assert_eq!(sources[1], "data:image/png;base64,b"); - assert!(!sources.contains(&"/generated-match3d-assets/g.png".to_string())); - } - - #[test] - fn match3d_public_reference_image_paths_are_limited_to_known_assets() { - assert_eq!( - normalize_match3d_public_reference_image_path( - "/match3d-background-references/pot-fused-reference.png?cache=1" - ) - .as_deref(), - Some("public/match3d-background-references/pot-fused-reference.png") - ); - assert!(normalize_match3d_public_reference_image_path("/icons/logo.png").is_none()); - assert!( - normalize_match3d_public_reference_image_path( - "/match3d-background-references/../secret.png" - ) - .is_none() - ); - } - - #[test] - fn match3d_cover_reference_prompt_marks_reference_images() { - let prompt = build_match3d_cover_reference_generation_prompt("水果封面", true); - - assert!(prompt.contains("一张或多张图片")); - assert!(prompt.contains("不要拼贴成素材墙")); - assert!(prompt.contains("水果封面")); - } - - #[test] - fn match3d_cover_edit_prompt_preserves_uploaded_image() { - let prompt = build_match3d_cover_edit_prompt("水果封面"); - - assert!(prompt.contains("上传的封面图作为第一优先级")); - assert!(prompt.contains("保留主图的主体、构图、视角和主要配色")); - } - - #[test] - fn match3d_fallback_work_profile_keeps_generated_background_asset() { - let assets = vec![Match3DGeneratedItemAsset { - item_id: "match3d-item-1".to_string(), - item_name: "草莓".to_string(), - item_size: Some(MATCH3D_ITEM_SIZE_SMALL.to_string()), - image_src: None, - image_object_key: None, - image_views: Vec::new(), - 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: Some(Match3DGeneratedBackgroundAsset { - prompt: "果园背景".to_string(), - image_src: Some( - "/generated-match3d-assets/session/profile/background/background.png" - .to_string(), - ), - image_object_key: Some( - "generated-match3d-assets/session/profile/background/background.png" - .to_string(), - ), - container_prompt: Some("果园容器".to_string()), - container_image_src: Some( - "/generated-match3d-assets/session/profile/ui-container/container.png" - .to_string(), - ), - container_image_object_key: Some( - "generated-match3d-assets/session/profile/ui-container/container.png" - .to_string(), - ), - status: "image_ready".to_string(), - error: None, - }), - status: "image_ready".to_string(), - error: None, - }]; - - let profile = build_match3d_work_profile_record_with_assets( - Match3DWorkProfileRecord { - work_id: "match3d-profile-1".to_string(), - profile_id: "match3d-profile-1".to_string(), - owner_user_id: "user-1".to_string(), - source_session_id: Some("match3d-session-1".to_string()), - author_display_name: "玩家".to_string(), - game_name: "水果抓大鹅".to_string(), - theme_text: "水果".to_string(), - summary: "水果主题".to_string(), - tags: vec!["水果".to_string()], - cover_image_src: None, - cover_asset_id: None, - reference_image_src: None, - clear_count: 3, - difficulty: 3, - publication_status: "draft".to_string(), - play_count: 0, - updated_at: "2026-05-14T00:00:00Z".to_string(), - published_at: None, - publish_ready: false, - generated_item_assets_json: None, - }, - &assets, - ); - let response = map_match3d_work_summary_response(profile); - - assert_eq!( - response.background_image_src.as_deref(), - Some("/generated-match3d-assets/session/profile/background/background.png") - ); - assert_eq!( - response.cover_image_src.as_deref(), - Some("/generated-match3d-assets/session/profile/ui-container/container.png") - ); - assert_eq!(response.generated_item_assets.len(), 1); - assert_eq!( - response - .generated_background_asset - .as_ref() - .and_then(|asset| asset.container_image_src.as_deref()), - Some("/generated-match3d-assets/session/profile/ui-container/container.png") - ); - } - - #[test] - fn match3d_agent_session_response_hydrates_persisted_ui_assets() { - let session = Match3DAgentSessionRecord { - session_id: "match3d-session-1".to_string(), - current_turn: 3, - progress_percent: 100, - stage: "DraftCompiled".to_string(), - anchor_pack: Match3DAnchorPackRecord { - theme: Match3DAnchorItemRecord { - key: "theme".to_string(), - label: "题材主题".to_string(), - value: "水果".to_string(), - status: "confirmed".to_string(), - }, - clear_count: Match3DAnchorItemRecord { - key: "clearCount".to_string(), - label: "消除次数".to_string(), - value: "12".to_string(), - status: "confirmed".to_string(), - }, - difficulty: Match3DAnchorItemRecord { - key: "difficulty".to_string(), - label: "难度".to_string(), - value: "4".to_string(), - status: "confirmed".to_string(), - }, - }, - config: None, - draft: Some(Match3DResultDraftRecord { - profile_id: "match3d-profile-1".to_string(), - game_name: "水果抓大鹅".to_string(), - theme_text: "水果".to_string(), - summary_text: "水果主题".to_string(), - tags: vec!["水果".to_string(), "抓大鹅".to_string()], - cover_image_src: None, - reference_image_src: None, - clear_count: 12, - difficulty: 4, - total_item_count: 36, - publish_ready: false, - blockers: Vec::new(), - generated_item_assets_json: None, - }), - messages: Vec::new(), - last_assistant_reply: None, - published_profile_id: None, - updated_at: "2026-05-15T00:00:00.000Z".to_string(), - }; - let assets = vec![Match3DGeneratedItemAsset { - item_id: "match3d-item-1".to_string(), - item_name: "草莓".to_string(), - item_size: Some(MATCH3D_ITEM_SIZE_SMALL.to_string()), - image_src: Some( - "/generated-match3d-assets/session/profile/items/strawberry/view-01.png" - .to_string(), - ), - image_object_key: Some( - "generated-match3d-assets/session/profile/items/strawberry/view-01.png".to_string(), - ), - image_views: Vec::new(), - 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: Some(Match3DGeneratedBackgroundAsset { - prompt: "果园背景".to_string(), - image_src: Some( - "/generated-match3d-assets/session/profile/background/background.png" - .to_string(), - ), - image_object_key: Some( - "generated-match3d-assets/session/profile/background/background.png" - .to_string(), - ), - container_prompt: Some("果园容器".to_string()), - container_image_src: Some( - "/generated-match3d-assets/session/profile/ui-container/container.png" - .to_string(), - ), - container_image_object_key: Some( - "generated-match3d-assets/session/profile/ui-container/container.png" - .to_string(), - ), - status: "image_ready".to_string(), - error: None, - }), - status: "image_ready".to_string(), - error: None, - }]; - - let response = map_match3d_agent_session_response_with_assets(session, &assets); - let draft = response.draft.expect("session draft should exist"); - - assert_eq!(draft.generated_item_assets.len(), 1); - assert_eq!(draft.background_prompt.as_deref(), Some("果园背景")); - assert_eq!( - draft.background_image_src.as_deref(), - Some("/generated-match3d-assets/session/profile/background/background.png") - ); - assert_eq!( - draft.cover_image_src.as_deref(), - Some("/generated-match3d-assets/session/profile/ui-container/container.png") - ); - assert_eq!( - draft - .generated_background_asset - .as_ref() - .and_then(|asset| asset.container_image_src.as_deref()), - Some("/generated-match3d-assets/session/profile/ui-container/container.png") - ); - assert_eq!( - draft.generated_item_assets[0] - .background_asset - .as_ref() - .and_then(|asset| asset.image_src.as_deref()), - Some("/generated-match3d-assets/session/profile/background/background.png") - ); - } - - #[test] - fn match3d_agent_session_response_keeps_draft_ui_assets_without_work_detail_hydration() { - let assets = vec![Match3DGeneratedItemAsset { - item_id: "match3d-item-1".to_string(), - item_name: "草莓".to_string(), - item_size: Some(MATCH3D_ITEM_SIZE_SMALL.to_string()), - image_src: Some( - "/generated-match3d-assets/session/profile/items/strawberry/view-01.png" - .to_string(), - ), - image_object_key: Some( - "generated-match3d-assets/session/profile/items/strawberry/view-01.png".to_string(), - ), - image_views: Vec::new(), - 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: Some(Match3DGeneratedBackgroundAsset { - prompt: "果园背景".to_string(), - image_src: Some( - "/generated-match3d-assets/session/profile/background/background.png" - .to_string(), - ), - image_object_key: Some( - "generated-match3d-assets/session/profile/background/background.png" - .to_string(), - ), - container_prompt: Some("果园容器".to_string()), - container_image_src: Some( - "/generated-match3d-assets/session/profile/ui-container/container.png" - .to_string(), - ), - container_image_object_key: Some( - "generated-match3d-assets/session/profile/ui-container/container.png" - .to_string(), - ), - status: "image_ready".to_string(), - error: None, - }), - status: "image_ready".to_string(), - error: None, - }]; - let session = Match3DAgentSessionRecord { - session_id: "match3d-session-1".to_string(), - current_turn: 3, - progress_percent: 100, - stage: "DraftCompiled".to_string(), - anchor_pack: Match3DAnchorPackRecord { - theme: Match3DAnchorItemRecord { - key: "theme".to_string(), - label: "题材主题".to_string(), - value: "水果".to_string(), - status: "confirmed".to_string(), - }, - clear_count: Match3DAnchorItemRecord { - key: "clearCount".to_string(), - label: "消除次数".to_string(), - value: "12".to_string(), - status: "confirmed".to_string(), - }, - difficulty: Match3DAnchorItemRecord { - key: "difficulty".to_string(), - label: "难度".to_string(), - value: "4".to_string(), - status: "confirmed".to_string(), - }, - }, - config: None, - draft: Some(Match3DResultDraftRecord { - profile_id: "match3d-profile-1".to_string(), - game_name: "水果抓大鹅".to_string(), - theme_text: "水果".to_string(), - summary_text: "水果主题".to_string(), - tags: vec!["水果".to_string(), "抓大鹅".to_string()], - cover_image_src: None, - reference_image_src: None, - clear_count: 12, - difficulty: 4, - total_item_count: 36, - publish_ready: false, - blockers: Vec::new(), - generated_item_assets_json: serialize_match3d_generated_item_assets(&assets), - }), - messages: Vec::new(), - last_assistant_reply: None, - published_profile_id: None, - updated_at: "2026-05-15T00:00:00.000Z".to_string(), - }; - - let response = map_match3d_agent_session_response_with_assets(session, &[]); - let draft = response.draft.expect("session draft should exist"); - - assert_eq!(draft.generated_item_assets.len(), 1); - assert_eq!( - draft.background_image_src.as_deref(), - Some("/generated-match3d-assets/session/profile/background/background.png") - ); - assert_eq!( - draft.background_image_object_key.as_deref(), - Some("generated-match3d-assets/session/profile/background/background.png") - ); - assert_eq!( - draft - .generated_background_asset - .as_ref() - .and_then(|asset| asset.container_image_src.as_deref()), - Some("/generated-match3d-assets/session/profile/ui-container/container.png") - ); - assert_eq!( - draft.generated_item_assets[0] - .background_asset - .as_ref() - .and_then(|asset| asset.image_src.as_deref()), - Some("/generated-match3d-assets/session/profile/background/background.png") - ); - } - - #[test] - fn match3d_tag_normalization_only_strips_numbered_list_prefix() { - assert_eq!(normalize_match3d_tag("3D素材"), "3D素材"); - assert_eq!(normalize_match3d_tag("1. 3D素材"), "3D素材"); - assert_eq!(normalize_match3d_tag("2、轻量休闲"), "轻量休闲"); - } - - #[test] - fn match3d_plan_tags_are_kept_before_local_fallback_tags() { - let tags = merge_match3d_plan_tags_with_fallback( - "果园大鹅宴", - "水果", - &["果园".to_string(), "轻快".to_string(), "抓大鹅".to_string()], - ); - - assert_eq!(tags[0], "果园"); - assert_eq!(tags[1], "轻快"); - assert_eq!(tags[2], "抓大鹅"); - assert!(tags.contains(&"水果".to_string())); - assert!(tags.contains(&"经典消除".to_string())); - } - - #[test] - fn match3d_model_download_metadata_normalizes_to_glb() { - assert_eq!( - normalize_match3d_model_file_name("https://example.com/Fruit Model.GLB?token=1"), - "fruit-model.glb" - ); - assert_eq!(normalize_match3d_model_file_name("模型文件"), "model.glb"); - assert_eq!( - normalize_match3d_model_content_type("application/octet-stream"), - "model/gltf-binary" - ); - assert_eq!( - normalize_match3d_model_content_type("model/gltf-binary; charset=utf-8"), - "model/gltf-binary" - ); - } - - #[test] - fn match3d_model_download_requires_valid_glb_header() { - let mut glb = Vec::new(); - glb.extend_from_slice(&0x4654_6c67_u32.to_le_bytes()); - glb.extend_from_slice(&2_u32.to_le_bytes()); - glb.extend_from_slice(&12_u32.to_le_bytes()); - - assert!(is_match3d_glb_binary_payload(&glb)); - assert!(!is_match3d_glb_binary_payload(b"expired")); - - let mut wrong_length = glb.clone(); - wrong_length[8..12].copy_from_slice(&16_u32.to_le_bytes()); - assert!(!is_match3d_glb_binary_payload(&wrong_length)); - } - - #[test] - fn match3d_generated_asset_resume_keeps_stable_item_order() { - let assets = normalize_match3d_generated_item_assets_for_resume(vec![ - Match3DGeneratedItemAsset { - item_id: "match3d-item-2".to_string(), - item_name: "苹果".to_string(), - item_size: Some(MATCH3D_ITEM_SIZE_MEDIUM.to_string()), - image_src: Some("/generated-match3d-assets/s/p/items/i2/image.png".to_string()), - image_object_key: Some( - "generated-match3d-assets/s/p/items/i2/image.png".to_string(), - ), - image_views: Vec::new(), - model_src: Some( - "/generated-match3d-assets/s/p/items/i2/model/model.glb".to_string(), - ), - model_object_key: Some( - "generated-match3d-assets/s/p/items/i2/model/model.glb".to_string(), - ), - model_file_name: Some("model.glb".to_string()), - task_uuid: Some("task-2".to_string()), - subscription_key: Some("sub-2".to_string()), - 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: "model_ready".to_string(), - error: None, - }, - Match3DGeneratedItemAsset { - item_id: "match3d-item-1".to_string(), - item_name: "草莓".to_string(), - item_size: Some(MATCH3D_ITEM_SIZE_SMALL.to_string()), - image_src: Some("/generated-match3d-assets/s/p/items/i1/image.png".to_string()), - image_object_key: Some( - "generated-match3d-assets/s/p/items/i1/image.png".to_string(), - ), - image_views: Vec::new(), - 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, - }, - ]); - - assert_eq!(assets[0].item_id, "match3d-item-1"); - assert_eq!(assets[1].item_id, "match3d-item-2"); - } - - #[test] - fn match3d_required_item_images_require_five_views() { - let assets = vec![ - Match3DGeneratedItemAsset { - item_id: "match3d-item-1".to_string(), - item_name: "草莓".to_string(), - item_size: Some(MATCH3D_ITEM_SIZE_SMALL.to_string()), - image_src: Some("/generated-match3d-assets/s/p/items/i1/image.png".to_string()), - image_object_key: Some( - "generated-match3d-assets/s/p/items/i1/image.png".to_string(), - ), - image_views: Vec::new(), - 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, - }, - Match3DGeneratedItemAsset { - item_id: "match3d-item-2".to_string(), - item_name: "苹果".to_string(), - item_size: Some(MATCH3D_ITEM_SIZE_MEDIUM.to_string()), - image_src: Some("/generated-match3d-assets/s/p/items/i2/image.png".to_string()), - image_object_key: Some( - "generated-match3d-assets/s/p/items/i2/image.png".to_string(), - ), - image_views: Vec::new(), - 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, - }, - Match3DGeneratedItemAsset { - item_id: "match3d-item-3".to_string(), - item_name: "香蕉".to_string(), - item_size: Some(MATCH3D_ITEM_SIZE_MEDIUM.to_string()), - image_src: Some("/generated-match3d-assets/s/p/items/i3/image.png".to_string()), - image_object_key: None, - image_views: Vec::new(), - 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, - }, - ]; - - 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::>(); - - assert!(has_match3d_required_item_images(&five_view_assets, 3)); - } - - #[test] - fn match3d_oss_config_error_lists_missing_env_keys() { - let mut app_config = AppConfig { - oss_bucket: Some("genarrative-assets".to_string()), - oss_endpoint: Some("oss-cn-shanghai.aliyuncs.com".to_string()), - ..AppConfig::default() - }; - - let missing = missing_match3d_oss_env_keys(&app_config); - assert_eq!( - missing, - vec!["ALIYUN_OSS_ACCESS_KEY_ID", "ALIYUN_OSS_ACCESS_KEY_SECRET"] - ); - assert_eq!( - match3d_oss_missing_reason(&missing), - "OSS 未完成环境变量配置,缺少:ALIYUN_OSS_ACCESS_KEY_ID, ALIYUN_OSS_ACCESS_KEY_SECRET" - ); - - app_config.oss_access_key_id = Some("ak".to_string()); - app_config.oss_access_key_secret = Some("sk".to_string()); - assert!(missing_match3d_oss_env_keys(&app_config).is_empty()); - } - - #[test] - fn match3d_work_summary_maps_persisted_generated_item_assets() { - let response = map_match3d_work_summary_response(Match3DWorkProfileRecord { - work_id: "match3d-profile-1".to_string(), - profile_id: "match3d-profile-1".to_string(), - owner_user_id: "user-1".to_string(), - source_session_id: Some("match3d-session-1".to_string()), - author_display_name: "玩家".to_string(), - game_name: "水果抓大鹅".to_string(), - theme_text: "水果".to_string(), - summary: "水果主题".to_string(), - tags: vec!["水果".to_string()], - cover_image_src: None, - cover_asset_id: None, - reference_image_src: None, - clear_count: 3, - difficulty: 3, - publication_status: "draft".to_string(), - play_count: 0, - updated_at: "2026-05-10T00:00:00.000Z".to_string(), - published_at: None, - publish_ready: false, - generated_item_assets_json: Some( - r#"[{"itemId":"match3d-item-1","itemName":"草莓","imageSrc":"/generated-match3d-assets/session/profile/items/match3d-item-1-item/image.png","imageObjectKey":"generated-match3d-assets/session/profile/items/match3d-item-1-item/image.png","status":"image_ready"}]"# - .to_string(), - ), - }); - - assert_eq!(response.generated_item_assets.len(), 1); - assert_eq!(response.generated_item_assets[0].item_name, "草莓"); - assert_eq!(response.generated_item_assets[0].status, "image_ready"); - assert_eq!(response.generation_status.as_deref(), Some("generating")); - assert_eq!( - response.generated_item_assets[0].image_src.as_deref(), - Some("/generated-match3d-assets/session/profile/items/match3d-item-1-item/image.png") - ); - } - - #[test] - fn match3d_work_summary_marks_complete_generated_assets_ready() { - let assets = vec![Match3DGeneratedItemAsset { - background_asset: Some(Match3DGeneratedBackgroundAsset { - prompt: "水果厨房背景".to_string(), - image_src: Some( - "/generated-match3d-assets/session/profile/background.png".to_string(), - ), - image_object_key: Some( - "generated-match3d-assets/session/profile/background.png".to_string(), - ), - container_prompt: None, - container_image_src: Some( - "/generated-match3d-assets/session/profile/container.png".to_string(), - ), - container_image_object_key: Some( - "generated-match3d-assets/session/profile/container.png".to_string(), - ), - status: "image_ready".to_string(), - error: None, - }), - ..test_match3d_generated_item_asset(1, "草莓") - }]; - let response = map_match3d_work_summary_response(Match3DWorkProfileRecord { - work_id: "match3d-profile-1".to_string(), - profile_id: "match3d-profile-1".to_string(), - owner_user_id: "user-1".to_string(), - source_session_id: Some("match3d-session-1".to_string()), - author_display_name: "玩家".to_string(), - game_name: "水果抓大鹅".to_string(), - theme_text: "水果".to_string(), - summary: "水果主题".to_string(), - tags: vec!["水果".to_string()], - cover_image_src: None, - cover_asset_id: None, - reference_image_src: None, - clear_count: 3, - difficulty: 3, - publication_status: "draft".to_string(), - play_count: 0, - updated_at: "2026-05-10T00:00:00.000Z".to_string(), - published_at: None, - publish_ready: false, - generated_item_assets_json: serialize_match3d_generated_item_assets(&assets), - }); - - assert_eq!(response.generation_status.as_deref(), Some("ready")); - } -} +mod tests; diff --git a/server-rs/crates/api-server/src/match3d/draft.rs b/server-rs/crates/api-server/src/match3d/draft.rs new file mode 100644 index 00000000..f4855b69 --- /dev/null +++ b/server-rs/crates/api-server/src/match3d/draft.rs @@ -0,0 +1,881 @@ +use super::*; + +pub(super) async fn submit_and_finalize_match3d_message( + state: &AppState, + request_context: &RequestContext, + owner_user_id: &str, + session_id: String, + payload: SendMatch3DAgentMessageRequest, +) -> Result { + ensure_non_empty( + request_context, + MATCH3D_AGENT_PROVIDER, + &session_id, + "sessionId", + )?; + ensure_non_empty( + request_context, + MATCH3D_AGENT_PROVIDER, + &payload.client_message_id, + "clientMessageId", + )?; + ensure_non_empty( + request_context, + MATCH3D_AGENT_PROVIDER, + &payload.text, + "text", + )?; + + let submitted = state + .spacetime_client() + .submit_match3d_agent_message(Match3DAgentMessageSubmitRecordInput { + session_id: session_id.clone(), + owner_user_id: owner_user_id.to_string(), + user_message_id: payload.client_message_id.clone(), + user_message_text: payload.text.clone(), + submitted_at_micros: current_utc_micros(), + }) + .await + .map_err(|error| { + match3d_error_response( + request_context, + MATCH3D_AGENT_PROVIDER, + map_match3d_client_error(error), + ) + })?; + let next_turn = submitted.current_turn.saturating_add(1); + let next_config = build_config_from_message(&submitted, &payload); + let assistant_reply = build_match3d_assistant_reply_for_turn(&next_config, next_turn); + let progress_percent = resolve_progress_percent_for_turn(next_turn); + let stage = if progress_percent >= 100 { + "ReadyToCompile" + } else { + "Collecting" + } + .to_string(); + + state + .spacetime_client() + .finalize_match3d_agent_message(Match3DAgentMessageFinalizeRecordInput { + session_id, + owner_user_id: owner_user_id.to_string(), + assistant_message_id: Some(build_prefixed_uuid_id(MATCH3D_MESSAGE_ID_PREFIX)), + assistant_reply_text: Some(assistant_reply), + config_json: serialize_match3d_config(&next_config), + progress_percent, + stage, + updated_at_micros: current_utc_micros(), + error_message: None, + }) + .await + .map_err(|error| { + match3d_error_response( + request_context, + MATCH3D_AGENT_PROVIDER, + map_match3d_client_error(error), + ) + }) +} + +pub(super) async fn load_match3d_agent_session_response_with_persisted_assets( + state: &AppState, + owner_user_id: &str, + session: Match3DAgentSessionRecord, +) -> Match3DAgentSessionSnapshotResponse { + let Some(profile_id) = resolve_match3d_session_existing_profile_id(&session) else { + return map_match3d_agent_session_response(session); + }; + let assets = + get_match3d_existing_generated_item_assets(state, owner_user_id, profile_id.as_str()).await; + map_match3d_agent_session_response_with_assets(session, &assets) +} + +fn resolve_match3d_session_existing_profile_id( + session: &Match3DAgentSessionRecord, +) -> Option { + session + .draft + .as_ref() + .map(|draft| draft.profile_id.trim()) + .filter(|profile_id| !profile_id.is_empty()) + .or_else(|| { + session + .published_profile_id + .as_deref() + .map(str::trim) + .filter(|profile_id| !profile_id.is_empty()) + }) + .map(str::to_string) +} + +pub(super) async fn compile_match3d_draft_for_session( + state: &AppState, + request_context: &RequestContext, + authenticated: &AuthenticatedAccessToken, + session_id: String, + game_name: Option, + summary: Option, + tags: Option>, + cover_image_src: Option, + generate_click_sound: Option, +) -> Result<(Match3DAgentSessionRecord, Vec), Response> { + let owner_user_id = authenticated.claims().user_id().to_string(); + let initial_session = state + .spacetime_client() + .get_match3d_agent_session(session_id.clone(), owner_user_id.clone()) + .await + .map_err(|error| { + match3d_error_response( + request_context, + MATCH3D_AGENT_PROVIDER, + map_match3d_client_error(error), + ) + })?; + let mut config = resolve_config_or_default(initial_session.config.as_ref()); + if let Some(generate_click_sound) = generate_click_sound { + config.generate_click_sound = generate_click_sound; + } + // 中文注释:抓大鹅入口已支持表单直创;完整表单创建的 session + // 不需要再伪造三轮聊天,只要配置字段完整即可进入草稿编译。 + let has_complete_form_config = !config.theme_text.trim().is_empty() + && config.clear_count > 0 + && (1..=10).contains(&config.difficulty); + if !has_complete_form_config + && (initial_session.current_turn < 3 || initial_session.progress_percent < 100) + { + return Err(match3d_bad_request( + request_context, + MATCH3D_AGENT_PROVIDER, + "match3d 创作配置尚未确认完成", + )); + } + + let requested_game_name = normalize_optional_match3d_text(game_name); + let requested_summary = normalize_optional_match3d_text(summary); + let requested_tags = tags.map(normalize_tags).filter(|items| !items.is_empty()); + let requested_cover_image_src = normalize_optional_match3d_text(cover_image_src); + let fallback_work_metadata = fallback_match3d_work_metadata(config.theme_text.as_str()); + let profile_id = resolve_match3d_draft_profile_id(&initial_session); + let initial_game_name = requested_game_name + .clone() + .unwrap_or_else(|| fallback_work_metadata.game_name.clone()); + let initial_tags = requested_tags + .clone() + .unwrap_or_else(|| fallback_work_metadata.tags.clone()); + let billing_asset_id = format!("{}:{}:{}", session_id, profile_id, current_utc_micros()); + execute_billable_match3d_draft_generation( + state, + request_context, + owner_user_id.as_str(), + billing_asset_id.as_str(), + async { + let mut session = upsert_match3d_draft_snapshot( + state, + request_context, + authenticated, + session_id.clone(), + owner_user_id.clone(), + profile_id.clone(), + Some(initial_game_name), + requested_summary.clone().or_else(|| Some(String::new())), + Some(serde_json::to_string(&initial_tags).unwrap_or_default()), + requested_cover_image_src.clone(), + None, + None, + ) + .await?; + + if session.draft.is_none() { + return Err(match3d_error_response( + request_context, + MATCH3D_AGENT_PROVIDER, + match3d_bad_gateway("抓大鹅草稿创建失败,请稍后重试"), + )); + } + + let mut generated_work_metadata = generate_match3d_draft_plan(state, &config).await; + let resolved_game_name = requested_game_name + .unwrap_or_else(|| generated_work_metadata.metadata.game_name.clone()); + let resolved_summary = requested_summary + .clone() + .unwrap_or_else(|| generated_work_metadata.metadata.summary.clone()); + let resolved_tags = match requested_tags { + Some(tags) => tags, + None => { + generate_match3d_work_tags_for_plan( + state, + resolved_game_name.as_str(), + config.theme_text.as_str(), + resolved_summary.as_str(), + &generated_work_metadata.metadata.tags, + ) + .await + } + }; + generated_work_metadata.metadata.tags = resolved_tags.clone(); + session = upsert_match3d_draft_snapshot( + state, + request_context, + authenticated, + session_id, + owner_user_id.clone(), + profile_id.clone(), + Some(resolved_game_name), + Some(resolved_summary), + Some(serde_json::to_string(&resolved_tags).unwrap_or_default()), + requested_cover_image_src.clone(), + None, + None, + ) + .await?; + + let existing_assets = get_match3d_existing_generated_item_assets( + state, + owner_user_id.as_str(), + profile_id.as_str(), + ) + .await; + let generated_item_assets = generate_match3d_item_assets( + state, + request_context, + authenticated, + owner_user_id.as_str(), + session.session_id.as_str(), + profile_id.as_str(), + &config, + generated_work_metadata.items, + existing_assets, + ) + .await?; + let generated_item_assets = ensure_match3d_background_asset( + state, + request_context, + authenticated, + owner_user_id.as_str(), + session.session_id.as_str(), + profile_id.as_str(), + &config, + generated_work_metadata.background_prompt.as_str(), + generated_item_assets, + ) + .await?; + let existing_cover_image_src = get_match3d_existing_cover_image_src( + state, + owner_user_id.as_str(), + profile_id.as_str(), + ) + .await; + let default_cover_image_src = requested_cover_image_src + .clone() + .or(existing_cover_image_src) + .or_else(|| resolve_match3d_default_cover_image_src(&generated_item_assets)); + let next_session = upsert_match3d_draft_snapshot( + state, + request_context, + authenticated, + session.session_id.clone(), + owner_user_id.clone(), + profile_id, + None, + None, + None, + default_cover_image_src, + None, + serialize_match3d_generated_item_assets(&generated_item_assets), + ) + .await?; + + Ok((next_session, generated_item_assets)) + }, + ) + .await +} + +/// 中文注释:抓大鹅草稿生成是一次完整外部生成动作,按 session/profile 幂等预扣 10 泥点。 +async fn execute_billable_match3d_draft_generation( + state: &AppState, + request_context: &RequestContext, + owner_user_id: &str, + billing_asset_id: &str, + operation: Fut, +) -> Result +where + Fut: Future>, +{ + let points_consumed = consume_match3d_draft_generation_points( + state, + request_context, + owner_user_id, + billing_asset_id, + ) + .await?; + + match operation.await { + Ok(value) => Ok(value), + Err(response) => { + if points_consumed { + refund_match3d_draft_generation_points(state, owner_user_id, billing_asset_id) + .await; + } + Err(response) + } + } +} + +async fn consume_match3d_draft_generation_points( + state: &AppState, + request_context: &RequestContext, + owner_user_id: &str, + billing_asset_id: &str, +) -> Result { + let ledger_id = format!( + "asset_operation_consume:{}:match3d_draft_generation:{}", + owner_user_id, billing_asset_id + ); + match state + .spacetime_client() + .consume_profile_wallet_points( + owner_user_id.to_string(), + MATCH3D_DRAFT_GENERATION_POINTS_COST, + ledger_id, + current_utc_micros(), + ) + .await + { + Ok(_) => Ok(true), + Err(error) if should_skip_asset_operation_billing_for_connectivity(&error) => { + tracing::warn!( + owner_user_id, + billing_asset_id, + error = %error, + "抓大鹅草稿泥点预扣因 SpacetimeDB 连接不可用而降级跳过" + ); + Ok(false) + } + Err(error) => Err(match3d_error_response( + request_context, + MATCH3D_AGENT_PROVIDER, + map_asset_operation_wallet_error(error), + )), + } +} + +async fn refund_match3d_draft_generation_points( + state: &AppState, + owner_user_id: &str, + billing_asset_id: &str, +) { + let ledger_id = format!( + "asset_operation_refund:{}:match3d_draft_generation:{}", + owner_user_id, billing_asset_id + ); + if let Err(error) = state + .spacetime_client() + .refund_profile_wallet_points( + owner_user_id.to_string(), + MATCH3D_DRAFT_GENERATION_POINTS_COST, + ledger_id, + current_utc_micros(), + ) + .await + { + tracing::error!( + owner_user_id, + billing_asset_id, + error = %error, + "抓大鹅草稿生成失败后的泥点退款失败" + ); + } +} + +fn resolve_match3d_draft_profile_id(session: &Match3DAgentSessionRecord) -> String { + session + .draft + .as_ref() + .map(|draft| draft.profile_id.trim()) + .filter(|profile_id| !profile_id.is_empty()) + .or_else(|| { + session + .published_profile_id + .as_deref() + .map(str::trim) + .filter(|profile_id| !profile_id.is_empty()) + }) + .map(str::to_string) + .unwrap_or_else(|| build_prefixed_uuid_id(MATCH3D_PROFILE_ID_PREFIX)) +} + +#[allow(clippy::too_many_arguments)] +pub(super) async fn upsert_match3d_draft_snapshot( + state: &AppState, + request_context: &RequestContext, + authenticated: &AuthenticatedAccessToken, + session_id: String, + owner_user_id: String, + profile_id: String, + game_name: Option, + summary_text: Option, + tags_json: Option, + cover_image_src: Option, + cover_asset_id: Option, + generated_item_assets_json: Option, +) -> Result { + state + .spacetime_client() + .compile_match3d_draft(Match3DCompileDraftRecordInput { + session_id, + owner_user_id, + profile_id, + author_display_name: resolve_author_display_name(state, authenticated), + game_name, + summary_text, + tags_json, + cover_image_src, + cover_asset_id, + compiled_at_micros: current_utc_micros(), + generated_item_assets_json, + }) + .await + .map_err(|error| { + match3d_error_response( + request_context, + MATCH3D_AGENT_PROVIDER, + map_match3d_client_error(error), + ) + }) +} +pub(super) fn build_config_from_create_request( + payload: &CreateMatch3DAgentSessionRequest, +) -> Match3DConfigJson { + Match3DConfigJson { + theme_text: payload + .theme_text + .as_deref() + .or(payload.seed_text.as_deref()) + .map(str::trim) + .filter(|value| !value.is_empty()) + .unwrap_or(MATCH3D_DEFAULT_THEME) + .to_string(), + reference_image_src: payload.reference_image_src.clone(), + clear_count: payload.clear_count.unwrap_or(MATCH3D_DEFAULT_CLEAR_COUNT), + difficulty: payload + .difficulty + .unwrap_or(MATCH3D_DEFAULT_DIFFICULTY) + .clamp(1, 10), + asset_style_id: normalize_optional_text(payload.asset_style_id.as_deref()), + asset_style_label: normalize_optional_text(payload.asset_style_label.as_deref()), + asset_style_prompt: normalize_optional_text(payload.asset_style_prompt.as_deref()), + generate_click_sound: payload.generate_click_sound.unwrap_or(false), + } +} + +fn build_config_from_message( + session: &Match3DAgentSessionRecord, + payload: &SendMatch3DAgentMessageRequest, +) -> Match3DConfigJson { + let current = resolve_config_or_default(session.config.as_ref()); + let text = payload.text.trim(); + let reference_image_src = payload + .reference_image_src + .as_deref() + .map(str::trim) + .filter(|value| !value.is_empty()) + .map(str::to_string) + .or(current.reference_image_src); + let quick_fill_requested = + payload.quick_fill_requested.unwrap_or(false) || text.contains("自动配置"); + + let mut theme_text = current.theme_text; + let mut clear_count = current.clear_count.max(1); + let mut difficulty = current.difficulty.clamp(1, 10); + let asset_style_id = current.asset_style_id; + let asset_style_label = current.asset_style_label; + let asset_style_prompt = current.asset_style_prompt; + let generate_click_sound = current.generate_click_sound; + + match session.current_turn { + 0 => { + theme_text = if quick_fill_requested { + MATCH3D_DEFAULT_THEME.to_string() + } else { + parse_theme_answer(text).unwrap_or(theme_text) + }; + } + 1 => { + clear_count = if quick_fill_requested { + clear_count + } else { + parse_number_after_keywords(text, &["消除", "次数", "clearCount"]) + .unwrap_or(clear_count) + } + .max(1); + } + _ => { + difficulty = if quick_fill_requested { + difficulty + } else { + parse_number_after_keywords(text, &["难度", "difficulty"]).unwrap_or(difficulty) + } + .clamp(1, 10); + } + } + + Match3DConfigJson { + theme_text, + reference_image_src, + clear_count, + difficulty, + asset_style_id, + asset_style_label, + asset_style_prompt, + generate_click_sound, + } +} + +pub(super) fn resolve_config_or_default(config: Option<&Match3DCreatorConfigRecord>) -> Match3DConfigJson { + config + .map(|config| Match3DConfigJson { + theme_text: config.theme_text.clone(), + reference_image_src: config.reference_image_src.clone(), + clear_count: config.clear_count.max(1), + difficulty: config.difficulty.clamp(1, 10), + asset_style_id: config.asset_style_id.clone(), + asset_style_label: config.asset_style_label.clone(), + asset_style_prompt: config.asset_style_prompt.clone(), + generate_click_sound: config.generate_click_sound, + }) + .unwrap_or_else(|| Match3DConfigJson { + theme_text: MATCH3D_DEFAULT_THEME.to_string(), + reference_image_src: None, + clear_count: MATCH3D_DEFAULT_CLEAR_COUNT, + difficulty: MATCH3D_DEFAULT_DIFFICULTY, + asset_style_id: None, + asset_style_label: None, + asset_style_prompt: None, + generate_click_sound: false, + }) +} + +pub(super) fn normalize_optional_text(value: Option<&str>) -> Option { + value + .map(str::trim) + .filter(|value| !value.is_empty()) + .map(str::to_string) +} + +pub(super) fn serialize_match3d_config(config: &Match3DConfigJson) -> Option { + serde_json::to_string(config).ok() +} + +pub(super) fn build_seed_text( + payload: &CreateMatch3DAgentSessionRequest, + config: &Match3DConfigJson, +) -> String { + payload + .seed_text + .as_deref() + .map(str::trim) + .filter(|value| !value.is_empty()) + .map(str::to_string) + .unwrap_or_else(|| { + format!( + "{}题材,消除{}次,难度{}", + config.theme_text, config.clear_count, config.difficulty + ) + }) +} + +fn build_match3d_assistant_reply(config: &Match3DConfigJson) -> String { + format!( + "已确认:{}题材,需要消除 {} 次,共 {} 件物品,难度 {}。", + config.theme_text, + config.clear_count, + config.clear_count.saturating_mul(3), + config.difficulty + ) +} + +pub(super) fn build_match3d_assistant_reply_for_turn(config: &Match3DConfigJson, current_turn: u32) -> String { + match current_turn { + 0 => MATCH3D_QUESTION_THEME.to_string(), + 1 => MATCH3D_QUESTION_CLEAR_COUNT.to_string(), + 2 => MATCH3D_QUESTION_DIFFICULTY.to_string(), + _ => build_match3d_assistant_reply(config), + } +} + +pub(super) fn resolve_progress_percent_for_turn(current_turn: u32) -> u32 { + match current_turn { + 0 => 0, + 1 => 33, + 2 => 66, + _ => 100, + } +} + +fn parse_theme_answer(text: &str) -> Option { + for marker in ["题材", "主题"] { + if let Some((_, value)) = text.split_once(marker) { + let normalized = value + .trim_matches(|ch: char| ch == ':' || ch == ':' || ch.is_whitespace()) + .split_whitespace() + .next() + .unwrap_or_default() + .trim_matches(['。', ',', ',', ';', ';']) + .to_string(); + if !normalized.is_empty() { + return Some(normalized); + } + } + } + let trimmed = text.trim(); + if (2..=24).contains(&trimmed.chars().count()) && !trimmed.chars().any(|ch| ch.is_ascii_digit()) + { + return Some(trimmed.to_string()); + } + None +} + +fn parse_number_after_keywords(text: &str, keywords: &[&str]) -> Option { + for keyword in keywords { + if let Some(index) = text.find(keyword) { + let suffix = &text[index + keyword.len()..]; + if let Some(value) = first_positive_integer(suffix) { + return Some(value); + } + } + } + first_positive_integer(text) +} + +fn first_positive_integer(text: &str) -> Option { + let mut digits = String::new(); + for ch in text.chars() { + if ch.is_ascii_digit() { + digits.push(ch); + } else if !digits.is_empty() { + break; + } + } + digits.parse::().ok().filter(|value| *value > 0) +} + +pub(super) fn normalize_tags(tags: Vec) -> Vec { + let mut result: Vec = Vec::new(); + for tag in tags { + let trimmed = normalize_match3d_tag(tag.as_str()); + if !trimmed.is_empty() && !result.iter().any(|value| value == &trimmed) { + result.push(trimmed); + } + if result.len() >= 6 { + break; + } + } + result +} + +fn normalize_optional_match3d_text(value: Option) -> Option { + value + .map(|value| value.trim().to_string()) + .filter(|value| !value.is_empty()) +} +async fn generate_match3d_draft_plan( + state: &AppState, + config: &Match3DConfigJson, +) -> Match3DGeneratedDraftPlan { + let Some(llm_client) = state + .creative_agent_gpt5_client() + .or_else(|| state.llm_client()) + else { + return fallback_match3d_draft_plan(config); + }; + let system_prompt = "你是抓大鹅游戏的草稿生成编辑,只返回 JSON。"; + let gameplay_item_count = resolve_match3d_gameplay_item_count(config); + let generated_item_count = resolve_match3d_generated_item_count(config); + let user_prompt = format!( + "题材设定:{}\n请生成抓大鹅游戏草稿生成计划。要求:只返回 JSON 对象,字段为 gameName、summary、tags、backgroundPrompt、items。gameName 为 4 到 12 个中文字符,不要包含“作品”“游戏”;summary 为 18 到 48 个中文字符的作品描述,说明题材氛围和核心体验,不要写规则说明;tags 为 3 到 6 个中文短标签,每个 2 到 6 个汉字,后续会用同一作品信息再次生成作品标签;backgroundPrompt 是用于生成局内纯背景图的中文提示词,只描述竖屏移动端抓大鹅题材氛围、色彩和环境,不得描述锅、圆盘、托盘、拼图槽、物品槽、HUD、UI、文字、按钮、倒计时、分数或物品;当前玩法需要 {} 种物品,但素材图固定每 5 个物品一批,因此 items 必须向上补齐并正好返回 {} 项,每项包含 name、itemSize 和 soundPrompt。name 为 2 到 6 个汉字;itemSize 只能是“大”“中”“小”之一,按物品真实相对尺寸判断,例如西瓜/大箱子偏大,苹果/杯子偏中,糖果/钥匙偏小;soundPrompt 只作为历史字段保留,可返回空字符串。", + config.theme_text, gameplay_item_count, generated_item_count + ); + let response = llm_client + .request_text( + LlmTextRequest::new(vec![ + LlmMessage::system(system_prompt), + LlmMessage::user(user_prompt), + ]) + .with_model(MATCH3D_WORK_METADATA_LLM_MODEL) + .with_responses_api(), + ) + .await; + + match response { + Ok(response) => parse_match3d_draft_plan(response.content.as_str(), config) + .unwrap_or_else(|| fallback_match3d_draft_plan(config)), + Err(error) => { + tracing::warn!( + provider = MATCH3D_AGENT_PROVIDER, + theme_text = config.theme_text.as_str(), + error = %error, + "抓大鹅草稿生成计划失败,降级使用本地生成计划" + ); + fallback_match3d_draft_plan(config) + } + } +} + +pub(super) fn parse_match3d_draft_plan( + raw: &str, + config: &Match3DConfigJson, +) -> Option { + let raw = raw.trim(); + let json_text = if let Some(start) = raw.find('{') + && let Some(end) = raw.rfind('}') + && end > start + { + &raw[start..=end] + } else { + raw + }; + let value = serde_json::from_str::(json_text).ok()?; + let game_name = normalize_match3d_game_name(value.get("gameName")?.as_str()?); + if game_name.is_empty() { + return None; + } + let tags = value + .get("tags") + .and_then(Value::as_array) + .map(|items| normalize_match3d_tag_candidates(items.iter().filter_map(Value::as_str))) + .unwrap_or_default(); + let fallback = fallback_match3d_draft_plan(config); + let summary = value + .get("summary") + .or_else(|| value.get("description")) + .or_else(|| value.get("workSummary")) + .or_else(|| value.get("work_summary")) + .and_then(Value::as_str) + .map(normalize_match3d_work_summary) + .filter(|value| !value.is_empty()) + .unwrap_or(fallback.metadata.summary); + let items = value + .get("items") + .and_then(Value::as_array) + .map(|items| { + items + .iter() + .filter_map(|item| { + let name = + normalize_match3d_item_name(item.get("name").and_then(Value::as_str)?); + if name.is_empty() { + return None; + } + let item_size = item + .get("itemSize") + .or_else(|| item.get("item_size")) + .or_else(|| item.get("size")) + .and_then(Value::as_str) + .map(normalize_match3d_item_size) + .filter(|value| !value.is_empty()) + .unwrap_or_else(|| infer_match3d_item_size(&name)); + let sound_prompt = item + .get("soundPrompt") + .or_else(|| item.get("sound_prompt")) + .and_then(Value::as_str) + .map(normalize_match3d_audio_prompt) + .filter(|value| !value.is_empty()) + .unwrap_or_else(|| build_fallback_match3d_item_sound_prompt(config, &name)); + Some(Match3DGeneratedItemPlan { + name, + item_size, + sound_prompt, + }) + }) + .collect::>() + }) + .unwrap_or_default(); + let background_prompt = value + .get("backgroundPrompt") + .or_else(|| value.get("background_prompt")) + .and_then(Value::as_str) + .map(normalize_match3d_background_prompt) + .filter(|value| !value.is_empty()) + .unwrap_or(fallback.background_prompt); + + Some(Match3DGeneratedDraftPlan { + metadata: Match3DGeneratedWorkMetadata { + game_name, + summary, + tags: normalize_match3d_tag_candidates(tags), + }, + items: normalize_match3d_item_plan(config, items), + background_prompt, + }) +} + +#[cfg(test)] +pub(super) fn parse_match3d_work_metadata(raw: &str) -> Option { + let config = Match3DConfigJson { + theme_text: MATCH3D_DEFAULT_THEME.to_string(), + reference_image_src: None, + clear_count: MATCH3D_DEFAULT_CLEAR_COUNT, + difficulty: MATCH3D_DEFAULT_DIFFICULTY, + asset_style_id: None, + asset_style_label: None, + asset_style_prompt: None, + generate_click_sound: false, + }; + parse_match3d_draft_plan(raw, &config).map(|plan| plan.metadata) +} + +fn normalize_match3d_game_name(raw: &str) -> String { + raw.trim() + .trim_matches(['"', '\'', '“', '”', '。', ',', ',', '、']) + .chars() + .filter(|character| !character.is_control()) + .take(16) + .collect::() + .trim() + .to_string() +} + +fn normalize_match3d_work_summary(raw: &str) -> String { + raw.trim() + .trim_matches(['"', '\'', '“', '”']) + .split_whitespace() + .collect::>() + .join("") + .chars() + .filter(|character| !character.is_control()) + .take(80) + .collect::() + .trim() + .to_string() +} + +pub(super) fn fallback_match3d_work_metadata(theme_text: &str) -> Match3DGeneratedWorkMetadata { + let theme = theme_text.trim(); + let normalized_theme = if theme.is_empty() { "主题" } else { theme }; + Match3DGeneratedWorkMetadata { + game_name: format!("{normalized_theme}抓大鹅"), + summary: normalize_match3d_work_summary( + format!("{normalized_theme}主题的轻量抓取消除作品,适合快速体验。").as_str(), + ), + tags: normalize_match3d_tag_candidates([normalized_theme, "抓大鹅", "经典消除", "2D素材"]), + } +} + +fn fallback_match3d_draft_plan(config: &Match3DConfigJson) -> Match3DGeneratedDraftPlan { + let metadata = fallback_match3d_work_metadata(config.theme_text.as_str()); + let items = fallback_match3d_item_names(config.theme_text.as_str()) + .into_iter() + .take(resolve_match3d_generated_item_count(config)) + .map(|name| Match3DGeneratedItemPlan { + item_size: infer_match3d_item_size(&name), + sound_prompt: build_fallback_match3d_item_sound_prompt(config, &name), + name, + }) + .collect::>(); + Match3DGeneratedDraftPlan { + background_prompt: build_fallback_match3d_background_prompt(config), + metadata, + items, + } +} diff --git a/server-rs/crates/api-server/src/match3d/handlers.rs b/server-rs/crates/api-server/src/match3d/handlers.rs new file mode 100644 index 00000000..b4837ec6 --- /dev/null +++ b/server-rs/crates/api-server/src/match3d/handlers.rs @@ -0,0 +1,1406 @@ +use super::*; + +pub async fn create_match3d_agent_session( + State(state): State, + Extension(request_context): Extension, + Extension(authenticated): Extension, + payload: Result, JsonRejection>, +) -> Result, Response> { + let Json(payload) = match3d_json(payload, &request_context, MATCH3D_AGENT_PROVIDER)?; + let config = build_config_from_create_request(&payload); + let seed_text = build_seed_text(&payload, &config); + let welcome_message_text = MATCH3D_QUESTION_THEME.to_string(); + + let session = state + .spacetime_client() + .create_match3d_agent_session(Match3DAgentSessionCreateRecordInput { + session_id: build_prefixed_uuid_id(MATCH3D_SESSION_ID_PREFIX), + owner_user_id: authenticated.claims().user_id().to_string(), + seed_text, + welcome_message_id: build_prefixed_uuid_id(MATCH3D_MESSAGE_ID_PREFIX), + welcome_message_text, + config_json: serialize_match3d_config(&config), + created_at_micros: current_utc_micros(), + }) + .await + .map_err(|error| { + match3d_error_response( + &request_context, + MATCH3D_AGENT_PROVIDER, + map_match3d_client_error(error), + ) + })?; + + Ok(json_success_body( + Some(&request_context), + Match3DAgentSessionResponse { + session: load_match3d_agent_session_response_with_persisted_assets( + &state, + authenticated.claims().user_id(), + session, + ) + .await, + }, + )) +} + +pub async fn get_match3d_agent_session( + State(state): State, + Path(session_id): Path, + Extension(request_context): Extension, + Extension(authenticated): Extension, +) -> Result, Response> { + ensure_non_empty( + &request_context, + MATCH3D_AGENT_PROVIDER, + &session_id, + "sessionId", + )?; + + let session = state + .spacetime_client() + .get_match3d_agent_session(session_id, authenticated.claims().user_id().to_string()) + .await + .map_err(|error| { + match3d_error_response( + &request_context, + MATCH3D_AGENT_PROVIDER, + map_match3d_client_error(error), + ) + })?; + + Ok(json_success_body( + Some(&request_context), + Match3DAgentSessionResponse { + session: load_match3d_agent_session_response_with_persisted_assets( + &state, + authenticated.claims().user_id(), + session, + ) + .await, + }, + )) +} + +pub async fn submit_match3d_agent_message( + State(state): State, + Path(session_id): Path, + Extension(request_context): Extension, + Extension(authenticated): Extension, + payload: Result, JsonRejection>, +) -> Result, Response> { + let Json(payload) = match3d_json(payload, &request_context, MATCH3D_AGENT_PROVIDER)?; + let session = submit_and_finalize_match3d_message( + &state, + &request_context, + authenticated.claims().user_id(), + session_id, + payload, + ) + .await?; + + Ok(json_success_body( + Some(&request_context), + Match3DAgentSessionResponse { + session: load_match3d_agent_session_response_with_persisted_assets( + &state, + authenticated.claims().user_id(), + session, + ) + .await, + }, + )) +} + +pub async fn stream_match3d_agent_message( + State(state): State, + Path(session_id): Path, + Extension(request_context): Extension, + Extension(authenticated): Extension, + payload: Result, JsonRejection>, +) -> Result { + let Json(payload) = match3d_json(payload, &request_context, MATCH3D_AGENT_PROVIDER)?; + ensure_non_empty( + &request_context, + MATCH3D_AGENT_PROVIDER, + &session_id, + "sessionId", + )?; + + let owner_user_id = authenticated.claims().user_id().to_string(); + let request_context_for_stream = request_context.clone(); + let stream = async_stream::stream! { + let result = submit_and_finalize_match3d_message( + &state, + &request_context_for_stream, + owner_user_id.as_str(), + session_id, + payload, + ) + .await; + + match result { + Ok(session) => { + let session_response = load_match3d_agent_session_response_with_persisted_assets( + &state, + owner_user_id.as_str(), + session, + ) + .await; + if let Some(reply) = session_response.last_assistant_reply.clone() { + yield Ok::(match3d_sse_json_event_or_error( + "reply_delta", + json!({ "text": reply }), + )); + } + yield Ok::(match3d_sse_json_event_or_error( + "session", + json!({ "session": session_response }), + )); + yield Ok::(match3d_sse_json_event_or_error( + "done", + json!({ "ok": true }), + )); + } + Err(response) => { + yield Ok::(match3d_sse_json_event_or_error( + "error", + json!({ "message": response.status().to_string() }), + )); + } + } + }; + + Ok(Sse::new(stream).into_response()) +} + +pub async fn execute_match3d_agent_action( + State(state): State, + Path(session_id): Path, + Extension(request_context): Extension, + Extension(authenticated): Extension, + payload: Result, JsonRejection>, +) -> Result, Response> { + let Json(payload) = match3d_json(payload, &request_context, MATCH3D_AGENT_PROVIDER)?; + ensure_non_empty( + &request_context, + MATCH3D_AGENT_PROVIDER, + &session_id, + "sessionId", + )?; + + if payload.action.trim() != "match3d_compile_draft" { + return Err(match3d_bad_request( + &request_context, + MATCH3D_AGENT_PROVIDER, + "unknown match3d action", + )); + } + + let (session, generated_item_assets) = compile_match3d_draft_for_session( + &state, + &request_context, + &authenticated, + session_id, + payload.game_name, + payload.summary, + payload.tags, + payload.cover_image_src, + payload.generate_click_sound, + ) + .await?; + + Ok(json_success_body( + Some(&request_context), + Match3DAgentActionResponse { + session: map_match3d_agent_session_response_with_assets( + session, + &generated_item_assets, + ), + }, + )) +} + +pub async fn compile_match3d_agent_draft( + State(state): State, + Path(session_id): Path, + Extension(request_context): Extension, + Extension(authenticated): Extension, + payload: Result, JsonRejection>, +) -> Result, Response> { + let payload = payload + .map(|Json(payload)| payload) + .unwrap_or(CompileMatch3DDraftRequest { + game_name: None, + summary: None, + tags: None, + cover_image_src: None, + generate_click_sound: None, + }); + ensure_non_empty( + &request_context, + MATCH3D_AGENT_PROVIDER, + &session_id, + "sessionId", + )?; + + let (session, generated_item_assets) = compile_match3d_draft_for_session( + &state, + &request_context, + &authenticated, + session_id, + payload.game_name, + payload.summary, + payload.tags, + payload.cover_image_src, + payload.generate_click_sound, + ) + .await?; + + Ok(json_success_body( + Some(&request_context), + Match3DAgentActionResponse { + session: map_match3d_agent_session_response_with_assets( + session, + &generated_item_assets, + ), + }, + )) +} + +pub async fn get_match3d_works( + State(state): State, + Extension(request_context): Extension, + Extension(authenticated): Extension, +) -> Result, Response> { + let items = state + .spacetime_client() + .list_match3d_works(authenticated.claims().user_id().to_string()) + .await + .map_err(|error| { + match3d_error_response( + &request_context, + MATCH3D_WORKS_PROVIDER, + map_match3d_client_error(error), + ) + })?; + + Ok(json_success_body( + Some(&request_context), + Match3DWorksResponse { + items: items + .into_iter() + .map(map_match3d_work_summary_response) + .collect(), + }, + )) +} + +pub async fn list_match3d_gallery( + State(state): State, + Extension(request_context): Extension, +) -> Result, Response> { + let items = state + .spacetime_client() + .list_match3d_gallery() + .await + .map_err(|error| { + match3d_error_response( + &request_context, + MATCH3D_WORKS_PROVIDER, + map_match3d_client_error(error), + ) + })?; + + Ok(json_success_body( + Some(&request_context), + Match3DWorksResponse { + items: items + .into_iter() + .map(map_match3d_work_summary_response) + .collect(), + }, + )) +} + +pub async fn get_match3d_work_detail( + State(state): State, + Path(profile_id): Path, + Extension(request_context): Extension, + Extension(authenticated): Extension, +) -> Result, Response> { + ensure_non_empty( + &request_context, + MATCH3D_WORKS_PROVIDER, + &profile_id, + "profileId", + )?; + + let item = state + .spacetime_client() + .get_match3d_work_detail(profile_id, authenticated.claims().user_id().to_string()) + .await + .map_err(|error| { + match3d_error_response( + &request_context, + MATCH3D_WORKS_PROVIDER, + map_match3d_client_error(error), + ) + })?; + + Ok(json_success_body( + Some(&request_context), + Match3DWorkDetailResponse { + item: map_match3d_work_profile_response(item), + }, + )) +} + +pub async fn put_match3d_work( + State(state): State, + Path(profile_id): Path, + Extension(request_context): Extension, + Extension(authenticated): Extension, + payload: Result, JsonRejection>, +) -> Result, Response> { + let Json(payload) = match3d_json(payload, &request_context, MATCH3D_WORKS_PROVIDER)?; + ensure_non_empty( + &request_context, + MATCH3D_WORKS_PROVIDER, + &profile_id, + "profileId", + )?; + + let existing = state + .spacetime_client() + .get_match3d_work_detail( + profile_id.clone(), + authenticated.claims().user_id().to_string(), + ) + .await + .map_err(|error| { + match3d_error_response( + &request_context, + MATCH3D_WORKS_PROVIDER, + map_match3d_client_error(error), + ) + })?; + let theme_text = payload + .theme_text + .clone() + .filter(|value| !value.trim().is_empty()) + .unwrap_or(existing.theme_text); + let item = state + .spacetime_client() + .update_match3d_work(Match3DWorkUpdateRecordInput { + profile_id, + owner_user_id: authenticated.claims().user_id().to_string(), + game_name: payload.game_name, + theme_text, + summary_text: payload.summary, + tags_json: serde_json::to_string(&normalize_tags(payload.tags)).unwrap_or_default(), + cover_image_src: payload.cover_image_src.unwrap_or_default(), + cover_asset_id: String::new(), + clear_count: payload.clear_count, + difficulty: payload.difficulty, + updated_at_micros: current_utc_micros(), + }) + .await + .map_err(|error| { + match3d_error_response( + &request_context, + MATCH3D_WORKS_PROVIDER, + map_match3d_client_error(error), + ) + })?; + + Ok(json_success_body( + Some(&request_context), + Match3DWorkMutationResponse { + item: map_match3d_work_profile_response(item), + }, + )) +} + +pub async fn put_match3d_audio_assets( + State(state): State, + Path(profile_id): Path, + Extension(request_context): Extension, + Extension(authenticated): Extension, + payload: Result, JsonRejection>, +) -> Result, Response> { + let Json(payload) = match3d_json(payload, &request_context, MATCH3D_WORKS_PROVIDER)?; + ensure_non_empty( + &request_context, + MATCH3D_WORKS_PROVIDER, + &profile_id, + "profileId", + )?; + + let owner_user_id = authenticated.claims().user_id().to_string(); + let existing = state + .spacetime_client() + .get_match3d_work_detail(profile_id.clone(), owner_user_id.clone()) + .await + .map_err(|error| { + match3d_error_response( + &request_context, + MATCH3D_WORKS_PROVIDER, + map_match3d_client_error(error), + ) + })?; + let session_id = existing.source_session_id.clone().ok_or_else(|| { + match3d_error_response( + &request_context, + MATCH3D_WORKS_PROVIDER, + AppError::from_status(StatusCode::BAD_REQUEST).with_details(json!({ + "provider": MATCH3D_WORKS_PROVIDER, + "message": "抓大鹅作品缺少来源 session,无法写回音频素材", + })), + ) + })?; + let assets = payload + .generated_item_assets + .into_iter() + .map(Match3DGeneratedItemAsset::from) + .collect::>(); + let session = upsert_match3d_draft_snapshot( + &state, + &request_context, + &authenticated, + session_id, + owner_user_id.clone(), + profile_id.clone(), + Some(existing.game_name), + Some(existing.summary), + Some(serde_json::to_string(&existing.tags).unwrap_or_default()), + existing.cover_image_src, + None, + serialize_match3d_generated_item_assets(&assets), + ) + .await?; + + let item = state + .spacetime_client() + .get_match3d_work_detail(profile_id, owner_user_id) + .await + .map_err(|error| { + match3d_error_response( + &request_context, + MATCH3D_WORKS_PROVIDER, + map_match3d_client_error(error), + ) + })?; + let _ = session; + Ok(json_success_body( + Some(&request_context), + Match3DWorkMutationResponse { + item: map_match3d_work_profile_response(item), + }, + )) +} + +pub async fn persist_match3d_generated_model( + State(state): State, + Path(profile_id): Path, + Extension(request_context): Extension, + Extension(authenticated): Extension, + payload: Result, JsonRejection>, +) -> Result, Response> { + let Json(payload) = match3d_json(payload, &request_context, MATCH3D_WORKS_PROVIDER)?; + ensure_non_empty( + &request_context, + MATCH3D_WORKS_PROVIDER, + &profile_id, + "profileId", + )?; + ensure_non_empty( + &request_context, + MATCH3D_WORKS_PROVIDER, + &payload.item_id, + "itemId", + )?; + ensure_non_empty( + &request_context, + MATCH3D_WORKS_PROVIDER, + &payload.item_name, + "itemName", + )?; + ensure_non_empty( + &request_context, + MATCH3D_WORKS_PROVIDER, + &payload.source_url, + "sourceUrl", + )?; + + let owner_user_id = authenticated.claims().user_id().to_string(); + let existing = state + .spacetime_client() + .get_match3d_work_detail(profile_id.clone(), owner_user_id.clone()) + .await + .map_err(|error| { + match3d_error_response( + &request_context, + MATCH3D_WORKS_PROVIDER, + map_match3d_client_error(error), + ) + })?; + let session_id = existing.source_session_id.clone().ok_or_else(|| { + match3d_error_response( + &request_context, + MATCH3D_WORKS_PROVIDER, + AppError::from_status(StatusCode::BAD_REQUEST).with_details(json!({ + "provider": MATCH3D_WORKS_PROVIDER, + "message": "抓大鹅作品缺少来源 session,无法保存历史模型", + })), + ) + })?; + + let mut assets = + parse_match3d_generated_item_assets(existing.generated_item_assets_json.as_deref()) + .into_iter() + .map(Match3DGeneratedItemAsset::from) + .collect::>(); + let current_asset = assets + .iter() + .find(|asset| asset.item_id == payload.item_id) + .cloned(); + let item_name = normalize_match3d_item_name(payload.item_name.as_str()); + let item_name = if item_name.is_empty() { + current_asset + .as_ref() + .map(|asset| asset.item_name.clone()) + .unwrap_or_else(|| payload.item_name.trim().to_string()) + } else { + item_name + }; + let model_file = hyper3d_contract::Hyper3dDownloadFilePayload { + name: normalize_optional_text(payload.file_name.as_deref()) + .unwrap_or_else(|| "model.glb".to_string()), + url: payload.source_url.trim().to_string(), + }; + let downloaded_model = download_match3d_legacy_model(&model_file) + .await + .map_err(|error| match3d_error_response(&request_context, MATCH3D_WORKS_PROVIDER, error))?; + let task_uuid = normalize_optional_text(payload.task_uuid.as_deref()); + let item_slug = build_match3d_item_slug(payload.item_id.as_str(), item_name.as_str()); + let generated_at_micros = current_utc_micros(); + let uploaded_model = persist_match3d_generated_bytes( + &state, + owner_user_id.as_str(), + session_id.as_str(), + profile_id.as_str(), + &[ + "items", + item_slug.as_str(), + "model", + task_uuid.as_deref().unwrap_or("manual"), + ], + downloaded_model.file_name.as_str(), + downloaded_model.content_type.as_str(), + downloaded_model.bytes, + "match3d_item_model", + task_uuid.as_deref(), + generated_at_micros, + ) + .await + .map_err(|error| match3d_error_response(&request_context, MATCH3D_WORKS_PROVIDER, error))?; + let next_asset = Match3DGeneratedItemAsset { + item_id: payload.item_id, + item_name, + item_size: current_asset + .as_ref() + .and_then(|asset| asset.item_size.clone()) + .or_else(|| Some(MATCH3D_ITEM_SIZE_LARGE.to_string())), + image_src: current_asset + .as_ref() + .and_then(|asset| asset.image_src.clone()), + image_object_key: current_asset + .as_ref() + .and_then(|asset| asset.image_object_key.clone()), + image_views: current_asset + .as_ref() + .map(|asset| asset.image_views.clone()) + .unwrap_or_default(), + model_src: Some(uploaded_model.src), + model_object_key: Some(uploaded_model.object_key), + model_file_name: Some(downloaded_model.file_name), + task_uuid, + subscription_key: normalize_optional_text(payload.subscription_key.as_deref()).or_else( + || { + current_asset + .as_ref() + .and_then(|asset| asset.subscription_key.clone()) + }, + ), + sound_prompt: current_asset + .as_ref() + .and_then(|asset| asset.sound_prompt.clone()), + background_music_title: current_asset + .as_ref() + .and_then(|asset| asset.background_music_title.clone()), + background_music_style: current_asset + .as_ref() + .and_then(|asset| asset.background_music_style.clone()), + background_music_prompt: current_asset + .as_ref() + .and_then(|asset| asset.background_music_prompt.clone()), + background_music: current_asset + .as_ref() + .and_then(|asset| asset.background_music.clone()), + click_sound: current_asset + .as_ref() + .and_then(|asset| asset.click_sound.clone()), + background_asset: current_asset + .as_ref() + .and_then(|asset| asset.background_asset.clone()), + status: "model_ready".to_string(), + error: None, + }; + upsert_match3d_generated_item_asset(&mut assets, next_asset.clone()); + persist_match3d_generated_item_assets_snapshot( + &state, + &request_context, + &authenticated, + session_id.as_str(), + owner_user_id.as_str(), + profile_id.as_str(), + &assets, + ) + .await?; + + Ok(json_success_body( + Some(&request_context), + PersistMatch3DGeneratedModelResponse { + asset: map_match3d_generated_item_asset_for_work(Match3DGeneratedItemAssetJson::from( + next_asset, + )), + }, + )) +} + +pub async fn generate_match3d_cover_image( + State(state): State, + Path(profile_id): Path, + Extension(request_context): Extension, + Extension(authenticated): Extension, + payload: Result, JsonRejection>, +) -> Result, Response> { + let Json(payload) = match3d_json(payload, &request_context, MATCH3D_WORKS_PROVIDER)?; + ensure_non_empty( + &request_context, + MATCH3D_WORKS_PROVIDER, + &profile_id, + "profileId", + )?; + let prompt = normalize_match3d_cover_prompt(payload.prompt.as_str()); + ensure_non_empty(&request_context, MATCH3D_WORKS_PROVIDER, &prompt, "prompt")?; + + let context = + load_match3d_work_asset_context(&state, &request_context, &authenticated, &profile_id) + .await?; + let generated_cover = generate_match3d_cover_image_asset( + &state, + &context.owner_user_id, + context.session_id.as_str(), + profile_id.as_str(), + &context.config, + prompt.as_str(), + payload.uploaded_image_src, + collect_match3d_cover_reference_image_sources( + payload.reference_image_src, + payload.reference_image_srcs, + ), + ) + .await + .map_err(|error| match3d_error_response(&request_context, MATCH3D_WORKS_PROVIDER, error))?; + + let item = update_match3d_work_cover_only( + &state, + &request_context, + context.owner_user_id.as_str(), + context.profile, + generated_cover.src.as_str(), + ) + .await?; + + Ok(json_success_body( + Some(&request_context), + GenerateMatch3DCoverImageResponse { + item: map_match3d_work_profile_response(item), + cover_image_src: generated_cover.src, + cover_image_object_key: generated_cover.object_key, + prompt, + }, + )) +} +pub async fn generate_match3d_background_image_for_work( + State(state): State, + Path(profile_id): Path, + Extension(request_context): Extension, + Extension(authenticated): Extension, + payload: Result, JsonRejection>, +) -> Result, Response> { + let Json(payload) = match3d_json(payload, &request_context, MATCH3D_WORKS_PROVIDER)?; + ensure_non_empty( + &request_context, + MATCH3D_WORKS_PROVIDER, + &profile_id, + "profileId", + )?; + let prompt = normalize_match3d_background_prompt(payload.prompt.as_str()); + ensure_non_empty(&request_context, MATCH3D_WORKS_PROVIDER, &prompt, "prompt")?; + let prompt_fingerprint = build_match3d_prompt_fingerprint(prompt.as_str()); + + let context = + load_match3d_work_asset_context(&state, &request_context, &authenticated, &profile_id) + .await?; + let Match3DWorkAssetContext { + owner_user_id, + session_id, + profile, + config, + assets, + } = context; + let billing_asset_id = format!("{}:{}:{}", session_id, profile_id, prompt_fingerprint); + let (generated_background, generated_assets) = execute_billable_asset_operation_with_cost( + &state, + owner_user_id.as_str(), + "match3d_ui_background_image", + billing_asset_id.as_str(), + MATCH3D_BACKGROUND_IMAGE_POINTS_COST, + async { + let generated_background = generate_match3d_background_image( + &state, + owner_user_id.as_str(), + session_id.as_str(), + profile_id.as_str(), + &config, + prompt.as_str(), + ) + .await?; + let mut assets = assets; + attach_match3d_background_asset_to_assets(&mut assets, generated_background.clone()); + let save_result = persist_match3d_generated_item_assets_snapshot( + &state, + &request_context, + &authenticated, + session_id.as_str(), + owner_user_id.as_str(), + profile_id.as_str(), + &assets, + ) + .await; + if let Err(response) = save_result { + tracing::warn!( + provider = MATCH3D_WORKS_PROVIDER, + profile_id, + owner_user_id = %owner_user_id, + status = %response.status(), + "抓大鹅 UI 背景图已生成但 SpacetimeDB 草稿写回不可用,降级返回本次生成资产" + ); + } + Ok((generated_background, assets)) + }, + ) + .await + .map_err(|error| match3d_error_response(&request_context, MATCH3D_WORKS_PROVIDER, error))?; + + let item = state + .spacetime_client() + .get_match3d_work_detail(profile_id.clone(), owner_user_id.clone()) + .await + .map(|item| map_match3d_work_profile_response(item)) + .unwrap_or_else(|error| { + tracing::warn!( + provider = MATCH3D_WORKS_PROVIDER, + profile_id, + owner_user_id = %owner_user_id, + error = %error, + "抓大鹅 UI 背景图生成后读取作品详情失败,降级使用写回前快照" + ); + map_match3d_work_profile_response(build_match3d_work_profile_record_with_assets( + profile, + &generated_assets, + )) + }); + let background_image_src = generated_background.image_src.clone().unwrap_or_default(); + let background_image_object_key = generated_background + .image_object_key + .clone() + .unwrap_or_default(); + + Ok(json_success_body( + Some(&request_context), + GenerateMatch3DBackgroundImageResponse { + item, + background_image_src, + background_image_object_key, + generated_background_asset: map_match3d_background_asset_for_work(generated_background), + prompt, + }, + )) +} + +pub async fn generate_match3d_container_image_for_work( + State(state): State, + Path(profile_id): Path, + Extension(request_context): Extension, + Extension(authenticated): Extension, + payload: Result, JsonRejection>, +) -> Result, Response> { + let Json(payload) = match3d_json(payload, &request_context, MATCH3D_WORKS_PROVIDER)?; + ensure_non_empty( + &request_context, + MATCH3D_WORKS_PROVIDER, + &profile_id, + "profileId", + )?; + let prompt = normalize_match3d_background_prompt(payload.prompt.as_str()); + ensure_non_empty(&request_context, MATCH3D_WORKS_PROVIDER, &prompt, "prompt")?; + let prompt_fingerprint = build_match3d_prompt_fingerprint(prompt.as_str()); + + let context = + load_match3d_work_asset_context(&state, &request_context, &authenticated, &profile_id) + .await?; + let Match3DWorkAssetContext { + owner_user_id, + session_id, + profile, + config, + assets, + } = context; + let billing_asset_id = format!( + "{}:{}:{}:container", + session_id, profile_id, prompt_fingerprint + ); + let (generated_background, generated_assets) = execute_billable_asset_operation_with_cost( + &state, + owner_user_id.as_str(), + "match3d_ui_container_image", + billing_asset_id.as_str(), + MATCH3D_BACKGROUND_IMAGE_POINTS_COST, + async { + let generated_container = generate_match3d_container_image( + &state, + owner_user_id.as_str(), + session_id.as_str(), + profile_id.as_str(), + &config, + prompt.as_str(), + ) + .await?; + let mut assets = assets; + let generated_background = + merge_match3d_container_image_into_background_asset(&assets, generated_container); + attach_match3d_background_asset_to_assets(&mut assets, generated_background.clone()); + let save_result = persist_match3d_generated_item_assets_snapshot( + &state, + &request_context, + &authenticated, + session_id.as_str(), + owner_user_id.as_str(), + profile_id.as_str(), + &assets, + ) + .await; + if let Err(response) = save_result { + tracing::warn!( + provider = MATCH3D_WORKS_PROVIDER, + profile_id, + owner_user_id = %owner_user_id, + status = %response.status(), + "抓大鹅容器形象已生成但 SpacetimeDB 草稿写回不可用,降级返回本次生成资产" + ); + } + Ok((generated_background, assets)) + }, + ) + .await + .map_err(|error| match3d_error_response(&request_context, MATCH3D_WORKS_PROVIDER, error))?; + + let item = state + .spacetime_client() + .get_match3d_work_detail(profile_id.clone(), owner_user_id.clone()) + .await + .map(|item| map_match3d_work_profile_response(item)) + .unwrap_or_else(|error| { + tracing::warn!( + provider = MATCH3D_WORKS_PROVIDER, + profile_id, + owner_user_id = %owner_user_id, + error = %error, + "抓大鹅容器形象生成后读取作品详情失败,降级使用写回前快照" + ); + map_match3d_work_profile_response(build_match3d_work_profile_record_with_assets( + profile, + &generated_assets, + )) + }); + let container_image_src = generated_background + .container_image_src + .clone() + .unwrap_or_default(); + let container_image_object_key = generated_background + .container_image_object_key + .clone() + .unwrap_or_default(); + + Ok(json_success_body( + Some(&request_context), + GenerateMatch3DContainerImageResponse { + item, + container_image_src, + container_image_object_key, + generated_background_asset: map_match3d_background_asset_for_work(generated_background), + prompt, + }, + )) +} + +pub async fn generate_match3d_item_assets_for_work( + State(state): State, + Path(profile_id): Path, + Extension(request_context): Extension, + Extension(authenticated): Extension, + payload: Result, JsonRejection>, +) -> Result, Response> { + let Json(payload) = match3d_json(payload, &request_context, MATCH3D_WORKS_PROVIDER)?; + ensure_non_empty( + &request_context, + MATCH3D_WORKS_PROVIDER, + &profile_id, + "profileId", + )?; + let item_names = normalize_match3d_batch_item_names(payload.item_names); + if item_names.is_empty() { + return Err(match3d_bad_request( + &request_context, + MATCH3D_WORKS_PROVIDER, + "请填写至少一个物品名称", + )); + } + let generation_mode = normalize_match3d_item_assets_generation_mode(payload.mode.as_deref()); + + let context = + load_match3d_work_asset_context(&state, &request_context, &authenticated, &profile_id) + .await?; + let Match3DWorkAssetContext { + owner_user_id, + session_id, + profile, + config, + assets, + } = context; + let generation_plan = + build_match3d_item_assets_generation_plan(generation_mode, item_names, &assets); + if generation_plan.billed_item_count() == 0 { + return Ok(json_success_body( + Some(&request_context), + GenerateMatch3DItemAssetsResponse { + item: map_match3d_work_profile_response(profile), + generated_item_assets: sort_match3d_generated_assets(assets) + .into_iter() + .map(Match3DGeneratedItemAssetJson::from) + .map(map_match3d_generated_item_asset_for_work) + .collect(), + }, + )); + } + let billed_item_count = generation_plan.billed_item_count(); + let points_cost = calculate_match3d_item_assets_points_cost(billed_item_count); + let billing_asset_id = format!( + "{}:{}:{}:{}", + session_id, + profile_id, + billed_item_count, + build_match3d_prompt_fingerprint(generation_plan.billing_fingerprint_source().as_str()) + ); + let generated_assets = execute_billable_asset_operation_with_cost( + &state, + owner_user_id.as_str(), + "match3d_item_assets", + billing_asset_id.as_str(), + points_cost, + async { + append_match3d_item_assets( + &state, + &request_context, + &authenticated, + owner_user_id.as_str(), + session_id.as_str(), + profile_id.as_str(), + &config, + generation_plan, + assets, + ) + .await + .map_err(|response| { + AppError::from_status(response.status()).with_details(json!({ + "provider": MATCH3D_WORKS_PROVIDER, + "message": "抓大鹅批量新增物品素材失败", + })) + }) + }, + ) + .await + .map_err(|error| match3d_error_response(&request_context, MATCH3D_WORKS_PROVIDER, error))?; + + let item = state + .spacetime_client() + .get_match3d_work_detail(profile_id, owner_user_id) + .await + .map_err(|error| { + match3d_error_response( + &request_context, + MATCH3D_WORKS_PROVIDER, + map_match3d_client_error(error), + ) + })?; + Ok(json_success_body( + Some(&request_context), + GenerateMatch3DItemAssetsResponse { + item: map_match3d_work_profile_response(item), + generated_item_assets: generated_assets + .into_iter() + .map(Match3DGeneratedItemAssetJson::from) + .map(map_match3d_generated_item_asset_for_work) + .collect(), + }, + )) +} + +pub async fn generate_match3d_work_tags( + State(state): State, + Extension(request_context): Extension, + Extension(_authenticated): Extension, + payload: Result, JsonRejection>, +) -> Result, Response> { + let Json(payload) = match3d_json(payload, &request_context, MATCH3D_WORKS_PROVIDER)?; + let tags = generate_match3d_work_tags_for_profile( + &state, + payload.game_name.as_str(), + payload.theme_text.as_str(), + payload.summary.as_deref(), + ) + .await; + + Ok(json_success_body( + Some(&request_context), + GenerateMatch3DWorkTagsResponse { tags }, + )) +} + +pub async fn publish_match3d_work( + State(state): State, + Path(profile_id): Path, + Extension(request_context): Extension, + Extension(authenticated): Extension, +) -> Result, Response> { + ensure_non_empty( + &request_context, + MATCH3D_WORKS_PROVIDER, + &profile_id, + "profileId", + )?; + + let item = state + .spacetime_client() + .publish_match3d_work( + profile_id, + authenticated.claims().user_id().to_string(), + current_utc_micros(), + ) + .await + .map_err(|error| { + match3d_error_response( + &request_context, + MATCH3D_WORKS_PROVIDER, + map_match3d_client_error(error), + ) + })?; + + Ok(json_success_body( + Some(&request_context), + Match3DWorkMutationResponse { + item: map_match3d_work_profile_response(item), + }, + )) +} + +pub async fn delete_match3d_work( + State(state): State, + Path(profile_id): Path, + Extension(request_context): Extension, + Extension(authenticated): Extension, +) -> Result, Response> { + ensure_non_empty( + &request_context, + MATCH3D_WORKS_PROVIDER, + &profile_id, + "profileId", + )?; + + let items = state + .spacetime_client() + .delete_match3d_work(profile_id, authenticated.claims().user_id().to_string()) + .await + .map_err(|error| { + match3d_error_response( + &request_context, + MATCH3D_WORKS_PROVIDER, + map_match3d_client_error(error), + ) + })?; + + Ok(json_success_body( + Some(&request_context), + Match3DWorksResponse { + items: items + .into_iter() + .map(map_match3d_work_summary_response) + .collect(), + }, + )) +} + +pub async fn start_match3d_run( + State(state): State, + Path(profile_id): Path, + Extension(request_context): Extension, + Extension(authenticated): Extension, + payload: Result, JsonRejection>, +) -> Result, Response> { + let maybe_payload = payload.ok().map(|Json(payload)| payload); + let profile_id = maybe_payload + .as_ref() + .map(|payload| payload.profile_id.clone()) + .filter(|value| !value.trim().is_empty()) + .unwrap_or(profile_id); + ensure_non_empty( + &request_context, + MATCH3D_RUNTIME_PROVIDER, + &profile_id, + "profileId", + )?; + + let run = state + .spacetime_client() + .start_match3d_run(Match3DRunStartRecordInput { + run_id: build_prefixed_uuid_id(MATCH3D_RUN_ID_PREFIX), + owner_user_id: authenticated.claims().user_id().to_string(), + profile_id: profile_id.clone(), + started_at_ms: current_utc_ms(), + item_type_count_override: maybe_payload + .as_ref() + .and_then(|payload| payload.item_type_count_override) + .unwrap_or(0), + }) + .await + .map_err(|error| { + match3d_error_response( + &request_context, + MATCH3D_RUNTIME_PROVIDER, + map_match3d_client_error(error), + ) + })?; + + record_work_play_start_after_success( + &state, + &request_context, + WorkPlayTrackingDraft::new( + "match3d", + profile_id.clone(), + &authenticated, + "/api/runtime/match3d/...", + ) + .profile_id(profile_id.clone()) + .extra(json!({ + "runId": run.run_id, + })), + ) + .await; + + Ok(json_success_body( + Some(&request_context), + Match3DRunResponse { + run: map_match3d_run_response(run), + }, + )) +} + +pub async fn get_match3d_run( + State(state): State, + Path(run_id): Path, + Extension(request_context): Extension, + Extension(authenticated): Extension, +) -> Result, Response> { + ensure_non_empty(&request_context, MATCH3D_RUNTIME_PROVIDER, &run_id, "runId")?; + + let run = state + .spacetime_client() + .get_match3d_run(run_id, authenticated.claims().user_id().to_string()) + .await + .map_err(|error| { + match3d_error_response( + &request_context, + MATCH3D_RUNTIME_PROVIDER, + map_match3d_client_error(error), + ) + })?; + + Ok(json_success_body( + Some(&request_context), + Match3DRunResponse { + run: map_match3d_run_response(run), + }, + )) +} + +pub async fn click_match3d_item( + State(state): State, + Path(run_id): Path, + Extension(request_context): Extension, + Extension(authenticated): Extension, + payload: Result, JsonRejection>, +) -> Result, Response> { + let Json(payload) = match3d_json(payload, &request_context, MATCH3D_RUNTIME_PROVIDER)?; + ensure_non_empty(&request_context, MATCH3D_RUNTIME_PROVIDER, &run_id, "runId")?; + ensure_non_empty( + &request_context, + MATCH3D_RUNTIME_PROVIDER, + &payload.item_instance_id, + "itemInstanceId", + )?; + ensure_non_empty( + &request_context, + MATCH3D_RUNTIME_PROVIDER, + &payload.client_event_id, + "clientEventId", + )?; + + let confirmation = state + .spacetime_client() + .click_match3d_item(Match3DRunClickRecordInput { + run_id: payload.run_id.unwrap_or(run_id), + owner_user_id: authenticated.claims().user_id().to_string(), + item_instance_id: payload.item_instance_id, + client_snapshot_version: payload.client_snapshot_version.min(u32::MAX as u64) as u32, + client_event_id: payload.client_event_id, + clicked_at_ms: payload.clicked_at_ms.min(i64::MAX as u64) as i64, + }) + .await + .map_err(|error| { + match3d_error_response( + &request_context, + MATCH3D_RUNTIME_PROVIDER, + map_match3d_client_error(error), + ) + })?; + + Ok(json_success_body( + Some(&request_context), + Match3DClickResponse { + confirmation: map_match3d_click_confirmation_response(confirmation), + }, + )) +} + +pub async fn stop_match3d_run( + State(state): State, + Path(run_id): Path, + Extension(request_context): Extension, + Extension(authenticated): Extension, + payload: Result, JsonRejection>, +) -> Result, Response> { + let _ = payload.ok(); + ensure_non_empty(&request_context, MATCH3D_RUNTIME_PROVIDER, &run_id, "runId")?; + + let run = state + .spacetime_client() + .stop_match3d_run(Match3DRunStopRecordInput { + run_id, + owner_user_id: authenticated.claims().user_id().to_string(), + stopped_at_ms: current_utc_ms(), + }) + .await + .map_err(|error| { + match3d_error_response( + &request_context, + MATCH3D_RUNTIME_PROVIDER, + map_match3d_client_error(error), + ) + })?; + + Ok(json_success_body( + Some(&request_context), + Match3DRunResponse { + run: map_match3d_run_response(run), + }, + )) +} + +pub async fn restart_match3d_run( + State(state): State, + Path(run_id): Path, + Extension(request_context): Extension, + Extension(authenticated): Extension, +) -> Result, Response> { + ensure_non_empty(&request_context, MATCH3D_RUNTIME_PROVIDER, &run_id, "runId")?; + + let run = state + .spacetime_client() + .restart_match3d_run(Match3DRunRestartRecordInput { + source_run_id: run_id, + next_run_id: build_prefixed_uuid_id(MATCH3D_RUN_ID_PREFIX), + owner_user_id: authenticated.claims().user_id().to_string(), + restarted_at_ms: current_utc_ms(), + }) + .await + .map_err(|error| { + match3d_error_response( + &request_context, + MATCH3D_RUNTIME_PROVIDER, + map_match3d_client_error(error), + ) + })?; + + Ok(json_success_body( + Some(&request_context), + Match3DRunResponse { + run: map_match3d_run_response(run), + }, + )) +} + +pub async fn finish_match3d_time_up( + State(state): State, + Path(run_id): Path, + Extension(request_context): Extension, + Extension(authenticated): Extension, +) -> Result, Response> { + ensure_non_empty(&request_context, MATCH3D_RUNTIME_PROVIDER, &run_id, "runId")?; + + let run = state + .spacetime_client() + .finish_match3d_time_up(Match3DRunTimeUpRecordInput { + run_id, + owner_user_id: authenticated.claims().user_id().to_string(), + finished_at_ms: current_utc_ms(), + }) + .await + .map_err(|error| { + match3d_error_response( + &request_context, + MATCH3D_RUNTIME_PROVIDER, + map_match3d_client_error(error), + ) + })?; + + Ok(json_success_body( + Some(&request_context), + Match3DRunResponse { + run: map_match3d_run_response(run), + }, + )) +} diff --git a/server-rs/crates/api-server/src/match3d/item_assets.rs b/server-rs/crates/api-server/src/match3d/item_assets.rs new file mode 100644 index 00000000..ab6d59c7 --- /dev/null +++ b/server-rs/crates/api-server/src/match3d/item_assets.rs @@ -0,0 +1,2631 @@ +use super::*; + +pub(super) async fn generate_match3d_item_assets( + state: &AppState, + request_context: &RequestContext, + authenticated: &AuthenticatedAccessToken, + owner_user_id: &str, + session_id: &str, + profile_id: &str, + config: &Match3DConfigJson, + item_plan: Vec, + existing_assets: Vec, +) -> Result, Response> { + // 中文注释:抓大鹅音频生成当前关闭;自动草稿只补齐 2D 物品图片和可选点击音效。 + let target_item_count = resolve_match3d_generated_item_count(config); + let mut assets = normalize_match3d_generated_item_assets_for_resume(existing_assets); + if has_match3d_required_generated_assets(&assets, target_item_count, config) { + return Ok(assets.into_iter().take(target_item_count).collect()); + } + + if !has_match3d_required_item_images(&assets, target_item_count) { + assets = ensure_match3d_item_image_assets( + state, + request_context, + authenticated, + owner_user_id, + session_id, + profile_id, + config, + item_plan, + assets, + ) + .await?; + } + assets = ensure_match3d_click_sound_assets( + state, + request_context, + authenticated, + owner_user_id, + session_id, + profile_id, + config, + assets, + ) + .await?; + persist_match3d_generated_item_assets_snapshot( + state, + request_context, + authenticated, + session_id, + owner_user_id, + profile_id, + &assets, + ) + .await?; + + Ok(assets.into_iter().take(target_item_count).collect()) +} + +#[allow(clippy::too_many_arguments)] +async fn ensure_match3d_item_image_assets( + state: &AppState, + request_context: &RequestContext, + authenticated: &AuthenticatedAccessToken, + owner_user_id: &str, + session_id: &str, + profile_id: &str, + config: &Match3DConfigJson, + item_plan: Vec, + existing_assets: Vec, +) -> Result, Response> { + let mut assets = normalize_match3d_generated_item_assets_for_resume(existing_assets); + let target_item_count = resolve_match3d_generated_item_count(config); + let item_plan = normalize_match3d_item_plan(config, item_plan); + let missing_items = item_plan + .iter() + .take(target_item_count) + .enumerate() + .filter_map(|(index, item)| { + let item_id = format!("match3d-item-{}", index + 1); + if assets.iter().any(|asset| { + asset.item_id == item_id && is_match3d_generated_asset_image_ready(asset) + }) { + return None; + } + Some(Match3DItemImageGenerationSeed { + item_id, + item_name: item.name.clone(), + item_size: item.item_size.clone(), + sound_prompt: item.sound_prompt.clone(), + persist_asset: true, + background_music_title: None, + background_music_style: None, + background_music_prompt: None, + background_asset: if index == 0 { + assets + .first() + .and_then(|asset| asset.background_asset.clone()) + } else { + None + }, + }) + }) + .collect::>(); + + let generated_assets = generate_match3d_item_image_assets_in_batches( + state, + request_context, + MATCH3D_AGENT_PROVIDER, + owner_user_id, + session_id, + profile_id, + config, + missing_items, + ) + .await?; + + for generated_asset in generated_assets + .into_iter() + .filter(|generated| generated.persist_asset) + .map(|generated| generated.asset) + { + upsert_match3d_generated_item_asset(&mut assets, generated_asset); + persist_match3d_generated_item_assets_snapshot( + state, + request_context, + authenticated, + session_id, + owner_user_id, + profile_id, + &assets, + ) + .await?; + } + + Ok(assets) +} + +#[derive(Clone)] +struct Match3DItemImageGenerationSeed { + item_id: String, + item_name: String, + item_size: String, + sound_prompt: String, + persist_asset: bool, + background_music_title: Option, + background_music_style: Option, + background_music_prompt: Option, + background_asset: Option, +} + +struct Match3DMaterialBatchOutput { + task_id: String, + generated_at_micros: i64, + items: Vec<(Match3DItemImageGenerationSeed, Vec)>, +} + +struct Match3DGeneratedItemImageAssetOutput { + asset: Match3DGeneratedItemAsset, + persist_asset: bool, +} + +#[allow(clippy::too_many_arguments)] +async fn generate_match3d_item_image_assets_in_batches( + state: &AppState, + request_context: &RequestContext, + provider: &str, + owner_user_id: &str, + session_id: &str, + profile_id: &str, + config: &Match3DConfigJson, + item_seeds: Vec, +) -> Result, Response> { + if item_seeds.is_empty() { + return Ok(Vec::new()); + } + require_match3d_oss_client(state) + .map_err(|error| match3d_error_response(request_context, provider, error))?; + + let mut batch_tasks = item_seeds + .chunks(MATCH3D_MATERIAL_ITEM_BATCH_SIZE) + .map(|chunk| { + let chunk_seeds = chunk.to_vec(); + async move { + let item_names = chunk_seeds + .iter() + .map(|item| item.item_name.clone()) + .collect::>(); + let material_sheet = + generate_match3d_material_sheet(state, config, &item_names).await?; + let generated_at_micros = current_utc_micros(); + let persisted_seed_count = chunk_seeds + .iter() + .position(|seed| !seed.persist_asset) + .unwrap_or(chunk_seeds.len()); + debug_assert!( + chunk_seeds[persisted_seed_count..] + .iter() + .all(|seed| !seed.persist_asset) + ); + let persisted_seeds = chunk_seeds + .into_iter() + .take(persisted_seed_count) + .collect::>(); + let persisted_item_names = persisted_seeds + .iter() + .map(|item| item.item_name.clone()) + .collect::>(); + let item_images = + slice_match3d_material_sheet(&material_sheet.image, &persisted_item_names)?; + Ok::<_, AppError>(Match3DMaterialBatchOutput { + task_id: material_sheet.task_id, + generated_at_micros, + items: persisted_seeds + .into_iter() + .zip(item_images.into_iter()) + .collect::>(), + }) + } + }) + .collect::>(); + + let mut batches = Vec::new(); + while let Some(batch_result) = batch_tasks.next().await { + batches.push( + batch_result + .map_err(|error| match3d_error_response(request_context, provider, error))?, + ); + } + + let mut generated_assets = Vec::new(); + for batch in batches { + let sheet_task_id = batch.task_id; + let generated_at_micros = batch.generated_at_micros; + for (item_index, (seed, item_images)) in batch.items.into_iter().enumerate() { + let item_slug = build_match3d_item_slug(seed.item_id.as_str(), seed.item_name.as_str()); + let mut image_views = Vec::with_capacity(item_images.len()); + for (view_index, item_image) in item_images.into_iter().enumerate() { + let view_number = view_index + 1; + let view_upload = persist_match3d_generated_bytes( + state, + owner_user_id, + session_id, + profile_id, + &["items", item_slug.as_str(), "views"], + format!("view-{view_number:02}.png").as_str(), + "image/png", + item_image.bytes, + "match3d_item_image_view", + Some(sheet_task_id.as_str()), + generated_at_micros.saturating_add( + (item_index * MATCH3D_ITEM_VIEW_COUNT + view_index) as i64 + 1, + ), + ) + .await + .map_err(|error| match3d_error_response(request_context, provider, error))?; + image_views.push(Match3DGeneratedItemImageView { + view_id: format!("view-{view_number:02}"), + view_index: view_number as u32, + image_src: Some(view_upload.src), + image_object_key: Some(view_upload.object_key), + }); + } + let primary_view = image_views.first().cloned(); + generated_assets.push(Match3DGeneratedItemImageAssetOutput { + persist_asset: seed.persist_asset, + asset: Match3DGeneratedItemAsset { + item_id: seed.item_id, + item_name: seed.item_name, + item_size: Some(normalize_match3d_item_size(seed.item_size.as_str())) + .filter(|value| !value.is_empty()) + .or_else(|| Some(MATCH3D_ITEM_SIZE_LARGE.to_string())), + image_src: primary_view + .as_ref() + .and_then(|view| view.image_src.clone()), + image_object_key: primary_view + .as_ref() + .and_then(|view| view.image_object_key.clone()), + image_views, + model_src: None, + model_object_key: None, + model_file_name: None, + task_uuid: None, + subscription_key: None, + sound_prompt: Some(seed.sound_prompt), + background_music_title: seed.background_music_title, + background_music_style: seed.background_music_style, + background_music_prompt: seed.background_music_prompt, + background_music: None, + click_sound: None, + background_asset: seed.background_asset, + status: "image_ready".to_string(), + error: None, + }, + }); + } + } + + generated_assets.sort_by(|left, right| { + match3d_item_sort_index(left.asset.item_id.as_str()) + .cmp(&match3d_item_sort_index(right.asset.item_id.as_str())) + .then_with(|| left.asset.item_id.cmp(&right.asset.item_id)) + }); + Ok(generated_assets) +} + +#[allow(clippy::too_many_arguments)] +pub(super) async fn append_match3d_item_assets( + state: &AppState, + request_context: &RequestContext, + authenticated: &AuthenticatedAccessToken, + owner_user_id: &str, + session_id: &str, + profile_id: &str, + config: &Match3DConfigJson, + generation_plan: Match3DItemAssetsGenerationPlan, + existing_assets: Vec, +) -> Result, Response> { + match generation_plan { + Match3DItemAssetsGenerationPlan::Append(append_plan) => { + append_match3d_new_item_assets( + state, + request_context, + authenticated, + owner_user_id, + session_id, + profile_id, + config, + append_plan, + existing_assets, + ) + .await + } + Match3DItemAssetsGenerationPlan::Replace(replace_plan) => { + replace_match3d_item_assets( + state, + request_context, + authenticated, + owner_user_id, + session_id, + profile_id, + config, + replace_plan, + existing_assets, + ) + .await + } + } +} + +#[allow(clippy::too_many_arguments)] +async fn ensure_match3d_click_sound_assets( + state: &AppState, + request_context: &RequestContext, + authenticated: &AuthenticatedAccessToken, + owner_user_id: &str, + session_id: &str, + profile_id: &str, + config: &Match3DConfigJson, + assets: Vec, +) -> Result, Response> { + if !config.generate_click_sound { + return Ok(assets); + } + + let mut assets = normalize_match3d_generated_item_assets_for_resume(assets); + let seeds = assets + .iter() + .filter(|asset| is_match3d_generated_asset_image_ready(asset)) + .filter(|asset| asset.click_sound.is_none()) + .cloned() + .collect::>(); + if seeds.is_empty() { + return Ok(assets); + } + + let mut sound_tasks = seeds + .into_iter() + .map(|asset| async move { + let prompt = asset + .sound_prompt + .clone() + .filter(|value| !value.trim().is_empty()) + .unwrap_or_else(|| { + build_fallback_match3d_item_sound_prompt(config, asset.item_name.as_str()) + }); + let result = generate_match3d_click_sound_asset( + state, + owner_user_id, + profile_id, + asset.item_id.as_str(), + asset.item_name.as_str(), + prompt.as_str(), + ) + .await; + (asset, prompt, result) + }) + .collect::>(); + + while let Some((mut asset, prompt, result)) = sound_tasks.next().await { + match result { + Ok(click_sound) => { + asset.sound_prompt = Some(prompt); + asset.click_sound = Some(click_sound); + asset.error = None; + } + Err(error) => { + tracing::warn!( + provider = MATCH3D_AGENT_PROVIDER, + session_id, + profile_id, + item_id = asset.item_id.as_str(), + error = %error, + "抓大鹅入口内联点击音效生成失败,保留草稿并允许结果页重试" + ); + } + } + upsert_match3d_generated_item_asset(&mut assets, asset); + persist_match3d_generated_item_assets_snapshot( + state, + request_context, + authenticated, + session_id, + owner_user_id, + profile_id, + &assets, + ) + .await?; + } + + Ok(assets) +} + +async fn generate_match3d_click_sound_asset( + state: &AppState, + owner_user_id: &str, + profile_id: &str, + item_id: &str, + item_name: &str, + prompt: &str, +) -> Result { + let mut asset = generate_sound_effect_asset_for_creation( + state, + owner_user_id, + prompt.to_string(), + Some(3), + None, + GeneratedCreationAudioTarget { + entity_kind: "match3d_item".to_string(), + entity_id: item_id.to_string(), + slot: "click_sound".to_string(), + asset_kind: MATCH3D_CLICK_SOUND_ASSET_KIND.to_string(), + profile_id: Some(profile_id.to_string()), + storage_prefix: LegacyAssetPrefix::Match3DAssets, + }, + ) + .await?; + asset.title = Some(format!("{item_name}点击音效")); + Ok(asset) +} + +#[allow(clippy::too_many_arguments)] +async fn append_match3d_new_item_assets( + state: &AppState, + request_context: &RequestContext, + authenticated: &AuthenticatedAccessToken, + owner_user_id: &str, + session_id: &str, + profile_id: &str, + config: &Match3DConfigJson, + append_plan: Match3DItemAssetAppendPlan, + existing_assets: Vec, +) -> Result, Response> { + let mut assets = sort_match3d_generated_assets(existing_assets); + let existing_item_count = assets.len(); + let requested_item_count = append_plan.requested_item_names.len(); + if requested_item_count == 0 { + return Ok(assets); + } + let mut next_item_index = next_match3d_generated_item_index(&assets); + let item_seeds = append_plan + .padded_item_names + .into_iter() + .enumerate() + .map(|(index, item_name)| { + let item_id = allocate_match3d_generated_item_id(&assets, &mut next_item_index); + Match3DItemImageGenerationSeed { + item_id, + item_size: infer_match3d_item_size(item_name.as_str()), + sound_prompt: build_fallback_match3d_item_sound_prompt(config, item_name.as_str()), + item_name, + persist_asset: index < requested_item_count, + background_music_title: None, + background_music_style: None, + background_music_prompt: None, + background_asset: None, + } + }) + .collect::>(); + let generated_assets = generate_match3d_item_image_assets_in_batches( + state, + request_context, + MATCH3D_WORKS_PROVIDER, + owner_user_id, + session_id, + profile_id, + config, + item_seeds, + ) + .await?; + for generated_asset in generated_assets + .into_iter() + .filter(|generated| generated.persist_asset) + .map(|generated| generated.asset) + { + upsert_match3d_generated_item_asset(&mut assets, generated_asset); + persist_match3d_generated_item_assets_snapshot( + state, + request_context, + authenticated, + session_id, + owner_user_id, + profile_id, + &assets, + ) + .await?; + } + ensure_match3d_click_sound_assets( + state, + request_context, + authenticated, + owner_user_id, + session_id, + profile_id, + config, + assets, + ) + .await + .map(|assets| { + sort_match3d_generated_assets(assets) + .into_iter() + .take(existing_item_count + requested_item_count) + .collect() + }) +} + +#[allow(clippy::too_many_arguments)] +async fn replace_match3d_item_assets( + state: &AppState, + request_context: &RequestContext, + authenticated: &AuthenticatedAccessToken, + owner_user_id: &str, + session_id: &str, + profile_id: &str, + config: &Match3DConfigJson, + replace_plan: Match3DItemAssetReplacePlan, + existing_assets: Vec, +) -> Result, Response> { + let mut assets = sort_match3d_generated_assets(existing_assets); + if replace_plan.target_assets.is_empty() { + return Ok(assets); + } + let target_by_name = replace_plan + .target_assets + .iter() + .map(|asset| (asset.item_name.trim().to_string(), asset.clone())) + .collect::>(); + let mut next_item_index = next_match3d_generated_item_index(&assets); + let requested_item_count = replace_plan.requested_item_names.len(); + let item_seeds = replace_plan + .padded_item_names + .into_iter() + .enumerate() + .map(|(index, item_name)| { + let matched_asset = target_by_name.get(item_name.trim()).cloned(); + let item_id = matched_asset + .as_ref() + .map(|asset| asset.item_id.clone()) + .unwrap_or_else(|| { + allocate_match3d_generated_item_id(&assets, &mut next_item_index) + }); + Match3DItemImageGenerationSeed { + item_id, + item_size: matched_asset + .as_ref() + .and_then(|asset| asset.item_size.clone()) + .map(|value| normalize_match3d_item_size(value.as_str())) + .filter(|value| !value.is_empty()) + .unwrap_or_else(|| infer_match3d_item_size(item_name.as_str())), + sound_prompt: matched_asset + .as_ref() + .and_then(|asset| asset.sound_prompt.clone()) + .filter(|value| !value.trim().is_empty()) + .unwrap_or_else(|| { + build_fallback_match3d_item_sound_prompt(config, item_name.as_str()) + }), + item_name, + persist_asset: index < requested_item_count, + background_music_title: matched_asset + .as_ref() + .and_then(|asset| asset.background_music_title.clone()), + background_music_style: matched_asset + .as_ref() + .and_then(|asset| asset.background_music_style.clone()), + background_music_prompt: matched_asset + .as_ref() + .and_then(|asset| asset.background_music_prompt.clone()), + background_asset: matched_asset + .as_ref() + .and_then(|asset| asset.background_asset.clone()), + } + }) + .collect::>(); + let generated_assets = generate_match3d_item_image_assets_in_batches( + state, + request_context, + MATCH3D_WORKS_PROVIDER, + owner_user_id, + session_id, + profile_id, + config, + item_seeds, + ) + .await?; + for generated_asset in generated_assets + .into_iter() + .filter(|generated| generated.persist_asset) + .map(|generated| generated.asset) + { + let current_asset = assets + .iter() + .find(|candidate| candidate.item_id == generated_asset.item_id) + .cloned(); + upsert_match3d_generated_item_asset( + &mut assets, + merge_regenerated_match3d_item_asset(current_asset, generated_asset), + ); + persist_match3d_generated_item_assets_snapshot( + state, + request_context, + authenticated, + session_id, + owner_user_id, + profile_id, + &assets, + ) + .await?; + } + ensure_match3d_click_sound_assets( + state, + request_context, + authenticated, + owner_user_id, + session_id, + profile_id, + config, + assets, + ) + .await + .map(sort_match3d_generated_assets) +} + +pub(super) struct Match3DMaterialSheet { + pub(super) task_id: String, + pub(super) image: DownloadedOpenAiImage, +} + +pub(super) struct Match3DVectorEngineGeminiImageSettings { + pub(super) base_url: String, + pub(super) api_key: String, + pub(super) request_timeout_ms: u64, +} + +pub(super) struct Match3DSlicedItemImage { + pub(super) bytes: Vec, +} +pub(super) fn normalize_match3d_item_name(raw: &str) -> String { + raw.trim() + .trim_matches(['"', '\'', '“', '”', '。', ',', ',', '、']) + .chars() + .filter(|character| !character.is_control()) + .take(12) + .collect::() + .trim() + .to_string() +} + +pub(super) fn normalize_match3d_item_size(raw: &str) -> String { + let normalized = raw + .trim() + .trim_matches(['"', '\'', '“', '”', '。', ',', ',', '、']); + match normalized { + "大" | "大型" | "偏大" | "large" | "Large" | "L" | "l" => { + MATCH3D_ITEM_SIZE_LARGE.to_string() + } + "中" | "中型" | "中等" | "medium" | "Medium" | "M" | "m" => { + MATCH3D_ITEM_SIZE_MEDIUM.to_string() + } + "小" | "小型" | "偏小" | "small" | "Small" | "S" | "s" => { + MATCH3D_ITEM_SIZE_SMALL.to_string() + } + _ => String::new(), + } +} + +pub(super) fn infer_match3d_item_size(item_name: &str) -> String { + let name = item_name.trim(); + let large_keywords = [ + "西瓜", "南瓜", "椰子", "箱", "盒", "桶", "盆", "锅", "坛", "瓶子", "大瓶", "包", "书包", + "枕", "抱枕", "玩偶", "球", "圆球", "足球", "篮球", "鼓", + ]; + if large_keywords.iter().any(|keyword| name.contains(keyword)) { + return MATCH3D_ITEM_SIZE_LARGE.to_string(); + } + let small_keywords = [ + "草莓", "蓝莓", "葡萄", "樱桃", "莓", "糖", "糖果", "钥匙", "硬币", "纽扣", "徽章", "戒指", + "耳环", "铃铛", "星星", "宝石", "叶片", "花瓣", "蘑菇", "贝壳", "印章", "彩蛋", "棋子", + "骰子", "挂件", + ]; + if small_keywords.iter().any(|keyword| name.contains(keyword)) { + return MATCH3D_ITEM_SIZE_SMALL.to_string(); + } + MATCH3D_ITEM_SIZE_MEDIUM.to_string() +} + +pub(super) fn fallback_match3d_item_names(theme_text: &str) -> Vec { + let theme = theme_text.trim(); + let normalized_theme = if theme.is_empty() { "主题" } else { theme }; + [ + "小物件", + "徽章", + "摆件", + "挂件", + "圆球", + "方块", + "钥匙", + "杯子", + "糖果", + "星星", + "宝石", + "铃铛", + "叶片", + "蘑菇", + "花朵", + "果冻", + "小瓶", + "帽子", + "贝壳", + "纽扣", + "积木", + "印章", + "彩蛋", + "小鼓", + "风车", + ] + .into_iter() + .map(|suffix| format!("{normalized_theme}{suffix}")) + .take(MATCH3D_MAX_GENERATED_ITEM_COUNT) + .collect() +} + +pub(super) fn normalize_match3d_item_plan( + config: &Match3DConfigJson, + items: Vec, +) -> Vec { + let target_item_count = resolve_match3d_generated_item_count(config); + let mut normalized = Vec::new(); + for item in items { + let name = normalize_match3d_item_name(item.name.as_str()); + if name.is_empty() + || normalized + .iter() + .any(|candidate: &Match3DGeneratedItemPlan| candidate.name == name) + { + continue; + } + let sound_prompt = normalize_match3d_audio_prompt(item.sound_prompt.as_str()); + let item_size = normalize_match3d_item_size(item.item_size.as_str()); + normalized.push(Match3DGeneratedItemPlan { + item_size: if item_size.is_empty() { + infer_match3d_item_size(&name) + } else { + item_size + }, + sound_prompt: if sound_prompt.is_empty() { + build_fallback_match3d_item_sound_prompt(config, &name) + } else { + sound_prompt + }, + name, + }); + if normalized.len() >= target_item_count { + break; + } + } + + if normalized.len() < target_item_count { + for name in fallback_match3d_item_names(config.theme_text.as_str()) { + if normalized.iter().any(|candidate| candidate.name == name) { + continue; + } + normalized.push(Match3DGeneratedItemPlan { + item_size: infer_match3d_item_size(&name), + sound_prompt: build_fallback_match3d_item_sound_prompt(config, &name), + name, + }); + if normalized.len() >= target_item_count { + break; + } + } + } + + if normalized.len() < target_item_count { + fill_match3d_item_plan_to_count(config, &mut normalized, target_item_count); + } + + normalized +} + +fn fill_match3d_item_plan_to_count( + config: &Match3DConfigJson, + normalized: &mut Vec, + target_item_count: usize, +) { + let normalized_theme = config.theme_text.trim(); + let fallback_prefix = if normalized_theme.is_empty() { + "补充物品".to_string() + } else { + format!("{normalized_theme}补充") + }; + let mut index = 1usize; + while normalized.len() < target_item_count { + let name = normalize_match3d_item_name(format!("{fallback_prefix}{index}").as_str()); + if !name.is_empty() + && !normalized + .iter() + .any(|candidate: &Match3DGeneratedItemPlan| candidate.name == name) + { + normalized.push(Match3DGeneratedItemPlan { + item_size: infer_match3d_item_size(&name), + sound_prompt: build_fallback_match3d_item_sound_prompt(config, &name), + name, + }); + } + index += 1; + } +} + +pub(super) fn normalize_match3d_batch_item_names(items: Vec) -> Vec { + let mut normalized: Vec = Vec::new(); + for item in items { + let name = normalize_match3d_item_name(item.as_str()); + if name.is_empty() || normalized.iter().any(|candidate| candidate == &name) { + continue; + } + normalized.push(name); + if normalized.len() >= MATCH3D_MAX_GENERATED_ITEM_COUNT { + break; + } + } + normalized +} + +pub(super) fn normalize_match3d_item_assets_generation_mode( + mode: Option<&str>, +) -> Match3DItemAssetsGenerationMode { + match mode + .unwrap_or_default() + .trim() + .to_ascii_lowercase() + .as_str() + { + "replace" | "regenerate" => Match3DItemAssetsGenerationMode::Replace, + _ => Match3DItemAssetsGenerationMode::Append, + } +} + +pub(super) fn build_match3d_item_assets_generation_plan( + mode: Match3DItemAssetsGenerationMode, + item_names: Vec, + existing_assets: &[Match3DGeneratedItemAsset], +) -> Match3DItemAssetsGenerationPlan { + match mode { + Match3DItemAssetsGenerationMode::Append => Match3DItemAssetsGenerationPlan::Append( + build_match3d_item_asset_append_plan(item_names, existing_assets), + ), + Match3DItemAssetsGenerationMode::Replace => Match3DItemAssetsGenerationPlan::Replace( + build_match3d_item_asset_replace_plan(item_names, existing_assets), + ), + } +} + +pub(super) fn build_match3d_item_asset_append_plan( + item_names: Vec, + existing_assets: &[Match3DGeneratedItemAsset], +) -> Match3DItemAssetAppendPlan { + let available_capacity = MATCH3D_MAX_GENERATED_ITEM_COUNT.saturating_sub(existing_assets.len()); + let mut requested_item_names = item_names + .into_iter() + .filter(|name| { + !existing_assets + .iter() + .any(|asset| asset.item_name.trim() == name.trim()) + }) + .take(available_capacity) + .collect::>(); + requested_item_names.truncate(available_capacity); + let padded_item_names = build_match3d_padded_item_names_for_generation( + &requested_item_names, + existing_assets, + available_capacity, + ); + + Match3DItemAssetAppendPlan { + requested_item_names, + padded_item_names, + } +} + +fn build_match3d_padded_item_names_for_generation( + item_names: &[String], + existing_assets: &[Match3DGeneratedItemAsset], + available_capacity: usize, +) -> Vec { + let mut padded = item_names + .iter() + .take(available_capacity) + .cloned() + .collect::>(); + let target_item_count = round_match3d_item_count_to_full_sheet(padded.len()); + let mut fallback_index = 1usize; + while padded.len() < target_item_count { + let candidate = normalize_match3d_item_name(format!("追加物品{fallback_index}").as_str()); + fallback_index += 1; + if candidate.is_empty() + || padded.iter().any(|name| name == &candidate) + || existing_assets + .iter() + .any(|asset| asset.item_name.trim() == candidate.as_str()) + { + continue; + } + padded.push(candidate); + } + padded +} + +pub(super) fn build_match3d_item_asset_replace_plan( + item_names: Vec, + existing_assets: &[Match3DGeneratedItemAsset], +) -> Match3DItemAssetReplacePlan { + let mut requested_item_names = Vec::new(); + let mut target_assets = Vec::new(); + for item_name in item_names { + let Some(asset) = existing_assets + .iter() + .find(|asset| asset.item_name.trim() == item_name.trim()) + else { + continue; + }; + if target_assets + .iter() + .any(|candidate: &Match3DGeneratedItemAsset| candidate.item_id == asset.item_id) + { + continue; + } + requested_item_names.push(asset.item_name.clone()); + target_assets.push(asset.clone()); + if requested_item_names.len() >= MATCH3D_MAX_GENERATED_ITEM_COUNT { + break; + } + } + let padded_item_names = build_match3d_padded_item_names_for_generation( + &requested_item_names, + existing_assets, + MATCH3D_MAX_GENERATED_ITEM_COUNT, + ); + + Match3DItemAssetReplacePlan { + requested_item_names, + padded_item_names, + target_assets, + } +} + +pub(super) fn calculate_match3d_item_assets_points_cost(item_count: usize) -> u64 { + if item_count == 0 { + return 0; + } + item_count.div_ceil(MATCH3D_MATERIAL_ITEM_BATCH_SIZE) as u64 + * MATCH3D_ITEM_ASSETS_POINTS_PER_BATCH +} + +pub(super) fn normalize_match3d_cover_prompt(raw: &str) -> String { + raw.trim() + .chars() + .filter(|character| !character.is_control()) + .take(900) + .collect::() + .trim() + .to_string() +} + +pub(super) fn normalize_match3d_audio_prompt(raw: &str) -> String { + raw.trim() + .chars() + .filter(|character| !character.is_control()) + .take(500) + .collect::() + .trim() + .to_string() +} + +pub(super) fn normalize_match3d_background_prompt(raw: &str) -> String { + raw.trim() + .chars() + .filter(|character| !character.is_control()) + .take(900) + .collect::() + .trim() + .to_string() +} + +pub(super) fn build_match3d_prompt_fingerprint(value: &str) -> String { + let mut hash = 0u32; + for character in value.chars() { + hash = hash.wrapping_mul(31).wrapping_add(character as u32); + } + format!("{hash:08x}") +} + +pub(super) fn build_fallback_match3d_background_prompt(config: &Match3DConfigJson) -> String { + let theme = config.theme_text.trim(); + let normalized_theme = if theme.is_empty() { "抓大鹅" } else { theme }; + normalize_match3d_background_prompt( + format!( + "{normalized_theme}题材抓大鹅游戏竖屏纯背景图,表现题材环境、绿色纵向渐变和轻快休闲氛围,中央区域保持干净通透,方便运行态叠加默认交互容器。无锅、无圆盘、无托盘、无拼图槽、无物品槽、无文字、无水印、无 UI、无按钮、无倒计时、无物品、无角色、无手。" + ) + .as_str(), + ) +} + +pub(super) fn build_fallback_match3d_item_sound_prompt(config: &Match3DConfigJson, item_name: &str) -> String { + let theme = config.theme_text.trim(); + let normalized_theme = if theme.is_empty() { "抓大鹅" } else { theme }; + normalize_match3d_audio_prompt( + format!( + "{normalized_theme}题材抓大鹅中“{item_name}”被点击并消除时的短促反馈音效,清脆、可爱、有轻微弹跳感,适合移动端休闲游戏。" + ) + .as_str(), + ) +} + +pub(super) fn normalize_match3d_generated_item_assets_for_resume( + assets: Vec, +) -> Vec { + let mut normalized = Vec::new(); + for asset in sort_match3d_generated_assets(assets) { + if asset.item_id.trim().is_empty() + || normalized + .iter() + .any(|candidate: &Match3DGeneratedItemAsset| candidate.item_id == asset.item_id) + { + continue; + } + normalized.push(asset); + if normalized.len() >= MATCH3D_MAX_GENERATED_ITEM_COUNT { + break; + } + } + normalized +} + +pub(super) fn resolve_match3d_gameplay_item_count(config: &Match3DConfigJson) -> usize { + match config.clear_count { + 8 => 3, + 12 => 9, + 16 => 15, + 20 | 21 => 21, + _ => match config.difficulty { + 0..=2 => 3, + 3..=4 => 9, + 5..=6 => 15, + _ => 21, + }, + } + .min(MATCH3D_MAX_GENERATED_ITEM_COUNT) +} + +pub(super) fn resolve_match3d_generated_item_count(config: &Match3DConfigJson) -> usize { + round_match3d_item_count_to_full_sheet(resolve_match3d_gameplay_item_count(config)) + .min(MATCH3D_MAX_GENERATED_ITEM_COUNT) +} + +fn round_match3d_item_count_to_full_sheet(item_count: usize) -> usize { + if item_count == 0 { + return 0; + } + item_count.div_ceil(MATCH3D_MATERIAL_ITEM_BATCH_SIZE) * MATCH3D_MATERIAL_ITEM_BATCH_SIZE +} + +pub(super) fn sort_match3d_generated_assets( + mut assets: Vec, +) -> Vec { + assets.sort_by(|left, right| { + match3d_item_sort_index(left.item_id.as_str()) + .cmp(&match3d_item_sort_index(right.item_id.as_str())) + .then_with(|| left.item_id.cmp(&right.item_id)) + }); + assets +} + +pub(super) fn match3d_item_sort_index(item_id: &str) -> u32 { + item_id + .rsplit('-') + .next() + .and_then(|value| value.parse::().ok()) + .unwrap_or(u32::MAX) +} + +fn is_match3d_generated_asset_image_ready(asset: &Match3DGeneratedItemAsset) -> bool { + let view_count = asset + .image_views + .iter() + .filter(|view| { + view.image_object_key + .as_deref() + .map(str::trim) + .filter(|value| !value.is_empty()) + .is_some() + || view + .image_src + .as_deref() + .map(str::trim) + .filter(|value| !value.is_empty()) + .is_some() + }) + .count(); + view_count >= MATCH3D_ITEM_VIEW_COUNT +} + +pub(super) fn has_match3d_required_item_images( + assets: &[Match3DGeneratedItemAsset], + required_item_count: usize, +) -> bool { + assets.len() >= required_item_count + && assets + .iter() + .take(required_item_count) + .all(is_match3d_generated_asset_image_ready) +} + +pub(super) fn has_match3d_required_generated_assets( + assets: &[Match3DGeneratedItemAsset], + required_item_count: usize, + config: &Match3DConfigJson, +) -> bool { + has_match3d_required_item_images(assets, required_item_count) + && (!config.generate_click_sound + || assets + .iter() + .take(required_item_count) + .all(|asset| asset.click_sound.is_some())) +} + +pub(super) fn upsert_match3d_generated_item_asset( + assets: &mut Vec, + asset: Match3DGeneratedItemAsset, +) { + if let Some(current) = assets + .iter_mut() + .find(|candidate| candidate.item_id == asset.item_id) + { + *current = asset; + *assets = sort_match3d_generated_assets(std::mem::take(assets)); + return; + } + assets.push(asset); + *assets = sort_match3d_generated_assets(std::mem::take(assets)); +} + +pub(super) fn merge_regenerated_match3d_item_asset( + current_asset: Option, + generated_asset: Match3DGeneratedItemAsset, +) -> Match3DGeneratedItemAsset { + let Some(current_asset) = current_asset else { + return generated_asset; + }; + + Match3DGeneratedItemAsset { + item_id: current_asset.item_id, + item_name: current_asset.item_name, + item_size: current_asset + .item_size + .or(generated_asset.item_size) + .or_else(|| Some(MATCH3D_ITEM_SIZE_LARGE.to_string())), + image_src: generated_asset.image_src, + image_object_key: generated_asset.image_object_key, + image_views: generated_asset.image_views, + model_src: current_asset.model_src, + model_object_key: current_asset.model_object_key, + model_file_name: current_asset.model_file_name, + task_uuid: generated_asset.task_uuid.or(current_asset.task_uuid), + subscription_key: generated_asset + .subscription_key + .or(current_asset.subscription_key), + sound_prompt: generated_asset.sound_prompt.or(current_asset.sound_prompt), + background_music_title: current_asset.background_music_title, + background_music_style: current_asset.background_music_style, + background_music_prompt: current_asset.background_music_prompt, + background_music: current_asset.background_music, + click_sound: current_asset.click_sound, + background_asset: current_asset.background_asset, + status: generated_asset.status, + error: generated_asset.error, + } +} + +fn next_match3d_generated_item_index(assets: &[Match3DGeneratedItemAsset]) -> u32 { + assets + .iter() + .filter_map(|asset| { + let value = match3d_item_sort_index(asset.item_id.as_str()); + if value == u32::MAX { None } else { Some(value) } + }) + .max() + .unwrap_or(0) + .saturating_add(1) +} + +fn allocate_match3d_generated_item_id( + assets: &[Match3DGeneratedItemAsset], + next_item_index: &mut u32, +) -> String { + loop { + let candidate = format!("match3d-item-{}", *next_item_index); + *next_item_index = next_item_index.saturating_add(1); + if !assets.iter().any(|asset| asset.item_id == candidate) { + return candidate; + } + } +} + +pub(super) fn is_match3d_background_asset_ready(asset: &Match3DGeneratedBackgroundAsset) -> bool { + asset.status == "image_ready" + && (asset + .image_object_key + .as_deref() + .map(str::trim) + .filter(|value| !value.is_empty()) + .is_some() + || asset + .image_src + .as_deref() + .map(str::trim) + .filter(|value| !value.is_empty()) + .is_some()) + && (asset + .container_image_object_key + .as_deref() + .map(str::trim) + .filter(|value| !value.is_empty()) + .is_some() + || asset + .container_image_src + .as_deref() + .map(str::trim) + .filter(|value| !value.is_empty()) + .is_some()) +} + +pub(super) fn build_match3d_material_sheet_prompt( + config: &Match3DConfigJson, + item_names: &[String], +) -> String { + let asset_style_prompt = resolve_match3d_asset_style_prompt(config); + let style_clause = asset_style_prompt + .as_ref() + .map(|prompt| format!("整体画风遵循:{prompt}。")) + .unwrap_or_default(); + let item_rows = item_names + .iter() + .enumerate() + .map(|(index, name)| format!("第{}行:{name} 的 5 个不同视角", index + 1)) + .collect::>() + .join(";"); + format!( + "生成一张1024x1024的1:1图片。固定生成5行*5列网格素材图,画面是{theme}题材的抓大鹅游戏2D物品素材。{style_clause}严格5*5均匀排布,严格按行组织:{item_rows}。同一行五格必须是同一物品的五个不同视角,依次为正面、左前、右前、俯视、背面;每个格子一个独立居中的完整物体,每格背景必须是统一纯绿色绿幕背景(高饱和亮绿色,接近 #00FF00),背景平整无纹理、无渐变、无阴影、无道具,方便后续抠成透明。物体本身不得使用与绿幕相同的纯绿色;若物品天然含绿色,必须使用更深、更黄或更蓝的绿色并用清晰描边与绿幕区分。统一柔和光照,清晰轮廓,适合直接切割成游戏2D图标。请让每个物体完整落在自己的格子中央,四周保留留白,相邻物体主体之间必须至少保留单个素材格宽度的1/4空白间距(约25%单格宽度),包含左右相邻格和上下相邻行,物体主体不得占满格子。禁止主体跨格、贴边或越界,禁止任何内容进入相邻格子影响裁剪后的效果。不要出现文字、水印、UI、边框、网格线、标签、底座、场景或其他物体。", + theme = config.theme_text, + style_clause = style_clause, + item_rows = item_rows, + ) +} + +pub(super) fn build_match3d_material_sheet_negative_prompt(config: &Match3DConfigJson) -> String { + let base = "文字、水印、UI、边框、网格线、标签、人物手部、复杂背景、非绿幕背景、白色背景、灰色背景、渐变背景、纹理背景"; + if !is_match3d_pixel_retro_style(config) { + return base.to_string(); + } + + format!( + "{base}、抗锯齿、平滑插画、柔焦、软边渐变、矢量扁平插画、真实 3D 渲染、PBR 材质、摄影棚光照" + ) +} + +pub(super) fn resolve_match3d_asset_style_prompt(config: &Match3DConfigJson) -> Option { + let prompt = config + .asset_style_prompt + .as_deref() + .or(config.asset_style_label.as_deref()) + .map(str::trim) + .filter(|value| !value.is_empty()) + .map(str::to_string); + if !is_match3d_pixel_retro_style(config) { + return prompt; + } + Some(match prompt { + Some(prompt) if prompt.contains("禁止抗锯齿") && prompt.contains("64x64") => prompt, + Some(prompt) => format!("{prompt};{MATCH3D_PIXEL_RETRO_STYLE_PROMPT}"), + None => MATCH3D_PIXEL_RETRO_STYLE_PROMPT.to_string(), + }) +} + +fn is_match3d_pixel_retro_style(config: &Match3DConfigJson) -> bool { + config + .asset_style_id + .as_deref() + .map(str::trim) + .is_some_and(|value| value.eq_ignore_ascii_case("pixel-retro")) + || config + .asset_style_label + .as_deref() + .map(str::trim) + .is_some_and(|value| value.contains("像素复古")) +} + +pub(super) fn slice_match3d_material_sheet( + image: &DownloadedOpenAiImage, + item_names: &[String], +) -> Result>, AppError> { + // 中文注释:素材图提示词固定要求 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(), + ) +} + +#[derive(Clone, Copy, Debug)] +struct Match3DMaterialCellBounds { + x0: u32, + y0: u32, + x1: u32, + y1: u32, +} + +impl Match3DMaterialCellBounds { + fn width(self) -> u32 { + self.x1.saturating_sub(self.x0).max(1) + } + + fn height(self) -> u32 { + self.y1.saturating_sub(self.y0).max(1) + } + + fn area(self) -> u32 { + self.width().saturating_mul(self.height()) + } + + fn to_crop_tuple(self) -> (u32, u32, u32, u32) { + (self.x0, self.y0, self.width(), self.height()) + } +} + +fn resolve_match3d_material_cell_bounds( + image_width: u32, + image_height: u32, + row_count: u32, + row: u32, + col: u32, +) -> Match3DMaterialCellBounds { + let normalized_rows = row_count.clamp(1, MATCH3D_MATERIAL_GRID_SIZE); + let cell_x0 = col.saturating_mul(image_width) / MATCH3D_MATERIAL_GRID_SIZE; + let cell_x1 = (col.saturating_add(1)).saturating_mul(image_width) / MATCH3D_MATERIAL_GRID_SIZE; + let cell_y0 = row.saturating_mul(image_height) / normalized_rows; + let cell_y1 = (row.saturating_add(1)).saturating_mul(image_height) / normalized_rows; + + Match3DMaterialCellBounds { + x0: cell_x0.min(image_width.saturating_sub(1)), + y0: cell_y0.min(image_height.saturating_sub(1)), + x1: cell_x1.clamp(cell_x0.saturating_add(1), image_width), + y1: cell_y1.clamp(cell_y0.saturating_add(1), image_height), + } +} + +fn detect_match3d_material_foreground_bounds( + source: &image::DynamicImage, + cell: Match3DMaterialCellBounds, +) -> Option { + let background = sample_match3d_material_cell_background(source, cell); + let mut foreground: Option = None; + let mut foreground_pixels = 0u32; + + for y in cell.y0..cell.y1 { + for x in cell.x0..cell.x1 { + if !is_match3d_material_foreground_pixel(source.get_pixel(x, y).0, background) { + continue; + } + foreground_pixels = foreground_pixels.saturating_add(1); + foreground = Some(match foreground { + Some(bounds) => Match3DMaterialCellBounds { + x0: bounds.x0.min(x), + y0: bounds.y0.min(y), + x1: bounds.x1.max(x.saturating_add(1)), + y1: bounds.y1.max(y.saturating_add(1)), + }, + None => Match3DMaterialCellBounds { + x0: x, + y0: y, + x1: x.saturating_add(1), + y1: y.saturating_add(1), + }, + }); + } + } + + let min_foreground_pixels = (cell.area() / 320).clamp(12, 220); + foreground.filter(|bounds| { + foreground_pixels >= min_foreground_pixels && bounds.width() > 2 && bounds.height() > 2 + }) +} + +fn detect_match3d_material_visible_bounds( + image: &image::RgbaImage, +) -> Option { + let (width, height) = image.dimensions(); + let mut bounds: Option = None; + let mut visible_pixels = 0u32; + + for y in 0..height { + for x in 0..width { + let pixel = image.get_pixel(x, y).0; + if !is_match3d_material_visible_pixel(pixel) { + continue; + } + visible_pixels = visible_pixels.saturating_add(1); + bounds = Some(match bounds { + Some(current) => Match3DMaterialCellBounds { + x0: current.x0.min(x), + y0: current.y0.min(y), + x1: current.x1.max(x.saturating_add(1)), + y1: current.y1.max(y.saturating_add(1)), + }, + None => Match3DMaterialCellBounds { + x0: x, + y0: y, + x1: x.saturating_add(1), + y1: y.saturating_add(1), + }, + }); + } + } + + let min_visible_pixels = ((width.saturating_mul(height)) / 540).clamp(10, 120); + bounds.filter(|visible_bounds| { + visible_pixels >= min_visible_pixels + && visible_bounds.width() > 2 + && visible_bounds.height() > 2 + }) +} + +fn sample_match3d_material_cell_background( + source: &image::DynamicImage, + cell: Match3DMaterialCellBounds, +) -> [u8; 4] { + let sample_size = (cell.width().min(cell.height()) / 12).clamp(2, 8); + let sample_points = [ + (cell.x0, cell.y0), + (cell.x1.saturating_sub(sample_size), cell.y0), + (cell.x0, cell.y1.saturating_sub(sample_size)), + ( + cell.x1.saturating_sub(sample_size), + cell.y1.saturating_sub(sample_size), + ), + ]; + let mut samples = Vec::new(); + for (start_x, start_y) in sample_points { + let mut totals = [0u32; 4]; + let mut count = 0u32; + for y in start_y..start_y.saturating_add(sample_size).min(cell.y1) { + for x in start_x..start_x.saturating_add(sample_size).min(cell.x1) { + let pixel = source.get_pixel(x, y).0; + totals[0] = totals[0].saturating_add(pixel[0] as u32); + totals[1] = totals[1].saturating_add(pixel[1] as u32); + totals[2] = totals[2].saturating_add(pixel[2] as u32); + totals[3] = totals[3].saturating_add(pixel[3] as u32); + count = count.saturating_add(1); + } + } + if count > 0 { + samples.push([ + (totals[0] / count) as u8, + (totals[1] / count) as u8, + (totals[2] / count) as u8, + (totals[3] / count) as u8, + ]); + } + } + + samples + .into_iter() + .min_by_key(|sample| { + let luminance = sample[0] as u16 + sample[1] as u16 + sample[2] as u16; + (sample[3] as u16, u16::MAX.saturating_sub(luminance)) + }) + .unwrap_or([255, 255, 255, 255]) +} + +fn clamp_match3d_material_unit(value: f32) -> f32 { + value.clamp(0.0, 1.0) +} + +fn lerp_match3d_material_channel(from: f32, to: f32, t: f32) -> f32 { + from + (to - from) * clamp_match3d_material_unit(t) +} + +fn is_match3d_material_foreground_pixel(pixel: [u8; 4], background: [u8; 4]) -> bool { + let alpha_diff = pixel[3] as i32 - background[3] as i32; + if alpha_diff.abs() >= MATCH3D_MATERIAL_FOREGROUND_ALPHA_THRESHOLD && pixel[3] > 24 { + return true; + } + if pixel[3] <= 24 { + return false; + } + + let color_diff = (pixel[0] as i32 - background[0] as i32).abs() + + (pixel[1] as i32 - background[1] as i32).abs() + + (pixel[2] as i32 - background[2] as i32).abs(); + color_diff >= MATCH3D_MATERIAL_FOREGROUND_DIFF_THRESHOLD +} + +fn remove_match3d_material_view_edge_matte(pixels: &mut [u8], width: usize, height: usize) -> bool { + let pixel_count = width.saturating_mul(height); + if pixel_count == 0 || pixels.len() < pixel_count.saturating_mul(4) { + return false; + } + + let mut changed = false; + let mut background_mask = vec![0u8; pixel_count]; + let mut queue = Vec::::new(); + let mut queue_index = 0usize; + let mut transparent_pixel_count = 0usize; + for pixel_index in 0..pixel_count { + let offset = pixel_index * 4; + if pixels[offset + 3] == 0 { + background_mask[pixel_index] = 1; + queue.push(pixel_index); + transparent_pixel_count = transparent_pixel_count.saturating_add(1); + } + } + let has_transparent_background = transparent_pixel_count > pixel_count / 200; + + // 中文注释:单图被前景边界收紧后,浅绿框可能正好贴在 PNG 外缘; + // 把外缘一段宽度作为去背种子,但只清理绿幕 / 近白 matte,避免误伤贴边主体。 + let edge_width = resolve_match3d_material_view_edge_cleanup_width(width, height); + for y in 0..height { + for x in 0..width { + if x >= edge_width + && y >= edge_width + && x.saturating_add(edge_width) < width + && y.saturating_add(edge_width) < height + { + continue; + } + let pixel_index = y * width + x; + if background_mask[pixel_index] != 0 { + continue; + } + let offset = pixel_index * 4; + let pixel = [ + pixels[offset], + pixels[offset + 1], + pixels[offset + 2], + pixels[offset + 3], + ]; + if !is_match3d_material_view_background_pixel(pixel) { + continue; + } + background_mask[pixel_index] = 1; + queue.push(pixel_index); + } + } + + while queue_index < queue.len() { + let pixel_index = queue[queue_index]; + queue_index += 1; + let x = pixel_index % width; + let y = pixel_index / width; + let neighbors = [ + (x > 0).then(|| pixel_index - 1), + (x + 1 < width).then_some(pixel_index + 1), + (y > 0).then(|| pixel_index - width), + (y + 1 < height).then_some(pixel_index + width), + ]; + + for next_pixel_index in neighbors.into_iter().flatten() { + if background_mask[next_pixel_index] != 0 { + continue; + } + let offset = next_pixel_index * 4; + let pixel = [ + pixels[offset], + pixels[offset + 1], + pixels[offset + 2], + pixels[offset + 3], + ]; + if !is_match3d_material_view_background_pixel(pixel) { + continue; + } + background_mask[next_pixel_index] = 1; + queue.push(next_pixel_index); + } + } + + for _ in 0..edge_width { + let mut expanded_mask = background_mask.clone(); + let mut changed_this_round = false; + for y in 0..height { + for x in 0..width { + let pixel_index = y * width + x; + if background_mask[pixel_index] != 0 { + continue; + } + let offset = pixel_index * 4; + if !is_match3d_material_view_background_pixel([ + pixels[offset], + pixels[offset + 1], + pixels[offset + 2], + pixels[offset + 3], + ]) { + continue; + } + + if touches_match3d_material_background_mask(x, y, width, height, &background_mask) { + expanded_mask[pixel_index] = 1; + changed_this_round = true; + } + } + } + background_mask = expanded_mask; + if !changed_this_round { + break; + } + } + + // 中文注释:边缘抗锯齿圈要直接从可见像素里剔除,再按剩余主体重新收紧裁边。 + for pixel_index in 0..pixel_count { + if background_mask[pixel_index] == 0 { + continue; + } + let offset = pixel_index * 4; + if pixels[offset + 3] != 0 + || pixels[offset] != 0 + || pixels[offset + 1] != 0 + || pixels[offset + 2] != 0 + { + pixels[offset] = 0; + pixels[offset + 1] = 0; + pixels[offset + 2] = 0; + pixels[offset + 3] = 0; + changed = true; + } + } + + if has_transparent_background { + let mut visible_mask = vec![0u8; pixel_count]; + for pixel_index in 0..pixel_count { + let offset = pixel_index * 4; + if is_match3d_material_visible_pixel([ + pixels[offset], + pixels[offset + 1], + pixels[offset + 2], + pixels[offset + 3], + ]) { + visible_mask[pixel_index] = 1; + } + } + + for _ in 0..2 { + let mut changed_this_round = false; + for y in 0..height { + for x in 0..width { + let pixel_index = y * width + x; + if visible_mask[pixel_index] == 0 { + continue; + } + let offset = pixel_index * 4; + let pixel = [ + pixels[offset], + pixels[offset + 1], + pixels[offset + 2], + pixels[offset + 3], + ]; + if !is_match3d_material_green_contaminated_edge_pixel(pixel) { + continue; + } + if !touches_match3d_material_background_mask( + x, + y, + width, + height, + &background_mask, + ) { + continue; + } + + if is_match3d_material_strong_green_contamination(pixel) { + pixels[offset] = 0; + pixels[offset + 1] = 0; + pixels[offset + 2] = 0; + pixels[offset + 3] = 0; + visible_mask[pixel_index] = 0; + background_mask[pixel_index] = 1; + changed = true; + changed_this_round = true; + continue; + } + + let replacement = collect_match3d_material_visible_neighbor_color( + pixels, + width, + height, + x, + y, + &background_mask, + &visible_mask, + ) + .unwrap_or(( + pixels[offset], + pixels[offset + 1], + pixels[offset + 2], + )); + let next_red = replacement.0.max(pixels[offset]); + let next_blue = replacement.2.max(pixels[offset + 2]); + let next_green = replacement + .1 + .min(next_red.max(next_blue).saturating_add(12)); + if next_red != pixels[offset] + || next_green != pixels[offset + 1] + || next_blue != pixels[offset + 2] + { + pixels[offset] = next_red; + pixels[offset + 1] = next_green; + pixels[offset + 2] = next_blue; + changed = true; + changed_this_round = true; + } + background_mask[pixel_index] = 1; + } + } + if !changed_this_round { + break; + } + } + } + + changed +} + +fn resolve_match3d_material_view_edge_cleanup_width(width: usize, height: usize) -> usize { + let min_side = width.min(height).max(1); + (min_side / 24).clamp(4, 12).min(min_side) +} + +fn is_match3d_material_view_background_pixel(pixel: [u8; 4]) -> bool { + pixel[3] < 16 + || is_match3d_material_soft_edge_pixel(pixel) + || compute_match3d_material_white_screen_score(pixel) > 0.18 +} + +fn is_match3d_material_visible_pixel(pixel: [u8; 4]) -> bool { + pixel[3] > 0 && (pixel[0] > 8 || pixel[1] > 8 || pixel[2] > 8) +} + +fn is_match3d_material_soft_edge_pixel(pixel: [u8; 4]) -> bool { + if pixel[3] == 0 { + return false; + } + + let red = pixel[0]; + let green = pixel[1]; + let blue = pixel[2]; + green >= 188 + && green.saturating_sub(red.max(blue)) >= 42 + && (red >= 48 || blue >= 96 || pixel[3] < 236) +} + +fn is_match3d_material_green_contaminated_edge_pixel(pixel: [u8; 4]) -> bool { + if pixel[3] == 0 { + return false; + } + + let red = pixel[0]; + let green = pixel[1]; + let blue = pixel[2]; + green >= 72 && green.saturating_sub(red.max(blue)) >= 18 +} + +fn is_match3d_material_strong_green_contamination(pixel: [u8; 4]) -> bool { + let red = pixel[0]; + let green = pixel[1]; + let blue = pixel[2]; + green >= 148 && green.saturating_sub(red.max(blue)) >= 34 +} + +fn collect_match3d_material_visible_neighbor_color( + pixels: &[u8], + width: usize, + height: usize, + x: usize, + y: usize, + background_mask: &[u8], + visible_mask: &[u8], +) -> Option<(u8, u8, u8)> { + let mut total_weight = 0.0f32; + let mut total_red = 0.0f32; + let mut total_green = 0.0f32; + let mut total_blue = 0.0f32; + + for offset_y in -3i32..=3 { + for offset_x in -3i32..=3 { + if offset_x == 0 && offset_y == 0 { + continue; + } + let next_x = x as i32 + offset_x; + let next_y = y as i32 + offset_y; + if next_x < 0 || next_x >= width as i32 || next_y < 0 || next_y >= height as i32 { + continue; + } + + let next_pixel_index = next_y as usize * width + next_x as usize; + if background_mask[next_pixel_index] != 0 || visible_mask[next_pixel_index] == 0 { + continue; + } + + let next_offset = next_pixel_index * 4; + let next_alpha = pixels[next_offset + 3]; + if next_alpha < 96 { + continue; + } + let pixel = [ + pixels[next_offset], + pixels[next_offset + 1], + pixels[next_offset + 2], + next_alpha, + ]; + if is_match3d_material_green_contaminated_edge_pixel(pixel) + || is_match3d_material_soft_edge_pixel(pixel) + { + continue; + } + + let distance = offset_x.unsigned_abs() + offset_y.unsigned_abs(); + let weight = (next_alpha as f32 / 255.0) + * if distance <= 1 { + 2.0 + } else if distance <= 3 { + 1.2 + } else { + 0.7 + }; + total_weight += weight; + total_red += pixels[next_offset] as f32 * weight; + total_green += pixels[next_offset + 1] as f32 * weight; + total_blue += pixels[next_offset + 2] as f32 * weight; + } + } + + if total_weight <= 0.0 { + return None; + } + + Some(( + (total_red / total_weight).round() as u8, + (total_green / total_weight).round() as u8, + (total_blue / total_weight).round() as u8, + )) +} + +fn apply_match3d_material_green_screen_alpha(source: image::DynamicImage) -> image::DynamicImage { + let mut image = source.to_rgba8(); + let (width, height) = image.dimensions(); + remove_match3d_material_green_screen_background( + image.as_mut(), + width as usize, + height as usize, + ); + image::DynamicImage::ImageRgba8(image) +} + +fn remove_match3d_material_green_screen_background( + pixels: &mut [u8], + width: usize, + height: usize, +) -> bool { + let pixel_count = width.saturating_mul(height); + if pixel_count == 0 || pixels.len() < pixel_count.saturating_mul(4) { + return false; + } + + let mut green_scores = vec![0.0f32; pixel_count]; + let mut white_scores = vec![0.0f32; pixel_count]; + let mut background_hints = vec![0.0f32; pixel_count]; + let mut background_mask = vec![0u8; pixel_count]; + let mut queue = Vec::::new(); + let mut queue_index = 0usize; + + for pixel_index in 0..pixel_count { + let offset = pixel_index * 4; + let red = pixels[offset]; + let green = pixels[offset + 1]; + let blue = pixels[offset + 2]; + let alpha = pixels[offset + 3]; + let green_score = compute_match3d_material_green_screen_score([red, green, blue, alpha]); + let white_score = compute_match3d_material_white_screen_score([red, green, blue, alpha]); + let transparency_hint = clamp_match3d_material_unit((56.0 - alpha as f32) / 56.0) * 0.75; + + green_scores[pixel_index] = green_score; + white_scores[pixel_index] = white_score; + background_hints[pixel_index] = green_score.max(white_score).max(transparency_hint); + } + + let seed_background_pixel = |pixel_index: usize, + background_mask: &mut [u8], + queue: &mut Vec| { + if background_mask[pixel_index] != 0 { + return; + } + let alpha = pixels[pixel_index * 4 + 3]; + let strong_candidate = alpha < 40 + || green_scores[pixel_index] >= MATCH3D_MATERIAL_HARD_GREEN_SCREEN_SCORE + || (alpha < 224 && green_scores[pixel_index] > MATCH3D_MATERIAL_GREEN_SCREEN_MIN_SCORE) + || white_scores[pixel_index] > 0.32; + if !strong_candidate { + return; + } + background_mask[pixel_index] = 1; + queue.push(pixel_index); + }; + + for x in 0..width { + seed_background_pixel(x, &mut background_mask, &mut queue); + seed_background_pixel((height - 1) * width + x, &mut background_mask, &mut queue); + } + for y in 1..height.saturating_sub(1) { + seed_background_pixel(y * width, &mut background_mask, &mut queue); + seed_background_pixel(y * width + width - 1, &mut background_mask, &mut queue); + } + + while queue_index < queue.len() { + let pixel_index = queue[queue_index]; + queue_index += 1; + + let x = pixel_index % width; + let y = pixel_index / width; + let neighbor_indexes = [ + if x > 0 { Some(pixel_index - 1) } else { None }, + if x + 1 < width { + Some(pixel_index + 1) + } else { + None + }, + if y > 0 { + Some(pixel_index - width) + } else { + None + }, + if y + 1 < height { + Some(pixel_index + width) + } else { + None + }, + ]; + + for next_pixel_index in neighbor_indexes.into_iter().flatten() { + if background_mask[next_pixel_index] != 0 { + continue; + } + let next_offset = next_pixel_index * 4; + let alpha = pixels[next_offset + 3]; + let green_score = green_scores[next_pixel_index]; + let white_score = white_scores[next_pixel_index]; + let hint = background_hints[next_pixel_index]; + let reachable_soft_edge = hint > 0.08 + && alpha < 224 + && (green_score > 0.04 || white_score > 0.08 || alpha < 180); + let green_background = green_score >= MATCH3D_MATERIAL_HARD_GREEN_SCREEN_SCORE + || (alpha < 224 && green_score > MATCH3D_MATERIAL_GREEN_SCREEN_MIN_SCORE); + if alpha < 40 || green_background || white_score > 0.32 || reachable_soft_edge { + background_mask[next_pixel_index] = 1; + queue.push(next_pixel_index); + } + } + } + + // 中文注释:Gemini 有时把每个素材格生成成独立绿幕块,块外又是近白背景; + // 这类绿幕不一定和整张 sheet 外边缘连通,必须用高置信绿幕直接补进背景层。 + for pixel_index in 0..pixel_count { + if background_mask[pixel_index] == 0 + && green_scores[pixel_index] >= MATCH3D_MATERIAL_HARD_GREEN_SCREEN_SCORE + { + background_mask[pixel_index] = 1; + } + } + + // 中文注释:较厚的抗锯齿绿边可能低于 hard 阈值;先沿整张 sheet 的透明背景向内吃掉 + // 软绿边,再进入格子裁剪,避免每张切图自带绿色描边。 + let soft_green_cleanup_rounds = (width.min(height) / 40).clamp(4, 14); + for _ in 0..soft_green_cleanup_rounds { + let mut expanded_mask = background_mask.clone(); + let mut changed_this_round = false; + for y in 0..height { + for x in 0..width { + let pixel_index = y * width + x; + if background_mask[pixel_index] != 0 { + continue; + } + let offset = pixel_index * 4; + let pixel = [ + pixels[offset], + pixels[offset + 1], + pixels[offset + 2], + pixels[offset + 3], + ]; + let green_score = green_scores[pixel_index]; + let white_score = white_scores[pixel_index]; + if !is_match3d_material_soft_green_matte_pixel(pixel, green_score, white_score) { + continue; + } + if !touches_match3d_material_background_mask(x, y, width, height, &background_mask) + { + continue; + } + + expanded_mask[pixel_index] = 1; + changed_this_round = true; + } + } + background_mask = expanded_mask; + if !changed_this_round { + break; + } + } + + // 中文注释:主体边缘常带一圈绿幕或白底抗锯齿,扩一层软边,避免切割后残留毛边。 + for _ in 0..2 { + let mut expanded_mask = background_mask.clone(); + for y in 0..height { + for x in 0..width { + let pixel_index = y * width + x; + if background_mask[pixel_index] != 0 { + continue; + } + let alpha = pixels[pixel_index * 4 + 3]; + let green_score = green_scores[pixel_index]; + let white_score = white_scores[pixel_index]; + let hint = background_hints[pixel_index]; + let soft_matte_candidate = alpha < 224 + || white_score > 0.10 + || green_score >= MATCH3D_MATERIAL_HARD_GREEN_SCREEN_SCORE; + if hint < MATCH3D_MATERIAL_GREEN_SCREEN_SOFT_SCORE || !soft_matte_candidate { + continue; + } + + let mut adjacent_background_count = 0usize; + for offset_y in -1i32..=1 { + for offset_x in -1i32..=1 { + if offset_x == 0 && offset_y == 0 { + continue; + } + let next_x = x as i32 + offset_x; + let next_y = y as i32 + offset_y; + if next_x < 0 + || next_x >= width as i32 + || next_y < 0 + || next_y >= height as i32 + { + adjacent_background_count += 1; + continue; + } + if background_mask[next_y as usize * width + next_x as usize] != 0 { + adjacent_background_count += 1; + } + } + } + + if adjacent_background_count >= 2 + || (adjacent_background_count >= 1 + && hint >= MATCH3D_MATERIAL_GREEN_SCREEN_MIN_SCORE) + { + expanded_mask[pixel_index] = 1; + } + } + } + background_mask = expanded_mask; + } + + let mut changed = false; + for pixel_index in 0..pixel_count { + if background_mask[pixel_index] == 0 { + continue; + } + let alpha_offset = pixel_index * 4 + 3; + if pixels[alpha_offset] != 0 { + pixels[alpha_offset] = 0; + changed = true; + } + } + + for y in 0..height { + for x in 0..width { + let pixel_index = y * width + x; + let offset = pixel_index * 4; + let alpha = pixels[offset + 3]; + if alpha == 0 { + continue; + } + + let mut touches_transparent_edge = false; + for offset_y in -1i32..=1 { + for offset_x in -1i32..=1 { + if offset_x == 0 && offset_y == 0 { + continue; + } + let next_x = x as i32 + offset_x; + let next_y = y as i32 + offset_y; + if next_x < 0 || next_x >= width as i32 || next_y < 0 || next_y >= height as i32 + { + touches_transparent_edge = true; + continue; + } + let next_pixel_index = next_y as usize * width + next_x as usize; + if background_mask[next_pixel_index] != 0 + || pixels[next_pixel_index * 4 + 3] < 16 + { + touches_transparent_edge = true; + } + } + } + + if !touches_transparent_edge { + continue; + } + + let green_score = green_scores[pixel_index]; + let white_score = white_scores[pixel_index]; + let contamination = green_score.max(white_score).max(if alpha < 220 { + ((220 - alpha) as f32 / 220.0) * 0.25 + } else { + 0.0 + }); + if contamination < 0.06 { + continue; + } + + let sample = collect_match3d_material_foreground_neighbor_color( + pixels, + width, + height, + x, + y, + &background_mask, + &background_hints, + ); + let mut red = pixels[offset] as f32; + let mut green = pixels[offset + 1] as f32; + let mut blue = pixels[offset + 2] as f32; + let blend = clamp_match3d_material_unit(contamination.max(0.22)); + + if let Some((sample_red, sample_green, sample_blue)) = sample { + red = lerp_match3d_material_channel(red, sample_red as f32, blend); + green = lerp_match3d_material_channel(green, sample_green as f32, blend); + blue = lerp_match3d_material_channel(blue, sample_blue as f32, blend); + + if green_score > 0.04 { + green = green.min(sample_green as f32 + 18.0); + } + if white_score > 0.1 { + red = red.min(sample_red as f32 + 26.0); + green = green.min(sample_green as f32 + 26.0); + blue = blue.min(sample_blue as f32 + 26.0); + } + } else { + if green_score > 0.04 { + let toned_green = (green - (green - red.max(blue)) * 0.78) + .round() + .max(red.max(blue)); + green = green.min(toned_green).min(red.max(blue) + 18.0); + } + + if white_score > 0.12 { + let spread = red.max(green).max(blue) - red.min(green).min(blue); + if spread < 20.0 { + let toned_value = ((red + green + blue) / 3.0 * 0.88).round(); + red = red.min(toned_value); + green = green.min(toned_value); + blue = blue.min(toned_value); + } + } + } + + let mut next_alpha = alpha; + let edge_fade = (green_score * 0.35).max(white_score * 0.28); + if edge_fade > 0.08 { + next_alpha = ((alpha as f32) * (1.0 - edge_fade)).round() as u8; + if next_alpha < 10 { + next_alpha = 0; + } + } + + let next_red = red.round().clamp(0.0, 255.0) as u8; + let next_green = green.round().clamp(0.0, 255.0) as u8; + let next_blue = blue.round().clamp(0.0, 255.0) as u8; + if next_red != pixels[offset] + || next_green != pixels[offset + 1] + || next_blue != pixels[offset + 2] + || next_alpha != alpha + { + pixels[offset] = next_red; + pixels[offset + 1] = next_green; + pixels[offset + 2] = next_blue; + pixels[offset + 3] = next_alpha; + changed = true; + } + } + } + + changed +} + +fn touches_match3d_material_background_mask( + x: usize, + y: usize, + width: usize, + height: usize, + background_mask: &[u8], +) -> bool { + for offset_y in -1i32..=1 { + for offset_x in -1i32..=1 { + if offset_x == 0 && offset_y == 0 { + continue; + } + let next_x = x as i32 + offset_x; + let next_y = y as i32 + offset_y; + if next_x < 0 || next_x >= width as i32 || next_y < 0 || next_y >= height as i32 { + return true; + } + if background_mask[next_y as usize * width + next_x as usize] != 0 { + return true; + } + } + } + false +} + +fn is_match3d_material_soft_green_matte_pixel( + pixel: [u8; 4], + green_score: f32, + white_score: f32, +) -> bool { + if pixel[3] == 0 || green_score < MATCH3D_MATERIAL_GREEN_SCREEN_MIN_SCORE { + return false; + } + + let red = pixel[0]; + let green = pixel[1]; + let blue = pixel[2]; + let foreground_mix = red.max(blue); + green >= 188 + && white_score < 0.34 + && green.saturating_sub(foreground_mix) >= 42 + && (red >= 48 || blue >= 96 || pixel[3] < 236) +} + +fn compute_match3d_material_green_screen_score(pixel: [u8; 4]) -> f32 { + if pixel[3] == 0 { + return 1.0; + } + + let red = pixel[0] as f32; + let green = pixel[1] as f32; + let blue = pixel[2] as f32; + let green_lead = green - red.max(blue); + if green < 96.0 || green_lead <= 18.0 { + return 0.0; + } + + let green_ratio = green / (red + blue).max(1.0); + if green_ratio <= 0.9 { + return 0.0; + } + + (((green - 96.0) / 128.0).clamp(0.0, 1.0) * 0.34 + + ((green_lead - 18.0) / 120.0).clamp(0.0, 1.0) * 0.46 + + ((green_ratio - 0.9) / 2.4).clamp(0.0, 1.0) * 0.20) + .clamp(0.0, 1.0) +} + +fn compute_match3d_material_white_screen_score(pixel: [u8; 4]) -> f32 { + if pixel[3] == 0 { + return 1.0; + } + + let red = pixel[0] as f32; + let green = pixel[1] as f32; + let blue = pixel[2] as f32; + let max_channel = red.max(green).max(blue); + let min_channel = red.min(green).min(blue); + let average = (red + green + blue) / 3.0; + if average < 188.0 || min_channel < 168.0 { + return 0.0; + } + + let spread = max_channel - min_channel; + let neutrality = 1.0 - clamp_match3d_material_unit((spread - 6.0) / 34.0); + let brightness = clamp_match3d_material_unit((average - 188.0) / 55.0); + let floor = clamp_match3d_material_unit((min_channel - 168.0) / 60.0); + clamp_match3d_material_unit(neutrality * (brightness * 0.85 + floor * 0.15)) +} + +pub(super) fn remove_match3d_container_plain_background( + pixels: &mut [u8], + width: usize, + height: usize, +) -> bool { + let pixel_count = width.saturating_mul(height); + if pixel_count == 0 || pixels.len() < pixel_count.saturating_mul(4) { + return false; + } + + let mut background_mask = vec![0u8; pixel_count]; + let mut queue = Vec::::new(); + let mut queue_index = 0usize; + + let seed_pixel = |pixel_index: usize, background_mask: &mut [u8], queue: &mut Vec| { + if background_mask[pixel_index] != 0 { + return; + } + let offset = pixel_index * 4; + if is_match3d_container_background_pixel([ + pixels[offset], + pixels[offset + 1], + pixels[offset + 2], + pixels[offset + 3], + ]) { + background_mask[pixel_index] = 1; + queue.push(pixel_index); + } + }; + + for x in 0..width { + seed_pixel(x, &mut background_mask, &mut queue); + seed_pixel((height - 1) * width + x, &mut background_mask, &mut queue); + } + for y in 1..height.saturating_sub(1) { + seed_pixel(y * width, &mut background_mask, &mut queue); + seed_pixel(y * width + width - 1, &mut background_mask, &mut queue); + } + + while queue_index < queue.len() { + let pixel_index = queue[queue_index]; + queue_index += 1; + let x = pixel_index % width; + let y = pixel_index / width; + let neighbors = [ + (x > 0).then(|| pixel_index - 1), + (x + 1 < width).then_some(pixel_index + 1), + (y > 0).then(|| pixel_index - width), + (y + 1 < height).then_some(pixel_index + width), + ]; + + for next_pixel_index in neighbors.into_iter().flatten() { + if background_mask[next_pixel_index] != 0 { + continue; + } + let offset = next_pixel_index * 4; + if is_match3d_container_background_pixel([ + pixels[offset], + pixels[offset + 1], + pixels[offset + 2], + pixels[offset + 3], + ]) { + background_mask[next_pixel_index] = 1; + queue.push(next_pixel_index); + } + } + } + + // 中文注释:图生图偶尔会在容器边缘留下白底抗锯齿,扩一层只清理连到背景的浅色边。 + for _ in 0..2 { + let mut expanded_mask = background_mask.clone(); + for y in 0..height { + for x in 0..width { + let pixel_index = y * width + x; + if background_mask[pixel_index] != 0 { + continue; + } + let offset = pixel_index * 4; + let pixel = [ + pixels[offset], + pixels[offset + 1], + pixels[offset + 2], + pixels[offset + 3], + ]; + if !is_match3d_container_soft_background_pixel(pixel) { + continue; + } + + let mut adjacent_background_count = 0usize; + for offset_y in -1i32..=1 { + for offset_x in -1i32..=1 { + if offset_x == 0 && offset_y == 0 { + continue; + } + let next_x = x as i32 + offset_x; + let next_y = y as i32 + offset_y; + if next_x < 0 + || next_x >= width as i32 + || next_y < 0 + || next_y >= height as i32 + { + adjacent_background_count += 1; + continue; + } + if background_mask[next_y as usize * width + next_x as usize] != 0 { + adjacent_background_count += 1; + } + } + } + + if adjacent_background_count >= 3 { + expanded_mask[pixel_index] = 1; + } + } + } + background_mask = expanded_mask; + } + + let mut changed = false; + for pixel_index in 0..pixel_count { + if background_mask[pixel_index] == 0 { + continue; + } + let offset = pixel_index * 4; + if pixels[offset + 3] != 0 { + pixels[offset + 3] = 0; + changed = true; + } + } + changed +} + +fn is_match3d_container_background_pixel(pixel: [u8; 4]) -> bool { + pixel[3] < 16 || compute_match3d_material_white_screen_score(pixel) > 0.34 +} + +fn is_match3d_container_soft_background_pixel(pixel: [u8; 4]) -> bool { + pixel[3] < 80 || compute_match3d_material_white_screen_score(pixel) > 0.18 +} + +fn collect_match3d_material_foreground_neighbor_color( + pixels: &[u8], + width: usize, + height: usize, + x: usize, + y: usize, + background_mask: &[u8], + background_hints: &[f32], +) -> Option<(u8, u8, u8)> { + let mut total_weight = 0.0f32; + let mut total_red = 0.0f32; + let mut total_green = 0.0f32; + let mut total_blue = 0.0f32; + + for offset_y in -2i32..=2 { + for offset_x in -2i32..=2 { + if offset_x == 0 && offset_y == 0 { + continue; + } + let next_x = x as i32 + offset_x; + let next_y = y as i32 + offset_y; + if next_x < 0 || next_x >= width as i32 || next_y < 0 || next_y >= height as i32 { + continue; + } + + let next_pixel_index = next_y as usize * width + next_x as usize; + if background_mask[next_pixel_index] != 0 || background_hints[next_pixel_index] >= 0.18 + { + continue; + } + + let next_offset = next_pixel_index * 4; + let next_alpha = pixels[next_offset + 3]; + if next_alpha < 96 { + continue; + } + let distance = offset_x.unsigned_abs() + offset_y.unsigned_abs(); + let weight = (next_alpha as f32 / 255.0) + * if distance <= 1 { + 1.8 + } else if distance == 2 { + 1.2 + } else { + 0.7 + }; + + total_weight += weight; + total_red += pixels[next_offset] as f32 * weight; + total_green += pixels[next_offset + 1] as f32 * weight; + total_blue += pixels[next_offset + 2] as f32 * weight; + } + } + + if total_weight <= 0.0 { + return None; + } + + Some(( + (total_red / total_weight).round() as u8, + (total_green / total_weight).round() as u8, + (total_blue / total_weight).round() as u8, + )) +} diff --git a/server-rs/crates/api-server/src/match3d/runtime.rs b/server-rs/crates/api-server/src/match3d/runtime.rs new file mode 100644 index 00000000..0063fa2e --- /dev/null +++ b/server-rs/crates/api-server/src/match3d/runtime.rs @@ -0,0 +1,37 @@ +pub(super) fn normalize_match3d_run_status(value: &str) -> &str { + match value { + "Running" => "running", + "Won" => "won", + "Failed" => "failed", + "Stopped" => "stopped", + _ => value, + } +} + +pub(super) fn normalize_match3d_item_state(value: &str) -> &str { + match value { + "InBoard" => "in_board", + "InTray" => "in_tray", + "Cleared" => "cleared", + _ => value, + } +} + +pub(super) fn normalize_match3d_failure_reason(value: &str) -> &str { + match value { + "TimeUp" => "time_up", + "TrayFull" => "tray_full", + _ => value, + } +} + +pub(super) fn normalize_match3d_click_reject_reason(value: &str) -> &str { + match value { + "RejectedNotClickable" => "item_not_clickable", + "RejectedAlreadyMoved" => "item_not_in_board", + "RejectedTrayFull" => "tray_full", + "VersionConflict" => "snapshot_version_mismatch", + "RunFinished" => "run_not_active", + _ => value, + } +} diff --git a/server-rs/crates/api-server/src/match3d/tests.rs b/server-rs/crates/api-server/src/match3d/tests.rs new file mode 100644 index 00000000..23bfb659 --- /dev/null +++ b/server-rs/crates/api-server/src/match3d/tests.rs @@ -0,0 +1,1875 @@ +use super::*; + + use super::*; + + fn test_match3d_generated_item_asset(index: u32, name: &str) -> Match3DGeneratedItemAsset { + Match3DGeneratedItemAsset { + item_id: format!("match3d-item-{index}"), + item_name: name.to_string(), + item_size: Some(infer_match3d_item_size(name)), + 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: Some(format!( + "/generated-match3d-assets/s/p/items/i{index}/model/model.glb" + )), + model_object_key: Some(format!( + "generated-match3d-assets/s/p/items/i{index}/model/model.glb" + )), + model_file_name: Some("model.glb".to_string()), + task_uuid: Some(format!("task-{index}")), + subscription_key: Some(format!("sub-{index}")), + sound_prompt: Some(format!("{name}点击音效")), + 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, + } + } + + fn config(theme_text: &str, clear_count: u32, difficulty: u32) -> Match3DConfigJson { + Match3DConfigJson { + theme_text: theme_text.to_string(), + reference_image_src: None, + clear_count, + difficulty, + asset_style_id: None, + asset_style_label: None, + asset_style_prompt: None, + generate_click_sound: false, + } + } + + #[test] + fn match3d_agent_reply_asks_three_questions_before_confirmation() { + let current = config("水果", 4, 6); + + assert_eq!( + build_match3d_assistant_reply_for_turn(¤t, 0), + MATCH3D_QUESTION_THEME + ); + assert_eq!( + build_match3d_assistant_reply_for_turn(¤t, 1), + MATCH3D_QUESTION_CLEAR_COUNT + ); + assert_eq!( + build_match3d_assistant_reply_for_turn(¤t, 2), + MATCH3D_QUESTION_DIFFICULTY + ); + assert_eq!( + build_match3d_assistant_reply_for_turn(¤t, 3), + "已确认:水果题材,需要消除 4 次,共 12 件物品,难度 6。" + ); + } + + #[test] + fn match3d_agent_progress_follows_question_turns() { + assert_eq!(resolve_progress_percent_for_turn(0), 0); + assert_eq!(resolve_progress_percent_for_turn(1), 33); + assert_eq!(resolve_progress_percent_for_turn(2), 66); + assert_eq!(resolve_progress_percent_for_turn(3), 100); + assert_eq!(resolve_progress_percent_for_turn(8), 100); + } + + #[test] + fn match3d_anchor_pack_masks_uncollected_default_values() { + let pack = Match3DAnchorPackRecord { + theme: Match3DAnchorItemRecord { + key: "theme".to_string(), + label: "题材主题".to_string(), + value: "缤纷玩具".to_string(), + status: "confirmed".to_string(), + }, + clear_count: Match3DAnchorItemRecord { + key: "clearCount".to_string(), + label: "需要消除次数".to_string(), + value: "12".to_string(), + status: "confirmed".to_string(), + }, + difficulty: Match3DAnchorItemRecord { + key: "difficulty".to_string(), + label: "难度".to_string(), + value: "4".to_string(), + status: "confirmed".to_string(), + }, + }; + + let response = map_match3d_anchor_pack_response_for_turn(pack, 0, "Collecting"); + + assert_eq!(response.theme.value, ""); + assert_eq!(response.theme.status, "missing"); + assert_eq!(response.clear_count.value, ""); + assert_eq!(response.clear_count.status, "missing"); + assert_eq!(response.difficulty.value, ""); + assert_eq!(response.difficulty.status, "missing"); + } + + #[test] + fn match3d_item_image_path_segments_stay_unique_for_chinese_names() { + let item_names = ["草莓", "苹果", "香蕉"]; + let slugs = item_names + .iter() + .enumerate() + .map(|(index, item_name)| { + let item_id = format!("match3d-item-{}", index + 1); + format!( + "{item_id}-{}", + sanitize_match3d_asset_segment(item_name, "item") + ) + }) + .collect::>(); + + assert_eq!( + slugs, + vec![ + "match3d-item-1-item", + "match3d-item-2-item", + "match3d-item-3-item", + ] + ); + } + + #[test] + 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..5 { + for col in 0..5 { + let color = image::Rgba([ + 32 + row as u8 * 40, + 24 + col as u8 * 36, + 210 - row as u8 * 30, + 255, + ]); + for y in row * 100..(row + 1) * 100 { + for x in col * 100..(col + 1) * 100 { + sheet.put_pixel(x, y, color); + } + } + } + } + let mut encoded = std::io::Cursor::new(Vec::new()); + image::DynamicImage::ImageRgba8(sheet) + .write_to(&mut encoded, ImageFormat::Png) + .expect("sheet should encode"); + let image = DownloadedOpenAiImage { + bytes: encoded.into_inner(), + mime_type: "image/png".to_string(), + extension: "png".to_string(), + }; + + let slices = slice_match3d_material_sheet(&image, &item_names).expect("sheet should slice"); + + assert_eq!(slices.len(), 3); + for (row, views) in slices.iter().enumerate() { + assert_eq!(views.len(), MATCH3D_ITEM_VIEW_COUNT); + for (col, view) in views.iter().enumerate() { + let decoded = image::load_from_memory(view.bytes.as_slice()) + .expect("view should decode") + .to_rgba8(); + let pixel = decoded.get_pixel(decoded.width() / 2, decoded.height() / 2); + assert_eq!( + pixel.0, + [ + 32 + row as u8 * 40, + 24 + col as u8 * 36, + 210 - row as u8 * 30, + 255, + ], + "row {row} col {col} should be cut from the fixed 5*5 grid row" + ); + } + } + } + + #[test] + fn match3d_material_sheet_slicing_keeps_near_edge_foreground_pixels() { + 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 { + for x in 18..82 { + sheet.put_pixel(x, y, image::Rgba([20, 80, 240, 255])); + } + } + for y in 5..96 { + for x in 18..82 { + sheet.put_pixel(x, y, image::Rgba([220, 32, 48, 255])); + } + } + for y in 96..99 { + for x in 18..82 { + sheet.put_pixel(x, y, image::Rgba([20, 180, 64, 255])); + } + } + let mut encoded = std::io::Cursor::new(Vec::new()); + image::DynamicImage::ImageRgba8(sheet) + .write_to(&mut encoded, ImageFormat::Png) + .expect("sheet should encode"); + let image = DownloadedOpenAiImage { + bytes: encoded.into_inner(), + mime_type: "image/png".to_string(), + extension: "png".to_string(), + }; + + let slices = slice_match3d_material_sheet(&image, &item_names).expect("sheet should slice"); + let decoded = image::load_from_memory(slices[0][0].bytes.as_slice()) + .expect("view should decode") + .to_rgba8(); + + let pixels = decoded.pixels().map(|pixel| pixel.0).collect::>(); + assert!( + pixels.iter().any(|pixel| *pixel == [20, 80, 240, 255]), + "贴近顶部的前景像素不能被固定内缩切掉" + ); + assert!( + pixels.iter().any(|pixel| *pixel == [20, 180, 64, 255]), + "贴近底部的前景像素不能被固定内缩切掉" + ); + } + + #[test] + fn match3d_material_sheet_slicing_makes_green_screen_transparent_before_crop() { + let width = 500; + let height = 500; + let item_names = vec!["草莓".to_string()]; + let mut sheet = image::RgbaImage::from_pixel(width, height, image::Rgba([0, 255, 0, 255])); + for y in 35..65 { + for x in 35..65 { + sheet.put_pixel(x, y, image::Rgba([220, 32, 48, 255])); + } + } + + let mut encoded = std::io::Cursor::new(Vec::new()); + image::DynamicImage::ImageRgba8(sheet) + .write_to(&mut encoded, ImageFormat::Png) + .expect("sheet should encode"); + let image = DownloadedOpenAiImage { + bytes: encoded.into_inner(), + mime_type: "image/png".to_string(), + extension: "png".to_string(), + }; + + let slices = slice_match3d_material_sheet(&image, &item_names).expect("sheet should slice"); + let decoded = image::load_from_memory(slices[0][0].bytes.as_slice()) + .expect("view should decode") + .to_rgba8(); + + assert!( + decoded.pixels().all(|pixel| { + let [red, green, blue, alpha] = pixel.0; + alpha == 0 || !(green > red.saturating_add(32) && green > blue.saturating_add(32)) + }), + "绿幕背景必须在切割输出中变成透明或被单素材二次裁边移除" + ); + assert!( + decoded.pixels().any(|pixel| pixel.0 == [220, 32, 48, 255]), + "物品主体不能被绿幕去背误删" + ); + } + + #[test] + fn match3d_material_sheet_slicing_removes_isolated_green_cell_background() { + let width = 500; + let height = 500; + let item_names = vec!["葡萄".to_string()]; + let mut sheet = + image::RgbaImage::from_pixel(width, height, image::Rgba([245, 245, 245, 255])); + for y in 8..92 { + for x in 8..92 { + sheet.put_pixel(x, y, image::Rgba([0, 236, 18, 255])); + } + } + for y in 35..65 { + for x in 35..65 { + sheet.put_pixel(x, y, image::Rgba([136, 64, 210, 255])); + } + } + + let mut encoded = std::io::Cursor::new(Vec::new()); + image::DynamicImage::ImageRgba8(sheet) + .write_to(&mut encoded, ImageFormat::Png) + .expect("sheet should encode"); + let image = DownloadedOpenAiImage { + bytes: encoded.into_inner(), + mime_type: "image/png".to_string(), + extension: "png".to_string(), + }; + + let slices = slice_match3d_material_sheet(&image, &item_names).expect("sheet should slice"); + let decoded = image::load_from_memory(slices[0][0].bytes.as_slice()) + .expect("view should decode") + .to_rgba8(); + + assert!( + decoded + .pixels() + .all(|pixel| pixel.0[3] == 0 || pixel.0[1] < 180), + "没有连到整张 sheet 外边缘的绿幕块也必须被转成透明" + ); + assert!( + decoded.pixels().any(|pixel| pixel.0 == [136, 64, 210, 255]), + "绿幕清理不能误删物品主体" + ); + } + + #[test] + fn match3d_material_sheet_slicing_removes_soft_green_matte_before_crop() { + let width = 500; + let height = 500; + let item_names = vec!["草莓".to_string()]; + let mut sheet = image::RgbaImage::from_pixel(width, height, image::Rgba([0, 255, 0, 255])); + for y in 28..72 { + for x in 28..72 { + sheet.put_pixel(x, y, image::Rgba([64, 198, 112, 255])); + } + } + for y in 36..64 { + for x in 36..64 { + sheet.put_pixel(x, y, image::Rgba([220, 32, 48, 255])); + } + } + + let mut encoded = std::io::Cursor::new(Vec::new()); + image::DynamicImage::ImageRgba8(sheet) + .write_to(&mut encoded, ImageFormat::Png) + .expect("sheet should encode"); + let image = DownloadedOpenAiImage { + bytes: encoded.into_inner(), + mime_type: "image/png".to_string(), + extension: "png".to_string(), + }; + + let slices = slice_match3d_material_sheet(&image, &item_names).expect("sheet should slice"); + let decoded = image::load_from_memory(slices[0][0].bytes.as_slice()) + .expect("view should decode") + .to_rgba8(); + + assert!( + decoded.pixels().all(|pixel| { + let [red, green, blue, alpha] = pixel.0; + alpha == 0 || green <= red.max(blue).saturating_add(32) + }), + "整张 sheet 去绿后再裁剪,输出 PNG 不能保留可见软绿边" + ); + assert!( + decoded.pixels().any(|pixel| pixel.0 == [220, 32, 48, 255]), + "软绿边清理不能误删物品主体" + ); + } + + #[test] + fn match3d_material_sheet_slicing_crops_single_view_green_antialias_border() { + let width = 500; + let height = 500; + let item_names = vec!["丸子".to_string()]; + let mut sheet = image::RgbaImage::from_pixel(width, height, image::Rgba([0, 255, 0, 255])); + for y in 22..78 { + for x in 22..78 { + if x <= 24 || x >= 75 || y <= 24 || y >= 75 { + sheet.put_pixel(x, y, image::Rgba([168, 246, 176, 255])); + } + } + } + for y in 40..60 { + for x in 40..60 { + sheet.put_pixel(x, y, image::Rgba([174, 92, 72, 255])); + } + } + + let mut encoded = std::io::Cursor::new(Vec::new()); + image::DynamicImage::ImageRgba8(sheet) + .write_to(&mut encoded, ImageFormat::Png) + .expect("sheet should encode"); + let image = DownloadedOpenAiImage { + bytes: encoded.into_inner(), + mime_type: "image/png".to_string(), + extension: "png".to_string(), + }; + + let slices = slice_match3d_material_sheet(&image, &item_names).expect("sheet should slice"); + let decoded = image::load_from_memory(slices[0][0].bytes.as_slice()) + .expect("view should decode") + .to_rgba8(); + + assert!( + decoded.width() <= 24 && decoded.height() <= 24, + "单素材裁剪后必须再吃掉浅绿抗锯齿边,不能把素材自带绿边算进输出尺寸;got {}x{}", + decoded.width(), + decoded.height() + ); + assert!( + decoded + .pixels() + .all(|pixel| pixel.0[3] == 0 || pixel.0 != [168, 246, 176, 255]), + "单素材输出 PNG 不能保留浅绿抗锯齿边像素" + ); + assert!( + decoded.pixels().any(|pixel| pixel.0 == [174, 92, 72, 255]), + "单素材二次裁边不能误删物品主体" + ); + } + + #[test] + fn match3d_material_view_edge_matte_removes_green_border_touching_png_edge() { + let width = 72; + let height = 72; + let mut view = + image::RgbaImage::from_pixel(width, height, image::Rgba([168, 246, 176, 255])); + for y in 10..62 { + for x in 10..62 { + view.put_pixel(x, y, image::Rgba([0, 0, 0, 0])); + } + } + for y in 24..48 { + for x in 24..48 { + view.put_pixel(x, y, image::Rgba([174, 92, 72, 255])); + } + } + + let cleaned = + crop_match3d_material_view_edge_matte(image::DynamicImage::ImageRgba8(view)).to_rgba8(); + + assert!( + cleaned.width() <= 28 && cleaned.height() <= 28, + "单图外缘浅绿框即使贴住 PNG 边界,也必须被透明化并从可见边界中移除;got {}x{}", + cleaned.width(), + cleaned.height() + ); + assert!( + cleaned + .pixels() + .all(|pixel| pixel.0[3] == 0 || pixel.0 != [168, 246, 176, 255]), + "单图外缘浅绿框不能残留为可见像素" + ); + assert!( + cleaned.pixels().any(|pixel| pixel.0 == [174, 92, 72, 255]), + "扩大边缘清理宽度不能误删物品主体" + ); + } + + #[test] + fn match3d_material_view_edge_matte_neutralizes_dark_green_contour_pixels() { + let width = 64; + let height = 64; + let mut view = image::RgbaImage::from_pixel(width, height, image::Rgba([0, 0, 0, 0])); + for y in 16..48 { + for x in 16..48 { + if x <= 18 || x >= 45 || y <= 18 || y >= 45 { + view.put_pixel(x, y, image::Rgba([42, 118, 36, 255])); + } else { + view.put_pixel(x, y, image::Rgba([174, 92, 72, 255])); + } + } + } + + let cleaned = + crop_match3d_material_view_edge_matte(image::DynamicImage::ImageRgba8(view)).to_rgba8(); + + assert!( + cleaned.pixels().all(|pixel| { + let [red, green, blue, alpha] = pixel.0; + alpha == 0 || green <= red.max(blue).saturating_add(18) + }), + "暗绿轮廓污染也必须被透明化或去绿,不能残留可见绿边" + ); + assert!( + cleaned.pixels().any(|pixel| pixel.0 == [174, 92, 72, 255]), + "暗绿轮廓清理不能误删物品主体" + ); + } + + #[test] + fn match3d_material_sheet_slicing_cleans_white_matte_edge() { + let width = 500; + let height = 500; + let item_names = vec!["羽毛".to_string()]; + let mut sheet = image::RgbaImage::from_pixel(width, height, image::Rgba([0, 255, 0, 255])); + for y in 32..68 { + for x in 32..68 { + sheet.put_pixel(x, y, image::Rgba([248, 248, 244, 255])); + } + } + for y in 36..64 { + for x in 36..64 { + sheet.put_pixel(x, y, image::Rgba([225, 174, 58, 255])); + } + } + + let mut encoded = std::io::Cursor::new(Vec::new()); + image::DynamicImage::ImageRgba8(sheet) + .write_to(&mut encoded, ImageFormat::Png) + .expect("sheet should encode"); + let image = DownloadedOpenAiImage { + bytes: encoded.into_inner(), + mime_type: "image/png".to_string(), + extension: "png".to_string(), + }; + + let slices = slice_match3d_material_sheet(&image, &item_names).expect("sheet should slice"); + let decoded = image::load_from_memory(slices[0][0].bytes.as_slice()) + .expect("view should decode") + .to_rgba8(); + + assert!( + decoded.pixels().all(|pixel| { + let [red, green, blue, alpha] = pixel.0; + alpha == 0 || !(red >= 238 && green >= 238 && blue >= 232) + }), + "近白抠图边必须被清成透明或去污染,不能在输出 PNG 中形成白边" + ); + assert!( + decoded.pixels().any(|pixel| pixel.0 == [225, 174, 58, 255]), + "白边清理不能误删物品主体" + ); + } + + #[test] + fn match3d_container_image_postprocess_removes_plain_background() { + let width = 256; + let height = 256; + let mut image = + image::RgbaImage::from_pixel(width, height, image::Rgba([248, 248, 246, 255])); + for y in 68..190 { + for x in 38..218 { + image.put_pixel(x, y, image::Rgba([160, 104, 54, 255])); + } + } + + let mut encoded = std::io::Cursor::new(Vec::new()); + image::DynamicImage::ImageRgba8(image) + .write_to(&mut encoded, ImageFormat::Png) + .expect("container should encode"); + let processed = make_match3d_container_image_transparent(DownloadedOpenAiImage { + bytes: encoded.into_inner(), + mime_type: "image/png".to_string(), + extension: "png".to_string(), + }) + .expect("container should postprocess"); + let decoded = image::load_from_memory(processed.bytes.as_slice()) + .expect("processed container 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, + "容器图四周白底必须在入库前转成透明 alpha" + ); + assert_eq!( + decoded.get_pixel(width / 2, height / 2).0[3], + 255, + "容器主体不能被透明化误删" + ); + } + + #[test] + fn match3d_background_image_postprocess_removes_transparent_pixels() { + let width = 16; + let height = 16; + let mut image = + image::RgbaImage::from_pixel(width, height, image::Rgba([80, 140, 190, 255])); + image.put_pixel(0, 0, image::Rgba([0, 0, 0, 0])); + image.put_pixel(8, 8, image::Rgba([240, 120, 40, 128])); + + let mut encoded = std::io::Cursor::new(Vec::new()); + image::DynamicImage::ImageRgba8(image) + .write_to(&mut encoded, ImageFormat::Png) + .expect("background should encode"); + let processed = make_match3d_background_image_opaque(DownloadedOpenAiImage { + bytes: encoded.into_inner(), + mime_type: "image/png".to_string(), + extension: "png".to_string(), + }) + .expect("background should postprocess"); + let decoded = image::load_from_memory(processed.bytes.as_slice()) + .expect("processed background should decode") + .to_rgba8(); + + assert_eq!(processed.mime_type, "image/png"); + assert_eq!(processed.extension, "png"); + assert!( + decoded.pixels().all(|pixel| pixel.0[3] == 255), + "抓大鹅 9:16 背景图入库前必须移除所有透明 alpha" + ); + assert_ne!( + decoded.get_pixel(0, 0).0, + [0, 0, 0, 0], + "原透明角落必须被合成到不透明背景色上" + ); + } + + #[test] + fn match3d_work_metadata_parses_gpt4o_json() { + let metadata = parse_match3d_work_metadata( + r#"{"gameName":"果园大鹅宴","summary":"在明亮果园里收集水果小物件,节奏轻快适合随手游玩。","tags":["水果","抓大鹅","经典消除","轻量休闲"],"backgroundMusic":{"title":"果园轻舞","style":"轻快, 休闲","prompt":"果园主题循环背景音乐"},"backgroundPrompt":"果园主题绿色果园竖屏纯背景图","items":[{"name":"草莓","soundPrompt":"草莓点击音效"},{"name":"苹果","soundPrompt":"苹果点击音效"},{"name":"香蕉","soundPrompt":"香蕉点击音效"}]}"#, + ) + .expect("metadata should parse"); + + assert_eq!(metadata.game_name, "果园大鹅宴"); + assert_eq!( + metadata.summary, + "在明亮果园里收集水果小物件,节奏轻快适合随手游玩。" + ); + assert_eq!( + metadata.tags, + vec!["水果", "抓大鹅", "经典消除", "轻量休闲", "2D素材", "收集"] + ); + } + + #[test] + fn match3d_work_metadata_fallback_keeps_empty_description_boundary() { + let metadata = fallback_match3d_work_metadata("水果"); + + assert_eq!(metadata.game_name, "水果抓大鹅"); + assert!(metadata.summary.contains("水果主题")); + assert!(metadata.tags.contains(&"水果".to_string())); + assert!(metadata.tags.contains(&"抓大鹅".to_string())); + } + + #[test] + fn match3d_draft_plan_parses_audio_prompts() { + let plan = parse_match3d_draft_plan( + r#"{"gameName":"果园大鹅宴","summary":"明亮果园里堆满水果小物,轻快收集感突出。","tags":["水果","抓大鹅"],"backgroundMusic":{"title":"果园轻舞","style":"轻快, 休闲","prompt":""},"backgroundPrompt":"果园主题抓大鹅竖屏纯背景,绿色渐变和明亮果园氛围","items":[{"name":"草莓","soundPrompt":"草莓点击消除的清脆音效"},{"name":"苹果","soundPrompt":"苹果落入托盘的弹跳音"},{"name":"香蕉","soundPrompt":"香蕉消除时的轻快提示音"}]}"#, + &config("水果", 3, 3), + ) + .expect("draft plan should parse"); + + assert_eq!(plan.metadata.game_name, "果园大鹅宴"); + assert_eq!( + plan.metadata.summary, + "明亮果园里堆满水果小物,轻快收集感突出。" + ); + assert!(plan.background_prompt.contains("纯背景")); + assert_eq!(plan.items[0].name, "草莓"); + assert_eq!(plan.items[0].item_size, MATCH3D_ITEM_SIZE_SMALL); + assert!(plan.items[0].sound_prompt.contains("草莓")); + } + + #[test] + fn match3d_draft_plan_parses_relative_item_sizes() { + let plan = parse_match3d_draft_plan( + r#"{"gameName":"果园大鹅宴","summary":"果园小物堆满浅盘,轻快明亮适合随手消除。","tags":["水果","抓大鹅"],"backgroundPrompt":"果园主题竖屏纯背景","items":[{"name":"西瓜","itemSize":"大","soundPrompt":""},{"name":"苹果","itemSize":"中","soundPrompt":""},{"name":"糖果","itemSize":"小","soundPrompt":""}]}"#, + &config("水果", 3, 3), + ) + .expect("draft plan should parse"); + + assert_eq!(plan.items[0].item_size, MATCH3D_ITEM_SIZE_LARGE); + assert_eq!(plan.items[1].item_size, MATCH3D_ITEM_SIZE_MEDIUM); + assert_eq!(plan.items[2].item_size, MATCH3D_ITEM_SIZE_SMALL); + } + + #[test] + fn match3d_legacy_item_asset_without_size_defaults_to_large() { + let assets = parse_match3d_generated_item_assets(Some( + r#"[{"itemId":"match3d-item-1","itemName":"草莓","status":"image_ready"}]"#, + )); + let asset = Match3DGeneratedItemAsset::from(assets[0].clone()); + + assert_eq!(asset.item_size.as_deref(), Some(MATCH3D_ITEM_SIZE_LARGE)); + } + + #[test] + 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(), 10); + assert_eq!(plan.items[8].name, "蓝莓"); + assert_ne!(plan.items[9].name, "蓝莓"); + } + + #[test] + fn match3d_generated_item_count_rounds_up_to_five_multiples() { + assert_eq!( + resolve_match3d_generated_item_count(&config("水果", 8, 2)), + 5 + ); + assert_eq!( + resolve_match3d_generated_item_count(&config("水果", 12, 4)), + 10 + ); + assert_eq!( + resolve_match3d_generated_item_count(&config("水果", 16, 6)), + 15 + ); + assert_eq!( + resolve_match3d_generated_item_count(&config("水果", 21, 8)), + 25 + ); + } + + #[test] + fn match3d_generated_assets_require_only_images_when_click_sound_is_closed() { + let assets = vec![test_match3d_generated_item_asset(1, "草莓")]; + + assert!(has_match3d_required_generated_assets( + &assets, + 1, + &config("水果", 3, 3) + )); + } + + #[test] + 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(5), 2); + assert_eq!(calculate_match3d_item_assets_points_cost(6), 4); + assert_eq!(calculate_match3d_item_assets_points_cost(10), 4); + } + + #[test] + fn match3d_item_asset_append_plan_pads_generation_without_persisting_padding() { + let existing_assets = vec![Match3DGeneratedItemAsset { + item_id: "match3d-item-1".to_string(), + item_name: "草莓".to_string(), + item_size: Some(MATCH3D_ITEM_SIZE_SMALL.to_string()), + image_src: None, + image_object_key: None, + image_views: Vec::new(), + 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, + }]; + + let plan = build_match3d_item_asset_append_plan( + vec![ + "草莓".to_string(), + "苹果".to_string(), + "香蕉".to_string(), + "梨子".to_string(), + ], + &existing_assets, + ); + + assert_eq!(plan.requested_item_names, vec!["苹果", "香蕉", "梨子"]); + 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()), + 2 + ); + } + + #[test] + fn match3d_item_asset_append_plan_still_generates_full_sheet_when_capacity_is_low() { + let existing_assets = (1..MATCH3D_MAX_GENERATED_ITEM_COUNT) + .map(|index| Match3DGeneratedItemAsset { + item_id: format!("match3d-item-{index}"), + item_name: format!("已有物品{index}"), + item_size: Some(MATCH3D_ITEM_SIZE_LARGE.to_string()), + image_src: None, + image_object_key: None, + image_views: Vec::new(), + 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::>(); + + let plan = + build_match3d_item_asset_append_plan(vec!["新物品".to_string()], &existing_assets); + + assert_eq!(plan.requested_item_names, vec!["新物品"]); + assert_eq!( + plan.padded_item_names.len(), + MATCH3D_MATERIAL_ITEM_BATCH_SIZE + ); + assert_eq!(plan.padded_item_names[0], "新物品"); + } + + #[test] + fn match3d_item_asset_replace_plan_only_targets_existing_names() { + let existing_assets = vec![ + test_match3d_generated_item_asset(1, "草莓"), + test_match3d_generated_item_asset(2, "苹果"), + ]; + let plan = build_match3d_item_asset_replace_plan( + vec!["苹果".to_string(), "不存在".to_string(), "苹果".to_string()], + &existing_assets, + ); + + assert_eq!(plan.requested_item_names, vec!["苹果"]); + assert_eq!(plan.target_assets.len(), 1); + assert_eq!(plan.target_assets[0].item_id, "match3d-item-2"); + assert_eq!( + plan.padded_item_names.len(), + MATCH3D_MATERIAL_ITEM_BATCH_SIZE + ); + assert_eq!(plan.padded_item_names[0], "苹果"); + } + + #[test] + fn match3d_item_assets_generation_mode_defaults_to_append() { + assert!(matches!( + normalize_match3d_item_assets_generation_mode(None), + Match3DItemAssetsGenerationMode::Append + )); + assert!(matches!( + normalize_match3d_item_assets_generation_mode(Some("replace")), + Match3DItemAssetsGenerationMode::Replace + )); + assert!(matches!( + normalize_match3d_item_assets_generation_mode(Some("regenerate")), + Match3DItemAssetsGenerationMode::Replace + )); + } + + #[test] + fn match3d_regenerated_asset_keeps_stable_identity_and_side_assets() { + let mut current_asset = test_match3d_generated_item_asset(1, "草莓"); + current_asset.background_music_title = Some("果园轻舞".to_string()); + current_asset.background_asset = Some(Match3DGeneratedBackgroundAsset { + prompt: "果园背景".to_string(), + image_src: Some("/generated-match3d-assets/s/p/background/bg.png".to_string()), + image_object_key: None, + container_prompt: Some("果园容器".to_string()), + container_image_src: Some( + "/generated-match3d-assets/s/p/ui-container/container.png".to_string(), + ), + container_image_object_key: None, + status: "image_ready".to_string(), + error: None, + }); + let mut generated_asset = test_match3d_generated_item_asset(99, "新草莓"); + generated_asset.image_src = + Some("/generated-match3d-assets/s/p/items/new/views/view-01.png".to_string()); + generated_asset.model_src = None; + generated_asset.model_object_key = None; + + let merged = + merge_regenerated_match3d_item_asset(Some(current_asset.clone()), generated_asset); + + assert_eq!(merged.item_id, "match3d-item-1"); + assert_eq!(merged.item_name, "草莓"); + assert_eq!( + merged.image_src.as_deref(), + Some("/generated-match3d-assets/s/p/items/new/views/view-01.png") + ); + assert_eq!( + merged.model_src.as_deref(), + current_asset.model_src.as_deref() + ); + assert_eq!(merged.background_music_title.as_deref(), Some("果园轻舞")); + assert!(merged.background_asset.is_some()); + assert_eq!(merged.status, "image_ready"); + } + + #[test] + 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("5行*5列")); + assert!(prompt.contains("严格5*5均匀排布")); + assert!(prompt.contains("绿幕背景")); + assert!(prompt.contains("#00FF00")); + assert!(prompt.contains("单个素材格宽度的1/4空白间距")); + assert!(prompt.contains("约25%单格宽度")); + assert!(prompt.contains("禁止主体跨格")); + assert!(prompt.contains("贴边或越界")); + } + + #[test] + fn match3d_material_sheet_prompt_hardens_pixel_retro_style() { + let mut config = config("水果", 12, 4); + config.asset_style_id = Some("pixel-retro".to_string()); + config.asset_style_label = Some("像素复古".to_string()); + let prompt = build_match3d_material_sheet_prompt(&config, &["草莓".to_string()]); + let negative_prompt = build_match3d_material_sheet_negative_prompt(&config); + + 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_material_sheet_request_uses_vector_engine_gemini_contract() { + let body = build_match3d_vector_engine_gemini_image_request_body( + "生成水果素材图", + "文字、水印", + MATCH3D_MATERIAL_VECTOR_ENGINE_GEMINI_ASPECT_RATIO, + ); + + assert_eq!(body["generationConfig"]["responseModalities"][0], "TEXT"); + assert_eq!(body["generationConfig"]["responseModalities"][1], "IMAGE"); + assert_eq!( + body["generationConfig"]["imageConfig"]["aspectRatio"], + MATCH3D_MATERIAL_VECTOR_ENGINE_GEMINI_ASPECT_RATIO + ); + assert!(body.get("model").is_none()); + assert!(body.get("n").is_none()); + assert!(body.get("official_fallback").is_none()); + assert!(body.get("image").is_none()); + assert!(body.get("image_urls").is_none()); + assert!( + body["contents"][0]["parts"][0]["text"] + .as_str() + .unwrap_or_default() + .contains("文字、水印") + ); + } + + #[test] + fn match3d_extracts_vector_engine_gemini_inline_image_data() { + let payload = json!({ + "candidates": [{ + "content": { + "parts": [ + { "text": "已生成" }, + { + "inlineData": { + "mimeType": "image/png", + "data": "iVBORw0KGgo=" + } + }, + { + "inline_data": { + "mime_type": "image/webp", + "data": "UklGRg==" + } + }, + { + "inlineData": { + "mimeType": "text/plain", + "data": "not-image-data" + } + }, + { + "data": "not-inline-image-data" + } + ] + } + }] + }); + + assert_eq!( + extract_match3d_b64_images(&payload), + vec!["iVBORw0KGgo=", "UklGRg=="] + ); + } + + #[test] + fn match3d_vector_engine_gemini_url_accepts_root_or_v1_base() { + let root_settings = Match3DVectorEngineGeminiImageSettings { + base_url: "https://api.vectorengine.cn".to_string(), + api_key: "test-key".to_string(), + request_timeout_ms: 1_000_000, + }; + let v1_settings = Match3DVectorEngineGeminiImageSettings { + base_url: "https://api.vectorengine.cn/v1".to_string(), + api_key: "test-key".to_string(), + request_timeout_ms: 1_000_000, + }; + + assert_eq!( + build_match3d_vector_engine_gemini_generate_content_url(&root_settings), + "https://api.vectorengine.cn/v1beta/models/gemini-3-pro-image-preview:generateContent" + ); + assert_eq!( + build_match3d_vector_engine_gemini_generate_content_url(&v1_settings), + "https://api.vectorengine.cn/v1beta/models/gemini-3-pro-image-preview:generateContent" + ); + } + + #[test] + fn match3d_background_and_container_prompts_keep_ui_layers_split() { + let config = config("水果", 3, 3); + let background_prompt = + build_match3d_background_generation_prompt(&config, "果园绿色竖屏纯背景"); + let container_prompt = + build_match3d_container_generation_prompt(&config, "果园绿色竖屏纯背景"); + + assert!(background_prompt.contains("9:16")); + assert!(background_prompt.contains("纯背景图")); + assert!(background_prompt.contains("不得出现锅")); + assert!(background_prompt.contains("拼图槽")); + assert!(background_prompt.contains("物品槽")); + assert!(background_prompt.contains("全画幅不透明")); + assert!(background_prompt.contains("透明 alpha")); + assert!(background_prompt.contains("默认交互容器")); + + assert!(container_prompt.contains("1:1")); + assert!(container_prompt.contains("中心容器 UI 图")); + assert!(container_prompt.contains("贴合题材设定")); + assert!(container_prompt.contains("占画布宽度约 86%-92%")); + assert!(container_prompt.contains("轻俯视 3/4")); + assert!(container_prompt.contains("横向椭圆形内口")); + assert!(container_prompt.contains("不能画成正俯视扁圆盘")); + assert!(container_prompt.contains("透明 alpha")); + assert!(container_prompt.contains("白底")); + assert!(container_prompt.contains("整页背景")); + assert!(container_prompt.contains("禁止文字")); + } + + #[test] + fn match3d_background_asset_requires_background_and_container_images() { + let background_only = Match3DGeneratedBackgroundAsset { + prompt: "果园背景".to_string(), + image_src: Some( + "/generated-match3d-assets/session/profile/background/bg.png".to_string(), + ), + image_object_key: None, + container_prompt: None, + container_image_src: None, + container_image_object_key: None, + status: "image_ready".to_string(), + error: None, + }; + let with_container = Match3DGeneratedBackgroundAsset { + container_prompt: Some("果园容器".to_string()), + container_image_src: Some( + "/generated-match3d-assets/session/profile/ui-container/container.png".to_string(), + ), + ..background_only.clone() + }; + + assert!(!is_match3d_background_asset_ready(&background_only)); + assert!(is_match3d_background_asset_ready(&with_container)); + } + + #[test] + fn match3d_default_cover_prefers_generated_container_ui_image() { + let assets = vec![Match3DGeneratedItemAsset { + item_id: "match3d-item-1".to_string(), + item_name: "草莓".to_string(), + item_size: Some(MATCH3D_ITEM_SIZE_SMALL.to_string()), + image_src: None, + image_object_key: None, + image_views: Vec::new(), + 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: Some(Match3DGeneratedBackgroundAsset { + prompt: "果园背景".to_string(), + image_src: Some( + "/generated-match3d-assets/session/profile/background/background.png" + .to_string(), + ), + image_object_key: None, + container_prompt: Some("果园容器".to_string()), + container_image_src: Some( + "/generated-match3d-assets/session/profile/ui-container/container.png" + .to_string(), + ), + container_image_object_key: None, + status: "image_ready".to_string(), + error: None, + }), + status: "image_ready".to_string(), + error: None, + }]; + + assert_eq!( + resolve_match3d_default_cover_image_src(&assets).as_deref(), + Some("/generated-match3d-assets/session/profile/ui-container/container.png") + ); + } + + #[test] + fn match3d_cover_reference_sources_are_deduped_and_limited() { + let sources = collect_match3d_cover_reference_image_sources( + Some("/generated-match3d-assets/a.png".to_string()), + vec![ + "/generated-match3d-assets/a.png".to_string(), + "data:image/png;base64,b".to_string(), + "/generated-match3d-assets/c.png".to_string(), + "/generated-match3d-assets/d.png".to_string(), + "/generated-match3d-assets/e.png".to_string(), + "/generated-match3d-assets/f.png".to_string(), + "/generated-match3d-assets/g.png".to_string(), + ], + ); + + assert_eq!(sources.len(), 6); + assert_eq!(sources[0], "/generated-match3d-assets/a.png"); + assert_eq!(sources[1], "data:image/png;base64,b"); + assert!(!sources.contains(&"/generated-match3d-assets/g.png".to_string())); + } + + #[test] + fn match3d_public_reference_image_paths_are_limited_to_known_assets() { + assert_eq!( + normalize_match3d_public_reference_image_path( + "/match3d-background-references/pot-fused-reference.png?cache=1" + ) + .as_deref(), + Some("public/match3d-background-references/pot-fused-reference.png") + ); + assert!(normalize_match3d_public_reference_image_path("/icons/logo.png").is_none()); + assert!( + normalize_match3d_public_reference_image_path( + "/match3d-background-references/../secret.png" + ) + .is_none() + ); + } + + #[test] + fn match3d_cover_reference_prompt_marks_reference_images() { + let prompt = build_match3d_cover_reference_generation_prompt("水果封面", true); + + assert!(prompt.contains("一张或多张图片")); + assert!(prompt.contains("不要拼贴成素材墙")); + assert!(prompt.contains("水果封面")); + } + + #[test] + fn match3d_cover_edit_prompt_preserves_uploaded_image() { + let prompt = build_match3d_cover_edit_prompt("水果封面"); + + assert!(prompt.contains("上传的封面图作为第一优先级")); + assert!(prompt.contains("保留主图的主体、构图、视角和主要配色")); + } + + #[test] + fn match3d_fallback_work_profile_keeps_generated_background_asset() { + let assets = vec![Match3DGeneratedItemAsset { + item_id: "match3d-item-1".to_string(), + item_name: "草莓".to_string(), + item_size: Some(MATCH3D_ITEM_SIZE_SMALL.to_string()), + image_src: None, + image_object_key: None, + image_views: Vec::new(), + 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: Some(Match3DGeneratedBackgroundAsset { + prompt: "果园背景".to_string(), + image_src: Some( + "/generated-match3d-assets/session/profile/background/background.png" + .to_string(), + ), + image_object_key: Some( + "generated-match3d-assets/session/profile/background/background.png" + .to_string(), + ), + container_prompt: Some("果园容器".to_string()), + container_image_src: Some( + "/generated-match3d-assets/session/profile/ui-container/container.png" + .to_string(), + ), + container_image_object_key: Some( + "generated-match3d-assets/session/profile/ui-container/container.png" + .to_string(), + ), + status: "image_ready".to_string(), + error: None, + }), + status: "image_ready".to_string(), + error: None, + }]; + + let profile = build_match3d_work_profile_record_with_assets( + Match3DWorkProfileRecord { + work_id: "match3d-profile-1".to_string(), + profile_id: "match3d-profile-1".to_string(), + owner_user_id: "user-1".to_string(), + source_session_id: Some("match3d-session-1".to_string()), + author_display_name: "玩家".to_string(), + game_name: "水果抓大鹅".to_string(), + theme_text: "水果".to_string(), + summary: "水果主题".to_string(), + tags: vec!["水果".to_string()], + cover_image_src: None, + cover_asset_id: None, + reference_image_src: None, + clear_count: 3, + difficulty: 3, + publication_status: "draft".to_string(), + play_count: 0, + updated_at: "2026-05-14T00:00:00Z".to_string(), + published_at: None, + publish_ready: false, + generated_item_assets_json: None, + }, + &assets, + ); + let response = map_match3d_work_summary_response(profile); + + assert_eq!( + response.background_image_src.as_deref(), + Some("/generated-match3d-assets/session/profile/background/background.png") + ); + assert_eq!( + response.cover_image_src.as_deref(), + Some("/generated-match3d-assets/session/profile/ui-container/container.png") + ); + assert_eq!(response.generated_item_assets.len(), 1); + assert_eq!( + response + .generated_background_asset + .as_ref() + .and_then(|asset| asset.container_image_src.as_deref()), + Some("/generated-match3d-assets/session/profile/ui-container/container.png") + ); + } + + #[test] + fn match3d_agent_session_response_hydrates_persisted_ui_assets() { + let session = Match3DAgentSessionRecord { + session_id: "match3d-session-1".to_string(), + current_turn: 3, + progress_percent: 100, + stage: "DraftCompiled".to_string(), + anchor_pack: Match3DAnchorPackRecord { + theme: Match3DAnchorItemRecord { + key: "theme".to_string(), + label: "题材主题".to_string(), + value: "水果".to_string(), + status: "confirmed".to_string(), + }, + clear_count: Match3DAnchorItemRecord { + key: "clearCount".to_string(), + label: "消除次数".to_string(), + value: "12".to_string(), + status: "confirmed".to_string(), + }, + difficulty: Match3DAnchorItemRecord { + key: "difficulty".to_string(), + label: "难度".to_string(), + value: "4".to_string(), + status: "confirmed".to_string(), + }, + }, + config: None, + draft: Some(Match3DResultDraftRecord { + profile_id: "match3d-profile-1".to_string(), + game_name: "水果抓大鹅".to_string(), + theme_text: "水果".to_string(), + summary_text: "水果主题".to_string(), + tags: vec!["水果".to_string(), "抓大鹅".to_string()], + cover_image_src: None, + reference_image_src: None, + clear_count: 12, + difficulty: 4, + total_item_count: 36, + publish_ready: false, + blockers: Vec::new(), + generated_item_assets_json: None, + }), + messages: Vec::new(), + last_assistant_reply: None, + published_profile_id: None, + updated_at: "2026-05-15T00:00:00.000Z".to_string(), + }; + let assets = vec![Match3DGeneratedItemAsset { + item_id: "match3d-item-1".to_string(), + item_name: "草莓".to_string(), + item_size: Some(MATCH3D_ITEM_SIZE_SMALL.to_string()), + image_src: Some( + "/generated-match3d-assets/session/profile/items/strawberry/view-01.png" + .to_string(), + ), + image_object_key: Some( + "generated-match3d-assets/session/profile/items/strawberry/view-01.png".to_string(), + ), + image_views: Vec::new(), + 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: Some(Match3DGeneratedBackgroundAsset { + prompt: "果园背景".to_string(), + image_src: Some( + "/generated-match3d-assets/session/profile/background/background.png" + .to_string(), + ), + image_object_key: Some( + "generated-match3d-assets/session/profile/background/background.png" + .to_string(), + ), + container_prompt: Some("果园容器".to_string()), + container_image_src: Some( + "/generated-match3d-assets/session/profile/ui-container/container.png" + .to_string(), + ), + container_image_object_key: Some( + "generated-match3d-assets/session/profile/ui-container/container.png" + .to_string(), + ), + status: "image_ready".to_string(), + error: None, + }), + status: "image_ready".to_string(), + error: None, + }]; + + let response = map_match3d_agent_session_response_with_assets(session, &assets); + let draft = response.draft.expect("session draft should exist"); + + assert_eq!(draft.generated_item_assets.len(), 1); + assert_eq!(draft.background_prompt.as_deref(), Some("果园背景")); + assert_eq!( + draft.background_image_src.as_deref(), + Some("/generated-match3d-assets/session/profile/background/background.png") + ); + assert_eq!( + draft.cover_image_src.as_deref(), + Some("/generated-match3d-assets/session/profile/ui-container/container.png") + ); + assert_eq!( + draft + .generated_background_asset + .as_ref() + .and_then(|asset| asset.container_image_src.as_deref()), + Some("/generated-match3d-assets/session/profile/ui-container/container.png") + ); + assert_eq!( + draft.generated_item_assets[0] + .background_asset + .as_ref() + .and_then(|asset| asset.image_src.as_deref()), + Some("/generated-match3d-assets/session/profile/background/background.png") + ); + } + + #[test] + fn match3d_agent_session_response_keeps_draft_ui_assets_without_work_detail_hydration() { + let assets = vec![Match3DGeneratedItemAsset { + item_id: "match3d-item-1".to_string(), + item_name: "草莓".to_string(), + item_size: Some(MATCH3D_ITEM_SIZE_SMALL.to_string()), + image_src: Some( + "/generated-match3d-assets/session/profile/items/strawberry/view-01.png" + .to_string(), + ), + image_object_key: Some( + "generated-match3d-assets/session/profile/items/strawberry/view-01.png".to_string(), + ), + image_views: Vec::new(), + 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: Some(Match3DGeneratedBackgroundAsset { + prompt: "果园背景".to_string(), + image_src: Some( + "/generated-match3d-assets/session/profile/background/background.png" + .to_string(), + ), + image_object_key: Some( + "generated-match3d-assets/session/profile/background/background.png" + .to_string(), + ), + container_prompt: Some("果园容器".to_string()), + container_image_src: Some( + "/generated-match3d-assets/session/profile/ui-container/container.png" + .to_string(), + ), + container_image_object_key: Some( + "generated-match3d-assets/session/profile/ui-container/container.png" + .to_string(), + ), + status: "image_ready".to_string(), + error: None, + }), + status: "image_ready".to_string(), + error: None, + }]; + let session = Match3DAgentSessionRecord { + session_id: "match3d-session-1".to_string(), + current_turn: 3, + progress_percent: 100, + stage: "DraftCompiled".to_string(), + anchor_pack: Match3DAnchorPackRecord { + theme: Match3DAnchorItemRecord { + key: "theme".to_string(), + label: "题材主题".to_string(), + value: "水果".to_string(), + status: "confirmed".to_string(), + }, + clear_count: Match3DAnchorItemRecord { + key: "clearCount".to_string(), + label: "消除次数".to_string(), + value: "12".to_string(), + status: "confirmed".to_string(), + }, + difficulty: Match3DAnchorItemRecord { + key: "difficulty".to_string(), + label: "难度".to_string(), + value: "4".to_string(), + status: "confirmed".to_string(), + }, + }, + config: None, + draft: Some(Match3DResultDraftRecord { + profile_id: "match3d-profile-1".to_string(), + game_name: "水果抓大鹅".to_string(), + theme_text: "水果".to_string(), + summary_text: "水果主题".to_string(), + tags: vec!["水果".to_string(), "抓大鹅".to_string()], + cover_image_src: None, + reference_image_src: None, + clear_count: 12, + difficulty: 4, + total_item_count: 36, + publish_ready: false, + blockers: Vec::new(), + generated_item_assets_json: serialize_match3d_generated_item_assets(&assets), + }), + messages: Vec::new(), + last_assistant_reply: None, + published_profile_id: None, + updated_at: "2026-05-15T00:00:00.000Z".to_string(), + }; + + let response = map_match3d_agent_session_response_with_assets(session, &[]); + let draft = response.draft.expect("session draft should exist"); + + assert_eq!(draft.generated_item_assets.len(), 1); + assert_eq!( + draft.background_image_src.as_deref(), + Some("/generated-match3d-assets/session/profile/background/background.png") + ); + assert_eq!( + draft.background_image_object_key.as_deref(), + Some("generated-match3d-assets/session/profile/background/background.png") + ); + assert_eq!( + draft + .generated_background_asset + .as_ref() + .and_then(|asset| asset.container_image_src.as_deref()), + Some("/generated-match3d-assets/session/profile/ui-container/container.png") + ); + assert_eq!( + draft.generated_item_assets[0] + .background_asset + .as_ref() + .and_then(|asset| asset.image_src.as_deref()), + Some("/generated-match3d-assets/session/profile/background/background.png") + ); + } + + #[test] + fn match3d_tag_normalization_only_strips_numbered_list_prefix() { + assert_eq!(normalize_match3d_tag("3D素材"), "3D素材"); + assert_eq!(normalize_match3d_tag("1. 3D素材"), "3D素材"); + assert_eq!(normalize_match3d_tag("2、轻量休闲"), "轻量休闲"); + } + + #[test] + fn match3d_plan_tags_are_kept_before_local_fallback_tags() { + let tags = merge_match3d_plan_tags_with_fallback( + "果园大鹅宴", + "水果", + &["果园".to_string(), "轻快".to_string(), "抓大鹅".to_string()], + ); + + assert_eq!(tags[0], "果园"); + assert_eq!(tags[1], "轻快"); + assert_eq!(tags[2], "抓大鹅"); + assert!(tags.contains(&"水果".to_string())); + assert!(tags.contains(&"经典消除".to_string())); + } + + #[test] + fn match3d_model_download_metadata_normalizes_to_glb() { + assert_eq!( + normalize_match3d_model_file_name("https://example.com/Fruit Model.GLB?token=1"), + "fruit-model.glb" + ); + assert_eq!(normalize_match3d_model_file_name("模型文件"), "model.glb"); + assert_eq!( + normalize_match3d_model_content_type("application/octet-stream"), + "model/gltf-binary" + ); + assert_eq!( + normalize_match3d_model_content_type("model/gltf-binary; charset=utf-8"), + "model/gltf-binary" + ); + } + + #[test] + fn match3d_model_download_requires_valid_glb_header() { + let mut glb = Vec::new(); + glb.extend_from_slice(&0x4654_6c67_u32.to_le_bytes()); + glb.extend_from_slice(&2_u32.to_le_bytes()); + glb.extend_from_slice(&12_u32.to_le_bytes()); + + assert!(is_match3d_glb_binary_payload(&glb)); + assert!(!is_match3d_glb_binary_payload(b"expired")); + + let mut wrong_length = glb.clone(); + wrong_length[8..12].copy_from_slice(&16_u32.to_le_bytes()); + assert!(!is_match3d_glb_binary_payload(&wrong_length)); + } + + #[test] + fn match3d_generated_asset_resume_keeps_stable_item_order() { + let assets = normalize_match3d_generated_item_assets_for_resume(vec![ + Match3DGeneratedItemAsset { + item_id: "match3d-item-2".to_string(), + item_name: "苹果".to_string(), + item_size: Some(MATCH3D_ITEM_SIZE_MEDIUM.to_string()), + image_src: Some("/generated-match3d-assets/s/p/items/i2/image.png".to_string()), + image_object_key: Some( + "generated-match3d-assets/s/p/items/i2/image.png".to_string(), + ), + image_views: Vec::new(), + model_src: Some( + "/generated-match3d-assets/s/p/items/i2/model/model.glb".to_string(), + ), + model_object_key: Some( + "generated-match3d-assets/s/p/items/i2/model/model.glb".to_string(), + ), + model_file_name: Some("model.glb".to_string()), + task_uuid: Some("task-2".to_string()), + subscription_key: Some("sub-2".to_string()), + 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: "model_ready".to_string(), + error: None, + }, + Match3DGeneratedItemAsset { + item_id: "match3d-item-1".to_string(), + item_name: "草莓".to_string(), + item_size: Some(MATCH3D_ITEM_SIZE_SMALL.to_string()), + image_src: Some("/generated-match3d-assets/s/p/items/i1/image.png".to_string()), + image_object_key: Some( + "generated-match3d-assets/s/p/items/i1/image.png".to_string(), + ), + image_views: Vec::new(), + 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, + }, + ]); + + assert_eq!(assets[0].item_id, "match3d-item-1"); + assert_eq!(assets[1].item_id, "match3d-item-2"); + } + + #[test] + fn match3d_required_item_images_require_five_views() { + let assets = vec![ + Match3DGeneratedItemAsset { + item_id: "match3d-item-1".to_string(), + item_name: "草莓".to_string(), + item_size: Some(MATCH3D_ITEM_SIZE_SMALL.to_string()), + image_src: Some("/generated-match3d-assets/s/p/items/i1/image.png".to_string()), + image_object_key: Some( + "generated-match3d-assets/s/p/items/i1/image.png".to_string(), + ), + image_views: Vec::new(), + 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, + }, + Match3DGeneratedItemAsset { + item_id: "match3d-item-2".to_string(), + item_name: "苹果".to_string(), + item_size: Some(MATCH3D_ITEM_SIZE_MEDIUM.to_string()), + image_src: Some("/generated-match3d-assets/s/p/items/i2/image.png".to_string()), + image_object_key: Some( + "generated-match3d-assets/s/p/items/i2/image.png".to_string(), + ), + image_views: Vec::new(), + 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, + }, + Match3DGeneratedItemAsset { + item_id: "match3d-item-3".to_string(), + item_name: "香蕉".to_string(), + item_size: Some(MATCH3D_ITEM_SIZE_MEDIUM.to_string()), + image_src: Some("/generated-match3d-assets/s/p/items/i3/image.png".to_string()), + image_object_key: None, + image_views: Vec::new(), + 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, + }, + ]; + + 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::>(); + + assert!(has_match3d_required_item_images(&five_view_assets, 3)); + } + + #[test] + fn match3d_oss_config_error_lists_missing_env_keys() { + let mut app_config = AppConfig { + oss_bucket: Some("genarrative-assets".to_string()), + oss_endpoint: Some("oss-cn-shanghai.aliyuncs.com".to_string()), + ..AppConfig::default() + }; + + let missing = missing_match3d_oss_env_keys(&app_config); + assert_eq!( + missing, + vec!["ALIYUN_OSS_ACCESS_KEY_ID", "ALIYUN_OSS_ACCESS_KEY_SECRET"] + ); + assert_eq!( + match3d_oss_missing_reason(&missing), + "OSS 未完成环境变量配置,缺少:ALIYUN_OSS_ACCESS_KEY_ID, ALIYUN_OSS_ACCESS_KEY_SECRET" + ); + + app_config.oss_access_key_id = Some("ak".to_string()); + app_config.oss_access_key_secret = Some("sk".to_string()); + assert!(missing_match3d_oss_env_keys(&app_config).is_empty()); + } + + #[test] + fn match3d_work_summary_maps_persisted_generated_item_assets() { + let response = map_match3d_work_summary_response(Match3DWorkProfileRecord { + work_id: "match3d-profile-1".to_string(), + profile_id: "match3d-profile-1".to_string(), + owner_user_id: "user-1".to_string(), + source_session_id: Some("match3d-session-1".to_string()), + author_display_name: "玩家".to_string(), + game_name: "水果抓大鹅".to_string(), + theme_text: "水果".to_string(), + summary: "水果主题".to_string(), + tags: vec!["水果".to_string()], + cover_image_src: None, + cover_asset_id: None, + reference_image_src: None, + clear_count: 3, + difficulty: 3, + publication_status: "draft".to_string(), + play_count: 0, + updated_at: "2026-05-10T00:00:00.000Z".to_string(), + published_at: None, + publish_ready: false, + generated_item_assets_json: Some( + r#"[{"itemId":"match3d-item-1","itemName":"草莓","imageSrc":"/generated-match3d-assets/session/profile/items/match3d-item-1-item/image.png","imageObjectKey":"generated-match3d-assets/session/profile/items/match3d-item-1-item/image.png","status":"image_ready"}]"# + .to_string(), + ), + }); + + assert_eq!(response.generated_item_assets.len(), 1); + assert_eq!(response.generated_item_assets[0].item_name, "草莓"); + assert_eq!(response.generated_item_assets[0].status, "image_ready"); + assert_eq!(response.generation_status.as_deref(), Some("generating")); + assert_eq!( + response.generated_item_assets[0].image_src.as_deref(), + Some("/generated-match3d-assets/session/profile/items/match3d-item-1-item/image.png") + ); + } + + #[test] + fn match3d_work_summary_marks_complete_generated_assets_ready() { + let assets = vec![Match3DGeneratedItemAsset { + background_asset: Some(Match3DGeneratedBackgroundAsset { + prompt: "水果厨房背景".to_string(), + image_src: Some( + "/generated-match3d-assets/session/profile/background.png".to_string(), + ), + image_object_key: Some( + "generated-match3d-assets/session/profile/background.png".to_string(), + ), + container_prompt: None, + container_image_src: Some( + "/generated-match3d-assets/session/profile/container.png".to_string(), + ), + container_image_object_key: Some( + "generated-match3d-assets/session/profile/container.png".to_string(), + ), + status: "image_ready".to_string(), + error: None, + }), + ..test_match3d_generated_item_asset(1, "草莓") + }]; + let response = map_match3d_work_summary_response(Match3DWorkProfileRecord { + work_id: "match3d-profile-1".to_string(), + profile_id: "match3d-profile-1".to_string(), + owner_user_id: "user-1".to_string(), + source_session_id: Some("match3d-session-1".to_string()), + author_display_name: "玩家".to_string(), + game_name: "水果抓大鹅".to_string(), + theme_text: "水果".to_string(), + summary: "水果主题".to_string(), + tags: vec!["水果".to_string()], + cover_image_src: None, + cover_asset_id: None, + reference_image_src: None, + clear_count: 3, + difficulty: 3, + publication_status: "draft".to_string(), + play_count: 0, + updated_at: "2026-05-10T00:00:00.000Z".to_string(), + published_at: None, + publish_ready: false, + generated_item_assets_json: serialize_match3d_generated_item_assets(&assets), + }); + + assert_eq!(response.generation_status.as_deref(), Some("ready")); + } diff --git a/server-rs/crates/api-server/src/match3d/vector_engine_gemini.rs b/server-rs/crates/api-server/src/match3d/vector_engine_gemini.rs new file mode 100644 index 00000000..b19e89c3 --- /dev/null +++ b/server-rs/crates/api-server/src/match3d/vector_engine_gemini.rs @@ -0,0 +1,483 @@ +use super::*; + +pub(super) async fn generate_match3d_material_sheet( + state: &AppState, + config: &Match3DConfigJson, + item_names: &[String], +) -> Result { + let settings = require_match3d_vector_engine_gemini_image_settings(state)?; + let http_client = build_match3d_vector_engine_gemini_image_http_client(&settings)?; + let prompt = build_match3d_material_sheet_prompt(config, item_names); + let negative_prompt = build_match3d_material_sheet_negative_prompt(config); + let generated = create_match3d_vector_engine_gemini_image_generation( + &http_client, + &settings, + prompt.as_str(), + negative_prompt.as_str(), + "抓大鹅素材图生成失败", + ) + .await?; + let image = generated.images.into_iter().next().ok_or_else(|| { + AppError::from_status(StatusCode::BAD_GATEWAY).with_details(json!({ + "provider": "vector-engine-gemini", + "message": "抓大鹅素材图生成失败:未返回图片", + })) + })?; + + Ok(Match3DMaterialSheet { + task_id: generated.task_id, + image, + }) +} + +fn require_match3d_vector_engine_gemini_image_settings( + state: &AppState, +) -> Result { + let base_url = state + .config + .vector_engine_base_url + .trim() + .trim_end_matches('/'); + if base_url.is_empty() { + return Err( + AppError::from_status(StatusCode::SERVICE_UNAVAILABLE).with_details(json!({ + "provider": "vector-engine-gemini", + "reason": "VECTOR_ENGINE_BASE_URL 未配置", + })), + ); + } + + let api_key = state + .config + .vector_engine_api_key + .as_deref() + .map(str::trim) + .filter(|value| !value.is_empty()) + .ok_or_else(|| { + AppError::from_status(StatusCode::SERVICE_UNAVAILABLE).with_details(json!({ + "provider": "vector-engine-gemini", + "reason": "VECTOR_ENGINE_API_KEY 未配置", + })) + })?; + + Ok(Match3DVectorEngineGeminiImageSettings { + base_url: base_url.to_string(), + api_key: api_key.to_string(), + request_timeout_ms: state.config.vector_engine_image_request_timeout_ms.max(1), + }) +} + +fn build_match3d_vector_engine_gemini_image_http_client( + settings: &Match3DVectorEngineGeminiImageSettings, +) -> Result { + reqwest::Client::builder() + .timeout(Duration::from_millis(settings.request_timeout_ms)) + .build() + .map_err(|error| { + AppError::from_status(StatusCode::INTERNAL_SERVER_ERROR).with_details(json!({ + "provider": "vector-engine-gemini", + "message": format!("构造抓大鹅 VectorEngine Gemini 图片生成 HTTP 客户端失败:{error}"), + })) + }) +} + +async fn create_match3d_vector_engine_gemini_image_generation( + http_client: &reqwest::Client, + settings: &Match3DVectorEngineGeminiImageSettings, + prompt: &str, + negative_prompt: &str, + failure_context: &str, +) -> Result { + let request_body = build_match3d_vector_engine_gemini_image_request_body( + prompt, + negative_prompt, + MATCH3D_MATERIAL_VECTOR_ENGINE_GEMINI_ASPECT_RATIO, + ); + let response = http_client + .post(build_match3d_vector_engine_gemini_generate_content_url( + settings, + )) + .query(&[("key", settings.api_key.as_str())]) + .header(header::ACCEPT, "application/json") + .header(header::CONTENT_TYPE, "application/json") + .json(&request_body) + .send() + .await + .map_err(|error| { + map_match3d_vector_engine_gemini_image_request_error(format!( + "{failure_context}:调用 VectorEngine Gemini 图片生成失败:{error}" + )) + })?; + let status = response.status(); + let response_text = response.text().await.map_err(|error| { + map_match3d_vector_engine_gemini_image_request_error(format!( + "{failure_context}:读取 VectorEngine Gemini 图片生成响应失败:{error}" + )) + })?; + if !status.is_success() { + return Err(map_match3d_vector_engine_gemini_image_upstream_error( + status, + response_text.as_str(), + failure_context, + )); + } + + let payload = parse_match3d_json_payload( + response_text.as_str(), + "解析抓大鹅 VectorEngine Gemini 图片生成响应失败", + "vector-engine-gemini", + )?; + let image_urls = extract_match3d_image_urls(&payload); + if !image_urls.is_empty() { + return download_match3d_images_from_urls( + http_client, + format!("vector-engine-gemini-{}", current_utc_micros()), + image_urls, + 1, + "vector-engine-gemini", + ) + .await; + } + + let b64_images = extract_match3d_b64_images(&payload); + if !b64_images.is_empty() { + return Ok(match3d_images_from_base64( + format!("vector-engine-gemini-{}", current_utc_micros()), + b64_images, + 1, + )); + } + + Err( + AppError::from_status(StatusCode::BAD_GATEWAY).with_details(json!({ + "provider": "vector-engine-gemini", + "message": "抓大鹅 VectorEngine Gemini 图片生成未返回图片", + "rawExcerpt": trim_match3d_upstream_excerpt(response_text.as_str(), 800), + })), + ) +} + +pub(super) fn build_match3d_vector_engine_gemini_image_request_body( + prompt: &str, + negative_prompt: &str, + aspect_ratio: &str, +) -> Value { + json!({ + "contents": [{ + "role": "user", + "parts": [{ + "text": build_match3d_vector_engine_gemini_prompt(prompt, negative_prompt), + }], + }], + "generationConfig": { + "responseModalities": ["TEXT", "IMAGE"], + "imageConfig": { + "aspectRatio": aspect_ratio, + }, + }, + }) +} + +pub(super) fn build_match3d_vector_engine_gemini_generate_content_url( + settings: &Match3DVectorEngineGeminiImageSettings, +) -> String { + let base_url = settings.base_url.trim_end_matches("/v1"); + format!( + "{}/v1beta/models/{}:generateContent", + base_url, MATCH3D_MATERIAL_VECTOR_ENGINE_GEMINI_MODEL + ) +} + +fn build_match3d_vector_engine_gemini_prompt(prompt: &str, negative_prompt: &str) -> String { + let prompt = prompt.trim(); + let negative_prompt = negative_prompt.trim(); + if negative_prompt.is_empty() { + return prompt.to_string(); + } + + format!("{prompt}\n避免:{negative_prompt}") +} + +async fn download_match3d_images_from_urls( + http_client: &reqwest::Client, + task_id: String, + image_urls: Vec, + candidate_count: u32, + provider: &str, +) -> Result { + let mut images = Vec::with_capacity(candidate_count.clamp(1, 4) as usize); + for image_url in image_urls + .into_iter() + .take(candidate_count.clamp(1, 4) as usize) + { + images + .push(download_match3d_remote_image(http_client, image_url.as_str(), provider).await?); + } + Ok(OpenAiGeneratedImages { + task_id, + actual_prompt: None, + images, + }) +} + +async fn download_match3d_remote_image( + http_client: &reqwest::Client, + image_url: &str, + provider: &str, +) -> Result { + let response = http_client.get(image_url).send().await.map_err(|error| { + AppError::from_status(StatusCode::BAD_GATEWAY).with_details(json!({ + "provider": provider, + "message": format!("下载抓大鹅生成图片失败:{error}"), + })) + })?; + let status = response.status(); + let content_type = response + .headers() + .get(header::CONTENT_TYPE) + .and_then(|value| value.to_str().ok()) + .unwrap_or("image/png") + .to_string(); + let body = response.bytes().await.map_err(|error| { + AppError::from_status(StatusCode::BAD_GATEWAY).with_details(json!({ + "provider": provider, + "message": format!("读取抓大鹅生成图片内容失败:{error}"), + })) + })?; + if !status.is_success() { + return Err( + AppError::from_status(StatusCode::BAD_GATEWAY).with_details(json!({ + "provider": provider, + "message": "下载抓大鹅生成图片失败", + "status": status.as_u16(), + })), + ); + } + + let mime_type = normalize_match3d_downloaded_image_mime_type(content_type.as_str()); + Ok(DownloadedOpenAiImage { + extension: match3d_mime_to_extension(mime_type.as_str()).to_string(), + mime_type, + bytes: body.to_vec(), + }) +} + +fn match3d_images_from_base64( + task_id: String, + b64_images: Vec, + candidate_count: u32, +) -> OpenAiGeneratedImages { + let images = b64_images + .into_iter() + .take(candidate_count.clamp(1, 4) as usize) + .filter_map(|raw| decode_match3d_base64_image(raw.as_str())) + .collect(); + OpenAiGeneratedImages { + task_id, + actual_prompt: None, + images, + } +} + +fn decode_match3d_base64_image(raw: &str) -> Option { + let bytes = BASE64_STANDARD.decode(raw.trim()).ok()?; + let mime_type = infer_match3d_image_mime_type(bytes.as_slice()).to_string(); + Some(DownloadedOpenAiImage { + extension: match3d_mime_to_extension(mime_type.as_str()).to_string(), + mime_type, + bytes, + }) +} + +fn parse_match3d_json_payload( + raw_text: &str, + failure_context: &str, + provider: &str, +) -> Result { + serde_json::from_str::(raw_text).map_err(|error| { + AppError::from_status(StatusCode::BAD_GATEWAY).with_details(json!({ + "provider": provider, + "message": format!("{failure_context}:{error}"), + "rawExcerpt": trim_match3d_upstream_excerpt(raw_text, 800), + })) + }) +} + +fn extract_match3d_image_urls(payload: &Value) -> Vec { + let mut urls = Vec::new(); + collect_match3d_strings_by_key(payload, "url", &mut urls); + collect_match3d_strings_by_key(payload, "image", &mut urls); + collect_match3d_strings_by_key(payload, "image_url", &mut urls); + let mut deduped = Vec::new(); + for url in urls { + if (url.starts_with("http://") || url.starts_with("https://")) && !deduped.contains(&url) { + deduped.push(url); + } + } + deduped +} + +pub(super) fn extract_match3d_b64_images(payload: &Value) -> Vec { + let mut values = Vec::new(); + collect_match3d_strings_by_key(payload, "b64_json", &mut values); + collect_match3d_inline_image_data(payload, &mut values); + values +} + +fn collect_match3d_inline_image_data(payload: &Value, results: &mut Vec) { + match payload { + Value::Array(entries) => { + for entry in entries { + collect_match3d_inline_image_data(entry, results); + } + } + Value::Object(object) => { + for key in ["inlineData", "inline_data"] { + if let Some(Value::Object(inline_data)) = object.get(key) { + let mime_type = inline_data + .get("mimeType") + .or_else(|| inline_data.get("mime_type")) + .and_then(Value::as_str) + .map(str::trim) + .unwrap_or("image/png") + .to_ascii_lowercase(); + if !mime_type.is_empty() && !mime_type.starts_with("image/") { + continue; + } + if let Some(data) = inline_data + .get("data") + .and_then(Value::as_str) + .map(str::trim) + .filter(|value| !value.is_empty()) + { + results.push(data.to_string()); + } + } + } + for nested_value in object.values() { + collect_match3d_inline_image_data(nested_value, results); + } + } + _ => {} + } +} + +fn find_first_match3d_string_by_key(payload: &Value, target_key: &str) -> Option { + let mut results = Vec::new(); + collect_match3d_strings_by_key(payload, target_key, &mut results); + results.into_iter().next() +} + +fn collect_match3d_strings_by_key(payload: &Value, target_key: &str, results: &mut Vec) { + match payload { + Value::Array(entries) => { + for entry in entries { + collect_match3d_strings_by_key(entry, target_key, results); + } + } + Value::Object(object) => { + for (key, nested_value) in object { + if key == target_key { + match nested_value { + Value::String(text) => { + let text = text.trim(); + if !text.is_empty() { + results.push(text.to_string()); + } + } + Value::Array(entries) => { + for entry in entries { + if let Some(text) = entry + .as_str() + .map(str::trim) + .filter(|value| !value.is_empty()) + { + results.push(text.to_string()); + } + } + } + _ => {} + } + } + collect_match3d_strings_by_key(nested_value, target_key, results); + } + } + _ => {} + } +} + +fn map_match3d_vector_engine_gemini_image_request_error(message: String) -> AppError { + AppError::from_status(StatusCode::BAD_GATEWAY).with_details(json!({ + "provider": "vector-engine-gemini", + "message": message, + })) +} + +fn map_match3d_vector_engine_gemini_image_upstream_error( + upstream_status: reqwest::StatusCode, + raw_text: &str, + fallback_message: &str, +) -> AppError { + let message = parse_match3d_api_error_message(raw_text, fallback_message); + let raw_excerpt = trim_match3d_upstream_excerpt(raw_text, 800); + tracing::warn!( + provider = "vector-engine-gemini", + upstream_status = upstream_status.as_u16(), + message = %message, + raw_excerpt = %raw_excerpt, + "抓大鹅 VectorEngine Gemini 图片生成上游请求失败" + ); + AppError::from_status(StatusCode::BAD_GATEWAY).with_details(json!({ + "provider": "vector-engine-gemini", + "upstreamStatus": upstream_status.as_u16(), + "message": message, + "rawExcerpt": raw_excerpt, + })) +} + +fn parse_match3d_api_error_message(raw_text: &str, fallback_message: &str) -> String { + let trimmed = raw_text.trim(); + if trimmed.is_empty() { + return fallback_message.to_string(); + } + if let Ok(payload) = serde_json::from_str::(trimmed) { + for key in ["message", "code"] { + if let Some(value) = find_first_match3d_string_by_key(&payload, key) { + return if key == "message" { + value + } else { + format!("{fallback_message}({value})") + }; + } + } + } + trimmed.to_string() +} + +fn trim_match3d_upstream_excerpt(raw_text: &str, max_chars: usize) -> String { + raw_text.chars().take(max_chars).collect() +} + +fn normalize_match3d_downloaded_image_mime_type(content_type: &str) -> String { + let mime_type = content_type + .split(';') + .next() + .map(str::trim) + .unwrap_or("image/png"); + match mime_type { + "image/png" | "image/webp" | "image/jpeg" | "image/jpg" | "image/gif" => { + mime_type.to_string() + } + _ => "image/png".to_string(), + } +} + +pub(super) fn match3d_mime_to_extension(mime_type: &str) -> &str { + match mime_type { + "image/png" => "png", + "image/webp" => "webp", + "image/gif" => "gif", + "image/jpeg" | "image/jpg" => "jpg", + _ => "png", + } +} diff --git a/server-rs/crates/api-server/src/match3d/works.rs b/server-rs/crates/api-server/src/match3d/works.rs new file mode 100644 index 00000000..0db5d0ef --- /dev/null +++ b/server-rs/crates/api-server/src/match3d/works.rs @@ -0,0 +1,1254 @@ +use super::*; + +pub(super) async fn update_match3d_work_cover_only( + state: &AppState, + request_context: &RequestContext, + owner_user_id: &str, + profile: Match3DWorkProfileRecord, + cover_image_src: &str, +) -> Result { + // 中文注释:封面生成是定向图片槽位更新,不能复用草稿编译路径重算题材、难度或素材 JSON。 + state + .spacetime_client() + .update_match3d_work(Match3DWorkUpdateRecordInput { + profile_id: profile.profile_id, + owner_user_id: owner_user_id.to_string(), + game_name: profile.game_name, + theme_text: profile.theme_text, + summary_text: profile.summary, + tags_json: serde_json::to_string(&normalize_tags(profile.tags)).unwrap_or_default(), + cover_image_src: cover_image_src.to_string(), + cover_asset_id: profile.cover_asset_id.unwrap_or_default(), + clear_count: profile.clear_count, + difficulty: profile.difficulty, + updated_at_micros: current_utc_micros(), + }) + .await + .map_err(|error| { + match3d_error_response( + request_context, + MATCH3D_WORKS_PROVIDER, + map_match3d_client_error(error), + ) + }) +} +pub(super) async fn get_match3d_existing_generated_item_assets( + state: &AppState, + owner_user_id: &str, + profile_id: &str, +) -> Vec { + match state + .spacetime_client() + .get_match3d_work_detail(profile_id.to_string(), owner_user_id.to_string()) + .await + { + Ok(profile) => { + parse_match3d_generated_item_assets(profile.generated_item_assets_json.as_deref()) + .into_iter() + .map(Match3DGeneratedItemAsset::from) + .collect() + } + Err(error) => { + tracing::debug!( + provider = MATCH3D_AGENT_PROVIDER, + profile_id, + error = %error, + "读取抓大鹅已有素材失败,按空素材继续生成" + ); + Vec::new() + } + } +} + +pub(super) async fn get_match3d_existing_cover_image_src( + state: &AppState, + owner_user_id: &str, + profile_id: &str, +) -> Option { + state + .spacetime_client() + .get_match3d_work_detail(profile_id.to_string(), owner_user_id.to_string()) + .await + .ok() + .and_then(|profile| profile.cover_image_src) + .map(|value| value.trim().to_string()) + .filter(|value| !value.is_empty()) +} + +pub(super) async fn load_match3d_work_asset_context( + state: &AppState, + request_context: &RequestContext, + authenticated: &AuthenticatedAccessToken, + profile_id: &str, +) -> Result { + let owner_user_id = authenticated.claims().user_id().to_string(); + let profile = state + .spacetime_client() + .get_match3d_work_detail(profile_id.to_string(), owner_user_id.clone()) + .await + .map_err(|error| { + match3d_error_response( + request_context, + MATCH3D_WORKS_PROVIDER, + map_match3d_client_error(error), + ) + })?; + let session_id = profile.source_session_id.clone().ok_or_else(|| { + match3d_error_response( + request_context, + MATCH3D_WORKS_PROVIDER, + AppError::from_status(StatusCode::BAD_REQUEST).with_details(json!({ + "provider": MATCH3D_WORKS_PROVIDER, + "message": "抓大鹅作品缺少来源 session,无法生成素材", + })), + ) + })?; + let config = match state + .spacetime_client() + .get_match3d_agent_session(session_id.clone(), owner_user_id.clone()) + .await + { + Ok(session) => { + let mut config = resolve_config_or_default(session.config.as_ref()); + if config.theme_text.trim().is_empty() { + config.theme_text = profile.theme_text.clone(); + } + config + } + Err(error) => { + tracing::debug!( + provider = MATCH3D_WORKS_PROVIDER, + profile_id, + session_id = session_id.as_str(), + error = %error, + "读取抓大鹅 session 配置失败,使用作品 profile 派生素材配置" + ); + Match3DConfigJson { + theme_text: profile.theme_text.clone(), + reference_image_src: profile.reference_image_src.clone(), + clear_count: profile.clear_count, + difficulty: profile.difficulty, + asset_style_id: None, + asset_style_label: None, + asset_style_prompt: None, + generate_click_sound: false, + } + } + }; + let assets = parse_match3d_generated_item_assets(profile.generated_item_assets_json.as_deref()) + .into_iter() + .map(Match3DGeneratedItemAsset::from) + .collect::>(); + Ok(Match3DWorkAssetContext { + owner_user_id, + session_id, + profile, + config, + assets, + }) +} + +#[allow(clippy::too_many_arguments)] +pub(super) async fn persist_match3d_generated_item_assets_snapshot( + state: &AppState, + request_context: &RequestContext, + authenticated: &AuthenticatedAccessToken, + session_id: &str, + owner_user_id: &str, + profile_id: &str, + assets: &[Match3DGeneratedItemAsset], +) -> Result<(), Response> { + upsert_match3d_draft_snapshot( + state, + request_context, + authenticated, + session_id.to_string(), + owner_user_id.to_string(), + profile_id.to_string(), + None, + None, + None, + None, + None, + serialize_match3d_generated_item_assets(assets), + ) + .await + .map(|_| ()) +} + +pub(super) fn resolve_author_display_name( + state: &AppState, + authenticated: &AuthenticatedAccessToken, +) -> String { + state + .auth_user_service() + .get_user_by_id(authenticated.claims().user_id()) + .ok() + .flatten() + .map(|user| user.display_name) + .filter(|value| !value.trim().is_empty()) + .unwrap_or_else(|| "玩家".to_string()) +} +pub(super) async fn ensure_match3d_background_asset( + state: &AppState, + request_context: &RequestContext, + authenticated: &AuthenticatedAccessToken, + owner_user_id: &str, + session_id: &str, + profile_id: &str, + config: &Match3DConfigJson, + background_prompt: &str, + mut assets: Vec, +) -> Result, Response> { + let normalized_prompt = normalize_match3d_background_prompt(background_prompt); + let resolved_prompt = if normalized_prompt.is_empty() { + build_fallback_match3d_background_prompt(config) + } else { + normalized_prompt + }; + if let Some(existing_background) = find_match3d_generated_background_asset(&assets) { + if is_match3d_background_asset_ready(&existing_background) { + return Ok(assets); + } + } + + let generated_background = generate_match3d_background_image( + state, + owner_user_id, + session_id, + profile_id, + config, + &resolved_prompt, + ) + .await + .map_err(|error| match3d_error_response(request_context, MATCH3D_AGENT_PROVIDER, error))?; + attach_match3d_background_asset_to_assets(&mut assets, generated_background); + persist_match3d_generated_item_assets_snapshot( + state, + request_context, + authenticated, + session_id, + owner_user_id, + profile_id, + &assets, + ) + .await?; + Ok(assets) +} + +pub(super) fn attach_match3d_background_asset_to_assets( + assets: &mut Vec, + background_asset: Match3DGeneratedBackgroundAsset, +) { + if let Some(first_asset) = assets + .iter_mut() + .min_by_key(|asset| match3d_item_sort_index(asset.item_id.as_str())) + { + first_asset.background_asset = Some(background_asset); + } +} + +pub(super) fn build_match3d_item_slug(item_id: &str, item_name: &str) -> String { + format!( + "{}-{}", + sanitize_match3d_asset_segment(item_id, "match3d-item"), + sanitize_match3d_asset_segment(item_name, "item") + ) +} + +pub(super) async fn generate_match3d_cover_image_asset( + state: &AppState, + owner_user_id: &str, + session_id: &str, + profile_id: &str, + config: &Match3DConfigJson, + prompt: &str, + uploaded_image_src: Option, + reference_image_srcs: Vec, +) -> Result { + require_match3d_oss_client(state)?; + let settings = require_openai_image_settings(state)?; + let http_client = build_openai_image_http_client(&settings)?; + let cover_prompt = build_match3d_cover_generation_prompt(config, prompt); + let generated = if let Some(uploaded_image) = resolve_match3d_reference_image_for_edit( + state, + uploaded_image_src.as_deref(), + MATCH3D_ITEM_IMAGE_MAX_BYTES, + "match3d-cover-upload", + ) + .await? + { + create_openai_image_edit( + &http_client, + &settings, + build_match3d_cover_edit_prompt(cover_prompt.as_str()).as_str(), + Some("文字、水印、UI、按钮、倒计时、分数、教程浮层、菜单、边框"), + "1:1", + &uploaded_image, + "抓大鹅封面图重绘失败", + ) + .await? + } else { + let reference_images = resolve_match3d_cover_reference_image_data_urls( + state, + reference_image_srcs, + MATCH3D_ITEM_IMAGE_MAX_BYTES, + ) + .await?; + create_openai_image_generation( + &http_client, + &settings, + build_match3d_cover_reference_generation_prompt( + cover_prompt.as_str(), + !reference_images.is_empty(), + ) + .as_str(), + Some("文字、水印、UI、按钮、倒计时、分数、教程浮层、菜单、边框"), + "1:1", + 1, + reference_images.as_slice(), + "抓大鹅封面图生成失败", + ) + .await? + }; + let image = generated.images.into_iter().next().ok_or_else(|| { + AppError::from_status(StatusCode::BAD_GATEWAY).with_details(json!({ + "provider": "vector-engine", + "message": "抓大鹅封面图生成失败:未返回图片", + })) + })?; + + let file_name = format!("cover.{}", image.extension); + persist_match3d_generated_bytes( + state, + owner_user_id, + session_id, + profile_id, + &["cover", generated.task_id.as_str()], + file_name.as_str(), + image.mime_type.as_str(), + image.bytes, + "match3d_cover_image", + Some(generated.task_id.as_str()), + current_utc_micros(), + ) + .await +} + +fn build_match3d_cover_generation_prompt(config: &Match3DConfigJson, prompt: &str) -> String { + let style_clause = resolve_match3d_asset_style_prompt(config) + .map(|style| format!("整体美术风格遵循:{style}。")) + .unwrap_or_default(); + format!( + "{theme}题材抓大鹅作品封面图。{style_clause}{prompt}。画面为1:1封面,主体清晰、色彩明亮、适合移动端作品卡片展示;可以包含生成物品或主题元素,但不要出现任何文字、按钮、倒计时、分数或 UI。", + theme = config.theme_text, + style_clause = style_clause, + prompt = prompt, + ) +} + +pub(super) fn build_match3d_cover_edit_prompt(prompt: &str) -> String { + format!( + concat!( + "请以随请求上传的封面图作为第一优先级重绘依据,保留主图的主体、构图、视角和主要配色;", + "允许按文字要求提升美术质量、统一风格和补充细节,但不要改成与主图无关的新画面。\n", + "{prompt}" + ), + prompt = prompt.trim() + ) +} + +pub(super) fn build_match3d_cover_reference_generation_prompt( + prompt: &str, + has_reference_images: bool, +) -> String { + if !has_reference_images { + return prompt.trim().to_string(); + } + format!( + concat!( + "请参考随请求提供的一张或多张图片作为题材、物体和美术风格参考,融合为一张新的抓大鹅作品封面;", + "参考图只用于主体元素、材质、配色和风格启发,不要拼贴成素材墙或多图排版。\n", + "{prompt}" + ), + prompt = prompt.trim() + ) +} + +pub(super) async fn generate_match3d_background_image( + state: &AppState, + owner_user_id: &str, + session_id: &str, + profile_id: &str, + config: &Match3DConfigJson, + prompt: &str, +) -> Result { + require_match3d_oss_client(state)?; + let settings = require_openai_image_settings(state)?; + let http_client = build_openai_image_http_client(&settings)?; + let reference_image = load_match3d_container_reference_image().await?; + let generated_background = create_openai_image_generation( + &http_client, + &settings, + build_match3d_background_generation_prompt(config, prompt).as_str(), + Some( + "文字、水印、UI、按钮、倒计时、分数、物品、角色、手、边框、教程浮层、菜单、透明区域、透明 alpha、镂空、棋盘格透明底", + ), + "9:16", + 1, + &[], + "抓大鹅背景图生成失败", + ) + .await?; + let background_image = generated_background + .images + .into_iter() + .next() + .ok_or_else(|| { + AppError::from_status(StatusCode::BAD_GATEWAY).with_details(json!({ + "provider": "vector-engine", + "message": "抓大鹅背景图生成失败:未返回图片", + })) + })?; + let background_image = make_match3d_background_image_opaque(background_image)?; + let background_upload = persist_match3d_generated_bytes( + state, + owner_user_id, + session_id, + profile_id, + &["background", generated_background.task_id.as_str()], + "background.png", + background_image.mime_type.as_str(), + background_image.bytes, + "match3d_background_image", + Some(generated_background.task_id.as_str()), + current_utc_micros(), + ) + .await?; + + let container_prompt = build_match3d_container_generation_prompt(config, prompt); + let generated_container = create_openai_image_edit( + &http_client, + &settings, + container_prompt.as_str(), + Some("文字、水印、按钮、倒计时、分数、物品、角色、手、教程浮层、菜单、整页背景、小容器、正俯视圆盘、侧视碗、餐盘、托盘、画布大留白"), + "1:1", + &reference_image, + "抓大鹅容器 UI 图生成失败", + ) + .await?; + let container_image = generated_container + .images + .into_iter() + .next() + .ok_or_else(|| { + AppError::from_status(StatusCode::BAD_GATEWAY).with_details(json!({ + "provider": "vector-engine", + "message": "抓大鹅容器 UI 图生成失败:未返回图片", + })) + })?; + let container_image = make_match3d_container_image_transparent(container_image)?; + let container_upload = persist_match3d_generated_bytes( + state, + owner_user_id, + session_id, + profile_id, + &["ui-container", generated_container.task_id.as_str()], + "container.png", + container_image.mime_type.as_str(), + container_image.bytes, + "match3d_ui_container_image", + Some(generated_container.task_id.as_str()), + current_utc_micros(), + ) + .await?; + + Ok(Match3DGeneratedBackgroundAsset { + prompt: prompt.to_string(), + image_src: Some(background_upload.src), + image_object_key: Some(background_upload.object_key), + container_prompt: Some(container_prompt), + container_image_src: Some(container_upload.src), + container_image_object_key: Some(container_upload.object_key), + status: "image_ready".to_string(), + error: None, + }) +} + +pub(super) async fn generate_match3d_container_image( + state: &AppState, + owner_user_id: &str, + session_id: &str, + profile_id: &str, + config: &Match3DConfigJson, + prompt: &str, +) -> Result { + require_match3d_oss_client(state)?; + let settings = require_openai_image_settings(state)?; + let http_client = build_openai_image_http_client(&settings)?; + let reference_image = load_match3d_container_reference_image().await?; + let container_prompt = build_match3d_container_generation_prompt(config, prompt); + let generated_container = create_openai_image_edit( + &http_client, + &settings, + container_prompt.as_str(), + Some("文字、水印、按钮、倒计时、分数、物品、角色、手、教程浮层、菜单、整页背景、小容器、正俯视圆盘、侧视碗、餐盘、托盘、画布大留白"), + "1:1", + &reference_image, + "抓大鹅容器 UI 图生成失败", + ) + .await?; + let container_image = generated_container + .images + .into_iter() + .next() + .ok_or_else(|| { + AppError::from_status(StatusCode::BAD_GATEWAY).with_details(json!({ + "provider": "vector-engine", + "message": "抓大鹅容器 UI 图生成失败:未返回图片", + })) + })?; + let container_image = make_match3d_container_image_transparent(container_image)?; + let container_upload = persist_match3d_generated_bytes( + state, + owner_user_id, + session_id, + profile_id, + &["ui-container", generated_container.task_id.as_str()], + "container.png", + container_image.mime_type.as_str(), + container_image.bytes, + "match3d_ui_container_image", + Some(generated_container.task_id.as_str()), + current_utc_micros(), + ) + .await?; + + Ok(Match3DGeneratedBackgroundAsset { + prompt: prompt.to_string(), + image_src: None, + image_object_key: None, + container_prompt: Some(container_prompt), + container_image_src: Some(container_upload.src), + container_image_object_key: Some(container_upload.object_key), + status: "image_ready".to_string(), + error: None, + }) +} + +pub(super) fn merge_match3d_container_image_into_background_asset( + assets: &[Match3DGeneratedItemAsset], + container_asset: Match3DGeneratedBackgroundAsset, +) -> Match3DGeneratedBackgroundAsset { + let existing_background = find_match3d_generated_background_asset(assets); + let prompt = existing_background + .as_ref() + .map(|asset| asset.prompt.trim()) + .filter(|value| !value.is_empty()) + .map(str::to_string) + .unwrap_or_else(|| container_asset.prompt.clone()); + Match3DGeneratedBackgroundAsset { + prompt, + image_src: existing_background + .as_ref() + .and_then(|asset| asset.image_src.clone()), + image_object_key: existing_background + .as_ref() + .and_then(|asset| asset.image_object_key.clone()), + container_prompt: container_asset.container_prompt, + container_image_src: container_asset.container_image_src, + container_image_object_key: container_asset.container_image_object_key, + status: "image_ready".to_string(), + error: container_asset.error, + } +} + +async fn load_match3d_container_reference_image() -> Result { + let bytes = tokio::fs::read(MATCH3D_CONTAINER_REFERENCE_IMAGE_PATH) + .await + .map_err(|error| { + AppError::from_status(StatusCode::INTERNAL_SERVER_ERROR).with_details(json!({ + "provider": MATCH3D_AGENT_PROVIDER, + "message": format!("读取抓大鹅容器参考图失败:{error}"), + })) + })?; + if bytes.is_empty() { + return Err( + AppError::from_status(StatusCode::INTERNAL_SERVER_ERROR).with_details(json!({ + "provider": MATCH3D_AGENT_PROVIDER, + "message": "抓大鹅容器参考图为空", + })), + ); + } + Ok(OpenAiReferenceImage { + bytes, + mime_type: "image/png".to_string(), + file_name: "match3d-container-reference.png".to_string(), + }) +} + +pub(super) fn build_match3d_background_generation_prompt(config: &Match3DConfigJson, prompt: &str) -> String { + let style_clause = resolve_match3d_asset_style_prompt(config) + .map(|style| format!("整体美术风格参考:{style}。")) + .unwrap_or_default(); + format!( + "{prompt}\n{style_clause}生成一张 9:16 竖屏抓大鹅游戏纯背景图,只表现题材氛围、色彩层次和场景环境。必须全画幅不透明,四边和角落都要有完整环境像素,不得出现透明 alpha、透明底、镂空或棋盘格透明区域。画面不得出现锅、圆盘、托盘、拼图槽、物品槽、棋盘、容器边框、HUD、文字、按钮、倒计时、分数、物品、角色或手。中央区域保持干净通透,方便运行态后续叠加默认交互容器和物品素材。" + ) +} + +pub(super) fn build_match3d_container_generation_prompt(config: &Match3DConfigJson, prompt: &str) -> String { + let style_clause = resolve_match3d_asset_style_prompt(config) + .map(|style| format!("整体美术风格参考:{style}。")) + .unwrap_or_default(); + format!( + "{prompt}\n{style_clause}生成一张 1:1 抓大鹅中心容器 UI 图,只绘制一个贴合题材设定的圆形或浅盘状竞技容器。严格参考输入参考图的容器范围和视图角度:容器外轮廓必须接近画布四边,占画布宽度约 86%-92%、高度约 82%-90%,中心在画布中心略偏下,只保留少量透明留白;视角为轻俯视 3/4 上方视角,能看到圆形碗体外壁、厚实前沿和横向椭圆形内口,不能画成正俯视扁圆盘、侧视碗、小托盘或居中的小容器。容器需要有清晰外沿、内侧可放置 2D 物品的干净空间、轻微阴影和高辨识边界;背景必须是透明 alpha,不得出现白底、纯色底、渐变底、场景底或整页背景。禁止文字、水印、按钮、倒计时、分数、物品、角色、手、教程浮层和菜单。" + ) +} + +// 中文注释:9:16 运行背景是整屏底图,必须和中心容器透明素材分层处理,避免局内露出透明底。 +pub(super) fn make_match3d_background_image_opaque( + image: DownloadedOpenAiImage, +) -> Result { + 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}"), + })) + })?; + let mut rgba = source.to_rgba8(); + let matte = sample_match3d_background_opaque_matte(&rgba).unwrap_or([246, 243, 236]); + let mut changed = false; + + for pixel in rgba.pixels_mut() { + let alpha = pixel.0[3]; + if alpha == 255 { + continue; + } + pixel.0 = blend_match3d_background_pixel_over_matte(pixel.0, matte); + changed = true; + } + + if !changed { + return Ok(image); + } + + let mut encoded = std::io::Cursor::new(Vec::new()); + image::DynamicImage::ImageRgba8(rgba) + .write_to(&mut encoded, ImageFormat::Png) + .map_err(|error| { + AppError::from_status(StatusCode::BAD_GATEWAY).with_details(json!({ + "provider": "match3d-assets", + "message": format!("抓大鹅背景图不透明化失败:{error}"), + })) + })?; + + Ok(DownloadedOpenAiImage { + bytes: encoded.into_inner(), + mime_type: "image/png".to_string(), + extension: "png".to_string(), + }) +} + +fn sample_match3d_background_opaque_matte(image: &image::RgbaImage) -> Option<[u8; 3]> { + sample_match3d_background_matte_from_edges(image) + .or_else(|| sample_match3d_background_matte_from_pixels(image)) +} + +fn sample_match3d_background_matte_from_edges(image: &image::RgbaImage) -> Option<[u8; 3]> { + let (width, height) = image.dimensions(); + if width == 0 || height == 0 { + return None; + } + + let mut sampler = Match3DBackgroundMatteSampler::default(); + for x in 0..width { + sampler.push(image.get_pixel(x, 0).0); + sampler.push(image.get_pixel(x, height - 1).0); + } + for y in 1..height.saturating_sub(1) { + sampler.push(image.get_pixel(0, y).0); + sampler.push(image.get_pixel(width - 1, y).0); + } + sampler.finish() +} + +fn sample_match3d_background_matte_from_pixels(image: &image::RgbaImage) -> Option<[u8; 3]> { + let mut sampler = Match3DBackgroundMatteSampler::default(); + for pixel in image.pixels() { + sampler.push(pixel.0); + } + sampler.finish() +} + +#[derive(Default)] +struct Match3DBackgroundMatteSampler { + red: u64, + green: u64, + blue: u64, + weight: u64, +} + +impl Match3DBackgroundMatteSampler { + fn push(&mut self, pixel: [u8; 4]) { + let alpha = pixel[3] as u64; + if alpha < 32 { + return; + } + self.red = self.red.saturating_add(pixel[0] as u64 * alpha); + self.green = self.green.saturating_add(pixel[1] as u64 * alpha); + self.blue = self.blue.saturating_add(pixel[2] as u64 * alpha); + self.weight = self.weight.saturating_add(alpha); + } + + fn finish(self) -> Option<[u8; 3]> { + (self.weight > 0).then(|| { + [ + (self.red / self.weight) as u8, + (self.green / self.weight) as u8, + (self.blue / self.weight) as u8, + ] + }) + } +} + +fn blend_match3d_background_pixel_over_matte(pixel: [u8; 4], matte: [u8; 3]) -> [u8; 4] { + let alpha = pixel[3] as u16; + let inverse_alpha = 255u16.saturating_sub(alpha); + [ + blend_match3d_background_channel(pixel[0], matte[0], alpha, inverse_alpha), + blend_match3d_background_channel(pixel[1], matte[1], alpha, inverse_alpha), + blend_match3d_background_channel(pixel[2], matte[2], alpha, inverse_alpha), + 255, + ] +} + +fn blend_match3d_background_channel( + foreground: u8, + matte: u8, + alpha: u16, + inverse_alpha: u16, +) -> u8 { + ((foreground as u16 * alpha + matte as u16 * inverse_alpha + 127) / 255) as u8 +} + +pub(super) fn make_match3d_container_image_transparent( + image: DownloadedOpenAiImage, +) -> Result { + 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}"), + })) + })?; + let mut rgba = source.to_rgba8(); + let (width, height) = rgba.dimensions(); + remove_match3d_container_plain_background(rgba.as_mut(), width as usize, height as usize); + + let mut encoded = std::io::Cursor::new(Vec::new()); + image::DynamicImage::ImageRgba8(rgba) + .write_to(&mut encoded, ImageFormat::Png) + .map_err(|error| { + AppError::from_status(StatusCode::BAD_GATEWAY).with_details(json!({ + "provider": "match3d-assets", + "message": format!("抓大鹅容器图透明化失败:{error}"), + })) + })?; + + Ok(DownloadedOpenAiImage { + bytes: encoded.into_inner(), + mime_type: "image/png".to_string(), + extension: "png".to_string(), + }) +} +pub(super) async fn download_match3d_legacy_model( + file: &hyper3d_contract::Hyper3dDownloadFilePayload, +) -> Result { + let http_client = reqwest::Client::builder() + .timeout(Duration::from_millis( + MATCH3D_LEGACY_MODEL_DOWNLOAD_TIMEOUT_MS, + )) + .build() + .map_err(|error| match3d_bad_gateway(format!("构造历史模型下载客户端失败:{error}")))?; + tracing::info!( + provider = MATCH3D_AGENT_PROVIDER, + file_name = file.name.as_str(), + "抓大鹅历史 GLB 下载开始" + ); + let response = http_client + .get(file.url.as_str()) + .send() + .await + .map_err(|error| match3d_bad_gateway(format!("下载历史模型失败:{error}")))?; + let status = response.status(); + let content_type = response + .headers() + .get(header::CONTENT_TYPE) + .and_then(|value| value.to_str().ok()) + .unwrap_or("model/gltf-binary") + .to_string(); + let bytes = response + .bytes() + .await + .map_err(|error| match3d_bad_gateway(format!("读取历史模型内容失败:{error}")))?; + if !status.is_success() { + return Err(match3d_bad_gateway(format!( + "下载历史模型失败:HTTP {}", + status.as_u16() + ))); + } + if !is_match3d_downloaded_model_payload(file.name.as_str(), content_type.as_str()) { + return Err(match3d_bad_gateway("历史模型下载结果不是 GLB 模型文件")); + } + if bytes.is_empty() || bytes.len() > MATCH3D_LEGACY_MODEL_MAX_BYTES { + return Err(match3d_bad_gateway("历史模型内容为空或超过大小上限")); + } + if !is_match3d_glb_binary_payload(&bytes) { + return Err(match3d_bad_gateway("历史模型下载结果不是有效 GLB 模型文件")); + } + + Ok(Match3DDownloadedModel { + bytes: bytes.to_vec(), + file_name: normalize_match3d_model_file_name(file.name.as_str()), + content_type: normalize_match3d_model_content_type(content_type.as_str()), + }) +} + +fn is_match3d_downloaded_model_payload(file_name: &str, content_type: &str) -> bool { + let normalized_file_name = file_name.to_ascii_lowercase(); + let normalized_content_type = content_type + .split(';') + .next() + .unwrap_or(content_type) + .trim() + .to_ascii_lowercase(); + normalized_file_name.ends_with(".glb") + || matches!( + normalized_content_type.as_str(), + "model/gltf-binary" | "application/octet-stream" + ) +} + +pub(super) fn normalize_match3d_model_file_name(raw: &str) -> String { + let trimmed = raw.trim().rsplit('/').next().unwrap_or(raw).trim(); + let without_query = trimmed.split('?').next().unwrap_or(trimmed).trim(); + let normalized = without_query.to_ascii_lowercase(); + let stem = without_query + .strip_suffix(".glb") + .or_else(|| { + normalized + .strip_suffix(".glb") + .map(|_| &without_query[..without_query.len().saturating_sub(4)]) + }) + .unwrap_or(without_query); + let sanitized_stem = sanitize_match3d_asset_segment(stem, "model"); + format!("{sanitized_stem}.glb") +} + +pub(super) fn normalize_match3d_model_content_type(raw: &str) -> String { + let normalized = raw.split(';').next().unwrap_or(raw).trim().to_lowercase(); + if normalized == "model/gltf-binary" { + return normalized; + } + "model/gltf-binary".to_string() +} + +pub(super) fn is_match3d_glb_binary_payload(bytes: &[u8]) -> bool { + if bytes.len() < 12 { + return false; + } + + let magic = u32::from_le_bytes([bytes[0], bytes[1], bytes[2], bytes[3]]); + let version = u32::from_le_bytes([bytes[4], bytes[5], bytes[6], bytes[7]]); + let declared_length = u32::from_le_bytes([bytes[8], bytes[9], bytes[10], bytes[11]]) as usize; + magic == 0x4654_6c67 && version == 2 && declared_length == bytes.len() +} + +async fn read_match3d_generated_object_bytes( + state: &AppState, + object_key: &str, + message_prefix: &str, + max_size_bytes: usize, +) -> Result, AppError> { + let object_key = object_key.trim().trim_start_matches('/'); + if object_key.is_empty() { + return Err( + AppError::from_status(StatusCode::BAD_REQUEST).with_details(json!({ + "provider": "match3d-assets", + "message": format!("{message_prefix}:objectKey 不能为空"), + })), + ); + } + let oss_client = state.oss_client().ok_or_else(|| { + AppError::from_status(StatusCode::SERVICE_UNAVAILABLE).with_details(json!({ + "provider": "aliyun-oss", + "reason": "OSS 未完成环境变量配置", + })) + })?; + let signed = oss_client + .sign_get_object_url(platform_oss::OssSignedGetObjectUrlRequest { + object_key: object_key.to_string(), + expire_seconds: Some(300), + }) + .map_err(|error| map_oss_error(error, "aliyun-oss"))?; + let response = reqwest::Client::new() + .get(signed.signed_url.as_str()) + .send() + .await + .map_err(|error| match3d_bad_gateway(format!("{message_prefix}:{error}")))?; + let status = response.status(); + if !status.is_success() { + return Err(match3d_bad_gateway(format!( + "{message_prefix}:HTTP {}", + status.as_u16() + ))); + } + let bytes = response + .bytes() + .await + .map_err(|error| match3d_bad_gateway(format!("{message_prefix}:{error}")))?; + if bytes.is_empty() || bytes.len() > max_size_bytes { + return Err(match3d_bad_gateway(format!( + "{message_prefix}:内容为空或超过大小上限" + ))); + } + Ok(bytes.to_vec()) +} + +async fn resolve_match3d_reference_image_data_url( + state: &AppState, + source: Option<&str>, + max_size_bytes: usize, +) -> Result, AppError> { + let Some(source) = source.map(str::trim).filter(|value| !value.is_empty()) else { + return Ok(None); + }; + if source.starts_with("data:image/") { + return Ok(Some(source.to_string())); + } + if let Some(public_path) = normalize_match3d_public_reference_image_path(source) { + let bytes = tokio::fs::read(public_path.as_str()) + .await + .map_err(|error| { + AppError::from_status(StatusCode::INTERNAL_SERVER_ERROR).with_details(json!({ + "provider": MATCH3D_WORKS_PROVIDER, + "message": format!("读取抓大鹅本地参考图失败:{error}"), + "path": public_path, + })) + })?; + if bytes.is_empty() || bytes.len() > max_size_bytes { + return Err( + AppError::from_status(StatusCode::BAD_REQUEST).with_details(json!({ + "provider": MATCH3D_WORKS_PROVIDER, + "field": "referenceImageSrcs", + "message": "封面参考图过大,请压缩后重试。", + "maxBytes": max_size_bytes, + "actualBytes": bytes.len(), + })), + ); + } + return Ok(Some(format!( + "data:{};base64,{}", + infer_match3d_image_mime_type(bytes.as_slice()), + BASE64_STANDARD.encode(bytes) + ))); + } + if !source.trim_start_matches('/').starts_with("generated-") { + return Ok(Some(source.to_string())); + } + let bytes = + read_match3d_generated_object_bytes(state, source, "读取抓大鹅参考图失败", max_size_bytes) + .await?; + Ok(Some(format!( + "data:{};base64,{}", + infer_match3d_image_mime_type(bytes.as_slice()), + BASE64_STANDARD.encode(bytes) + ))) +} + +pub(super) fn normalize_match3d_public_reference_image_path(source: &str) -> Option { + let source = source + .trim() + .split('?') + .next() + .unwrap_or_default() + .trim() + .trim_start_matches('/'); + if !source.starts_with("match3d-background-references/") { + return None; + } + if source.contains("..") || source.contains('\\') { + return None; + } + let lower = source.to_ascii_lowercase(); + if !matches!( + lower.rsplit('.').next(), + Some("png" | "jpg" | "jpeg" | "webp") + ) { + return None; + } + Some(format!("public/{source}")) +} + +pub(super) fn collect_match3d_cover_reference_image_sources( + legacy_reference_image_src: Option, + reference_image_srcs: Vec, +) -> Vec { + let mut sources = Vec::new(); + for source in legacy_reference_image_src + .into_iter() + .chain(reference_image_srcs) + { + let normalized = source.trim(); + if normalized.is_empty() { + continue; + } + if !sources + .iter() + .any(|existing: &String| existing == normalized) + { + sources.push(normalized.to_string()); + } + if sources.len() >= 6 { + break; + } + } + sources +} + +async fn resolve_match3d_cover_reference_image_data_urls( + state: &AppState, + sources: Vec, + max_size_bytes: usize, +) -> Result, AppError> { + let mut resolved = Vec::new(); + for source in sources { + if let Some(data_url) = + resolve_match3d_reference_image_data_url(state, Some(source.as_str()), max_size_bytes) + .await? + { + resolved.push(data_url); + } + } + Ok(resolved) +} + +async fn resolve_match3d_reference_image_for_edit( + state: &AppState, + source: Option<&str>, + max_size_bytes: usize, + file_name_prefix: &str, +) -> Result, AppError> { + let Some(source) = source.map(str::trim).filter(|value| !value.is_empty()) else { + return Ok(None); + }; + let bytes = if source.starts_with("data:image/") { + decode_match3d_data_url_bytes(source)? + } else if source.trim_start_matches('/').starts_with("generated-") { + read_match3d_generated_object_bytes( + state, + source, + "读取抓大鹅封面上传图失败", + max_size_bytes, + ) + .await? + } else { + return Err( + AppError::from_status(StatusCode::BAD_REQUEST).with_details(json!({ + "provider": MATCH3D_WORKS_PROVIDER, + "field": "uploadedImageSrc", + "message": "封面上传图必须是图片 Data URL 或 /generated-* 路径。", + })), + ); + }; + if bytes.is_empty() || bytes.len() > max_size_bytes { + return Err( + AppError::from_status(StatusCode::BAD_REQUEST).with_details(json!({ + "provider": MATCH3D_WORKS_PROVIDER, + "field": "uploadedImageSrc", + "message": "封面上传图过大,请压缩后重试。", + "maxBytes": max_size_bytes, + "actualBytes": bytes.len(), + })), + ); + } + let mime_type = infer_match3d_image_mime_type(bytes.as_slice()).to_string(); + Ok(Some(OpenAiReferenceImage { + file_name: format!( + "{}.{}", + file_name_prefix, + match3d_mime_to_extension(mime_type.as_str()) + ), + mime_type, + bytes, + })) +} + +fn decode_match3d_data_url_bytes(source: &str) -> Result, AppError> { + let Some((header, data)) = source.split_once(',') else { + return Err( + AppError::from_status(StatusCode::BAD_REQUEST).with_details(json!({ + "provider": MATCH3D_WORKS_PROVIDER, + "field": "uploadedImageSrc", + "message": "图片 Data URL 格式不正确。", + })), + ); + }; + if !header.starts_with("data:image/") || !header.contains(";base64") { + return Err( + AppError::from_status(StatusCode::BAD_REQUEST).with_details(json!({ + "provider": MATCH3D_WORKS_PROVIDER, + "field": "uploadedImageSrc", + "message": "图片 Data URL 必须是 base64 图片。", + })), + ); + } + BASE64_STANDARD.decode(data.trim()).map_err(|error| { + AppError::from_status(StatusCode::BAD_REQUEST).with_details(json!({ + "provider": MATCH3D_WORKS_PROVIDER, + "field": "uploadedImageSrc", + "message": format!("图片 Data URL 解码失败:{error}"), + })) + }) +} + +pub(super) fn infer_match3d_image_mime_type(bytes: &[u8]) -> &'static str { + if bytes.starts_with(b"\x89PNG\r\n\x1a\n") { + return "image/png"; + } + if bytes.starts_with(&[0xff, 0xd8, 0xff]) { + return "image/jpeg"; + } + if bytes.starts_with(b"RIFF") && bytes.get(8..12) == Some(b"WEBP") { + return "image/webp"; + } + "image/png" +} + +pub(super) async fn persist_match3d_generated_bytes( + state: &AppState, + owner_user_id: &str, + session_id: &str, + profile_id: &str, + path_segments: &[&str], + file_name: &str, + content_type: &str, + bytes: Vec, + asset_kind: &str, + source_job_id: Option<&str>, + generated_at_micros: i64, +) -> Result { + let oss_client = require_match3d_oss_client(state)?; + let mut metadata = BTreeMap::new(); + metadata.insert("x-oss-meta-asset-kind".to_string(), asset_kind.to_string()); + metadata.insert( + "x-oss-meta-owner-user-id".to_string(), + owner_user_id.to_string(), + ); + metadata.insert("x-oss-meta-profile-id".to_string(), profile_id.to_string()); + if let Some(source_job_id) = source_job_id.filter(|value| !value.trim().is_empty()) { + metadata.insert( + "x-oss-meta-source-job-id".to_string(), + source_job_id.to_string(), + ); + } + + let oss_http_client = reqwest::Client::builder() + .timeout(Duration::from_millis(MATCH3D_OSS_PUT_TIMEOUT_MS)) + .build() + .map_err(|error| match3d_bad_gateway(format!("构造抓大鹅 OSS 上传客户端失败:{error}")))?; + let put_result = oss_client + .put_object( + &oss_http_client, + OssPutObjectRequest { + prefix: LegacyAssetPrefix::Match3DAssets, + path_segments: std::iter::once(session_id) + .chain(std::iter::once(profile_id)) + .chain(path_segments.iter().copied()) + .map(|segment| sanitize_match3d_asset_segment(segment, "asset")) + .collect(), + file_name: file_name.to_string(), + content_type: Some(content_type.to_string()), + access: OssObjectAccess::Private, + metadata, + body: bytes, + }, + ) + .await + .map_err(|error| map_oss_error(error, "aliyun-oss"))?; + + let _ = generated_at_micros; + Ok(Match3DAssetUpload { + src: put_result.legacy_public_path, + object_key: put_result.object_key, + }) +} + +pub(super) fn require_match3d_oss_client(state: &AppState) -> Result<&platform_oss::OssClient, AppError> { + state + .oss_client() + .ok_or_else(|| match3d_oss_config_error(&state.config)) +} + +fn match3d_oss_config_error(config: &AppConfig) -> AppError { + let missing = missing_match3d_oss_env_keys(config); + let reason = match3d_oss_missing_reason(&missing); + + AppError::from_status(StatusCode::SERVICE_UNAVAILABLE).with_details(json!({ + "provider": "aliyun-oss", + "reason": reason, + "missingEnv": missing, + })) +} + +pub(super) fn match3d_oss_missing_reason(missing: &[&str]) -> String { + if missing.is_empty() { + "OSS 未完成环境变量配置".to_string() + } else { + format!("OSS 未完成环境变量配置,缺少:{}", missing.join(", ")) + } +} + +pub(super) fn missing_match3d_oss_env_keys(config: &AppConfig) -> Vec<&'static str> { + [ + ("ALIYUN_OSS_BUCKET", config.oss_bucket.as_deref()), + ("ALIYUN_OSS_ENDPOINT", config.oss_endpoint.as_deref()), + ( + "ALIYUN_OSS_ACCESS_KEY_ID", + config.oss_access_key_id.as_deref(), + ), + ( + "ALIYUN_OSS_ACCESS_KEY_SECRET", + config.oss_access_key_secret.as_deref(), + ), + ] + .into_iter() + .filter_map(|(name, value)| match value { + Some(value) if !value.trim().is_empty() => None, + _ => Some(name), + }) + .collect() +} + +pub(super) fn sanitize_match3d_asset_segment(raw: &str, fallback: &str) -> String { + let normalized = raw + .trim() + .chars() + .map(|ch| { + if ch.is_ascii_alphanumeric() || ch == '-' || ch == '_' { + ch.to_ascii_lowercase() + } else { + '-' + } + }) + .collect::(); + let collapsed = normalized + .split('-') + .filter(|part| !part.is_empty()) + .collect::>() + .join("-"); + if collapsed.is_empty() { + fallback.to_string() + } else { + collapsed.chars().take(64).collect() + } +} diff --git a/server-rs/crates/api-server/src/modules/mod.rs b/server-rs/crates/api-server/src/modules.rs similarity index 100% rename from server-rs/crates/api-server/src/modules/mod.rs rename to server-rs/crates/api-server/src/modules.rs diff --git a/server-rs/crates/api-server/src/prompt/mod.rs b/server-rs/crates/api-server/src/prompt.rs similarity index 100% rename from server-rs/crates/api-server/src/prompt/mod.rs rename to server-rs/crates/api-server/src/prompt.rs diff --git a/server-rs/crates/api-server/src/prompt/puzzle/mod.rs b/server-rs/crates/api-server/src/prompt/puzzle.rs similarity index 100% rename from server-rs/crates/api-server/src/prompt/puzzle/mod.rs rename to server-rs/crates/api-server/src/prompt/puzzle.rs diff --git a/server-rs/crates/api-server/src/prompt/rpg/mod.rs b/server-rs/crates/api-server/src/prompt/rpg.rs similarity index 100% rename from server-rs/crates/api-server/src/prompt/rpg/mod.rs rename to server-rs/crates/api-server/src/prompt/rpg.rs diff --git a/server-rs/crates/api-server/src/puzzle/mod.rs b/server-rs/crates/api-server/src/puzzle.rs similarity index 100% rename from server-rs/crates/api-server/src/puzzle/mod.rs rename to server-rs/crates/api-server/src/puzzle.rs diff --git a/server-rs/crates/spacetime-client/src/mapper.rs b/server-rs/crates/spacetime-client/src/mapper.rs index 6ee3b1ca..7f6a1904 100644 --- a/server-rs/crates/spacetime-client/src/mapper.rs +++ b/server-rs/crates/spacetime-client/src/mapper.rs @@ -1,8442 +1,204 @@ use super::*; -impl From for AssetEntityBindingInput { - fn from(input: module_assets::AssetEntityBindingInput) -> Self { - Self { - binding_id: input.binding_id, - asset_object_id: input.asset_object_id, - entity_kind: input.entity_kind, - entity_id: input.entity_id, - slot: input.slot, - asset_kind: input.asset_kind, - owner_user_id: input.owner_user_id, - profile_id: input.profile_id, - updated_at_micros: input.updated_at_micros, - } - } -} - -impl From for AssetObjectUpsertInput { - fn from(input: module_assets::AssetObjectUpsertInput) -> Self { - Self { - asset_object_id: input.asset_object_id, - bucket: input.bucket, - object_key: input.object_key, - access_policy: map_access_policy(input.access_policy), - content_type: input.content_type, - content_length: input.content_length, - content_hash: input.content_hash, - version: input.version, - source_job_id: input.source_job_id, - owner_user_id: input.owner_user_id, - profile_id: input.profile_id, - entity_id: input.entity_id, - asset_kind: input.asset_kind, - updated_at_micros: input.updated_at_micros, - } - } -} - -impl From for AssetHistoryListInput { - fn from(input: module_assets::AssetHistoryListInput) -> Self { - Self { - asset_kind: input.asset_kind, - limit: input.limit, - } - } -} - -impl From for CreationEntryTypeAdminUpsertInput { - fn from(input: module_runtime::CreationEntryTypeAdminUpsertInput) -> Self { - Self { - id: input.id, - title: input.title, - subtitle: input.subtitle, - badge: input.badge, - image_src: input.image_src, - visible: input.visible, - open: input.open, - sort_order: input.sort_order, - } - } -} - -impl From for RuntimeSettingGetInput { - fn from(input: module_runtime::RuntimeSettingGetInput) -> Self { - Self { - user_id: input.user_id, - } - } -} - -impl From for RuntimeSettingUpsertInput { - fn from(input: module_runtime::RuntimeSettingUpsertInput) -> Self { - Self { - user_id: input.user_id, - music_volume: input.music_volume, - platform_theme: map_runtime_platform_theme(input.platform_theme), - updated_at_micros: input.updated_at_micros, - } - } -} - -impl From for RuntimeBrowseHistoryListInput { - fn from(input: module_runtime::RuntimeBrowseHistoryListInput) -> Self { - Self { - user_id: input.user_id, - } - } -} - -impl From for RuntimeBrowseHistoryClearInput { - fn from(input: module_runtime::RuntimeBrowseHistoryClearInput) -> Self { - Self { - user_id: input.user_id, - } - } -} - -impl From for RuntimeBrowseHistorySyncInput { - fn from(input: module_runtime::RuntimeBrowseHistorySyncInput) -> Self { - Self { - user_id: input.user_id, - entries: input.entries.into_iter().map(Into::into).collect(), - updated_at_micros: input.updated_at_micros, - } - } -} - -impl From for RuntimeBrowseHistoryWriteInput { - fn from(input: module_runtime::RuntimeBrowseHistoryWriteInput) -> Self { - Self { - owner_user_id: input.owner_user_id, - profile_id: input.profile_id, - world_name: input.world_name, - subtitle: input.subtitle, - summary_text: input.summary_text, - cover_image_src: input.cover_image_src, - theme_mode: input.theme_mode, - author_display_name: input.author_display_name, - visited_at: input.visited_at, - } - } -} - -impl From for RuntimeProfileDashboardGetInput { - fn from(input: module_runtime::RuntimeProfileDashboardGetInput) -> Self { - Self { - user_id: input.user_id, - } - } -} - -impl From - for RuntimeProfileWalletLedgerListInput -{ - fn from(input: module_runtime::RuntimeProfileWalletLedgerListInput) -> Self { - Self { - user_id: input.user_id, - } - } -} - -impl From - for RuntimeProfileWalletAdjustmentInput -{ - fn from(input: module_runtime::RuntimeProfileWalletAdjustmentInput) -> Self { - Self { - user_id: input.user_id, - amount: input.amount, - ledger_id: input.ledger_id, - created_at_micros: input.created_at_micros, - } - } -} - -impl From - for RuntimeProfileRechargeCenterGetInput -{ - fn from(input: module_runtime::RuntimeProfileRechargeCenterGetInput) -> Self { - Self { - user_id: input.user_id, - } - } -} - -impl From - for RuntimeProfileRechargeOrderGetInput -{ - fn from(input: module_runtime::RuntimeProfileRechargeOrderGetInput) -> Self { - Self { - order_id: input.order_id, - } - } -} - -impl From - for RuntimeProfileRechargeOrderCreateInput -{ - fn from(input: module_runtime::RuntimeProfileRechargeOrderCreateInput) -> Self { - Self { - user_id: input.user_id, - product_id: input.product_id, - payment_channel: input.payment_channel, - created_at_micros: input.created_at_micros, - } - } -} - -impl From - for RuntimeProfileRechargeOrderPaidInput -{ - fn from(input: module_runtime::RuntimeProfileRechargeOrderPaidInput) -> Self { - Self { - order_id: input.order_id, - paid_at_micros: input.paid_at_micros, - provider_transaction_id: input.provider_transaction_id, - } - } -} - -impl From - for RuntimeProfileFeedbackSubmissionInput -{ - fn from(input: module_runtime::RuntimeProfileFeedbackSubmissionInput) -> Self { - Self { - user_id: input.user_id, - description: input.description, - contact_phone: input.contact_phone, - evidence_items: input.evidence_items.into_iter().map(Into::into).collect(), - created_at_micros: input.created_at_micros, - } - } -} - -impl From - for RuntimeProfileFeedbackEvidenceSnapshot -{ - fn from(input: module_runtime::RuntimeProfileFeedbackEvidenceSnapshot) -> Self { - Self { - evidence_id: input.evidence_id, - file_name: input.file_name, - content_type: input.content_type, - size_bytes: input.size_bytes, - data_url: input.data_url, - } - } -} - -impl From - for RuntimeProfileRewardCodeRedeemInput -{ - fn from(input: module_runtime::RuntimeProfileRewardCodeRedeemInput) -> Self { - Self { - user_id: input.user_id, - code: input.code, - redeemed_at_micros: input.redeemed_at_micros, - } - } -} - -impl From for RuntimeProfileTaskCenterGetInput { - fn from(input: module_runtime::RuntimeProfileTaskCenterGetInput) -> Self { - Self { - user_id: input.user_id, - } - } -} - -impl From for AnalyticsMetricQueryInput { - fn from(input: module_runtime::AnalyticsMetricQueryInput) -> Self { - Self { - event_key: input.event_key, - scope_kind: map_runtime_tracking_scope_kind(input.scope_kind), - scope_id: input.scope_id, - granularity: map_analytics_granularity(input.granularity), - } - } -} - -impl From for RuntimeProfileTaskClaimInput { - fn from(input: module_runtime::RuntimeProfileTaskClaimInput) -> Self { - Self { - user_id: input.user_id, - task_id: input.task_id, - } - } -} - -impl From - for RuntimeProfileTaskConfigAdminListInput -{ - fn from(input: module_runtime::RuntimeProfileTaskConfigAdminListInput) -> Self { - Self { - admin_user_id: input.admin_user_id, - } - } -} - -impl From - for RuntimeProfileTaskConfigAdminUpsertInput -{ - fn from(input: module_runtime::RuntimeProfileTaskConfigAdminUpsertInput) -> Self { - Self { - admin_user_id: input.admin_user_id, - task_id: input.task_id, - title: input.title, - description: input.description, - event_key: input.event_key, - cycle: map_runtime_profile_task_cycle(input.cycle), - scope_kind: map_runtime_tracking_scope_kind(input.scope_kind), - threshold: input.threshold, - reward_points: input.reward_points, - enabled: input.enabled, - sort_order: input.sort_order, - updated_at_micros: input.updated_at_micros, - } - } -} - -impl From - for RuntimeProfileTaskConfigAdminDisableInput -{ - fn from(input: module_runtime::RuntimeProfileTaskConfigAdminDisableInput) -> Self { - Self { - admin_user_id: input.admin_user_id, - task_id: input.task_id, - updated_at_micros: input.updated_at_micros, - } - } -} - -impl From - for RuntimeProfileRechargeProductAdminListInput -{ - fn from(input: module_runtime::RuntimeProfileRechargeProductAdminListInput) -> Self { - Self { - admin_user_id: input.admin_user_id, - } - } -} - -impl From - for RuntimeProfileRechargeProductAdminUpsertInput -{ - fn from(input: module_runtime::RuntimeProfileRechargeProductAdminUpsertInput) -> Self { - Self { - admin_user_id: input.admin_user_id, - product_id: input.product_id, - title: input.title, - price_cents: input.price_cents, - kind: map_runtime_profile_recharge_product_kind(input.kind), - points_amount: input.points_amount, - bonus_points: input.bonus_points, - duration_days: input.duration_days, - badge_label: input.badge_label, - description: input.description, - tier: map_runtime_profile_membership_tier(input.tier), - enabled: input.enabled, - sort_order: input.sort_order, - updated_at_micros: input.updated_at_micros, - } - } -} - -impl From - for RuntimeProfileRedeemCodeAdminUpsertInput -{ - fn from(input: module_runtime::RuntimeProfileRedeemCodeAdminUpsertInput) -> Self { - Self { - admin_user_id: input.admin_user_id, - code: input.code, - mode: map_runtime_profile_redeem_code_mode(input.mode), - reward_points: input.reward_points, - max_uses: input.max_uses, - enabled: input.enabled, - allowed_user_ids: input.allowed_user_ids, - allowed_public_user_codes: input.allowed_public_user_codes, - updated_at_micros: input.updated_at_micros, - } - } -} - -impl From - for RuntimeProfileRedeemCodeAdminDisableInput -{ - fn from(input: module_runtime::RuntimeProfileRedeemCodeAdminDisableInput) -> Self { - Self { - admin_user_id: input.admin_user_id, - code: input.code, - updated_at_micros: input.updated_at_micros, - } - } -} - -impl From - for RuntimeProfileRedeemCodeAdminListInput -{ - fn from(input: module_runtime::RuntimeProfileRedeemCodeAdminListInput) -> Self { - Self { - admin_user_id: input.admin_user_id, - } - } -} - -impl From - for RuntimeProfileInviteCodeAdminUpsertInput -{ - fn from(input: module_runtime::RuntimeProfileInviteCodeAdminUpsertInput) -> Self { - Self { - admin_user_id: input.admin_user_id, - invite_code: input.invite_code, - metadata_json: input.metadata_json, - starts_at_micros: input.starts_at_micros, - expires_at_micros: input.expires_at_micros, - updated_at_micros: input.updated_at_micros, - } - } -} - -impl From - for RuntimeProfileInviteCodeAdminListInput -{ - fn from(input: module_runtime::RuntimeProfileInviteCodeAdminListInput) -> Self { - Self { - admin_user_id: input.admin_user_id, - } - } -} - -impl From - for RuntimeReferralInviteCenterGetInput -{ - fn from(input: module_runtime::RuntimeReferralInviteCenterGetInput) -> Self { - Self { - user_id: input.user_id, - } - } -} - -impl From for RuntimeReferralRedeemInput { - fn from(input: module_runtime::RuntimeReferralRedeemInput) -> Self { - Self { - user_id: input.user_id, - invite_code: input.invite_code, - updated_at_micros: input.updated_at_micros, - } - } -} - -impl From for RuntimeProfilePlayStatsGetInput { - fn from(input: module_runtime::RuntimeProfilePlayStatsGetInput) -> Self { - Self { - user_id: input.user_id, - } - } -} - -impl From for RuntimeSnapshotGetInput { - fn from(input: module_runtime::RuntimeSnapshotGetInput) -> Self { - Self { - user_id: input.user_id, - } - } -} - -impl From for RuntimeSnapshotUpsertInput { - fn from(input: module_runtime::RuntimeSnapshotUpsertInput) -> Self { - Self { - user_id: input.user_id, - saved_at_micros: input.saved_at_micros, - bottom_tab: input.bottom_tab, - game_state_json: input.game_state_json, - current_story_json: input.current_story_json, - updated_at_micros: input.updated_at_micros, - } - } -} - -impl From for RuntimeSnapshotDeleteInput { - fn from(input: module_runtime::RuntimeSnapshotDeleteInput) -> Self { - Self { - user_id: input.user_id, - } - } -} - -impl From - for RuntimeProfileSaveArchiveListInput -{ - fn from(input: module_runtime::RuntimeProfileSaveArchiveListInput) -> Self { - Self { - user_id: input.user_id, - } - } -} - -impl From - for RuntimeProfileSaveArchiveResumeInput -{ - fn from(input: module_runtime::RuntimeProfileSaveArchiveResumeInput) -> Self { - Self { - user_id: input.user_id, - world_key: input.world_key, - } - } -} - -impl From for AiTaskCreateInput { - fn from(input: DomainAiTaskCreateInput) -> Self { - Self { - task_id: input.task_id, - task_kind: map_ai_task_kind(input.task_kind), - owner_user_id: input.owner_user_id, - request_label: input.request_label, - source_module: input.source_module, - source_entity_id: input.source_entity_id, - request_payload_json: input.request_payload_json, - stages: input.stages.into_iter().map(Into::into).collect(), - created_at_micros: input.created_at_micros, - } - } -} - -impl From for AiTaskStartInput { - fn from(input: DomainAiTaskStartInput) -> Self { - Self { - task_id: input.task_id, - started_at_micros: input.started_at_micros, - } - } -} - -impl From for AiTaskStageStartInput { - fn from(input: DomainAiTaskStageStartInput) -> Self { - Self { - task_id: input.task_id, - stage_kind: map_ai_task_stage_kind(input.stage_kind), - started_at_micros: input.started_at_micros, - } - } -} - -impl From for AiTextChunkAppendInput { - fn from(input: DomainAiTextChunkAppendInput) -> Self { - Self { - task_id: input.task_id, - stage_kind: map_ai_task_stage_kind(input.stage_kind), - sequence: input.sequence, - delta_text: input.delta_text, - created_at_micros: input.created_at_micros, - } - } -} - -impl From for AiStageCompletionInput { - fn from(input: DomainAiStageCompletionInput) -> Self { - Self { - task_id: input.task_id, - stage_kind: map_ai_task_stage_kind(input.stage_kind), - text_output: input.text_output, - structured_payload_json: input.structured_payload_json, - warning_messages: input.warning_messages, - completed_at_micros: input.completed_at_micros, - } - } -} - -impl From for AiResultReferenceInput { - fn from(input: DomainAiResultReferenceInput) -> Self { - Self { - task_id: input.task_id, - reference_kind: map_ai_result_reference_kind(input.reference_kind), - reference_id: input.reference_id, - label: input.label, - created_at_micros: input.created_at_micros, - } - } -} - -impl From for AiTaskFinishInput { - fn from(input: DomainAiTaskFinishInput) -> Self { - Self { - task_id: input.task_id, - completed_at_micros: input.completed_at_micros, - } - } -} - -impl From for AiTaskFailureInput { - fn from(input: DomainAiTaskFailureInput) -> Self { - Self { - task_id: input.task_id, - failure_message: input.failure_message, - completed_at_micros: input.completed_at_micros, - } - } -} - -impl From for AiTaskCancelInput { - fn from(input: DomainAiTaskCancelInput) -> Self { - Self { - task_id: input.task_id, - completed_at_micros: input.completed_at_micros, - } - } -} - -impl From for AiTaskStageBlueprint { - fn from(blueprint: DomainAiTaskStageBlueprint) -> Self { - Self { - stage_kind: map_ai_task_stage_kind(blueprint.stage_kind), - label: blueprint.label, - detail: blueprint.detail, - order: blueprint.order, - } - } -} - -impl From for CustomWorldProfileUpsertInput { - fn from(input: CustomWorldProfileUpsertRecordInput) -> Self { - Self { - profile_id: input.profile_id, - owner_user_id: input.owner_user_id, - public_work_code: input.public_work_code, - author_public_user_code: input.author_public_user_code, - source_agent_session_id: input.source_agent_session_id, - world_name: input.world_name, - subtitle: input.subtitle, - summary_text: input.summary_text, - theme_mode: map_custom_world_theme_mode(input.theme_mode), - cover_image_src: input.cover_image_src, - profile_payload_json: input.profile_payload_json, - playable_npc_count: input.playable_npc_count, - landmark_count: input.landmark_count, - author_display_name: input.author_display_name, - updated_at_micros: input.updated_at_micros, - } - } -} - -impl From for CustomWorldPublishWorldInput { - fn from(input: CustomWorldPublishWorldRecordInput) -> Self { - Self { - session_id: input.session_id, - profile_id: input.profile_id, - owner_user_id: input.owner_user_id, - public_work_code: input.public_work_code, - author_public_user_code: input.author_public_user_code, - draft_profile_json: input.draft_profile_json, - legacy_result_profile_json: input.legacy_result_profile_json, - setting_text: input.setting_text, - author_display_name: input.author_display_name, - published_at_micros: input.published_at_micros, - } - } -} - -impl From for StorySessionInput { - fn from(input: DomainStorySessionInput) -> Self { - Self { - story_session_id: input.story_session_id, - runtime_session_id: input.runtime_session_id, - actor_user_id: input.actor_user_id, - world_profile_id: input.world_profile_id, - initial_prompt: input.initial_prompt, - opening_summary: input.opening_summary, - created_at_micros: input.created_at_micros, - } - } -} - -impl From for StoryContinueInput { - fn from(input: DomainStoryContinueInput) -> Self { - Self { - story_session_id: input.story_session_id, - event_id: input.event_id, - narrative_text: input.narrative_text, - choice_function_id: input.choice_function_id, - updated_at_micros: input.updated_at_micros, - } - } -} - -impl From for StorySessionStateInput { - fn from(input: DomainStorySessionStateInput) -> Self { - Self { - story_session_id: input.story_session_id, - } - } -} - -impl From for RuntimeInventoryStateQueryInput { - fn from(input: DomainRuntimeInventoryStateQueryInput) -> Self { - Self { - runtime_session_id: input.runtime_session_id, - actor_user_id: input.actor_user_id, - } - } -} - -impl From for BattleStateQueryInput { - fn from(input: DomainBattleStateQueryInput) -> Self { - Self { - battle_state_id: input.battle_state_id, - } - } -} - -impl From for BattleStateInput { - fn from(input: DomainBattleStateInput) -> Self { - Self { - battle_state_id: input.battle_state_id, - story_session_id: input.story_session_id, - runtime_session_id: input.runtime_session_id, - actor_user_id: input.actor_user_id, - chapter_id: input.chapter_id, - target_npc_id: input.target_npc_id, - target_name: input.target_name, - battle_mode: map_battle_mode(input.battle_mode), - player_hp: input.player_hp, - player_max_hp: input.player_max_hp, - player_mana: input.player_mana, - player_max_mana: input.player_max_mana, - target_hp: input.target_hp, - target_max_hp: input.target_max_hp, - experience_reward: input.experience_reward, - reward_items: input - .reward_items - .into_iter() - .map(map_runtime_item_reward_item_snapshot) - .collect(), - created_at_micros: input.created_at_micros, - } - } -} - -impl From for ResolveCombatActionInput { - fn from(input: DomainResolveCombatActionInput) -> Self { - Self { - battle_state_id: input.battle_state_id, - function_id: input.function_id, - action_text: input.action_text, - base_damage: input.base_damage, - mana_cost: input.mana_cost, - heal: input.heal, - mana_restore: input.mana_restore, - counter_multiplier_basis_points: input.counter_multiplier_basis_points, - updated_at_micros: input.updated_at_micros, - } - } -} - -pub(crate) fn map_procedure_result( - result: AssetObjectProcedureResult, -) -> Result { - if !result.ok { - return Err(SpacetimeClientError::procedure_failed(result.error_message)); - } - - let snapshot = result - .record - .ok_or_else(|| SpacetimeClientError::missing_snapshot("对象快照"))?; - - Ok(build_asset_object_record(map_snapshot(snapshot))) -} - -pub(crate) fn map_entity_binding_procedure_result( - result: AssetEntityBindingProcedureResult, -) -> Result { - if !result.ok { - return Err(SpacetimeClientError::procedure_failed(result.error_message)); - } - - let snapshot = result - .record - .ok_or_else(|| SpacetimeClientError::missing_snapshot("绑定快照"))?; - - Ok(build_asset_entity_binding_record( - map_entity_binding_snapshot(snapshot), - )) -} - -pub(crate) fn map_asset_history_list_result( - result: AssetHistoryListResult, -) -> Result, SpacetimeClientError> { - if !result.ok { - return Err(SpacetimeClientError::procedure_failed(result.error_message)); - } - - Ok(result - .entries - .into_iter() - .map(map_asset_history_entry_snapshot) - .map(build_asset_history_entry_record) - .collect()) -} - -pub type BarkBattleDraftConfigRecord = serde_json::Value; -pub type BarkBattleRuntimeConfigRecord = serde_json::Value; -pub type BarkBattleRunRecord = serde_json::Value; - -pub(crate) fn map_bark_battle_draft_config_procedure_result( - result: BarkBattleProcedureResult, -) -> Result { - if !result.ok { - return Err(SpacetimeClientError::procedure_failed(result.error_message)); - } - result - .draft_config - .ok_or_else(|| SpacetimeClientError::missing_snapshot("Bark Battle draft config")) - .map(bark_battle_draft_config_to_value) -} - -pub(crate) fn map_bark_battle_runtime_config_procedure_result( - result: BarkBattleProcedureResult, -) -> Result { - if !result.ok { - return Err(SpacetimeClientError::procedure_failed(result.error_message)); - } - result - .runtime_config - .ok_or_else(|| SpacetimeClientError::missing_snapshot("Bark Battle runtime config")) - .map(bark_battle_runtime_config_to_value) -} - -pub(crate) fn map_bark_battle_run_procedure_result( - result: BarkBattleProcedureResult, -) -> Result { - if !result.ok { - return Err(SpacetimeClientError::procedure_failed(result.error_message)); - } - result - .run - .ok_or_else(|| SpacetimeClientError::missing_snapshot("Bark Battle run")) - .map(bark_battle_run_to_value) -} - -fn bark_battle_draft_config_to_value(snapshot: BarkBattleDraftConfigSnapshot) -> serde_json::Value { - serde_json::json!({ - "draftId": snapshot.draft_id, - "ownerUserId": snapshot.owner_user_id, - "workId": snapshot.work_id, - "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, - "updatedAtMicros": snapshot.updated_at_micros, - }) -} - -fn bark_battle_runtime_config_to_value( - snapshot: BarkBattleRuntimeConfigSnapshot, -) -> serde_json::Value { - serde_json::json!({ - "workId": snapshot.work_id, - "ownerUserId": snapshot.owner_user_id, - "sourceDraftId": snapshot.source_draft_id, - "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, - "updatedAtMicros": snapshot.updated_at_micros, - }) -} - -fn bark_battle_run_to_value(snapshot: BarkBattleRunSnapshot) -> serde_json::Value { - serde_json::json!({ - "runId": snapshot.run_id, - "ownerUserId": snapshot.owner_user_id, - "workId": snapshot.work_id, - "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, - "clientFinishedAtMicros": snapshot.client_finished_at_micros, - "serverFinishedAtMicros": snapshot.server_finished_at_micros, - "metricsJson": snapshot.metrics_json, - "serverResult": snapshot.server_result, - "validationStatus": snapshot.validation_status, - "antiCheatFlagsJson": snapshot.anti_cheat_flags_json, - "leaderboardScore": snapshot.leaderboard_score, - "scoreId": snapshot.score_id, - }) -} - -pub type CreationEntryConfigRecord = - shared_contracts::creation_entry_config::CreationEntryConfigResponse; - -pub(crate) fn map_creation_entry_config_procedure_result( - result: CreationEntryConfigProcedureResult, -) -> Result { - if !result.ok { - return Err(SpacetimeClientError::procedure_failed(result.error_message)); - } - - let snapshot = result - .record - .ok_or_else(|| SpacetimeClientError::missing_snapshot("创作入口配置快照"))?; - - Ok(module_runtime::build_creation_entry_config_response( - map_creation_entry_config_snapshot(snapshot), - )) -} - -pub(crate) fn build_creation_entry_config_record_from_rows( - header: CreationEntryConfig, - mut creation_types: Vec, -) -> CreationEntryConfigRecord { - creation_types.sort_by(|left, right| { - left.sort_order - .cmp(&right.sort_order) - .then_with(|| left.id.cmp(&right.id)) - }); - - module_runtime::build_creation_entry_config_response( - module_runtime::CreationEntryConfigSnapshot { - config_id: header.config_id, - start_card: module_runtime::CreationEntryStartCardSnapshot { - title: header.start_title, - description: header.start_description, - idle_badge: header.start_idle_badge, - busy_badge: header.start_busy_badge, - }, - type_modal: module_runtime::CreationEntryTypeModalSnapshot { - title: header.modal_title, - description: header.modal_description, - }, - creation_types: creation_types - .into_iter() - .map(|item| module_runtime::CreationEntryTypeSnapshot { - id: item.id, - title: item.title, - subtitle: item.subtitle, - badge: item.badge, - image_src: item.image_src, - visible: item.visible, - open: item.open, - sort_order: item.sort_order, - updated_at_micros: item.updated_at.to_micros_since_unix_epoch(), - }) - .collect(), - updated_at_micros: header.updated_at.to_micros_since_unix_epoch(), - }, - ) -} - -fn map_creation_entry_config_snapshot( - snapshot: CreationEntryConfigSnapshot, -) -> module_runtime::CreationEntryConfigSnapshot { - module_runtime::CreationEntryConfigSnapshot { - config_id: snapshot.config_id, - start_card: module_runtime::CreationEntryStartCardSnapshot { - title: snapshot.start_card.title, - description: snapshot.start_card.description, - idle_badge: snapshot.start_card.idle_badge, - busy_badge: snapshot.start_card.busy_badge, - }, - type_modal: module_runtime::CreationEntryTypeModalSnapshot { - title: snapshot.type_modal.title, - description: snapshot.type_modal.description, - }, - creation_types: snapshot - .creation_types - .into_iter() - .map(|item| module_runtime::CreationEntryTypeSnapshot { - id: item.id, - title: item.title, - subtitle: item.subtitle, - badge: item.badge, - image_src: item.image_src, - visible: item.visible, - open: item.open, - sort_order: item.sort_order, - updated_at_micros: item.updated_at_micros, - }) - .collect(), - updated_at_micros: snapshot.updated_at_micros, - } -} - -pub(crate) fn map_runtime_setting_procedure_result( - result: RuntimeSettingProcedureResult, -) -> Result { - if !result.ok { - return Err(SpacetimeClientError::procedure_failed(result.error_message)); - } - - let snapshot = result - .record - .ok_or_else(|| SpacetimeClientError::missing_snapshot("runtime settings 快照"))?; - - Ok(build_runtime_setting_record(map_runtime_setting_snapshot( - snapshot, - ))) -} - -pub(crate) fn map_auth_store_snapshot_procedure_result( - result: AuthStoreSnapshotProcedureResult, -) -> Result { - if !result.ok { - return Err(SpacetimeClientError::procedure_failed(result.error_message)); - } - - let record = result - .record - .ok_or_else(|| SpacetimeClientError::missing_snapshot("认证快照"))?; - - Ok(map_auth_store_snapshot_record(record)) -} - -pub(crate) fn map_auth_store_snapshot_record( - record: crate::module_bindings::AuthStoreSnapshotRecord, -) -> crate::AuthStoreSnapshotRecord { - crate::AuthStoreSnapshotRecord { - snapshot_json: record.snapshot_json, - updated_at_micros: record.updated_at_micros, - } -} - -pub(crate) fn map_auth_store_snapshot_import_procedure_result( - result: AuthStoreSnapshotImportProcedureResult, -) -> Result { - if !result.ok { - return Err(SpacetimeClientError::procedure_failed(result.error_message)); - } - - let record = result - .record - .ok_or_else(|| SpacetimeClientError::missing_snapshot("认证快照导入结果"))?; - - Ok(AuthStoreSnapshotImportRecord { - imported_user_count: record.imported_user_count, - imported_identity_count: record.imported_identity_count, - imported_refresh_session_count: record.imported_refresh_session_count, - }) -} - -pub(crate) fn map_runtime_browse_history_procedure_result( - result: RuntimeBrowseHistoryProcedureResult, -) -> Result, SpacetimeClientError> { - if !result.ok { - return Err(SpacetimeClientError::procedure_failed(result.error_message)); - } - - Ok(result - .entries - .into_iter() - .map(|snapshot| { - build_runtime_browse_history_record(map_runtime_browse_history_snapshot(snapshot)) - }) - .collect()) -} - -pub(crate) fn map_runtime_profile_dashboard_procedure_result( - result: RuntimeProfileDashboardProcedureResult, -) -> Result { - if !result.ok { - return Err(SpacetimeClientError::procedure_failed(result.error_message)); - } - - let snapshot = result - .record - .ok_or_else(|| SpacetimeClientError::missing_snapshot("profile dashboard 快照"))?; - - Ok(build_runtime_profile_dashboard_record( - map_runtime_profile_dashboard_snapshot(snapshot), - )) -} - -pub(crate) fn map_runtime_profile_wallet_ledger_procedure_result( - result: RuntimeProfileWalletLedgerProcedureResult, -) -> Result, SpacetimeClientError> { - if !result.ok { - return Err(SpacetimeClientError::procedure_failed(result.error_message)); - } - - Ok(result - .entries - .into_iter() - .map(|snapshot| { - build_runtime_profile_wallet_ledger_entry_record( - map_runtime_profile_wallet_ledger_entry_snapshot(snapshot), - ) - }) - .collect()) -} - -pub(crate) fn map_runtime_profile_wallet_adjustment_procedure_result( - result: RuntimeProfileWalletAdjustmentProcedureResult, -) -> Result { - if !result.ok { - return Err(SpacetimeClientError::procedure_failed(result.error_message)); - } - - let snapshot = result - .record - .ok_or_else(|| SpacetimeClientError::missing_snapshot("profile dashboard 快照"))?; - - Ok(build_runtime_profile_dashboard_record( - map_runtime_profile_dashboard_snapshot(snapshot), - )) -} - -pub(crate) fn map_runtime_profile_recharge_center_procedure_result( - result: RuntimeProfileRechargeCenterProcedureResult, -) -> Result { - if !result.ok { - return Err(SpacetimeClientError::procedure_failed(result.error_message)); - } - - let snapshot = result - .record - .ok_or_else(|| SpacetimeClientError::missing_snapshot("profile recharge center 快照"))?; - - Ok(build_runtime_profile_recharge_center_record( - map_runtime_profile_recharge_center_snapshot(snapshot), - )) -} - -pub(crate) fn map_runtime_profile_recharge_order_procedure_result( - result: RuntimeProfileRechargeCenterProcedureResult, -) -> Result< - ( - RuntimeProfileRechargeCenterRecord, - RuntimeProfileRechargeOrderRecord, - ), - SpacetimeClientError, -> { - if !result.ok { - return Err(SpacetimeClientError::procedure_failed(result.error_message)); - } - - let center = result - .record - .ok_or_else(|| SpacetimeClientError::missing_snapshot("profile recharge center 快照"))?; - let order = result - .order - .ok_or_else(|| SpacetimeClientError::missing_snapshot("profile recharge order 快照"))?; - - Ok(( - build_runtime_profile_recharge_center_record(map_runtime_profile_recharge_center_snapshot( - center, - )), - module_runtime::build_runtime_profile_recharge_order_record( - map_runtime_profile_recharge_order_snapshot(order), - ), - )) -} - -pub(crate) fn map_runtime_profile_feedback_submission_procedure_result( - result: RuntimeProfileFeedbackSubmissionProcedureResult, -) -> Result { - if !result.ok { - return Err(SpacetimeClientError::procedure_failed(result.error_message)); - } - - let snapshot = result - .record - .ok_or_else(|| SpacetimeClientError::missing_snapshot("profile feedback 快照"))?; - - build_runtime_profile_feedback_submission_record( - map_runtime_profile_feedback_submission_snapshot(snapshot), - ) - .map_err(SpacetimeClientError::validation_failed) -} - -pub(crate) fn map_runtime_referral_invite_center_procedure_result( - result: RuntimeReferralInviteCenterProcedureResult, -) -> Result { - if !result.ok { - return Err(SpacetimeClientError::procedure_failed(result.error_message)); - } - - let snapshot = result - .record - .ok_or_else(|| SpacetimeClientError::missing_snapshot("referral invite center 快照"))?; - - Ok(build_runtime_referral_invite_center_record( - map_runtime_referral_invite_center_snapshot(snapshot), - )) -} - -pub(crate) fn map_runtime_referral_redeem_procedure_result( - result: RuntimeReferralRedeemProcedureResult, -) -> Result { - if !result.ok { - return Err(SpacetimeClientError::procedure_failed(result.error_message)); - } - - let snapshot = result - .record - .ok_or_else(|| SpacetimeClientError::missing_snapshot("referral redeem 快照"))?; - - Ok(build_runtime_referral_redeem_record( - map_runtime_referral_redeem_snapshot(snapshot), - )) -} - -pub(crate) fn map_runtime_profile_reward_code_redeem_procedure_result( - result: RuntimeProfileRewardCodeRedeemProcedureResult, -) -> Result { - if !result.ok { - return Err(SpacetimeClientError::procedure_failed(result.error_message)); - } - - let snapshot = result - .record - .ok_or_else(|| SpacetimeClientError::missing_snapshot("reward redeem 快照"))?; - - Ok(build_runtime_profile_reward_code_redeem_record( - map_runtime_profile_reward_code_redeem_snapshot(snapshot), - )) -} - -pub(crate) fn map_runtime_tracking_event_procedure_result( - result: RuntimeTrackingEventProcedureResult, -) -> Result<(), SpacetimeClientError> { - if !result.ok { - return Err(SpacetimeClientError::procedure_failed(result.error_message)); - } - - Ok(()) -} - -pub(crate) fn map_runtime_profile_task_center_procedure_result( - result: RuntimeProfileTaskCenterProcedureResult, -) -> Result { - if !result.ok { - return Err(SpacetimeClientError::procedure_failed(result.error_message)); - } - - let snapshot = result - .record - .ok_or_else(|| SpacetimeClientError::missing_snapshot("profile task center 快照"))?; - - Ok(build_runtime_profile_task_center_record( - map_runtime_profile_task_center_snapshot(snapshot), - )) -} - -pub(crate) fn map_analytics_metric_query_procedure_result( - result: AnalyticsMetricQueryProcedureResult, -) -> Result { - if !result.ok { - return Err(SpacetimeClientError::procedure_failed(result.error_message)); - } - - Ok(DomainAnalyticsMetricQueryResponse { - buckets: result - .buckets - .into_iter() - .map(map_analytics_bucket_metric) - .collect(), - }) -} - -pub(crate) fn map_runtime_profile_task_claim_procedure_result( - result: RuntimeProfileTaskClaimProcedureResult, -) -> Result { - if !result.ok { - return Err(SpacetimeClientError::procedure_failed(result.error_message)); - } - - let snapshot = result - .record - .ok_or_else(|| SpacetimeClientError::missing_snapshot("profile task claim 快照"))?; - - Ok(build_runtime_profile_task_claim_record( - map_runtime_profile_task_claim_snapshot(snapshot), - )) -} - -pub(crate) fn map_runtime_profile_task_config_admin_list_procedure_result( - result: RuntimeProfileTaskConfigAdminListProcedureResult, -) -> Result, SpacetimeClientError> { - if !result.ok { - return Err(SpacetimeClientError::procedure_failed(result.error_message)); - } - - Ok(result - .entries - .into_iter() - .map(|snapshot| { - build_runtime_profile_task_config_record(map_runtime_profile_task_config_snapshot( - snapshot, - )) - }) - .collect()) -} - -pub(crate) fn map_runtime_profile_task_config_admin_procedure_result( - result: RuntimeProfileTaskConfigAdminProcedureResult, -) -> Result { - if !result.ok { - return Err(SpacetimeClientError::procedure_failed(result.error_message)); - } - - let snapshot = result - .record - .ok_or_else(|| SpacetimeClientError::missing_snapshot("profile task config 快照"))?; - - Ok(build_runtime_profile_task_config_record( - map_runtime_profile_task_config_snapshot(snapshot), - )) -} - -pub(crate) fn map_runtime_profile_recharge_product_admin_list_procedure_result( - result: RuntimeProfileRechargeProductAdminListProcedureResult, -) -> Result, SpacetimeClientError> { - if !result.ok { - return Err(SpacetimeClientError::procedure_failed(result.error_message)); - } - - Ok(result - .entries - .into_iter() - .map(|snapshot| { - build_runtime_profile_recharge_product_config_record( - map_runtime_profile_recharge_product_config_snapshot(snapshot), - ) - }) - .collect()) -} - -pub(crate) fn map_runtime_profile_recharge_product_admin_procedure_result( - result: RuntimeProfileRechargeProductAdminProcedureResult, -) -> Result { - if !result.ok { - return Err(SpacetimeClientError::procedure_failed(result.error_message)); - } - - let snapshot = result - .record - .ok_or_else(|| SpacetimeClientError::missing_snapshot("recharge product config 快照"))?; - - Ok(build_runtime_profile_recharge_product_config_record( - map_runtime_profile_recharge_product_config_snapshot(snapshot), - )) -} - -pub(crate) fn map_runtime_profile_redeem_code_admin_procedure_result( - result: RuntimeProfileRedeemCodeAdminProcedureResult, -) -> Result { - if !result.ok { - return Err(SpacetimeClientError::procedure_failed(result.error_message)); - } - - let snapshot = result - .record - .ok_or_else(|| SpacetimeClientError::missing_snapshot("redeem code 快照"))?; - - Ok(build_runtime_profile_redeem_code_record( - map_runtime_profile_redeem_code_snapshot(snapshot), - )) -} - -pub(crate) fn map_runtime_profile_redeem_code_admin_list_procedure_result( - result: RuntimeProfileRedeemCodeAdminListProcedureResult, -) -> Result, SpacetimeClientError> { - if !result.ok { - return Err(SpacetimeClientError::procedure_failed(result.error_message)); - } - - Ok(result - .entries - .into_iter() - .map(|snapshot| { - build_runtime_profile_redeem_code_record(map_runtime_profile_redeem_code_snapshot( - snapshot, - )) - }) - .collect()) -} - -pub(crate) fn map_runtime_profile_invite_code_admin_procedure_result( - result: RuntimeProfileInviteCodeAdminProcedureResult, -) -> Result { - if !result.ok { - return Err(SpacetimeClientError::Procedure( - result - .error_message - .unwrap_or_else(|| "SpacetimeDB procedure 返回未知错误".to_string()), - )); - } - - let snapshot = result.record.ok_or_else(|| { - SpacetimeClientError::Procedure("SpacetimeDB procedure 未返回 invite code 快照".to_string()) - })?; - - Ok(build_runtime_profile_invite_code_record( - map_runtime_profile_invite_code_snapshot(snapshot), - )) -} - -pub(crate) fn map_runtime_profile_invite_code_admin_list_procedure_result( - result: RuntimeProfileInviteCodeAdminListProcedureResult, -) -> Result, SpacetimeClientError> { - if !result.ok { - return Err(SpacetimeClientError::procedure_failed(result.error_message)); - } - - Ok(result - .entries - .into_iter() - .map(|snapshot| { - build_runtime_profile_invite_code_record(map_runtime_profile_invite_code_snapshot( - snapshot, - )) - }) - .collect()) -} - -pub(crate) fn map_runtime_profile_play_stats_procedure_result( - result: RuntimeProfilePlayStatsProcedureResult, -) -> Result { - if !result.ok { - return Err(SpacetimeClientError::procedure_failed(result.error_message)); - } - - let snapshot = result - .record - .ok_or_else(|| SpacetimeClientError::missing_snapshot("profile play stats 快照"))?; - - Ok(build_runtime_profile_play_stats_record( - map_runtime_profile_play_stats_snapshot(snapshot), - )) -} - -pub(crate) fn map_runtime_snapshot_procedure_result( - result: RuntimeSnapshotProcedureResult, -) -> Result, SpacetimeClientError> { - if !result.ok { - return Err(SpacetimeClientError::procedure_failed(result.error_message)); - } - - result - .record - .map(|snapshot| { - build_runtime_snapshot_record(map_runtime_snapshot_snapshot(snapshot)) - .map_err(|error| SpacetimeClientError::Runtime(error.to_string())) - }) - .transpose() -} - -pub(crate) fn map_runtime_snapshot_required_procedure_result( - result: RuntimeSnapshotProcedureResult, -) -> Result { - map_runtime_snapshot_procedure_result(result)? - .ok_or_else(|| SpacetimeClientError::missing_snapshot("runtime snapshot 快照")) -} - -pub(crate) fn map_runtime_snapshot_delete_procedure_result( - result: RuntimeSnapshotProcedureResult, -) -> Result { - map_runtime_snapshot_procedure_result(result).map(|record| record.is_some()) -} - -pub(crate) fn map_runtime_profile_save_archive_list_procedure_result( - result: RuntimeProfileSaveArchiveProcedureResult, -) -> Result, SpacetimeClientError> { - if !result.ok { - return Err(SpacetimeClientError::procedure_failed(result.error_message)); - } - - result - .entries - .into_iter() - .map(|snapshot| { - build_runtime_profile_save_archive_record(map_runtime_profile_save_archive_snapshot( - snapshot, - )) - .map_err(|error| SpacetimeClientError::Runtime(error.to_string())) - }) - .collect() -} - -pub(crate) fn map_runtime_profile_save_archive_resume_procedure_result( - result: RuntimeProfileSaveArchiveProcedureResult, -) -> Result<(RuntimeProfileSaveArchiveRecord, RuntimeSnapshotRecord), SpacetimeClientError> { - if !result.ok { - return Err(SpacetimeClientError::procedure_failed(result.error_message)); - } - - let archive = result - .record - .ok_or_else(|| SpacetimeClientError::missing_snapshot("save archive 快照"))?; - let snapshot = result - .current_snapshot - .ok_or_else(|| SpacetimeClientError::missing_snapshot("恢复后的 runtime snapshot"))?; - - Ok(( - build_runtime_profile_save_archive_record(map_runtime_profile_save_archive_snapshot( - archive, - )) - .map_err(|error| SpacetimeClientError::Runtime(error.to_string()))?, - build_runtime_snapshot_record(map_runtime_snapshot_snapshot(snapshot)) - .map_err(|error| SpacetimeClientError::Runtime(error.to_string()))?, - )) -} - -pub(crate) fn map_ai_task_procedure_result( - result: AiTaskProcedureResult, -) -> Result { - if !result.ok { - return Err(SpacetimeClientError::procedure_failed(result.error_message)); - } - - let task = result - .task - .ok_or_else(|| SpacetimeClientError::missing_snapshot("ai_task 快照"))?; - - Ok(AiTaskMutationRecord { - task: map_ai_task_snapshot(task), - text_chunk: result.text_chunk.map(map_ai_text_chunk_snapshot), - }) -} - -pub(crate) fn map_custom_world_profile_list_result( - result: CustomWorldProfileListResult, -) -> Result, SpacetimeClientError> { - if !result.ok { - return Err(SpacetimeClientError::procedure_failed(result.error_message)); - } - - result - .entries - .into_iter() - .map(map_custom_world_library_entry_from_profile_snapshot) - .collect() -} - -pub(crate) fn map_custom_world_library_detail_result( - result: CustomWorldLibraryMutationResult, -) -> Result { - if !result.ok { - return Err(SpacetimeClientError::procedure_failed(result.error_message)); - } - - let entry = result - .entry - .ok_or_else(|| SpacetimeClientError::Procedure("custom_world_profile 不存在".to_string())) - .and_then(map_custom_world_library_entry_from_profile_snapshot)?; - let gallery_entry = result - .gallery_entry - .map(map_custom_world_gallery_entry_snapshot) - .transpose()?; - - Ok(CustomWorldLibraryMutationRecord { - entry, - gallery_entry, - }) -} - -pub(crate) fn map_custom_world_gallery_list_result( - result: CustomWorldGalleryListResult, -) -> Result, SpacetimeClientError> { - if !result.ok { - return Err(SpacetimeClientError::procedure_failed(result.error_message)); - } - - Ok(result - .entries - .into_iter() - .map(map_custom_world_gallery_entry_snapshot) - .collect::, _>>()?) -} - -pub(crate) fn map_custom_world_library_mutation_result( - result: CustomWorldLibraryMutationResult, -) -> Result { - if !result.ok { - return Err(SpacetimeClientError::procedure_failed(result.error_message)); - } - - let entry = result - .entry - .ok_or_else(|| SpacetimeClientError::missing_snapshot("custom world entry")) - .and_then(map_custom_world_library_entry_from_profile_snapshot)?; - let gallery_entry = result - .gallery_entry - .map(map_custom_world_gallery_entry_snapshot) - .transpose()?; - - Ok(CustomWorldLibraryMutationRecord { - entry, - gallery_entry, - }) -} - -pub(crate) fn map_custom_world_publish_world_result( - result: CustomWorldPublishWorldResult, -) -> Result { - if !result.ok { - return Err(SpacetimeClientError::procedure_failed(result.error_message)); - } - - let compiled_record = result - .compiled_record - .ok_or_else(|| SpacetimeClientError::missing_snapshot("published profile compile 快照")) - .and_then(map_custom_world_published_profile_compile_snapshot)?; - let entry = result - .entry - .ok_or_else(|| SpacetimeClientError::missing_snapshot("custom world entry")) - .and_then(map_custom_world_library_entry_from_profile_snapshot)?; - let gallery_entry = result - .gallery_entry - .map(map_custom_world_gallery_entry_snapshot) - .transpose()?; - let session_stage = result - .session_stage - .ok_or_else(|| SpacetimeClientError::missing_snapshot("session stage")) - .map(map_rpg_agent_stage)?; - - Ok(CustomWorldPublishWorldRecord { - compiled_record, - entry, - gallery_entry, - session_stage, - }) -} - -pub(crate) fn map_custom_world_agent_session_procedure_result( - result: CustomWorldAgentSessionProcedureResult, -) -> Result { - if !result.ok { - return Err(SpacetimeClientError::procedure_failed(result.error_message)); - } - - let session = result - .session - .ok_or_else(|| SpacetimeClientError::missing_snapshot("custom world agent session 快照"))?; - - map_custom_world_agent_session_snapshot(session) -} - -pub(crate) fn map_custom_world_agent_operation_procedure_result( - result: CustomWorldAgentOperationProcedureResult, -) -> Result { - if !result.ok { - return Err(SpacetimeClientError::procedure_failed(result.error_message)); - } - - let operation = result.operation.ok_or_else(|| { - SpacetimeClientError::missing_snapshot("custom world agent operation 快照") - })?; - - Ok(map_custom_world_agent_operation_snapshot(operation)) -} - -pub(crate) fn map_custom_world_works_list_result( - result: CustomWorldWorksListResult, -) -> Result, SpacetimeClientError> { - if !result.ok { - return Err(SpacetimeClientError::procedure_failed(result.error_message)); - } - - result - .items - .into_iter() - .map(map_custom_world_work_summary_snapshot) - .collect() -} - -pub(crate) fn map_custom_world_draft_card_detail_result( - result: CustomWorldDraftCardDetailResult, -) -> Result { - if !result.ok { - return Err(SpacetimeClientError::procedure_failed(result.error_message)); - } - - let card = result - .card - .ok_or_else(|| SpacetimeClientError::missing_snapshot("custom world card detail 快照"))?; - - map_custom_world_draft_card_detail_snapshot(card) -} - -pub(crate) fn map_custom_world_agent_action_execute_result( - result: CustomWorldAgentActionExecuteResult, -) -> Result { - if !result.ok { - return Err(SpacetimeClientError::procedure_failed(result.error_message)); - } - - let operation = result.operation.ok_or_else(|| { - SpacetimeClientError::missing_snapshot("custom world action operation 快照") - })?; - - Ok(CustomWorldAgentActionExecuteRecord { - operation: map_custom_world_agent_operation_snapshot(operation), - }) -} - -pub(crate) fn map_puzzle_agent_session_procedure_result( - result: PuzzleAgentSessionProcedureResult, -) -> Result { - if !result.ok { - return Err(SpacetimeClientError::procedure_failed(result.error_message)); - } - - let session = result - .session - .ok_or_else(|| SpacetimeClientError::missing_snapshot("puzzle agent session 快照"))?; - Ok(map_puzzle_agent_session_snapshot(session)) -} - -pub(crate) fn map_puzzle_work_procedure_result( - result: PuzzleWorkProcedureResult, -) -> Result { - if !result.ok { - return Err(SpacetimeClientError::procedure_failed(result.error_message)); - } - - let item = result - .item - .ok_or_else(|| SpacetimeClientError::missing_snapshot("puzzle work 快照"))?; - Ok(map_puzzle_work_profile(item)) -} - -pub(crate) fn map_puzzle_works_procedure_result( - result: PuzzleWorksProcedureResult, -) -> Result, SpacetimeClientError> { - if !result.ok { - return Err(SpacetimeClientError::procedure_failed(result.error_message)); - } - - Ok(result - .items - .into_iter() - .map(map_puzzle_work_profile) - .collect()) -} - -pub(crate) fn map_puzzle_run_procedure_result( - result: PuzzleRunProcedureResult, -) -> Result { - if !result.ok { - return Err(SpacetimeClientError::procedure_failed(result.error_message)); - } - - let run = result - .run - .ok_or_else(|| SpacetimeClientError::missing_snapshot("puzzle run 快照"))?; - Ok(map_puzzle_run_snapshot(run)) -} - -pub(crate) fn map_big_fish_session_procedure_result( - result: BigFishSessionProcedureResult, -) -> Result { - if !result.ok { - return Err(SpacetimeClientError::procedure_failed(result.error_message)); - } - - let session = result - .session - .ok_or_else(|| SpacetimeClientError::missing_snapshot("big fish session 快照"))?; - - Ok(map_big_fish_session_snapshot(session)) -} - -pub(crate) fn map_big_fish_works_procedure_result( - result: BigFishWorksProcedureResult, - _fallback_owner_user_id: Option<&str>, -) -> Result, SpacetimeClientError> { - if !result.ok { - return Err(SpacetimeClientError::procedure_failed(result.error_message)); - } - - Ok(result - .items - .into_iter() - .map(map_big_fish_work_summary_snapshot) - .collect()) -} - -pub(crate) fn map_big_fish_run_procedure_result( - result: BigFishRunProcedureResult, -) -> Result { - if !result.ok { - return Err(SpacetimeClientError::procedure_failed(result.error_message)); - } - - let run = result - .run - .ok_or_else(|| SpacetimeClientError::missing_snapshot("big fish run 快照"))?; - Ok(map_big_fish_runtime_snapshot(run)) -} - -pub(crate) fn map_match3d_agent_session_procedure_result( - result: Match3DAgentSessionProcedureResult, -) -> Result { - if !result.ok { - return Err(SpacetimeClientError::Procedure( - result - .error_message - .unwrap_or_else(|| "SpacetimeDB procedure 返回未知错误".to_string()), - )); - } - - let session = result.session.ok_or_else(|| { - SpacetimeClientError::Procedure( - "SpacetimeDB procedure 未返回 match3d agent session 快照".to_string(), - ) - })?; - - Ok(map_match3d_agent_session_snapshot(session)) -} - -pub(crate) fn map_match3d_work_procedure_result( - result: Match3DWorkProcedureResult, -) -> Result { - if !result.ok { - return Err(SpacetimeClientError::Procedure( - result - .error_message - .unwrap_or_else(|| "SpacetimeDB procedure 返回未知错误".to_string()), - )); - } - - let work = result.work.ok_or_else(|| { - SpacetimeClientError::Procedure( - "SpacetimeDB procedure 未返回 match3d work 快照".to_string(), - ) - })?; - - Ok(map_match3d_work_snapshot(work)) -} - -pub(crate) fn map_match3d_works_procedure_result( - result: Match3DWorksProcedureResult, -) -> Result, SpacetimeClientError> { - if !result.ok { - return Err(SpacetimeClientError::Procedure( - result - .error_message - .unwrap_or_else(|| "SpacetimeDB procedure 返回未知错误".to_string()), - )); - } - - Ok(result - .items - .into_iter() - .map(map_match3d_work_snapshot) - .collect()) -} - -pub(crate) fn map_match3d_run_procedure_result( - result: Match3DRunProcedureResult, -) -> Result { - if !result.ok { - return Err(SpacetimeClientError::Procedure( - result - .error_message - .unwrap_or_else(|| "SpacetimeDB procedure 返回未知错误".to_string()), - )); - } - - let run = result.run.ok_or_else(|| { - SpacetimeClientError::Procedure("SpacetimeDB procedure 未返回 match3d run 快照".to_string()) - })?; - Ok(map_match3d_run_snapshot(run)) -} - -pub(crate) fn map_match3d_click_item_procedure_result( - result: Match3DClickItemProcedureResult, -) -> Result { - if !result.ok { - return Err(SpacetimeClientError::Procedure( - result - .error_message - .unwrap_or_else(|| "SpacetimeDB procedure 返回未知错误".to_string()), - )); - } - - let run = result.run.ok_or_else(|| { - SpacetimeClientError::Procedure( - "SpacetimeDB procedure 未返回 match3d click run 快照".to_string(), - ) - })?; - let run = map_match3d_run_snapshot(run); - let accepted = result.status == "Accepted"; - let accepted_item_instance_id = result.accepted_item_instance_id.clone(); - let entered_slot_index = accepted_item_instance_id.as_deref().and_then(|item_id| { - run.items - .iter() - .find(|item| item.item_instance_id == item_id) - .and_then(|item| item.tray_slot_index) - }); - - Ok(Match3DClickConfirmationRecord { - status: result.status.clone(), - accepted, - reject_reason: if accepted { None } else { Some(result.status) }, - accepted_item_instance_id, - entered_slot_index, - cleared_item_instance_ids: result.cleared_item_instance_ids, - failure_reason: result.failure_reason, - run, - }) -} - -pub(crate) fn map_square_hole_agent_session_procedure_result( - result: SquareHoleAgentSessionProcedureResult, -) -> Result { - if !result.ok { - return Err(SpacetimeClientError::procedure_failed(result.error_message)); - } - - let session = result - .session - .ok_or_else(|| SpacetimeClientError::missing_snapshot("square hole agent session 快照"))?; - - Ok(map_square_hole_agent_session_snapshot(session)) -} - -pub(crate) fn map_square_hole_work_procedure_result( - result: SquareHoleWorkProcedureResult, -) -> Result { - if !result.ok { - return Err(SpacetimeClientError::procedure_failed(result.error_message)); - } - - let work = result - .work - .ok_or_else(|| SpacetimeClientError::missing_snapshot("square hole work 快照"))?; - - Ok(map_square_hole_work_snapshot(work)) -} - -pub(crate) fn map_square_hole_works_procedure_result( - result: SquareHoleWorksProcedureResult, -) -> Result, SpacetimeClientError> { - if !result.ok { - return Err(SpacetimeClientError::procedure_failed(result.error_message)); - } - - Ok(result - .items - .into_iter() - .map(map_square_hole_work_snapshot) - .collect()) -} - -pub(crate) fn map_square_hole_run_procedure_result( - result: SquareHoleRunProcedureResult, -) -> Result { - if !result.ok { - return Err(SpacetimeClientError::procedure_failed(result.error_message)); - } - - let run = result - .run - .ok_or_else(|| SpacetimeClientError::missing_snapshot("square hole run 快照"))?; - Ok(map_square_hole_run_snapshot(run)) -} - -pub(crate) fn map_square_hole_drop_shape_procedure_result( - result: SquareHoleDropShapeProcedureResult, -) -> Result { - if !result.ok { - return Err(SpacetimeClientError::procedure_failed(result.error_message)); - } - - let run = result - .run - .ok_or_else(|| SpacetimeClientError::missing_snapshot("square hole drop run 快照"))?; - let feedback = result - .feedback - .ok_or_else(|| SpacetimeClientError::missing_snapshot("square hole drop feedback 快照"))?; - let run = map_square_hole_run_snapshot(run); - - Ok(SquareHoleDropConfirmationRecord { - status: result.status, - accepted: feedback.accepted, - reject_reason: feedback.reject_reason.clone(), - failure_reason: result.failure_reason, - feedback: map_square_hole_feedback_snapshot(feedback), - run, - }) -} - -pub(crate) fn map_visual_novel_agent_session_procedure_result( - result: VisualNovelAgentSessionProcedureResult, -) -> Result { - if !result.ok { - return Err(SpacetimeClientError::procedure_failed(result.error_message)); - } - - let session = result - .session - .ok_or_else(|| SpacetimeClientError::missing_snapshot("visual novel agent session 快照"))?; - - Ok(map_visual_novel_agent_session_snapshot(session)) -} - -pub(crate) fn map_visual_novel_work_procedure_result( - result: VisualNovelWorkProcedureResult, -) -> Result { - if !result.ok { - return Err(SpacetimeClientError::procedure_failed(result.error_message)); - } - - let work = result - .work - .ok_or_else(|| SpacetimeClientError::missing_snapshot("visual novel work 快照"))?; - - Ok(map_visual_novel_work_snapshot(work)) -} - -pub(crate) fn map_visual_novel_works_procedure_result( - result: VisualNovelWorksProcedureResult, -) -> Result, SpacetimeClientError> { - if !result.ok { - return Err(SpacetimeClientError::procedure_failed(result.error_message)); - } - - Ok(result - .items - .into_iter() - .map(map_visual_novel_work_snapshot) - .collect()) -} - -pub(crate) fn map_visual_novel_run_procedure_result( - result: VisualNovelRunProcedureResult, -) -> Result { - if !result.ok { - return Err(SpacetimeClientError::procedure_failed(result.error_message)); - } - - let run = result - .run - .ok_or_else(|| SpacetimeClientError::missing_snapshot("visual novel run 快照"))?; - - Ok(map_visual_novel_run_snapshot(run)) -} - -pub(crate) fn map_visual_novel_history_procedure_result( - result: VisualNovelHistoryProcedureResult, -) -> Result, SpacetimeClientError> { - if !result.ok { - return Err(SpacetimeClientError::procedure_failed(result.error_message)); - } - - Ok(result - .items - .into_iter() - .map(map_visual_novel_history_entry) - .collect()) -} - -pub(crate) fn map_visual_novel_runtime_event_procedure_result( - result: VisualNovelRuntimeEventProcedureResult, -) -> Result { - if !result.ok { - return Err(SpacetimeClientError::procedure_failed(result.error_message)); - } - - let event = result - .event - .ok_or_else(|| SpacetimeClientError::missing_snapshot("visual novel runtime event 快照"))?; - - Ok(map_visual_novel_runtime_event(event)) -} - -pub(crate) fn map_story_session_procedure_result( - result: StorySessionProcedureResult, -) -> Result { - if !result.ok { - return Err(SpacetimeClientError::procedure_failed(result.error_message)); - } - - let session = result - .session - .ok_or_else(|| SpacetimeClientError::missing_snapshot("story session 快照"))?; - let event = result - .event - .ok_or_else(|| SpacetimeClientError::missing_snapshot("story event 快照"))?; - - Ok(StorySessionResultRecord { - session: map_story_session_snapshot(session), - event: map_story_event_snapshot(event), - }) -} - -pub(crate) fn map_story_session_state_procedure_result( - result: StorySessionStateProcedureResult, -) -> Result { - if !result.ok { - return Err(SpacetimeClientError::procedure_failed(result.error_message)); - } - - let session = result - .session - .ok_or_else(|| SpacetimeClientError::missing_snapshot("story session state 快照"))?; - - Ok(StorySessionStateRecord { - session: map_story_session_snapshot(session), - events: result - .events - .into_iter() - .map(map_story_event_snapshot) - .collect(), - }) -} - -pub(crate) fn map_runtime_inventory_state_procedure_result( - result: RuntimeInventoryStateProcedureResult, -) -> Result { - if !result.ok { - return Err(SpacetimeClientError::procedure_failed(result.error_message)); - } - - let snapshot = result - .snapshot - .ok_or_else(|| SpacetimeClientError::missing_snapshot("runtime inventory state 快照"))?; - - Ok(build_runtime_inventory_state_record( - map_runtime_inventory_state_snapshot(snapshot), - )) -} - -pub(crate) fn map_battle_state_procedure_result( - result: BattleStateProcedureResult, -) -> Result { - if !result.ok { - return Err(SpacetimeClientError::procedure_failed(result.error_message)); - } - - let snapshot = result - .snapshot - .ok_or_else(|| SpacetimeClientError::missing_snapshot("battle_state 快照"))?; - - Ok(build_battle_state_record(map_battle_state_snapshot( - snapshot, - ))) -} - -pub(crate) fn map_resolve_combat_action_procedure_result( - result: ResolveCombatActionProcedureResult, -) -> Result { - if !result.ok { - return Err(SpacetimeClientError::procedure_failed(result.error_message)); - } - - let action_result = result - .result - .ok_or_else(|| SpacetimeClientError::missing_snapshot("战斗结算结果"))?; - - Ok(build_resolve_combat_action_record( - map_resolve_combat_action_result(action_result), - )) -} - -pub(crate) fn map_npc_battle_interaction_procedure_result( - result: NpcBattleInteractionProcedureResult, -) -> Result { - if !result.ok { - return Err(SpacetimeClientError::procedure_failed(result.error_message)); - } - - let interaction_result = result - .result - .ok_or_else(|| SpacetimeClientError::missing_snapshot("NPC 开战结果"))?; - - Ok(build_npc_battle_interaction_record( - map_npc_battle_interaction_result(interaction_result), - )) -} - -pub(crate) fn map_entity_binding_snapshot( - snapshot: AssetEntityBindingSnapshot, -) -> module_assets::AssetEntityBindingSnapshot { - module_assets::AssetEntityBindingSnapshot { - binding_id: snapshot.binding_id, - asset_object_id: snapshot.asset_object_id, - entity_kind: snapshot.entity_kind, - entity_id: snapshot.entity_id, - slot: snapshot.slot, - asset_kind: snapshot.asset_kind, - owner_user_id: snapshot.owner_user_id, - profile_id: snapshot.profile_id, - created_at_micros: snapshot.created_at_micros, - updated_at_micros: snapshot.updated_at_micros, - } -} - -pub(crate) fn map_snapshot( - snapshot: AssetObjectUpsertSnapshot, -) -> module_assets::AssetObjectUpsertSnapshot { - module_assets::AssetObjectUpsertSnapshot { - asset_object_id: snapshot.asset_object_id, - bucket: snapshot.bucket, - object_key: snapshot.object_key, - access_policy: map_access_policy_back(snapshot.access_policy), - content_type: snapshot.content_type, - content_length: snapshot.content_length, - content_hash: snapshot.content_hash, - version: snapshot.version, - source_job_id: snapshot.source_job_id, - owner_user_id: snapshot.owner_user_id, - profile_id: snapshot.profile_id, - entity_id: snapshot.entity_id, - asset_kind: snapshot.asset_kind, - created_at_micros: snapshot.created_at_micros, - updated_at_micros: snapshot.updated_at_micros, - } -} - -pub(crate) fn map_asset_history_entry_snapshot( - snapshot: AssetHistoryEntrySnapshot, -) -> module_assets::AssetHistoryEntrySnapshot { - module_assets::AssetHistoryEntrySnapshot { - asset_object_id: snapshot.asset_object_id, - asset_kind: snapshot.asset_kind, - image_src: snapshot.image_src, - owner_user_id: snapshot.owner_user_id, - profile_id: snapshot.profile_id, - entity_id: snapshot.entity_id, - created_at_micros: snapshot.created_at_micros, - updated_at_micros: snapshot.updated_at_micros, - } -} - -pub(crate) fn map_runtime_setting_snapshot( - snapshot: RuntimeSettingSnapshot, -) -> module_runtime::RuntimeSettingSnapshot { - module_runtime::RuntimeSettingSnapshot { - user_id: snapshot.user_id, - music_volume: snapshot.music_volume, - platform_theme: map_runtime_platform_theme_back(snapshot.platform_theme), - created_at_micros: snapshot.created_at_micros, - updated_at_micros: snapshot.updated_at_micros, - } -} - -pub(crate) fn map_runtime_browse_history_snapshot( - snapshot: RuntimeBrowseHistorySnapshot, -) -> module_runtime::RuntimeBrowseHistorySnapshot { - module_runtime::RuntimeBrowseHistorySnapshot { - browse_history_id: snapshot.browse_history_id, - user_id: snapshot.user_id, - owner_user_id: snapshot.owner_user_id, - profile_id: snapshot.profile_id, - world_name: snapshot.world_name, - subtitle: snapshot.subtitle, - summary_text: snapshot.summary_text, - cover_image_src: snapshot.cover_image_src, - theme_mode: map_runtime_browse_history_theme_mode_back(snapshot.theme_mode), - author_display_name: snapshot.author_display_name, - visited_at_micros: snapshot.visited_at_micros, - created_at_micros: snapshot.created_at_micros, - updated_at_micros: snapshot.updated_at_micros, - } -} - -pub(crate) fn map_runtime_profile_dashboard_snapshot( - snapshot: RuntimeProfileDashboardSnapshot, -) -> module_runtime::RuntimeProfileDashboardSnapshot { - module_runtime::RuntimeProfileDashboardSnapshot { - user_id: snapshot.user_id, - wallet_balance: snapshot.wallet_balance, - total_play_time_ms: snapshot.total_play_time_ms, - played_world_count: snapshot.played_world_count, - updated_at_micros: snapshot.updated_at_micros, - } -} - -pub(crate) fn map_analytics_bucket_metric( - bucket: AnalyticsBucketMetric, -) -> module_runtime::AnalyticsBucketMetric { - module_runtime::AnalyticsBucketMetric { - bucket_key: bucket.bucket_key, - bucket_start_date_key: bucket.bucket_start_date_key, - bucket_end_date_key: bucket.bucket_end_date_key, - value: bucket.value, - } -} - -pub(crate) fn map_runtime_profile_wallet_ledger_entry_snapshot( - snapshot: RuntimeProfileWalletLedgerEntrySnapshot, -) -> module_runtime::RuntimeProfileWalletLedgerEntrySnapshot { - module_runtime::RuntimeProfileWalletLedgerEntrySnapshot { - wallet_ledger_id: snapshot.wallet_ledger_id, - user_id: snapshot.user_id, - amount_delta: snapshot.amount_delta, - balance_after: snapshot.balance_after, - source_type: map_runtime_profile_wallet_ledger_source_type_back(snapshot.source_type), - created_at_micros: snapshot.created_at_micros, - } -} - -pub(crate) fn map_runtime_profile_recharge_center_snapshot( - snapshot: RuntimeProfileRechargeCenterSnapshot, -) -> module_runtime::RuntimeProfileRechargeCenterSnapshot { - module_runtime::RuntimeProfileRechargeCenterSnapshot { - user_id: snapshot.user_id, - wallet_balance: snapshot.wallet_balance, - membership: map_runtime_profile_membership_snapshot(snapshot.membership), - point_products: snapshot - .point_products - .into_iter() - .map(map_runtime_profile_recharge_product_snapshot) - .collect(), - membership_products: snapshot - .membership_products - .into_iter() - .map(map_runtime_profile_recharge_product_snapshot) - .collect(), - benefits: snapshot - .benefits - .into_iter() - .map(map_runtime_profile_membership_benefit_snapshot) - .collect(), - latest_order: snapshot - .latest_order - .map(map_runtime_profile_recharge_order_snapshot), - has_points_recharged: snapshot.has_points_recharged, - } -} - -pub(crate) fn map_runtime_profile_recharge_product_snapshot( - snapshot: RuntimeProfileRechargeProductSnapshot, -) -> module_runtime::RuntimeProfileRechargeProductSnapshot { - module_runtime::RuntimeProfileRechargeProductSnapshot { - product_id: snapshot.product_id, - title: snapshot.title, - price_cents: snapshot.price_cents, - kind: map_runtime_profile_recharge_product_kind_back(snapshot.kind), - points_amount: snapshot.points_amount, - bonus_points: snapshot.bonus_points, - duration_days: snapshot.duration_days, - badge_label: snapshot.badge_label, - description: snapshot.description, - tier: map_runtime_profile_membership_tier_back(snapshot.tier), - } -} - -pub(crate) fn map_runtime_profile_recharge_product_config_snapshot( - snapshot: RuntimeProfileRechargeProductConfigSnapshot, -) -> module_runtime::RuntimeProfileRechargeProductConfigSnapshot { - module_runtime::RuntimeProfileRechargeProductConfigSnapshot { - product_id: snapshot.product_id, - title: snapshot.title, - price_cents: snapshot.price_cents, - kind: map_runtime_profile_recharge_product_kind_back(snapshot.kind), - points_amount: snapshot.points_amount, - bonus_points: snapshot.bonus_points, - duration_days: snapshot.duration_days, - badge_label: snapshot.badge_label, - description: snapshot.description, - tier: map_runtime_profile_membership_tier_back(snapshot.tier), - enabled: snapshot.enabled, - sort_order: snapshot.sort_order, - created_by: snapshot.created_by, - created_at_micros: snapshot.created_at_micros, - updated_by: snapshot.updated_by, - updated_at_micros: snapshot.updated_at_micros, - } -} - -pub(crate) fn map_runtime_profile_membership_benefit_snapshot( - snapshot: RuntimeProfileMembershipBenefitSnapshot, -) -> module_runtime::RuntimeProfileMembershipBenefitSnapshot { - module_runtime::RuntimeProfileMembershipBenefitSnapshot { - benefit_name: snapshot.benefit_name, - normal_value: snapshot.normal_value, - month_value: snapshot.month_value, - season_value: snapshot.season_value, - year_value: snapshot.year_value, - } -} - -pub(crate) fn map_runtime_profile_membership_snapshot( - snapshot: RuntimeProfileMembershipSnapshot, -) -> module_runtime::RuntimeProfileMembershipSnapshot { - module_runtime::RuntimeProfileMembershipSnapshot { - user_id: snapshot.user_id, - status: map_runtime_profile_membership_status_back(snapshot.status), - tier: map_runtime_profile_membership_tier_back(snapshot.tier), - started_at_micros: snapshot.started_at_micros, - expires_at_micros: snapshot.expires_at_micros, - updated_at_micros: snapshot.updated_at_micros, - } -} - -pub(crate) fn map_runtime_profile_recharge_order_snapshot( - snapshot: RuntimeProfileRechargeOrderSnapshot, -) -> module_runtime::RuntimeProfileRechargeOrderSnapshot { - module_runtime::RuntimeProfileRechargeOrderSnapshot { - order_id: snapshot.order_id, - user_id: snapshot.user_id, - product_id: snapshot.product_id, - product_title: snapshot.product_title, - kind: map_runtime_profile_recharge_product_kind_back(snapshot.kind), - amount_cents: snapshot.amount_cents, - status: map_runtime_profile_recharge_order_status_back(snapshot.status), - payment_channel: snapshot.payment_channel, - paid_at_micros: snapshot.paid_at_micros, - provider_transaction_id: snapshot.provider_transaction_id, - created_at_micros: snapshot.created_at_micros, - points_delta: snapshot.points_delta, - membership_expires_at_micros: snapshot.membership_expires_at_micros, - } -} - -pub(crate) fn map_runtime_profile_feedback_submission_snapshot( - snapshot: RuntimeProfileFeedbackSubmissionSnapshot, -) -> module_runtime::RuntimeProfileFeedbackSubmissionSnapshot { - module_runtime::RuntimeProfileFeedbackSubmissionSnapshot { - feedback_id: snapshot.feedback_id, - user_id: snapshot.user_id, - description: snapshot.description, - contact_phone: snapshot.contact_phone, - evidence_json: snapshot.evidence_json, - status: map_runtime_profile_feedback_status_back(snapshot.status), - created_at_micros: snapshot.created_at_micros, - updated_at_micros: snapshot.updated_at_micros, - } -} - -pub(crate) fn map_runtime_referral_invite_center_snapshot( - snapshot: RuntimeReferralInviteCenterSnapshot, -) -> module_runtime::RuntimeReferralInviteCenterSnapshot { - module_runtime::RuntimeReferralInviteCenterSnapshot { - user_id: snapshot.user_id, - invite_code: snapshot.invite_code, - invite_link_path: snapshot.invite_link_path, - invited_count: snapshot.invited_count, - rewarded_invite_count: snapshot.rewarded_invite_count, - today_inviter_reward_count: snapshot.today_inviter_reward_count, - today_inviter_reward_remaining: snapshot.today_inviter_reward_remaining, - reward_points: snapshot.reward_points, - invited_users: snapshot - .invited_users - .into_iter() - .map(|user| module_runtime::RuntimeReferralInvitedUserSnapshot { - user_id: user.user_id, - display_name: user.display_name, - avatar_url: user.avatar_url, - bound_at_micros: user.bound_at_micros, - }) - .collect(), - has_redeemed_code: snapshot.has_redeemed_code, - bound_inviter_user_id: snapshot.bound_inviter_user_id, - bound_at_micros: snapshot.bound_at_micros, - updated_at_micros: snapshot.updated_at_micros, - } -} - -pub(crate) fn map_runtime_referral_redeem_snapshot( - snapshot: RuntimeReferralRedeemSnapshot, -) -> module_runtime::RuntimeReferralRedeemSnapshot { - module_runtime::RuntimeReferralRedeemSnapshot { - center: map_runtime_referral_invite_center_snapshot(snapshot.center), - invitee_reward_granted: snapshot.invitee_reward_granted, - inviter_reward_granted: snapshot.inviter_reward_granted, - invitee_balance_after: snapshot.invitee_balance_after, - inviter_balance_after: snapshot.inviter_balance_after, - } -} - -pub(crate) fn map_runtime_profile_reward_code_redeem_snapshot( - snapshot: RuntimeProfileRewardCodeRedeemSnapshot, -) -> module_runtime::RuntimeProfileRewardCodeRedeemSnapshot { - module_runtime::RuntimeProfileRewardCodeRedeemSnapshot { - wallet_balance: snapshot.wallet_balance, - amount_granted: snapshot.amount_granted, - ledger_entry: map_runtime_profile_wallet_ledger_entry_snapshot(snapshot.ledger_entry), - } -} - -pub(crate) fn map_runtime_profile_task_config_snapshot( - snapshot: RuntimeProfileTaskConfigSnapshot, -) -> module_runtime::RuntimeProfileTaskConfigSnapshot { - module_runtime::RuntimeProfileTaskConfigSnapshot { - task_id: snapshot.task_id, - title: snapshot.title, - description: snapshot.description, - event_key: snapshot.event_key, - cycle: map_runtime_profile_task_cycle_back(snapshot.cycle), - scope_kind: map_runtime_tracking_scope_kind_back(snapshot.scope_kind), - threshold: snapshot.threshold, - reward_points: snapshot.reward_points, - enabled: snapshot.enabled, - sort_order: snapshot.sort_order, - created_by: snapshot.created_by, - created_at_micros: snapshot.created_at_micros, - updated_by: snapshot.updated_by, - updated_at_micros: snapshot.updated_at_micros, - } -} - -pub(crate) fn map_runtime_profile_task_item_snapshot( - snapshot: RuntimeProfileTaskItemSnapshot, -) -> module_runtime::RuntimeProfileTaskItemSnapshot { - module_runtime::RuntimeProfileTaskItemSnapshot { - task_id: snapshot.task_id, - title: snapshot.title, - description: snapshot.description, - event_key: snapshot.event_key, - cycle: map_runtime_profile_task_cycle_back(snapshot.cycle), - threshold: snapshot.threshold, - progress_count: snapshot.progress_count, - reward_points: snapshot.reward_points, - status: map_runtime_profile_task_status_back(snapshot.status), - day_key: snapshot.day_key, - claimed_at_micros: snapshot.claimed_at_micros, - updated_at_micros: snapshot.updated_at_micros, - } -} - -pub(crate) fn map_runtime_profile_task_center_snapshot( - snapshot: RuntimeProfileTaskCenterSnapshot, -) -> module_runtime::RuntimeProfileTaskCenterSnapshot { - module_runtime::RuntimeProfileTaskCenterSnapshot { - user_id: snapshot.user_id, - day_key: snapshot.day_key, - wallet_balance: snapshot.wallet_balance, - tasks: snapshot - .tasks - .into_iter() - .map(map_runtime_profile_task_item_snapshot) - .collect(), - updated_at_micros: snapshot.updated_at_micros, - } -} - -pub(crate) fn map_runtime_profile_task_claim_snapshot( - snapshot: RuntimeProfileTaskClaimSnapshot, -) -> module_runtime::RuntimeProfileTaskClaimSnapshot { - module_runtime::RuntimeProfileTaskClaimSnapshot { - user_id: snapshot.user_id, - task_id: snapshot.task_id, - day_key: snapshot.day_key, - reward_points: snapshot.reward_points, - wallet_balance: snapshot.wallet_balance, - ledger_entry: map_runtime_profile_wallet_ledger_entry_snapshot(snapshot.ledger_entry), - center: map_runtime_profile_task_center_snapshot(snapshot.center), - } -} - -pub(crate) fn map_runtime_profile_redeem_code_snapshot( - snapshot: RuntimeProfileRedeemCodeSnapshot, -) -> module_runtime::RuntimeProfileRedeemCodeSnapshot { - module_runtime::RuntimeProfileRedeemCodeSnapshot { - code: snapshot.code, - mode: map_runtime_profile_redeem_code_mode_back(snapshot.mode), - reward_points: snapshot.reward_points, - max_uses: snapshot.max_uses, - global_used_count: snapshot.global_used_count, - enabled: snapshot.enabled, - allowed_user_ids: snapshot.allowed_user_ids, - created_by: snapshot.created_by, - created_at_micros: snapshot.created_at_micros, - updated_at_micros: snapshot.updated_at_micros, - } -} - -pub(crate) fn map_runtime_profile_invite_code_snapshot( - snapshot: RuntimeProfileInviteCodeSnapshot, -) -> module_runtime::RuntimeProfileInviteCodeSnapshot { - module_runtime::RuntimeProfileInviteCodeSnapshot { - user_id: snapshot.user_id, - invite_code: snapshot.invite_code, - metadata_json: snapshot.metadata_json, - starts_at_micros: snapshot.starts_at_micros, - expires_at_micros: snapshot.expires_at_micros, - created_at_micros: snapshot.created_at_micros, - updated_at_micros: snapshot.updated_at_micros, - } -} - -pub(crate) fn map_runtime_profile_played_world_snapshot( - snapshot: RuntimeProfilePlayedWorldSnapshot, -) -> module_runtime::RuntimeProfilePlayedWorldSnapshot { - module_runtime::RuntimeProfilePlayedWorldSnapshot { - played_world_id: snapshot.played_world_id, - user_id: snapshot.user_id, - world_key: snapshot.world_key, - owner_user_id: snapshot.owner_user_id, - profile_id: snapshot.profile_id, - world_type: snapshot.world_type, - world_title: snapshot.world_title, - world_subtitle: snapshot.world_subtitle, - first_played_at_micros: snapshot.first_played_at_micros, - last_played_at_micros: snapshot.last_played_at_micros, - last_observed_play_time_ms: snapshot.last_observed_play_time_ms, - } -} - -pub(crate) fn map_runtime_profile_play_stats_snapshot( - snapshot: RuntimeProfilePlayStatsSnapshot, -) -> module_runtime::RuntimeProfilePlayStatsSnapshot { - module_runtime::RuntimeProfilePlayStatsSnapshot { - user_id: snapshot.user_id, - total_play_time_ms: snapshot.total_play_time_ms, - played_works: snapshot - .played_works - .into_iter() - .map(map_runtime_profile_played_world_snapshot) - .collect(), - updated_at_micros: snapshot.updated_at_micros, - } -} - -pub(crate) fn map_runtime_snapshot_snapshot( - snapshot: RuntimeSnapshot, -) -> module_runtime::RuntimeSnapshot { - module_runtime::RuntimeSnapshot { - user_id: snapshot.user_id, - version: snapshot.version, - saved_at_micros: snapshot.saved_at_micros, - bottom_tab: snapshot.bottom_tab, - game_state_json: snapshot.game_state_json, - current_story_json: snapshot.current_story_json, - created_at_micros: snapshot.created_at_micros, - updated_at_micros: snapshot.updated_at_micros, - } -} - -pub(crate) fn map_runtime_profile_save_archive_snapshot( - snapshot: RuntimeProfileSaveArchiveSnapshot, -) -> module_runtime::RuntimeProfileSaveArchiveSnapshot { - module_runtime::RuntimeProfileSaveArchiveSnapshot { - archive_id: snapshot.archive_id, - user_id: snapshot.user_id, - world_key: snapshot.world_key, - owner_user_id: snapshot.owner_user_id, - profile_id: snapshot.profile_id, - world_type: snapshot.world_type, - world_name: snapshot.world_name, - subtitle: snapshot.subtitle, - summary_text: snapshot.summary_text, - cover_image_src: snapshot.cover_image_src, - saved_at_micros: snapshot.saved_at_micros, - bottom_tab: snapshot.bottom_tab, - game_state_json: snapshot.game_state_json, - current_story_json: snapshot.current_story_json, - created_at_micros: snapshot.created_at_micros, - updated_at_micros: snapshot.updated_at_micros, - } -} - -pub(crate) fn map_custom_world_library_entry_from_profile_snapshot( - snapshot: CustomWorldProfileSnapshot, -) -> Result { - let profile = serde_json::from_str::(&snapshot.profile_payload_json) - .map_err(|error| { - SpacetimeClientError::Runtime(format!( - "custom world profile payload JSON 非法: {error}" - )) - })?; - - Ok(CustomWorldLibraryEntryRecord { - owner_user_id: snapshot.owner_user_id, - profile_id: snapshot.profile_id, - public_work_code: snapshot.public_work_code, - author_public_user_code: snapshot.author_public_user_code, - profile, - visibility: map_custom_world_publication_status(snapshot.publication_status).to_string(), - published_at: snapshot.published_at_micros.map(format_timestamp_micros), - updated_at: format_timestamp_micros(snapshot.updated_at_micros), - author_display_name: snapshot.author_display_name, - world_name: snapshot.world_name, - subtitle: snapshot.subtitle, - summary_text: snapshot.summary_text, - cover_image_src: snapshot.cover_image_src, - theme_mode: format_custom_world_theme_mode(map_custom_world_theme_mode_back( - snapshot.theme_mode, - )) - .to_string(), - playable_npc_count: snapshot.playable_npc_count, - landmark_count: snapshot.landmark_count, - play_count: snapshot.play_count, - remix_count: snapshot.remix_count, - like_count: snapshot.like_count, - recent_play_count_7d: 0, - }) -} - -pub(crate) fn map_custom_world_gallery_entry_snapshot( - snapshot: CustomWorldGalleryEntrySnapshot, -) -> Result { - Ok(CustomWorldGalleryEntryRecord { - owner_user_id: snapshot.owner_user_id, - profile_id: snapshot.profile_id, - public_work_code: snapshot.public_work_code, - author_public_user_code: snapshot.author_public_user_code, - visibility: "published".to_string(), - published_at: Some(format_timestamp_micros(snapshot.published_at_micros)), - updated_at: format_timestamp_micros(snapshot.updated_at_micros), - author_display_name: snapshot.author_display_name, - world_name: snapshot.world_name, - subtitle: snapshot.subtitle, - summary_text: snapshot.summary_text, - cover_image_src: snapshot.cover_image_src, - theme_mode: format_custom_world_theme_mode(map_custom_world_theme_mode_back( - snapshot.theme_mode, - )) - .to_string(), - playable_npc_count: snapshot.playable_npc_count, - landmark_count: snapshot.landmark_count, - play_count: snapshot.play_count, - remix_count: snapshot.remix_count, - like_count: snapshot.like_count, - recent_play_count_7d: snapshot.recent_play_count_7_d, - }) -} - -pub(crate) fn map_custom_world_gallery_entry_row( - row: CustomWorldGalleryEntry, - recent_play_count_7d: u32, -) -> CustomWorldGalleryEntryRecord { - CustomWorldGalleryEntryRecord { - owner_user_id: row.owner_user_id, - profile_id: row.profile_id, - public_work_code: row.public_work_code, - author_public_user_code: row.author_public_user_code, - visibility: "published".to_string(), - published_at: Some(format_timestamp_micros( - row.published_at.to_micros_since_unix_epoch(), - )), - updated_at: format_timestamp_micros(row.updated_at.to_micros_since_unix_epoch()), - author_display_name: row.author_display_name, - world_name: row.world_name, - subtitle: row.subtitle, - summary_text: row.summary_text, - cover_image_src: row.cover_image_src, - theme_mode: format_custom_world_theme_mode(map_custom_world_theme_mode_back( - row.theme_mode, - )) - .to_string(), - playable_npc_count: row.playable_npc_count, - landmark_count: row.landmark_count, - play_count: row.play_count, - remix_count: row.remix_count, - like_count: row.like_count, - recent_play_count_7d, - } -} - -pub(crate) fn map_custom_world_published_profile_compile_snapshot( - snapshot: CustomWorldPublishedProfileCompileSnapshot, -) -> Result { - let compiled_profile = - serde_json::from_str::(&snapshot.compiled_profile_payload_json) - .map_err(|error| { - SpacetimeClientError::Runtime(format!( - "published profile compile JSON 非法: {error}" - )) - })?; - - Ok(CustomWorldPublishedProfileCompileRecord { - profile_id: snapshot.profile_id, - owner_user_id: snapshot.owner_user_id, - world_name: snapshot.world_name, - subtitle: snapshot.subtitle, - summary_text: snapshot.summary_text, - theme_mode: format_custom_world_theme_mode(map_custom_world_theme_mode_back( - snapshot.theme_mode, - )) - .to_string(), - cover_image_src: snapshot.cover_image_src, - playable_npc_count: snapshot.playable_npc_count, - landmark_count: snapshot.landmark_count, - author_display_name: snapshot.author_display_name, - compiled_profile: compiled_profile, - updated_at: format_timestamp_micros(snapshot.updated_at_micros), - }) -} - -pub(crate) fn map_custom_world_work_summary_snapshot( - snapshot: CustomWorldWorkSummarySnapshot, -) -> Result { - Ok(CustomWorldWorkSummaryRecord { - work_id: snapshot.work_id, - source_type: snapshot.source_type, - status: snapshot.status, - title: snapshot.title, - subtitle: snapshot.subtitle, - summary: snapshot.summary, - cover_image_src: snapshot.cover_image_src, - cover_render_mode: snapshot.cover_render_mode, - cover_character_image_srcs: parse_json_string_array( - &snapshot.cover_character_image_srcs_json, - "custom world work cover_character_image_srcs_json", - )?, - updated_at: format_timestamp_micros(snapshot.updated_at_micros), - published_at: snapshot.published_at_micros.map(format_timestamp_micros), - stage: snapshot.stage.map(map_rpg_agent_stage), - stage_label: snapshot.stage_label, - playable_npc_count: snapshot.playable_npc_count, - landmark_count: snapshot.landmark_count, - role_visual_ready_count: snapshot.role_visual_ready_count, - role_animation_ready_count: snapshot.role_animation_ready_count, - role_asset_summary_label: snapshot.role_asset_summary_label, - session_id: snapshot.session_id, - profile_id: snapshot.profile_id, - can_resume: snapshot.can_resume, - can_enter_world: snapshot.can_enter_world, - blocker_count: snapshot.blocker_count, - publish_ready: snapshot.publish_ready, - }) -} - -pub(crate) fn map_custom_world_agent_session_snapshot( - snapshot: CustomWorldAgentSessionSnapshot, -) -> Result { - let anchor_content = parse_json_value( - &snapshot.anchor_content_json, - "custom world agent anchor_content_json", - )?; - let creator_intent = parse_optional_json_value( - snapshot.creator_intent_json.as_deref(), - serde_json::json!({}), - "custom world agent creator_intent_json", - )?; - let creator_intent_readiness = parse_json_value( - &snapshot.creator_intent_readiness_json, - "custom world agent creator_intent_readiness_json", - )?; - let anchor_pack = parse_optional_json_value( - snapshot.anchor_pack_json.as_deref(), - serde_json::json!({}), - "custom world agent anchor_pack_json", - )?; - let lock_state = parse_optional_json_value( - snapshot.lock_state_json.as_deref(), - serde_json::json!({}), - "custom world agent lock_state_json", - )?; - let draft_profile = parse_optional_json_value( - snapshot.draft_profile_json.as_deref(), - serde_json::json!({}), - "custom world agent draft_profile_json", - )?; - let pending_clarifications = parse_json_array( - &snapshot.pending_clarifications_json, - "custom world agent pending_clarifications_json", - )?; - let suggested_actions = parse_json_array( - &snapshot.suggested_actions_json, - "custom world agent suggested_actions_json", - )?; - let recommended_replies = parse_json_string_array( - &snapshot.recommended_replies_json, - "custom world agent recommended_replies_json", - )?; - let quality_findings = parse_json_array( - &snapshot.quality_findings_json, - "custom world agent quality_findings_json", - )?; - let asset_coverage = parse_json_value( - &snapshot.asset_coverage_json, - "custom world agent asset_coverage_json", - )?; - let checkpoints_json = parse_json_array( - &snapshot.checkpoints_json, - "custom world agent checkpoints_json", - )?; - let checkpoints = checkpoints_json - .into_iter() - .map(map_custom_world_checkpoint_record) - .collect::, _>>()?; - let supported_actions = parse_supported_actions_json(&snapshot.supported_actions_json)?; - let publish_gate = snapshot - .publish_gate_json - .as_deref() - .map(parse_custom_world_publish_gate_record) - .transpose()?; - - Ok(CustomWorldAgentSessionRecord { - session_id: snapshot.session_id, - seed_text: snapshot.seed_text, - current_turn: snapshot.current_turn, - anchor_content, - progress_percent: snapshot.progress_percent, - last_assistant_reply: snapshot.last_assistant_reply, - stage: map_rpg_agent_stage(snapshot.stage), - focus_card_id: snapshot.focus_card_id, - creator_intent, - creator_intent_readiness, - anchor_pack, - lock_state, - draft_profile, - messages: snapshot - .messages - .into_iter() - .map(map_custom_world_agent_message_snapshot) - .collect(), - draft_cards: snapshot - .draft_cards - .into_iter() - .map(map_custom_world_draft_card_snapshot) - .collect::, _>>()?, - pending_clarifications, - suggested_actions, - recommended_replies, - quality_findings, - asset_coverage, - checkpoints, - supported_actions, - publish_gate, - result_preview: snapshot - .result_preview_json - .as_deref() - .map(|value| parse_json_value(value, "custom world agent result_preview_json")) - .transpose()?, - updated_at: format_timestamp_micros(snapshot.updated_at_micros), - }) -} - -pub(crate) fn map_custom_world_agent_message_snapshot( - snapshot: CustomWorldAgentMessageSnapshot, -) -> CustomWorldAgentMessageRecord { - CustomWorldAgentMessageRecord { - message_id: snapshot.message_id, - role: format_rpg_agent_message_role(snapshot.role).to_string(), - kind: format_rpg_agent_message_kind(snapshot.kind).to_string(), - text: snapshot.text, - created_at: format_timestamp_micros(snapshot.created_at_micros), - related_operation_id: snapshot.related_operation_id, - } -} - -pub(crate) fn map_custom_world_agent_operation_snapshot( - snapshot: CustomWorldAgentOperationSnapshot, -) -> CustomWorldAgentOperationRecord { - CustomWorldAgentOperationRecord { - operation_id: snapshot.operation_id, - operation_type: format_rpg_agent_operation_type(snapshot.operation_type).to_string(), - status: format_rpg_agent_operation_status(snapshot.status).to_string(), - phase_label: snapshot.phase_label, - phase_detail: snapshot.phase_detail, - progress: snapshot.progress, - error_message: snapshot.error_message, - started_at_micros: snapshot.created_at_micros, - updated_at_micros: snapshot.updated_at_micros, - } -} - -pub(crate) fn map_custom_world_draft_card_snapshot( - snapshot: CustomWorldDraftCardSnapshot, -) -> Result { - Ok(CustomWorldDraftCardRecord { - card_id: snapshot.card_id, - kind: format_rpg_agent_draft_card_kind(snapshot.kind).to_string(), - title: snapshot.title, - subtitle: snapshot.subtitle, - summary: snapshot.summary, - status: format_rpg_agent_draft_card_status(snapshot.status).to_string(), - linked_ids: parse_json_string_array( - &snapshot.linked_ids_json, - "custom world draft_card linked_ids_json", - )?, - warning_count: snapshot.warning_count, - asset_status: snapshot - .asset_status - .map(format_custom_world_role_asset_status_back), - asset_status_label: snapshot.asset_status_label, - detail_payload: snapshot - .detail_payload_json - .as_deref() - .map(|value| parse_json_value(value, "custom world draft_card detail_payload_json")) - .transpose()?, - }) -} - -pub(crate) fn map_custom_world_draft_card_detail_snapshot( - snapshot: CustomWorldDraftCardDetailSnapshot, -) -> Result { - Ok(CustomWorldDraftCardDetailRecord { - card_id: snapshot.card_id, - kind: format_rpg_agent_draft_card_kind(snapshot.kind).to_string(), - title: snapshot.title, - sections: snapshot - .sections - .into_iter() - .map(map_custom_world_draft_card_detail_section_snapshot) - .collect(), - linked_ids: parse_json_string_array( - &snapshot.linked_ids_json, - "custom world card detail linked_ids_json", - )?, - locked: snapshot.locked, - editable: snapshot.editable, - editable_section_ids: parse_json_string_array( - &snapshot.editable_section_ids_json, - "custom world card detail editable_section_ids_json", - )?, - warning_messages: parse_json_string_array( - &snapshot.warning_messages_json, - "custom world card detail warning_messages_json", - )?, - asset_status: snapshot - .asset_status - .map(format_custom_world_role_asset_status_back), - asset_status_label: snapshot.asset_status_label, - }) -} - -pub(crate) fn map_custom_world_draft_card_detail_section_snapshot( - snapshot: CustomWorldDraftCardDetailSectionSnapshot, -) -> CustomWorldDraftCardDetailSectionRecord { - CustomWorldDraftCardDetailSectionRecord { - section_id: snapshot.section_id, - label: snapshot.label, - value: snapshot.value, - } -} - -pub(crate) fn map_big_fish_session_snapshot( - snapshot: BigFishSessionSnapshot, -) -> BigFishSessionRecord { - BigFishSessionRecord { - session_id: snapshot.session_id, - current_turn: snapshot.current_turn, - progress_percent: snapshot.progress_percent, - stage: format_big_fish_creation_stage(snapshot.stage).to_string(), - anchor_pack: map_big_fish_anchor_pack(snapshot.anchor_pack), - draft: snapshot.draft.map(map_big_fish_game_draft), - asset_slots: snapshot - .asset_slots - .into_iter() - .map(map_big_fish_asset_slot_snapshot) - .collect(), - asset_coverage: map_big_fish_asset_coverage(snapshot.asset_coverage), - messages: snapshot - .messages - .into_iter() - .map(map_big_fish_agent_message_snapshot) - .collect(), - last_assistant_reply: snapshot.last_assistant_reply, - publish_ready: snapshot.publish_ready, - updated_at: format_timestamp_micros(snapshot.updated_at_micros), - } -} - -pub(crate) fn map_puzzle_agent_session_snapshot( - snapshot: PuzzleAgentSessionSnapshot, -) -> PuzzleAgentSessionRecord { - PuzzleAgentSessionRecord { - session_id: snapshot.session_id, - seed_text: snapshot.seed_text, - current_turn: snapshot.current_turn, - progress_percent: snapshot.progress_percent, - stage: format_puzzle_agent_stage(snapshot.stage).to_string(), - anchor_pack: map_puzzle_anchor_pack(snapshot.anchor_pack), - draft: snapshot.draft.map(map_puzzle_result_draft), - messages: snapshot - .messages - .into_iter() - .map(map_puzzle_agent_message_snapshot) - .collect(), - last_assistant_reply: snapshot.last_assistant_reply, - published_profile_id: snapshot.published_profile_id, - suggested_actions: snapshot - .suggested_actions - .into_iter() - .map(map_puzzle_suggested_action) - .collect(), - result_preview: snapshot.result_preview.map(map_puzzle_result_preview), - updated_at: format_timestamp_micros(snapshot.updated_at_micros), - } -} - -pub(crate) fn map_puzzle_anchor_pack(snapshot: PuzzleAnchorPack) -> PuzzleAnchorPackRecord { - PuzzleAnchorPackRecord { - theme_promise: map_puzzle_anchor_item(snapshot.theme_promise), - visual_subject: map_puzzle_anchor_item(snapshot.visual_subject), - visual_mood: map_puzzle_anchor_item(snapshot.visual_mood), - composition_hooks: map_puzzle_anchor_item(snapshot.composition_hooks), - tags_and_forbidden: map_puzzle_anchor_item(snapshot.tags_and_forbidden), - } -} - -pub(crate) fn map_puzzle_anchor_item(snapshot: PuzzleAnchorItem) -> PuzzleAnchorItemRecord { - PuzzleAnchorItemRecord { - key: snapshot.key, - label: snapshot.label, - value: snapshot.value, - status: format_puzzle_anchor_status(snapshot.status).to_string(), - } -} - -pub(crate) fn map_puzzle_result_draft(snapshot: PuzzleResultDraft) -> PuzzleResultDraftRecord { - PuzzleResultDraftRecord { - work_title: snapshot.work_title, - work_description: snapshot.work_description, - level_name: snapshot.level_name, - summary: snapshot.summary, - theme_tags: snapshot.theme_tags, - forbidden_directives: snapshot.forbidden_directives, - creator_intent: snapshot.creator_intent.map(map_puzzle_creator_intent), - anchor_pack: map_puzzle_anchor_pack(snapshot.anchor_pack), - candidates: snapshot - .candidates - .into_iter() - .map(map_puzzle_generated_image_candidate) - .collect(), - selected_candidate_id: snapshot.selected_candidate_id, - cover_image_src: snapshot.cover_image_src, - cover_asset_id: snapshot.cover_asset_id, - generation_status: snapshot.generation_status, - levels: snapshot - .levels - .into_iter() - .map(map_puzzle_draft_level) - .collect(), - form_draft: snapshot.form_draft.map(map_puzzle_form_draft), - } -} - -pub(crate) fn map_puzzle_form_draft(snapshot: PuzzleFormDraft) -> PuzzleFormDraftRecord { - PuzzleFormDraftRecord { - work_title: snapshot.work_title, - work_description: snapshot.work_description, - picture_description: snapshot.picture_description, - } -} - -pub(crate) fn map_puzzle_draft_level(snapshot: PuzzleDraftLevel) -> PuzzleDraftLevelRecord { - PuzzleDraftLevelRecord { - level_id: snapshot.level_id, - level_name: snapshot.level_name, - picture_description: snapshot.picture_description, - picture_reference: snapshot.picture_reference, - ui_background_prompt: snapshot.ui_background_prompt, - ui_background_image_src: snapshot.ui_background_image_src, - ui_background_image_object_key: snapshot.ui_background_image_object_key, - background_music: snapshot.background_music.map(map_puzzle_audio_asset), - candidates: snapshot - .candidates - .into_iter() - .map(map_puzzle_generated_image_candidate) - .collect(), - selected_candidate_id: snapshot.selected_candidate_id, - cover_image_src: snapshot.cover_image_src, - cover_asset_id: snapshot.cover_asset_id, - generation_status: snapshot.generation_status, - } -} - -pub(crate) fn map_puzzle_audio_asset(asset: PuzzleAudioAsset) -> PuzzleAudioAssetRecord { - PuzzleAudioAssetRecord { - task_id: asset.task_id, - provider: asset.provider, - asset_object_id: asset.asset_object_id, - asset_kind: asset.asset_kind, - audio_src: asset.audio_src, - prompt: asset.prompt, - title: asset.title, - updated_at: asset.updated_at, - } -} - -pub(crate) fn map_puzzle_creator_intent( - snapshot: PuzzleCreatorIntent, -) -> PuzzleCreatorIntentRecord { - PuzzleCreatorIntentRecord { - source_mode: snapshot.source_mode, - raw_messages_summary: snapshot.raw_messages_summary, - theme_promise: snapshot.theme_promise, - visual_subject: snapshot.visual_subject, - visual_mood: snapshot.visual_mood, - composition_hooks: snapshot.composition_hooks, - theme_tags: snapshot.theme_tags, - forbidden_directives: snapshot.forbidden_directives, - } -} - -pub(crate) fn map_puzzle_generated_image_candidate( - snapshot: PuzzleGeneratedImageCandidate, -) -> PuzzleGeneratedImageCandidateRecord { - PuzzleGeneratedImageCandidateRecord { - candidate_id: snapshot.candidate_id, - image_src: snapshot.image_src, - asset_id: snapshot.asset_id, - prompt: snapshot.prompt, - actual_prompt: snapshot.actual_prompt, - source_type: snapshot.source_type, - selected: snapshot.selected, - } -} - -pub(crate) fn map_puzzle_agent_message_snapshot( - snapshot: PuzzleAgentMessageSnapshot, -) -> PuzzleAgentMessageRecord { - PuzzleAgentMessageRecord { - message_id: snapshot.message_id, - role: format_puzzle_agent_message_role(snapshot.role).to_string(), - kind: format_puzzle_agent_message_kind(snapshot.kind).to_string(), - text: snapshot.text, - created_at: format_timestamp_micros(snapshot.created_at_micros), - } -} - -fn map_match3d_agent_session_snapshot( - snapshot: Match3DAgentSessionSnapshot, -) -> Match3DAgentSessionRecord { - let config = map_match3d_creator_config(snapshot.config); - Match3DAgentSessionRecord { - session_id: snapshot.session_id, - current_turn: snapshot.current_turn, - progress_percent: snapshot.progress_percent, - stage: normalize_match3d_stage(&snapshot.stage).to_string(), - anchor_pack: build_match3d_anchor_pack(&config), - draft: snapshot - .draft - .map(|draft| map_match3d_result_draft(draft, config.reference_image_src.clone())), - config: Some(config), - messages: snapshot - .messages - .into_iter() - .map(map_match3d_agent_message_snapshot) - .collect(), - last_assistant_reply: empty_string_to_none(snapshot.last_assistant_reply), - published_profile_id: snapshot.published_profile_id, - updated_at: format_timestamp_micros(snapshot.updated_at_micros), - } -} - -fn map_match3d_creator_config( - snapshot: Match3DCreatorConfigSnapshot, -) -> Match3DCreatorConfigRecord { - Match3DCreatorConfigRecord { - theme_text: snapshot.theme_text, - reference_image_src: snapshot.reference_image_src, - clear_count: snapshot.clear_count, - difficulty: snapshot.difficulty, - asset_style_id: snapshot.asset_style_id, - asset_style_label: snapshot.asset_style_label, - asset_style_prompt: snapshot.asset_style_prompt, - generate_click_sound: snapshot.generate_click_sound, - } -} - -fn map_match3d_result_draft( - snapshot: Match3DDraftSnapshot, - reference_image_src: Option, -) -> Match3DResultDraftRecord { - Match3DResultDraftRecord { - profile_id: snapshot.profile_id, - game_name: snapshot.game_name, - theme_text: snapshot.theme_text, - summary_text: snapshot.summary_text, - tags: snapshot.tags, - cover_image_src: None, - reference_image_src, - clear_count: snapshot.clear_count, - difficulty: snapshot.difficulty, - generated_item_assets_json: snapshot.generated_item_assets_json, - total_item_count: snapshot.clear_count.saturating_mul(3), - publish_ready: false, - blockers: Vec::new(), - } -} - -fn map_match3d_agent_message_snapshot( - snapshot: Match3DAgentMessageSnapshot, -) -> Match3DAgentMessageRecord { - Match3DAgentMessageRecord { - message_id: snapshot.message_id, - role: snapshot.role, - kind: normalize_match3d_message_kind(&snapshot.kind).to_string(), - text: snapshot.text, - created_at: format_timestamp_micros(snapshot.created_at_micros), - } -} - -fn map_match3d_work_snapshot(snapshot: Match3DWorkSnapshot) -> Match3DWorkProfileRecord { - let config = map_match3d_creator_config(snapshot.config); - Match3DWorkProfileRecord { - work_id: snapshot.profile_id.clone(), - profile_id: snapshot.profile_id, - owner_user_id: snapshot.owner_user_id, - source_session_id: empty_string_to_none(snapshot.source_session_id), - author_display_name: snapshot.author_display_name, - game_name: snapshot.game_name, - theme_text: snapshot.theme_text, - summary: snapshot.summary_text, - tags: snapshot.tags, - cover_image_src: empty_string_to_none(snapshot.cover_image_src), - cover_asset_id: empty_string_to_none(snapshot.cover_asset_id), - reference_image_src: config.reference_image_src, - clear_count: snapshot.clear_count, - difficulty: snapshot.difficulty, - publication_status: normalize_match3d_publication_status(&snapshot.publication_status) - .to_string(), - play_count: snapshot.play_count, - updated_at: format_timestamp_micros(snapshot.updated_at_micros), - published_at: snapshot.published_at_micros.map(format_timestamp_micros), - publish_ready: snapshot.publish_ready, - generated_item_assets_json: snapshot.generated_item_assets_json, - } -} - -pub(crate) fn map_match3d_gallery_view_row(row: Match3DGalleryViewRow) -> Match3DWorkProfileRecord { - Match3DWorkProfileRecord { - work_id: row.profile_id.clone(), - profile_id: row.profile_id, - owner_user_id: row.owner_user_id, - source_session_id: empty_string_to_none(row.source_session_id), - author_display_name: row.author_display_name, - game_name: row.game_name, - theme_text: row.theme_text, - summary: row.summary_text, - tags: row.tags, - cover_image_src: empty_string_to_none(row.cover_image_src), - cover_asset_id: empty_string_to_none(row.cover_asset_id), - reference_image_src: row.reference_image_src, - clear_count: row.clear_count, - difficulty: row.difficulty, - publication_status: normalize_match3d_publication_status(&row.publication_status) - .to_string(), - play_count: row.play_count, - updated_at: format_timestamp_micros(row.updated_at_micros), - published_at: row.published_at_micros.map(format_timestamp_micros), - publish_ready: row.publish_ready, - generated_item_assets_json: row.generated_item_assets_json, - } -} - -fn map_match3d_run_snapshot(snapshot: Match3DRunSnapshot) -> Match3DRunRecord { - let tray_slots = snapshot - .tray_slots - .into_iter() - .map(map_match3d_tray_slot_snapshot) - .collect::>(); - let items = snapshot - .items - .into_iter() - .map(|item| { - let tray_slot_index = tray_slots - .iter() - .find(|slot| { - slot.item_instance_id.as_deref() == Some(item.item_instance_id.as_str()) - }) - .map(|slot| slot.slot_index); - map_match3d_item_snapshot(item, tray_slot_index) - }) - .collect(); - - Match3DRunRecord { - run_id: snapshot.run_id, - profile_id: snapshot.profile_id, - owner_user_id: String::new(), - status: snapshot.status, - snapshot_version: u64::from(snapshot.snapshot_version), - started_at_ms: i64_to_u64_ms(snapshot.started_at_ms), - duration_limit_ms: i64_to_u64_ms(snapshot.duration_limit_ms), - server_now_ms: Some(i64_to_u64_ms(snapshot.server_now_ms)), - remaining_ms: i64_to_u64_ms(snapshot.remaining_ms), - clear_count: snapshot.clear_count, - total_item_count: snapshot.total_item_count, - cleared_item_count: snapshot.cleared_item_count, - items, - tray_slots, - failure_reason: snapshot.failure_reason, - last_confirmed_action_id: None, - } -} - -fn map_match3d_item_snapshot( - snapshot: Match3DItemSnapshot, - tray_slot_index: Option, -) -> Match3DItemSnapshotRecord { - Match3DItemSnapshotRecord { - item_instance_id: snapshot.item_instance_id, - item_type_id: snapshot.item_type_id, - visual_key: snapshot.visual_key, - x: snapshot.x, - y: snapshot.y, - radius: snapshot.radius, - layer: snapshot.layer, - state: snapshot.state, - clickable: snapshot.clickable, - tray_slot_index, - } -} - -fn map_match3d_tray_slot_snapshot(snapshot: Match3DTraySlotSnapshot) -> Match3DTraySlotRecord { - Match3DTraySlotRecord { - slot_index: snapshot.slot_index, - item_instance_id: snapshot.item_instance_id, - item_type_id: snapshot.item_type_id, - visual_key: snapshot.visual_key, - } -} - -fn build_match3d_anchor_pack(config: &Match3DCreatorConfigRecord) -> Match3DAnchorPackRecord { - let clear_count = config.clear_count.to_string(); - let difficulty = config.difficulty.to_string(); - Match3DAnchorPackRecord { - theme: build_match3d_anchor_item("theme", "题材主题", config.theme_text.as_str()), - clear_count: build_match3d_anchor_item("clearCount", "需要消除次数", clear_count.as_str()), - difficulty: build_match3d_anchor_item("difficulty", "难度", difficulty.as_str()), - } -} - -fn build_match3d_anchor_item(key: &str, label: &str, value: &str) -> Match3DAnchorItemRecord { - Match3DAnchorItemRecord { - key: key.to_string(), - label: label.to_string(), - value: value.to_string(), - status: if value.trim().is_empty() { - "missing" - } else { - "confirmed" - } - .to_string(), - } -} - -fn map_square_hole_agent_session_snapshot( - snapshot: SquareHoleAgentSessionSnapshot, -) -> SquareHoleAgentSessionRecord { - let config = map_square_hole_creator_config(snapshot.config); - SquareHoleAgentSessionRecord { - session_id: snapshot.session_id, - current_turn: snapshot.current_turn, - progress_percent: snapshot.progress_percent, - stage: normalize_square_hole_stage(&snapshot.stage).to_string(), - anchor_pack: build_square_hole_anchor_pack(&config), - config, - draft: snapshot.draft.map(map_square_hole_result_draft), - messages: snapshot - .messages - .into_iter() - .map(map_square_hole_agent_message_snapshot) - .collect(), - last_assistant_reply: empty_string_to_none(snapshot.last_assistant_reply), - published_profile_id: snapshot.published_profile_id, - updated_at: format_timestamp_micros(snapshot.updated_at_micros), - } -} - -fn map_square_hole_creator_config( - snapshot: SquareHoleCreatorConfigSnapshot, -) -> SquareHoleCreatorConfigRecord { - SquareHoleCreatorConfigRecord { - theme_text: snapshot.theme_text, - twist_rule: snapshot.twist_rule, - shape_count: snapshot.shape_count, - difficulty: snapshot.difficulty, - shape_options: snapshot - .shape_options - .into_iter() - .map(map_square_hole_shape_option) - .collect(), - hole_options: snapshot - .hole_options - .into_iter() - .map(map_square_hole_hole_option) - .collect(), - background_prompt: snapshot.background_prompt, - cover_image_src: empty_string_to_none(snapshot.cover_image_src), - background_image_src: empty_string_to_none(snapshot.background_image_src), - } -} - -fn map_square_hole_result_draft(snapshot: SquareHoleDraftSnapshot) -> SquareHoleResultDraftRecord { - SquareHoleResultDraftRecord { - profile_id: snapshot.profile_id, - game_name: snapshot.game_name, - theme_text: snapshot.theme_text, - twist_rule: snapshot.twist_rule, - summary: snapshot.summary_text, - tags: snapshot.tags, - cover_image_src: empty_string_to_none(snapshot.cover_image_src), - background_prompt: snapshot.background_prompt, - background_image_src: empty_string_to_none(snapshot.background_image_src), - shape_options: snapshot - .shape_options - .into_iter() - .map(map_square_hole_shape_option) - .collect(), - hole_options: snapshot - .hole_options - .into_iter() - .map(map_square_hole_hole_option) - .collect(), - shape_count: snapshot.shape_count, - difficulty: snapshot.difficulty, - publish_ready: false, - blockers: Vec::new(), - } -} - -fn map_square_hole_agent_message_snapshot( - snapshot: SquareHoleAgentMessageSnapshot, -) -> SquareHoleAgentMessageRecord { - SquareHoleAgentMessageRecord { - id: snapshot.message_id, - role: snapshot.role, - kind: normalize_square_hole_message_kind(&snapshot.kind).to_string(), - text: snapshot.text, - created_at: format_timestamp_micros(snapshot.created_at_micros), - } -} - -fn map_square_hole_work_snapshot(snapshot: SquareHoleWorkSnapshot) -> SquareHoleWorkProfileRecord { - SquareHoleWorkProfileRecord { - work_id: snapshot.work_id, - profile_id: snapshot.profile_id, - owner_user_id: snapshot.owner_user_id, - source_session_id: empty_string_to_none(snapshot.source_session_id), - author_display_name: snapshot.author_display_name, - game_name: snapshot.game_name, - theme_text: snapshot.theme_text, - twist_rule: snapshot.twist_rule, - summary: snapshot.summary_text, - tags: snapshot.tags, - cover_image_src: empty_string_to_none(snapshot.cover_image_src), - background_prompt: snapshot.background_prompt, - background_image_src: empty_string_to_none(snapshot.background_image_src), - shape_options: snapshot - .shape_options - .into_iter() - .map(map_square_hole_shape_option) - .collect(), - hole_options: snapshot - .hole_options - .into_iter() - .map(map_square_hole_hole_option) - .collect(), - shape_count: snapshot.shape_count, - difficulty: snapshot.difficulty, - publication_status: normalize_square_hole_publication_status(&snapshot.publication_status) - .to_string(), - play_count: snapshot.play_count, - updated_at: format_timestamp_micros(snapshot.updated_at_micros), - published_at: snapshot.published_at_micros.map(format_timestamp_micros), - publish_ready: snapshot.publish_ready, - } -} - -pub(crate) fn map_square_hole_gallery_view_row( - row: SquareHoleGalleryViewRow, -) -> SquareHoleWorkProfileRecord { - SquareHoleWorkProfileRecord { - work_id: row.work_id, - profile_id: row.profile_id, - owner_user_id: row.owner_user_id, - source_session_id: empty_string_to_none(row.source_session_id), - author_display_name: row.author_display_name, - game_name: row.game_name, - theme_text: row.theme_text, - twist_rule: row.twist_rule, - summary: row.summary_text, - tags: row.tags, - cover_image_src: empty_string_to_none(row.cover_image_src), - background_prompt: row.background_prompt, - background_image_src: empty_string_to_none(row.background_image_src), - shape_options: row - .shape_options - .into_iter() - .map(map_square_hole_shape_option) - .collect(), - hole_options: row - .hole_options - .into_iter() - .map(map_square_hole_hole_option) - .collect(), - shape_count: row.shape_count, - difficulty: row.difficulty, - publication_status: normalize_square_hole_publication_status(&row.publication_status) - .to_string(), - play_count: row.play_count, - updated_at: format_timestamp_micros(row.updated_at_micros), - published_at: row.published_at_micros.map(format_timestamp_micros), - publish_ready: row.publish_ready, - } -} - -fn map_square_hole_run_snapshot(snapshot: SquareHoleRunSnapshot) -> SquareHoleRunRecord { - SquareHoleRunRecord { - run_id: snapshot.run_id, - profile_id: snapshot.profile_id, - owner_user_id: snapshot.owner_user_id, - status: normalize_square_hole_run_status(&snapshot.status).to_string(), - snapshot_version: snapshot.snapshot_version, - started_at_ms: i64_to_u64_ms(snapshot.started_at_ms), - duration_limit_ms: i64_to_u64_ms(snapshot.duration_limit_ms), - server_now_ms: Some(i64_to_u64_ms(snapshot.server_now_ms)), - remaining_ms: i64_to_u64_ms(snapshot.remaining_ms), - total_shape_count: snapshot.total_shape_count, - completed_shape_count: snapshot.completed_shape_count, - combo: snapshot.combo, - best_combo: snapshot.best_combo, - score: snapshot.score, - rule_label: snapshot.rule_label, - background_image_src: empty_string_to_none(snapshot.background_image_src), - current_shape: snapshot.current_shape.map(map_square_hole_shape_snapshot), - holes: snapshot - .holes - .into_iter() - .map(map_square_hole_hole_snapshot) - .collect(), - last_feedback: snapshot - .last_feedback - .map(map_square_hole_feedback_snapshot), - last_confirmed_action_id: None, - } -} - -fn map_square_hole_shape_snapshot( - snapshot: SquareHoleShapeSnapshot, -) -> SquareHoleShapeSnapshotRecord { - SquareHoleShapeSnapshotRecord { - shape_id: snapshot.shape_id, - shape_kind: snapshot.shape_kind, - label: snapshot.label, - target_hole_id: snapshot.target_hole_id, - color: snapshot.color, - image_src: empty_string_to_none(snapshot.image_src), - } -} - -fn map_square_hole_hole_snapshot(snapshot: SquareHoleHoleSnapshot) -> SquareHoleHoleSnapshotRecord { - SquareHoleHoleSnapshotRecord { - hole_id: snapshot.hole_id, - hole_kind: snapshot.hole_kind, - label: snapshot.label, - x: snapshot.x, - y: snapshot.y, - image_src: empty_string_to_none(snapshot.image_src), - } -} - -fn map_square_hole_shape_option( - snapshot: SquareHoleShapeOptionSnapshot, -) -> SquareHoleShapeOptionRecord { - SquareHoleShapeOptionRecord { - option_id: snapshot.option_id, - shape_kind: snapshot.shape_kind, - label: snapshot.label, - target_hole_id: snapshot.target_hole_id, - image_prompt: snapshot.image_prompt, - image_src: empty_string_to_none(snapshot.image_src), - } -} - -fn map_square_hole_hole_option( - snapshot: SquareHoleHoleOptionSnapshot, -) -> SquareHoleHoleOptionRecord { - SquareHoleHoleOptionRecord { - hole_id: snapshot.hole_id, - hole_kind: snapshot.hole_kind, - label: snapshot.label, - image_prompt: snapshot.image_prompt, - image_src: empty_string_to_none(snapshot.image_src), - } -} - -fn map_square_hole_feedback_snapshot( - snapshot: SquareHoleDropFeedbackSnapshot, -) -> SquareHoleDropFeedbackRecord { - SquareHoleDropFeedbackRecord { - accepted: snapshot.accepted, - reject_reason: snapshot - .reject_reason - .map(|value| normalize_square_hole_reject_reason(&value).to_string()), - message: snapshot.message, - } -} - -fn build_square_hole_anchor_pack( - config: &SquareHoleCreatorConfigRecord, -) -> SquareHoleAnchorPackRecord { - let shape_count = config.shape_count.to_string(); - let difficulty = config.difficulty.to_string(); - SquareHoleAnchorPackRecord { - theme: build_square_hole_anchor_item("theme", "题材主题", config.theme_text.as_str()), - twist_rule: build_square_hole_anchor_item( - "twistRule", - "反差规则", - config.twist_rule.as_str(), - ), - shape_count: build_square_hole_anchor_item("shapeCount", "形状数量", shape_count.as_str()), - difficulty: build_square_hole_anchor_item("difficulty", "难度", difficulty.as_str()), - } -} - -fn build_square_hole_anchor_item( - key: &str, - label: &str, - value: &str, -) -> SquareHoleAnchorItemRecord { - SquareHoleAnchorItemRecord { - key: key.to_string(), - label: label.to_string(), - value: value.to_string(), - status: if value.trim().is_empty() { - "missing" - } else { - "confirmed" - } - .to_string(), - } -} - -fn map_visual_novel_agent_session_snapshot( - snapshot: VisualNovelAgentSessionSnapshot, -) -> VisualNovelAgentSessionRecord { - VisualNovelAgentSessionRecord { - session_id: snapshot.session_id, - owner_user_id: snapshot.owner_user_id, - source_mode: snapshot.source_mode, - status: snapshot.status, - seed_text: snapshot.seed_text, - source_asset_ids: snapshot.source_asset_ids, - current_turn: snapshot.current_turn, - progress_percent: snapshot.progress_percent, - messages: snapshot - .messages - .into_iter() - .map(map_visual_novel_agent_message) - .collect(), - draft: snapshot.draft.map(visual_novel_json_to_value), - pending_action: snapshot.pending_action.map(visual_novel_json_to_value), - last_assistant_reply: snapshot.last_assistant_reply, - published_profile_id: snapshot.published_profile_id, - created_at: format_timestamp_micros(snapshot.created_at_micros), - updated_at: format_timestamp_micros(snapshot.updated_at_micros), - } -} - -fn map_visual_novel_agent_message( - snapshot: VisualNovelAgentMessageSnapshot, -) -> VisualNovelAgentMessageRecord { - VisualNovelAgentMessageRecord { - message_id: snapshot.message_id, - session_id: snapshot.session_id, - role: snapshot.role, - kind: snapshot.kind, - text: snapshot.text, - created_at: format_timestamp_micros(snapshot.created_at_micros), - } -} - -fn map_visual_novel_work_snapshot( - snapshot: VisualNovelWorkSnapshot, -) -> VisualNovelWorkProfileRecord { - VisualNovelWorkProfileRecord { - work_id: snapshot.work_id, - profile_id: snapshot.profile_id, - owner_user_id: snapshot.owner_user_id, - source_session_id: snapshot.source_session_id, - author_display_name: snapshot.author_display_name, - work_title: snapshot.work_title, - work_description: snapshot.work_description, - tags: snapshot.tags, - cover_image_src: snapshot.cover_image_src, - source_asset_ids: snapshot.source_asset_ids, - draft: visual_novel_json_to_value(snapshot.draft), - publication_status: snapshot.publication_status, - publish_ready: snapshot.publish_ready, - play_count: snapshot.play_count, - created_at: format_timestamp_micros(snapshot.created_at_micros), - updated_at: format_timestamp_micros(snapshot.updated_at_micros), - published_at: snapshot.published_at_micros.map(format_timestamp_micros), - } -} - -pub(crate) fn map_visual_novel_gallery_view_row( - row: VisualNovelGalleryViewRow, -) -> VisualNovelWorkProfileRecord { - VisualNovelWorkProfileRecord { - work_id: row.work_id, - profile_id: row.profile_id, - owner_user_id: row.owner_user_id, - source_session_id: row.source_session_id, - author_display_name: row.author_display_name, - work_title: row.work_title, - work_description: row.work_description, - tags: row.tags, - cover_image_src: row.cover_image_src, - source_asset_ids: row.source_asset_ids, - // 中文注释:公开列表 view 不暴露完整 draft,详情页仍通过 detail procedure 读取。 - draft: serde_json::Value::Null, - publication_status: row.publication_status, - publish_ready: row.publish_ready, - play_count: row.play_count, - created_at: format_timestamp_micros(row.created_at_micros), - updated_at: format_timestamp_micros(row.updated_at_micros), - published_at: row.published_at_micros.map(format_timestamp_micros), - } -} - -fn map_visual_novel_run_snapshot(snapshot: VisualNovelRunSnapshot) -> VisualNovelRunRecord { - VisualNovelRunRecord { - run_id: snapshot.run_id, - owner_user_id: snapshot.owner_user_id, - profile_id: snapshot.profile_id, - mode: snapshot.mode, - status: snapshot.status, - current_scene_id: snapshot.current_scene_id, - current_phase_id: snapshot.current_phase_id, - visible_character_ids: snapshot.visible_character_ids, - flags: visual_novel_json_to_value(snapshot.flags), - metrics: visual_novel_json_to_value(snapshot.metrics), - history: snapshot - .history - .into_iter() - .map(map_visual_novel_history_entry) - .collect(), - available_choices: visual_novel_json_to_value(snapshot.available_choices), - text_mode_enabled: snapshot.text_mode_enabled, - created_at: format_timestamp_micros(snapshot.created_at_micros), - updated_at: format_timestamp_micros(snapshot.updated_at_micros), - } -} - -fn map_visual_novel_history_entry( - snapshot: VisualNovelRuntimeHistoryEntrySnapshot, -) -> VisualNovelHistoryEntryRecord { - VisualNovelHistoryEntryRecord { - entry_id: snapshot.entry_id, - run_id: snapshot.run_id, - owner_user_id: snapshot.owner_user_id, - profile_id: snapshot.profile_id, - turn_index: snapshot.turn_index, - source: snapshot.source, - action_text: snapshot.action_text, - steps: visual_novel_json_to_value(snapshot.steps), - snapshot_before_hash: snapshot.snapshot_before_hash, - snapshot_after_hash: snapshot.snapshot_after_hash, - created_at: format_timestamp_micros(snapshot.created_at_micros), - } -} - -fn map_visual_novel_runtime_event( - snapshot: VisualNovelRuntimeEventSnapshot, -) -> VisualNovelRuntimeEventRecord { - VisualNovelRuntimeEventRecord { - event_id: snapshot.event_id, - run_id: snapshot.run_id, - owner_user_id: snapshot.owner_user_id, - profile_id: snapshot.profile_id, - event_kind: snapshot.event_kind, - client_event_id: snapshot.client_event_id, - history_entry_id: snapshot.history_entry_id, - payload: visual_novel_json_to_value(snapshot.payload), - occurred_at: format_timestamp_micros(snapshot.occurred_at_micros), - } -} - -fn visual_novel_json_to_value(value: VisualNovelJsonValue) -> serde_json::Value { - match value { - VisualNovelJsonValue::Null => serde_json::Value::Null, - VisualNovelJsonValue::Bool(value) => serde_json::Value::Bool(value), - VisualNovelJsonValue::Number(value) => serde_json::Number::from_f64(value) - .map(serde_json::Value::Number) - .unwrap_or(serde_json::Value::Null), - VisualNovelJsonValue::String(value) => serde_json::Value::String(value), - VisualNovelJsonValue::Array(items) => { - serde_json::Value::Array(items.into_iter().map(visual_novel_json_to_value).collect()) - } - VisualNovelJsonValue::Object(fields) => { - let object = fields - .into_iter() - .map(|field| (field.key, visual_novel_json_to_value(field.value))) - .collect(); - serde_json::Value::Object(object) - } - } -} - -fn normalize_match3d_stage(value: &str) -> &str { - match value { - "Collecting" | "collecting" | "collecting_config" => "collecting_config", - "ReadyToCompile" | "ready_to_compile" => "ready_to_compile", - "DraftCompiled" | "draft_compiled" | "draft_ready" => "draft_ready", - "Published" | "published" => "published", - _ => value, - } -} - -fn normalize_match3d_publication_status(value: &str) -> &str { - match value { - "Draft" | "draft" => "draft", - "Published" | "published" => "published", - _ => value, - } -} - -fn normalize_match3d_message_kind(value: &str) -> &str { - match value { - "text" => "chat", - _ => value, - } -} - -fn normalize_square_hole_stage(value: &str) -> &str { - match value { - "Collecting" | "CollectingConfig" | "collecting" | "collecting_config" => { - "collecting_config" - } - "ReadyToCompile" | "ready_to_compile" => "ready_to_compile", - "DraftCompiled" | "DraftReady" | "draft_compiled" | "draft_ready" => "draft_ready", - "Published" | "published" => "published", - _ => value, - } -} - -fn normalize_square_hole_publication_status(value: &str) -> &str { - match value { - "Draft" | "draft" => "draft", - "Published" | "published" => "published", - _ => value, - } -} - -fn normalize_square_hole_run_status(value: &str) -> &str { - match value { - "Running" | "running" => "running", - "Won" | "won" => "won", - "Failed" | "failed" => "failed", - "Stopped" | "stopped" => "stopped", - _ => value, - } -} - -fn normalize_square_hole_message_kind(value: &str) -> &str { - match value { - "text" => "chat", - _ => value, - } -} - -fn normalize_square_hole_reject_reason(value: &str) -> &str { - match value { - "RunNotActive" | "run_not_active" => "run_not_active", - "SnapshotVersionMismatch" | "snapshot_version_mismatch" => "snapshot_version_mismatch", - "HoleNotFound" | "hole_not_found" => "hole_not_found", - "Incompatible" | "incompatible" => "incompatible", - "TimeUp" | "time_up" => "time_up", - _ => value, - } -} - -fn empty_string_to_none(value: String) -> Option { - let trimmed = value.trim(); - if trimmed.is_empty() { - None - } else { - Some(trimmed.to_string()) - } -} - -fn i64_to_u64_ms(value: i64) -> u64 { - value.max(0) as u64 -} - -pub(crate) fn map_puzzle_suggested_action( - snapshot: PuzzleAgentSuggestedAction, -) -> PuzzleAgentSuggestedActionRecord { - PuzzleAgentSuggestedActionRecord { - action_id: snapshot.id, - action_type: snapshot.action_type, - label: snapshot.label, - } -} - -pub(crate) fn map_puzzle_result_preview( - snapshot: PuzzleResultPreviewEnvelope, -) -> PuzzleResultPreviewRecord { - PuzzleResultPreviewRecord { - draft: map_puzzle_result_draft(snapshot.draft), - blockers: snapshot - .blockers - .into_iter() - .map(map_puzzle_result_preview_blocker) - .collect(), - quality_findings: snapshot - .quality_findings - .into_iter() - .map(map_puzzle_result_preview_finding) - .collect(), - publish_ready: snapshot.publish_ready, - } -} - -pub(crate) fn map_puzzle_result_preview_blocker( - snapshot: PuzzleResultPreviewBlocker, -) -> PuzzleResultPreviewBlockerRecord { - PuzzleResultPreviewBlockerRecord { - blocker_id: snapshot.id, - code: snapshot.code, - message: snapshot.message, - } -} - -pub(crate) fn map_puzzle_result_preview_finding( - snapshot: PuzzleResultPreviewFinding, -) -> PuzzleResultPreviewFindingRecord { - PuzzleResultPreviewFindingRecord { - finding_id: snapshot.id, - severity: snapshot.severity, - code: snapshot.code, - message: snapshot.message, - } -} - -pub(crate) fn map_puzzle_work_profile(snapshot: PuzzleWorkProfile) -> PuzzleWorkProfileRecord { - PuzzleWorkProfileRecord { - work_id: snapshot.work_id, - profile_id: snapshot.profile_id, - owner_user_id: snapshot.owner_user_id, - source_session_id: snapshot.source_session_id, - author_display_name: snapshot.author_display_name, - work_title: snapshot.work_title, - work_description: snapshot.work_description, - level_name: snapshot.level_name, - summary: snapshot.summary, - theme_tags: snapshot.theme_tags, - cover_image_src: snapshot.cover_image_src, - cover_asset_id: snapshot.cover_asset_id, - publication_status: format_puzzle_publication_status(snapshot.publication_status) - .to_string(), - updated_at: format_timestamp_micros(snapshot.updated_at_micros), - published_at: snapshot.published_at_micros.map(format_timestamp_micros), - play_count: snapshot.play_count, - remix_count: snapshot.remix_count, - like_count: snapshot.like_count, - recent_play_count_7d: snapshot.recent_play_count_7_d, - point_incentive_total_half_points: snapshot.point_incentive_total_half_points, - point_incentive_claimed_points: snapshot.point_incentive_claimed_points, - publish_ready: snapshot.publish_ready, - anchor_pack: map_puzzle_anchor_pack(snapshot.anchor_pack), - levels: snapshot - .levels - .into_iter() - .map(map_puzzle_draft_level) - .collect(), - } -} - -pub(crate) fn map_puzzle_gallery_card_view_row( - snapshot: PuzzleGalleryCardViewRow, - recent_play_count_7d: u32, -) -> PuzzleGalleryCardRecord { - PuzzleGalleryCardRecord { - work_id: snapshot.work_id, - profile_id: snapshot.profile_id, - owner_user_id: snapshot.owner_user_id, - source_session_id: snapshot.source_session_id, - author_display_name: snapshot.author_display_name, - work_title: snapshot.work_title, - work_description: snapshot.work_description, - level_name: snapshot.level_name, - summary: snapshot.summary, - theme_tags: snapshot.theme_tags, - cover_image_src: snapshot.cover_image_src, - cover_asset_id: snapshot.cover_asset_id, - publication_status: format_puzzle_publication_status(snapshot.publication_status) - .to_string(), - updated_at: format_timestamp_micros(snapshot.updated_at_micros), - published_at: snapshot.published_at_micros.map(format_timestamp_micros), - play_count: snapshot.play_count, - remix_count: snapshot.remix_count, - like_count: snapshot.like_count, - recent_play_count_7d, - point_incentive_total_half_points: snapshot.point_incentive_total_half_points, - point_incentive_claimed_points: snapshot.point_incentive_claimed_points, - publish_ready: snapshot.publish_ready, - generation_status: snapshot.generation_status, - } -} - -pub(crate) fn map_puzzle_run_snapshot(snapshot: PuzzleRunSnapshot) -> PuzzleRunRecord { - PuzzleRunRecord { - run_id: snapshot.run_id, - entry_profile_id: snapshot.entry_profile_id, - cleared_level_count: snapshot.cleared_level_count, - current_level_index: snapshot.current_level_index, - current_grid_size: snapshot.current_grid_size, - played_profile_ids: snapshot.played_profile_ids, - previous_level_tags: snapshot.previous_level_tags, - current_level: snapshot - .current_level - .map(map_puzzle_runtime_level_snapshot), - recommended_next_profile_id: snapshot.recommended_next_profile_id, - next_level_mode: snapshot.next_level_mode, - next_level_profile_id: snapshot.next_level_profile_id, - next_level_id: snapshot.next_level_id, - recommended_next_works: snapshot - .recommended_next_works - .into_iter() - .map(map_puzzle_recommended_next_work) - .collect(), - leaderboard_entries: snapshot - .leaderboard_entries - .into_iter() - .map(map_puzzle_leaderboard_entry) - .collect(), - } -} - -fn map_puzzle_recommended_next_work( - snapshot: PuzzleRecommendedNextWork, -) -> PuzzleRecommendedNextWorkRecord { - PuzzleRecommendedNextWorkRecord { - profile_id: snapshot.profile_id, - level_name: snapshot.level_name, - author_display_name: snapshot.author_display_name, - theme_tags: snapshot.theme_tags, - cover_image_src: snapshot.cover_image_src, - similarity_score: snapshot.similarity_score, - } -} - -pub(crate) fn map_puzzle_runtime_level_snapshot( - snapshot: PuzzleRuntimeLevelSnapshot, -) -> PuzzleRuntimeLevelRecord { - let started_at_ms = if snapshot.started_at_ms == 0 { - // 中文注释:运行态快照缺少可用开始时间时只补一个可用值,其余限时字段保持快照原值。 - current_unix_millis_for_legacy_puzzle_snapshot() - } else { - snapshot.started_at_ms - }; - - PuzzleRuntimeLevelRecord { - run_id: snapshot.run_id, - level_index: snapshot.level_index, - level_id: snapshot.level_id, - grid_size: snapshot.grid_size, - profile_id: snapshot.profile_id, - level_name: snapshot.level_name, - author_display_name: snapshot.author_display_name, - theme_tags: snapshot.theme_tags, - cover_image_src: snapshot.cover_image_src, - ui_background_image_src: snapshot.ui_background_image_src, - ui_background_image_object_key: snapshot.ui_background_image_object_key, - background_music: snapshot.background_music.map(map_puzzle_audio_asset), - board: map_puzzle_board_snapshot(snapshot.board), - status: format_puzzle_runtime_level_status(snapshot.status).to_string(), - started_at_ms, - cleared_at_ms: snapshot.cleared_at_ms, - elapsed_ms: snapshot.elapsed_ms, - time_limit_ms: snapshot.time_limit_ms, - remaining_ms: snapshot.remaining_ms, - paused_accumulated_ms: snapshot.paused_accumulated_ms, - pause_started_at_ms: snapshot.pause_started_at_ms, - freeze_accumulated_ms: snapshot.freeze_accumulated_ms, - freeze_started_at_ms: snapshot.freeze_started_at_ms, - freeze_until_ms: snapshot.freeze_until_ms, - leaderboard_entries: snapshot - .leaderboard_entries - .into_iter() - .map(map_puzzle_leaderboard_entry) - .collect(), - } -} - -fn current_unix_millis_for_legacy_puzzle_snapshot() -> u64 { - std::time::SystemTime::now() - .duration_since(std::time::UNIX_EPOCH) - .map(|duration| duration.as_millis().min(u128::from(u64::MAX)) as u64) - .unwrap_or(1) -} - -pub(crate) fn map_puzzle_leaderboard_entry( - snapshot: PuzzleLeaderboardEntry, -) -> PuzzleLeaderboardEntryRecord { - PuzzleLeaderboardEntryRecord { - rank: snapshot.rank, - nickname: snapshot.nickname, - elapsed_ms: snapshot.elapsed_ms, - visible_tags: snapshot.visible_tags, - is_current_player: snapshot.is_current_player, - } -} - -pub(crate) fn map_puzzle_board_snapshot(snapshot: PuzzleBoardSnapshot) -> PuzzleBoardRecord { - PuzzleBoardRecord { - rows: snapshot.rows, - cols: snapshot.cols, - pieces: snapshot - .pieces - .into_iter() - .map(map_puzzle_piece_state) - .collect(), - merged_groups: snapshot - .merged_groups - .into_iter() - .map(map_puzzle_merged_group_state) - .collect(), - selected_piece_id: snapshot.selected_piece_id, - all_tiles_resolved: snapshot.all_tiles_resolved, - } -} - -pub(crate) fn map_puzzle_piece_state(snapshot: PuzzlePieceState) -> PuzzlePieceStateRecord { - PuzzlePieceStateRecord { - piece_id: snapshot.piece_id, - correct_row: snapshot.correct_row, - correct_col: snapshot.correct_col, - current_row: snapshot.current_row, - current_col: snapshot.current_col, - merged_group_id: snapshot.merged_group_id, - } -} - -pub(crate) fn map_puzzle_merged_group_state( - snapshot: PuzzleMergedGroupState, -) -> PuzzleMergedGroupRecord { - PuzzleMergedGroupRecord { - group_id: snapshot.group_id, - piece_ids: snapshot.piece_ids, - occupied_cells: snapshot - .occupied_cells - .into_iter() - .map(map_puzzle_cell_position) - .collect(), - } -} - -pub(crate) fn map_puzzle_cell_position(snapshot: PuzzleCellPosition) -> PuzzleCellPositionRecord { - PuzzleCellPositionRecord { - row: snapshot.row, - col: snapshot.col, - } -} - -pub(crate) fn map_big_fish_anchor_pack(snapshot: BigFishAnchorPack) -> BigFishAnchorPackRecord { - BigFishAnchorPackRecord { - gameplay_promise: map_big_fish_anchor_item(snapshot.gameplay_promise), - ecology_visual_theme: map_big_fish_anchor_item(snapshot.ecology_visual_theme), - growth_ladder: map_big_fish_anchor_item(snapshot.growth_ladder), - risk_tempo: map_big_fish_anchor_item(snapshot.risk_tempo), - } -} - -pub(crate) fn map_big_fish_anchor_item(snapshot: BigFishAnchorItem) -> BigFishAnchorItemRecord { - BigFishAnchorItemRecord { - key: snapshot.key, - label: snapshot.label, - value: snapshot.value, - status: format_big_fish_anchor_status(snapshot.status).to_string(), - } -} - -pub(crate) fn map_big_fish_game_draft(snapshot: BigFishGameDraft) -> BigFishGameDraftRecord { - BigFishGameDraftRecord { - title: snapshot.title, - subtitle: snapshot.subtitle, - core_fun: snapshot.core_fun, - ecology_theme: snapshot.ecology_theme, - levels: snapshot - .levels - .into_iter() - .map(map_big_fish_level_blueprint) - .collect(), - background: map_big_fish_background_blueprint(snapshot.background), - runtime_params: map_big_fish_runtime_params(snapshot.runtime_params), - } -} - -pub(crate) fn map_big_fish_level_blueprint( - snapshot: BigFishLevelBlueprint, -) -> BigFishLevelBlueprintRecord { - BigFishLevelBlueprintRecord { - level: snapshot.level, - name: snapshot.name, - one_line_fantasy: snapshot.one_line_fantasy, - text_description: snapshot.text_description, - silhouette_direction: snapshot.silhouette_direction, - size_ratio: snapshot.size_ratio, - visual_description: snapshot.visual_description, - visual_prompt_seed: snapshot.visual_prompt_seed, - idle_motion_description: snapshot.idle_motion_description, - move_motion_description: snapshot.move_motion_description, - motion_prompt_seed: snapshot.motion_prompt_seed, - merge_source_level: snapshot.merge_source_level, - prey_window: snapshot.prey_window, - threat_window: snapshot.threat_window, - is_final_level: snapshot.is_final_level, - } -} - -pub(crate) fn map_big_fish_background_blueprint( - snapshot: BigFishBackgroundBlueprint, -) -> BigFishBackgroundBlueprintRecord { - BigFishBackgroundBlueprintRecord { - theme: snapshot.theme, - color_mood: snapshot.color_mood, - foreground_hints: snapshot.foreground_hints, - midground_composition: snapshot.midground_composition, - background_depth: snapshot.background_depth, - safe_play_area_hint: snapshot.safe_play_area_hint, - spawn_edge_hint: snapshot.spawn_edge_hint, - background_prompt_seed: snapshot.background_prompt_seed, - } -} - -pub(crate) fn map_big_fish_runtime_params( - snapshot: BigFishRuntimeParams, -) -> BigFishRuntimeParamsRecord { - BigFishRuntimeParamsRecord { - level_count: snapshot.level_count, - merge_count_per_upgrade: snapshot.merge_count_per_upgrade, - spawn_target_count: snapshot.spawn_target_count, - leader_move_speed: snapshot.leader_move_speed, - follower_catch_up_speed: snapshot.follower_catch_up_speed, - offscreen_cull_seconds: snapshot.offscreen_cull_seconds, - prey_spawn_delta_levels: snapshot.prey_spawn_delta_levels, - threat_spawn_delta_levels: snapshot.threat_spawn_delta_levels, - win_level: snapshot.win_level, - } -} - -pub(crate) fn map_big_fish_asset_slot_snapshot( - snapshot: BigFishAssetSlotSnapshot, -) -> BigFishAssetSlotRecord { - BigFishAssetSlotRecord { - slot_id: snapshot.slot_id, - asset_kind: format_big_fish_asset_kind(snapshot.asset_kind).to_string(), - level: snapshot.level, - motion_key: snapshot.motion_key, - status: format_big_fish_asset_status(snapshot.status).to_string(), - asset_url: snapshot.asset_url, - prompt_snapshot: snapshot.prompt_snapshot, - updated_at: format_timestamp_micros(snapshot.updated_at_micros), - } -} - -pub(crate) fn map_big_fish_asset_coverage( - snapshot: BigFishAssetCoverage, -) -> BigFishAssetCoverageRecord { - BigFishAssetCoverageRecord { - level_main_image_ready_count: snapshot.level_main_image_ready_count, - level_motion_ready_count: snapshot.level_motion_ready_count, - background_ready: snapshot.background_ready, - required_level_count: snapshot.required_level_count, - publish_ready: snapshot.publish_ready, - blockers: snapshot.blockers, - } -} - -pub(crate) fn map_big_fish_agent_message_snapshot( - snapshot: BigFishAgentMessageSnapshot, -) -> BigFishAgentMessageRecord { - BigFishAgentMessageRecord { - message_id: snapshot.message_id, - role: format_big_fish_agent_message_role(snapshot.role).to_string(), - kind: format_big_fish_agent_message_kind(snapshot.kind).to_string(), - text: snapshot.text, - created_at: format_timestamp_micros(snapshot.created_at_micros), - } -} - -pub(crate) fn map_big_fish_work_summary_snapshot( - snapshot: BigFishWorkSummarySnapshot, -) -> BigFishWorkSummaryRecord { - BigFishWorkSummaryRecord { - work_id: snapshot.work_id, - source_session_id: snapshot.source_session_id, - owner_user_id: snapshot.owner_user_id, - title: snapshot.title, - subtitle: snapshot.subtitle, - summary: snapshot.summary, - cover_image_src: snapshot.cover_image_src, - status: snapshot.status, - updated_at_micros: snapshot.updated_at_micros, - published_at_micros: snapshot.published_at_micros, - publish_ready: snapshot.publish_ready, - level_count: snapshot.level_count, - level_main_image_ready_count: snapshot.level_main_image_ready_count, - level_motion_ready_count: snapshot.level_motion_ready_count, - background_ready: snapshot.background_ready, - play_count: snapshot.play_count, - remix_count: snapshot.remix_count, - like_count: snapshot.like_count, - recent_play_count_7d: snapshot.recent_play_count_7_d, - } -} - -pub(crate) fn map_big_fish_gallery_view_row( - row: BigFishWorkSummarySnapshot, - recent_play_count_7d: u32, -) -> BigFishWorkSummaryRecord { - let mut record = map_big_fish_work_summary_snapshot(row); - record.recent_play_count_7d = recent_play_count_7d; - record -} - -pub(crate) fn map_big_fish_runtime_snapshot( - snapshot: BigFishRuntimeSnapshot, -) -> BigFishRuntimeRunRecord { - BigFishRuntimeRunRecord { - run_id: snapshot.run_id, - session_id: snapshot.session_id, - status: format_big_fish_run_status(snapshot.status).to_string(), - tick: snapshot.tick, - player_level: snapshot.player_level, - win_level: snapshot.win_level, - leader_entity_id: snapshot.leader_entity_id, - owned_entities: snapshot - .owned_entities - .into_iter() - .map(map_big_fish_runtime_entity_snapshot) - .collect(), - wild_entities: snapshot - .wild_entities - .into_iter() - .map(map_big_fish_runtime_entity_snapshot) - .collect(), - camera_center: map_big_fish_vector2(snapshot.camera_center), - last_input: map_big_fish_vector2(snapshot.last_input), - event_log: snapshot.event_log, - updated_at: format_timestamp_micros(snapshot.updated_at_micros), - } -} - -fn map_big_fish_runtime_entity_snapshot( - snapshot: BigFishRuntimeEntitySnapshot, -) -> BigFishRuntimeEntityRecord { - BigFishRuntimeEntityRecord { - entity_id: snapshot.entity_id, - level: snapshot.level, - position: map_big_fish_vector2(snapshot.position), - radius: snapshot.radius, - offscreen_seconds: snapshot.offscreen_seconds, - } -} - -fn map_big_fish_vector2(snapshot: BigFishVector2) -> BigFishVector2Record { - BigFishVector2Record { - x: snapshot.x, - y: snapshot.y, - } -} - -pub(crate) fn map_story_session_snapshot(snapshot: StorySessionSnapshot) -> StorySessionRecord { - StorySessionRecord { - story_session_id: snapshot.story_session_id, - runtime_session_id: snapshot.runtime_session_id, - actor_user_id: snapshot.actor_user_id, - world_profile_id: snapshot.world_profile_id, - initial_prompt: snapshot.initial_prompt, - opening_summary: snapshot.opening_summary, - latest_narrative_text: snapshot.latest_narrative_text, - latest_choice_function_id: snapshot.latest_choice_function_id, - status: map_story_session_status(snapshot.status) - .as_str() - .to_string(), - version: snapshot.version, - created_at: format_timestamp_micros(snapshot.created_at_micros), - updated_at: format_timestamp_micros(snapshot.updated_at_micros), - } -} - -pub(crate) fn map_ai_task_snapshot(snapshot: AiTaskSnapshot) -> AiTaskRecord { - AiTaskRecord { - task_id: snapshot.task_id, - task_kind: format_ai_task_kind(snapshot.task_kind).to_string(), - owner_user_id: snapshot.owner_user_id, - request_label: snapshot.request_label, - source_module: snapshot.source_module, - source_entity_id: snapshot.source_entity_id, - request_payload_json: snapshot.request_payload_json, - status: format_ai_task_status(snapshot.status).to_string(), - failure_message: snapshot.failure_message, - stages: snapshot - .stages - .into_iter() - .map(map_ai_task_stage_snapshot) - .collect(), - result_references: snapshot - .result_references - .into_iter() - .map(map_ai_result_reference_snapshot) - .collect(), - latest_text_output: snapshot.latest_text_output, - latest_structured_payload_json: snapshot.latest_structured_payload_json, - version: snapshot.version, - created_at: format_timestamp_micros(snapshot.created_at_micros), - started_at: snapshot.started_at_micros.map(format_timestamp_micros), - completed_at: snapshot.completed_at_micros.map(format_timestamp_micros), - updated_at: format_timestamp_micros(snapshot.updated_at_micros), - } -} - -pub(crate) fn map_ai_task_stage_snapshot(snapshot: AiTaskStageSnapshot) -> AiTaskStageRecord { - AiTaskStageRecord { - stage_kind: format_ai_task_stage_kind(snapshot.stage_kind).to_string(), - label: snapshot.label, - detail: snapshot.detail, - order: snapshot.order, - status: format_ai_task_stage_status(snapshot.status).to_string(), - text_output: snapshot.text_output, - structured_payload_json: snapshot.structured_payload_json, - warning_messages: snapshot.warning_messages, - started_at: snapshot.started_at_micros.map(format_timestamp_micros), - completed_at: snapshot.completed_at_micros.map(format_timestamp_micros), - } -} - -pub(crate) fn map_ai_text_chunk_snapshot(snapshot: AiTextChunkSnapshot) -> AiTextChunkRecord { - AiTextChunkRecord { - chunk_id: snapshot.chunk_id, - task_id: snapshot.task_id, - stage_kind: format_ai_task_stage_kind(snapshot.stage_kind).to_string(), - sequence: snapshot.sequence, - delta_text: snapshot.delta_text, - created_at: format_timestamp_micros(snapshot.created_at_micros), - } -} - -pub(crate) fn map_ai_result_reference_snapshot( - snapshot: AiResultReferenceSnapshot, -) -> AiResultReferenceRecord { - AiResultReferenceRecord { - result_ref_id: snapshot.result_ref_id, - task_id: snapshot.task_id, - reference_kind: format_ai_result_reference_kind(snapshot.reference_kind).to_string(), - reference_id: snapshot.reference_id, - label: snapshot.label, - created_at: format_timestamp_micros(snapshot.created_at_micros), - } -} - -pub(crate) fn map_story_event_snapshot(snapshot: StoryEventSnapshot) -> StoryEventRecord { - StoryEventRecord { - event_id: snapshot.event_id, - story_session_id: snapshot.story_session_id, - event_kind: map_story_event_kind(snapshot.event_kind) - .as_str() - .to_string(), - narrative_text: snapshot.narrative_text, - choice_function_id: snapshot.choice_function_id, - created_at: format_timestamp_micros(snapshot.created_at_micros), - } -} - -pub(crate) fn map_battle_state_snapshot( - snapshot: BattleStateSnapshot, -) -> DomainBattleStateSnapshot { - DomainBattleStateSnapshot { - battle_state_id: snapshot.battle_state_id, - story_session_id: snapshot.story_session_id, - runtime_session_id: snapshot.runtime_session_id, - actor_user_id: snapshot.actor_user_id, - chapter_id: snapshot.chapter_id, - target_npc_id: snapshot.target_npc_id, - target_name: snapshot.target_name, - battle_mode: map_battle_mode_back(snapshot.battle_mode), - status: map_battle_status(snapshot.status), - player_hp: snapshot.player_hp, - player_max_hp: snapshot.player_max_hp, - player_mana: snapshot.player_mana, - player_max_mana: snapshot.player_max_mana, - target_hp: snapshot.target_hp, - target_max_hp: snapshot.target_max_hp, - experience_reward: snapshot.experience_reward, - reward_items: snapshot - .reward_items - .into_iter() - .map(map_runtime_item_reward_item_snapshot_back) - .collect(), - turn_index: snapshot.turn_index, - last_action_function_id: snapshot.last_action_function_id, - last_action_text: snapshot.last_action_text, - last_result_text: snapshot.last_result_text, - last_damage_dealt: snapshot.last_damage_dealt, - last_damage_taken: snapshot.last_damage_taken, - last_outcome: map_combat_outcome(snapshot.last_outcome), - version: snapshot.version, - created_at_micros: snapshot.created_at_micros, - updated_at_micros: snapshot.updated_at_micros, - } -} - -pub(crate) fn map_runtime_inventory_state_snapshot( - snapshot: RuntimeInventoryStateSnapshot, -) -> DomainRuntimeInventoryStateSnapshot { - DomainRuntimeInventoryStateSnapshot { - runtime_session_id: snapshot.runtime_session_id, - actor_user_id: snapshot.actor_user_id, - backpack_items: snapshot - .backpack_items - .into_iter() - .map(map_inventory_slot_snapshot) - .collect(), - equipment_items: snapshot - .equipment_items - .into_iter() - .map(map_inventory_slot_snapshot) - .collect(), - } -} - -pub(crate) fn map_resolve_combat_action_result( - result: ResolveCombatActionResult, -) -> DomainResolveCombatActionResult { - DomainResolveCombatActionResult { - snapshot: map_battle_state_snapshot(result.snapshot), - damage_dealt: result.damage_dealt, - damage_taken: result.damage_taken, - outcome: map_combat_outcome(result.outcome), - } -} - -pub(crate) fn map_npc_battle_interaction_result( - result: NpcBattleInteractionResult, -) -> NpcBattleInteractionSnapshot { - NpcBattleInteractionSnapshot { - interaction: map_npc_interaction_result(result.interaction), - battle_state: map_battle_state_snapshot(result.battle_state), - } -} - -pub(crate) fn map_inventory_slot_snapshot( - snapshot: InventorySlotSnapshot, -) -> module_inventory::InventorySlotSnapshot { - module_inventory::InventorySlotSnapshot { - slot_id: snapshot.slot_id, - runtime_session_id: snapshot.runtime_session_id, - story_session_id: snapshot.story_session_id, - actor_user_id: snapshot.actor_user_id, - container_kind: map_inventory_container_kind(snapshot.container_kind), - slot_key: snapshot.slot_key, - item_id: snapshot.item_id, - category: snapshot.category, - name: snapshot.name, - description: snapshot.description, - quantity: snapshot.quantity, - rarity: map_inventory_item_rarity(snapshot.rarity), - tags: snapshot.tags, - stackable: snapshot.stackable, - stack_key: snapshot.stack_key, - equipment_slot_id: snapshot.equipment_slot_id.map(map_inventory_equipment_slot), - source_kind: map_inventory_item_source_kind(snapshot.source_kind), - source_reference_id: snapshot.source_reference_id, - created_at_micros: snapshot.created_at_micros, - updated_at_micros: snapshot.updated_at_micros, - } -} - -pub(crate) fn map_npc_interaction_result( - result: NpcInteractionResult, -) -> DomainNpcInteractionResult { - DomainNpcInteractionResult { - npc_state: map_npc_state_snapshot(result.npc_state), - interaction_status: map_npc_interaction_status(result.interaction_status), - action_text: result.action_text, - result_text: result.result_text, - story_text: result.story_text, - battle_mode: result.battle_mode.map(map_npc_interaction_battle_mode), - encounter_closed: result.encounter_closed, - affinity_changed: result.affinity_changed, - previous_affinity: result.previous_affinity, - next_affinity: result.next_affinity, - } -} - -pub(crate) fn map_npc_state_snapshot(snapshot: NpcStateSnapshot) -> DomainNpcStateSnapshot { - DomainNpcStateSnapshot { - npc_state_id: snapshot.npc_state_id, - runtime_session_id: snapshot.runtime_session_id, - npc_id: snapshot.npc_id, - npc_name: snapshot.npc_name, - affinity: snapshot.affinity, - relation_state: map_npc_relation_state(snapshot.relation_state), - help_used: snapshot.help_used, - chatted_count: snapshot.chatted_count, - gifts_given: snapshot.gifts_given, - recruited: snapshot.recruited, - trade_stock_signature: snapshot.trade_stock_signature, - revealed_facts: snapshot.revealed_facts, - known_attribute_rumors: snapshot.known_attribute_rumors, - first_meaningful_contact_resolved: snapshot.first_meaningful_contact_resolved, - seen_backstory_chapter_ids: snapshot.seen_backstory_chapter_ids, - stance_profile: map_npc_stance_profile(snapshot.stance_profile), - created_at_micros: snapshot.created_at_micros, - updated_at_micros: snapshot.updated_at_micros, - } -} - -pub(crate) fn map_npc_relation_state(value: NpcRelationState) -> DomainNpcRelationState { - DomainNpcRelationState { - affinity: value.affinity, - stance: map_npc_relation_stance(value.stance), - } -} - -pub(crate) fn map_npc_stance_profile(value: NpcStanceProfile) -> DomainNpcStanceProfile { - DomainNpcStanceProfile { - trust: value.trust, - warmth: value.warmth, - ideological_fit: value.ideological_fit, - fear_or_guard: value.fear_or_guard, - loyalty: value.loyalty, - current_conflict_tag: value.current_conflict_tag, - recent_approvals: value.recent_approvals, - recent_disapprovals: value.recent_disapprovals, - } -} - -pub(crate) fn map_npc_interaction_status( - value: NpcInteractionStatus, -) -> DomainNpcInteractionStatus { - match value { - NpcInteractionStatus::Previewed => DomainNpcInteractionStatus::Previewed, - NpcInteractionStatus::Dialogue => DomainNpcInteractionStatus::Dialogue, - NpcInteractionStatus::Resolved => DomainNpcInteractionStatus::Resolved, - NpcInteractionStatus::Recruited => DomainNpcInteractionStatus::Recruited, - NpcInteractionStatus::BattlePending => DomainNpcInteractionStatus::BattlePending, - NpcInteractionStatus::Left => DomainNpcInteractionStatus::Left, - } -} - -pub(crate) fn map_npc_interaction_battle_mode( - value: NpcInteractionBattleMode, -) -> DomainNpcInteractionBattleMode { - match value { - NpcInteractionBattleMode::Fight => DomainNpcInteractionBattleMode::Fight, - NpcInteractionBattleMode::Spar => DomainNpcInteractionBattleMode::Spar, - } -} - -pub(crate) fn map_npc_relation_stance(value: NpcRelationStance) -> DomainNpcRelationStance { - match value { - NpcRelationStance::Hostile => DomainNpcRelationStance::Hostile, - NpcRelationStance::Guarded => DomainNpcRelationStance::Guarded, - NpcRelationStance::Neutral => DomainNpcRelationStance::Neutral, - NpcRelationStance::Cooperative => DomainNpcRelationStance::Cooperative, - NpcRelationStance::Bonded => DomainNpcRelationStance::Bonded, - } -} - -pub(crate) fn map_access_policy( - value: AssetObjectAccessPolicy, -) -> crate::module_bindings::AssetObjectAccessPolicy { - match value { - AssetObjectAccessPolicy::Private => { - crate::module_bindings::AssetObjectAccessPolicy::Private - } - AssetObjectAccessPolicy::PublicRead => { - crate::module_bindings::AssetObjectAccessPolicy::PublicRead - } - } -} - -pub(crate) fn map_access_policy_back( - value: crate::module_bindings::AssetObjectAccessPolicy, -) -> AssetObjectAccessPolicy { - match value { - crate::module_bindings::AssetObjectAccessPolicy::Private => { - AssetObjectAccessPolicy::Private - } - crate::module_bindings::AssetObjectAccessPolicy::PublicRead => { - AssetObjectAccessPolicy::PublicRead - } - } -} - -pub(crate) fn map_runtime_platform_theme( - value: DomainRuntimePlatformTheme, -) -> crate::module_bindings::RuntimePlatformTheme { - match value { - DomainRuntimePlatformTheme::Light => crate::module_bindings::RuntimePlatformTheme::Light, - DomainRuntimePlatformTheme::Dark => crate::module_bindings::RuntimePlatformTheme::Dark, - } -} - -pub(crate) fn map_runtime_item_reward_item_rarity( - value: DomainRuntimeItemRewardItemRarity, -) -> RuntimeItemRewardItemRarity { - match value { - DomainRuntimeItemRewardItemRarity::Common => RuntimeItemRewardItemRarity::Common, - DomainRuntimeItemRewardItemRarity::Uncommon => RuntimeItemRewardItemRarity::Uncommon, - DomainRuntimeItemRewardItemRarity::Rare => RuntimeItemRewardItemRarity::Rare, - DomainRuntimeItemRewardItemRarity::Epic => RuntimeItemRewardItemRarity::Epic, - DomainRuntimeItemRewardItemRarity::Legendary => RuntimeItemRewardItemRarity::Legendary, - } -} - -pub(crate) fn map_runtime_item_equipment_slot( - value: DomainRuntimeItemEquipmentSlot, -) -> RuntimeItemEquipmentSlot { - match value { - DomainRuntimeItemEquipmentSlot::Weapon => RuntimeItemEquipmentSlot::Weapon, - DomainRuntimeItemEquipmentSlot::Armor => RuntimeItemEquipmentSlot::Armor, - DomainRuntimeItemEquipmentSlot::Relic => RuntimeItemEquipmentSlot::Relic, - } -} - -pub(crate) fn map_custom_world_theme_mode( - value: DomainCustomWorldThemeMode, -) -> CustomWorldThemeMode { - match value { - DomainCustomWorldThemeMode::Martial => CustomWorldThemeMode::Martial, - DomainCustomWorldThemeMode::Arcane => CustomWorldThemeMode::Arcane, - DomainCustomWorldThemeMode::Machina => CustomWorldThemeMode::Machina, - DomainCustomWorldThemeMode::Tide => CustomWorldThemeMode::Tide, - DomainCustomWorldThemeMode::Rift => CustomWorldThemeMode::Rift, - DomainCustomWorldThemeMode::Mythic => CustomWorldThemeMode::Mythic, - } -} - -pub(crate) fn map_battle_mode(value: DomainBattleMode) -> BattleMode { - match value { - DomainBattleMode::Fight => BattleMode::Fight, - DomainBattleMode::Spar => BattleMode::Spar, - } -} - -pub(crate) fn map_runtime_platform_theme_back( - value: crate::module_bindings::RuntimePlatformTheme, -) -> DomainRuntimePlatformTheme { - match value { - crate::module_bindings::RuntimePlatformTheme::Light => DomainRuntimePlatformTheme::Light, - crate::module_bindings::RuntimePlatformTheme::Dark => DomainRuntimePlatformTheme::Dark, - } -} - -pub(crate) fn map_runtime_item_reward_item_rarity_back( - value: RuntimeItemRewardItemRarity, -) -> DomainRuntimeItemRewardItemRarity { - match value { - RuntimeItemRewardItemRarity::Common => DomainRuntimeItemRewardItemRarity::Common, - RuntimeItemRewardItemRarity::Uncommon => DomainRuntimeItemRewardItemRarity::Uncommon, - RuntimeItemRewardItemRarity::Rare => DomainRuntimeItemRewardItemRarity::Rare, - RuntimeItemRewardItemRarity::Epic => DomainRuntimeItemRewardItemRarity::Epic, - RuntimeItemRewardItemRarity::Legendary => DomainRuntimeItemRewardItemRarity::Legendary, - } -} - -pub(crate) fn map_runtime_item_equipment_slot_back( - value: RuntimeItemEquipmentSlot, -) -> DomainRuntimeItemEquipmentSlot { - match value { - RuntimeItemEquipmentSlot::Weapon => DomainRuntimeItemEquipmentSlot::Weapon, - RuntimeItemEquipmentSlot::Armor => DomainRuntimeItemEquipmentSlot::Armor, - RuntimeItemEquipmentSlot::Relic => DomainRuntimeItemEquipmentSlot::Relic, - } -} - -pub(crate) fn map_custom_world_theme_mode_back( - value: CustomWorldThemeMode, -) -> DomainCustomWorldThemeMode { - match value { - CustomWorldThemeMode::Martial => DomainCustomWorldThemeMode::Martial, - CustomWorldThemeMode::Arcane => DomainCustomWorldThemeMode::Arcane, - CustomWorldThemeMode::Machina => DomainCustomWorldThemeMode::Machina, - CustomWorldThemeMode::Tide => DomainCustomWorldThemeMode::Tide, - CustomWorldThemeMode::Rift => DomainCustomWorldThemeMode::Rift, - CustomWorldThemeMode::Mythic => DomainCustomWorldThemeMode::Mythic, - } -} - -pub(crate) fn map_custom_world_publication_status( - value: CustomWorldPublicationStatus, -) -> &'static str { - match value { - CustomWorldPublicationStatus::Draft => "draft", - CustomWorldPublicationStatus::Published => "published", - } -} - -pub(crate) fn map_rpg_agent_stage(value: crate::module_bindings::RpgAgentStage) -> String { - match value { - crate::module_bindings::RpgAgentStage::CollectingIntent => "collecting_intent", - crate::module_bindings::RpgAgentStage::Clarifying => "clarifying", - crate::module_bindings::RpgAgentStage::FoundationReview => "foundation_review", - crate::module_bindings::RpgAgentStage::ObjectRefining => "object_refining", - crate::module_bindings::RpgAgentStage::VisualRefining => "visual_refining", - crate::module_bindings::RpgAgentStage::LongTailReview => "long_tail_review", - crate::module_bindings::RpgAgentStage::ReadyToPublish => "ready_to_publish", - crate::module_bindings::RpgAgentStage::Published => "published", - crate::module_bindings::RpgAgentStage::Error => "error", - } - .to_string() -} - -pub(crate) fn parse_puzzle_agent_stage_record( - value: &str, -) -> Result { - match value.trim() { - "collecting_anchors" => Ok(crate::module_bindings::PuzzleAgentStage::CollectingAnchors), - "draft_ready" => Ok(crate::module_bindings::PuzzleAgentStage::DraftReady), - "image_refining" => Ok(crate::module_bindings::PuzzleAgentStage::ImageRefining), - "ready_to_publish" => Ok(crate::module_bindings::PuzzleAgentStage::ReadyToPublish), - "published" => Ok(crate::module_bindings::PuzzleAgentStage::Published), - other => Err(SpacetimeClientError::Runtime(format!( - "未知 puzzle agent stage: {other}" - ))), - } -} - -pub(crate) fn format_puzzle_agent_stage(value: PuzzleAgentStage) -> &'static str { - match value { - PuzzleAgentStage::CollectingAnchors => "collecting_anchors", - PuzzleAgentStage::DraftReady => "draft_ready", - PuzzleAgentStage::ImageRefining => "image_refining", - PuzzleAgentStage::ReadyToPublish => "ready_to_publish", - PuzzleAgentStage::Published => "published", - } -} - -pub(crate) fn format_puzzle_anchor_status(value: PuzzleAnchorStatus) -> &'static str { - match value { - PuzzleAnchorStatus::Missing => "missing", - PuzzleAnchorStatus::Inferred => "inferred", - PuzzleAnchorStatus::Confirmed => "confirmed", - PuzzleAnchorStatus::Locked => "locked", - } -} - -pub(crate) fn format_puzzle_agent_message_role(value: PuzzleAgentMessageRole) -> &'static str { - match value { - PuzzleAgentMessageRole::User => "user", - PuzzleAgentMessageRole::Assistant => "assistant", - PuzzleAgentMessageRole::System => "system", - } -} - -pub(crate) fn format_puzzle_agent_message_kind(value: PuzzleAgentMessageKind) -> &'static str { - match value { - PuzzleAgentMessageKind::Chat => "chat", - PuzzleAgentMessageKind::Summary => "summary", - PuzzleAgentMessageKind::ActionResult => "action_result", - PuzzleAgentMessageKind::Warning => "warning", - } -} - -pub(crate) fn format_puzzle_publication_status(value: PuzzlePublicationStatus) -> &'static str { - match value { - PuzzlePublicationStatus::Draft => "draft", - PuzzlePublicationStatus::Published => "published", - } -} - -pub(crate) fn format_puzzle_runtime_level_status(value: PuzzleRuntimeLevelStatus) -> &'static str { - match value { - PuzzleRuntimeLevelStatus::Playing => "playing", - PuzzleRuntimeLevelStatus::Cleared => "cleared", - PuzzleRuntimeLevelStatus::Failed => "failed", - } -} - -pub(crate) fn parse_rpg_agent_stage_record( - value: &str, -) -> Result { - match value.trim() { - "collecting_intent" => Ok(crate::module_bindings::RpgAgentStage::CollectingIntent), - "clarifying" => Ok(crate::module_bindings::RpgAgentStage::Clarifying), - "foundation_review" => Ok(crate::module_bindings::RpgAgentStage::FoundationReview), - "object_refining" => Ok(crate::module_bindings::RpgAgentStage::ObjectRefining), - "visual_refining" => Ok(crate::module_bindings::RpgAgentStage::VisualRefining), - "long_tail_review" => Ok(crate::module_bindings::RpgAgentStage::LongTailReview), - "ready_to_publish" => Ok(crate::module_bindings::RpgAgentStage::ReadyToPublish), - "published" => Ok(crate::module_bindings::RpgAgentStage::Published), - "error" => Ok(crate::module_bindings::RpgAgentStage::Error), - other => Err(SpacetimeClientError::Runtime(format!( - "未知 rpg agent stage: {other}" - ))), - } -} - -pub(crate) fn format_rpg_agent_message_role( - value: crate::module_bindings::RpgAgentMessageRole, -) -> &'static str { - match value { - crate::module_bindings::RpgAgentMessageRole::User => "user", - crate::module_bindings::RpgAgentMessageRole::Assistant => "assistant", - crate::module_bindings::RpgAgentMessageRole::System => "system", - } -} - -pub(crate) fn format_rpg_agent_message_kind( - value: crate::module_bindings::RpgAgentMessageKind, -) -> &'static str { - match value { - crate::module_bindings::RpgAgentMessageKind::Chat => "chat", - crate::module_bindings::RpgAgentMessageKind::Clarification => "clarification", - crate::module_bindings::RpgAgentMessageKind::Summary => "summary", - crate::module_bindings::RpgAgentMessageKind::Checkpoint => "checkpoint", - crate::module_bindings::RpgAgentMessageKind::Warning => "warning", - crate::module_bindings::RpgAgentMessageKind::ActionResult => "action_result", - } -} - -pub(crate) fn format_rpg_agent_operation_type( - value: crate::module_bindings::RpgAgentOperationType, -) -> &'static str { - match value { - crate::module_bindings::RpgAgentOperationType::ProcessMessage => "process_message", - crate::module_bindings::RpgAgentOperationType::DraftFoundation => "draft_foundation", - crate::module_bindings::RpgAgentOperationType::UpdateDraftCard => "update_draft_card", - crate::module_bindings::RpgAgentOperationType::SyncResultProfile => "sync_result_profile", - crate::module_bindings::RpgAgentOperationType::GenerateCharacters => "generate_characters", - crate::module_bindings::RpgAgentOperationType::GenerateLandmarks => "generate_landmarks", - crate::module_bindings::RpgAgentOperationType::GenerateRoleAssets => "generate_role_assets", - crate::module_bindings::RpgAgentOperationType::SyncRoleAssets => "sync_role_assets", - crate::module_bindings::RpgAgentOperationType::GenerateSceneAssets => { - "generate_scene_assets" - } - crate::module_bindings::RpgAgentOperationType::SyncSceneAssets => "sync_scene_assets", - crate::module_bindings::RpgAgentOperationType::ExpandLongTail => "expand_long_tail", - crate::module_bindings::RpgAgentOperationType::PublishWorld => "publish_world", - crate::module_bindings::RpgAgentOperationType::RevertCheckpoint => "revert_checkpoint", - crate::module_bindings::RpgAgentOperationType::DeleteCharacters => "delete_characters", - crate::module_bindings::RpgAgentOperationType::DeleteLandmarks => "delete_landmarks", - } -} - -pub(crate) fn parse_rpg_agent_operation_type_record( - value: &str, -) -> Result { - match value.trim() { - "process_message" => Ok(crate::module_bindings::RpgAgentOperationType::ProcessMessage), - "draft_foundation" => Ok(crate::module_bindings::RpgAgentOperationType::DraftFoundation), - "update_draft_card" => Ok(crate::module_bindings::RpgAgentOperationType::UpdateDraftCard), - "sync_result_profile" => { - Ok(crate::module_bindings::RpgAgentOperationType::SyncResultProfile) - } - "generate_characters" => { - Ok(crate::module_bindings::RpgAgentOperationType::GenerateCharacters) - } - "generate_landmarks" => { - Ok(crate::module_bindings::RpgAgentOperationType::GenerateLandmarks) - } - "generate_role_assets" => { - Ok(crate::module_bindings::RpgAgentOperationType::GenerateRoleAssets) - } - "sync_role_assets" => Ok(crate::module_bindings::RpgAgentOperationType::SyncRoleAssets), - "generate_scene_assets" => { - Ok(crate::module_bindings::RpgAgentOperationType::GenerateSceneAssets) - } - "sync_scene_assets" => Ok(crate::module_bindings::RpgAgentOperationType::SyncSceneAssets), - "expand_long_tail" => Ok(crate::module_bindings::RpgAgentOperationType::ExpandLongTail), - "publish_world" => Ok(crate::module_bindings::RpgAgentOperationType::PublishWorld), - "revert_checkpoint" => Ok(crate::module_bindings::RpgAgentOperationType::RevertCheckpoint), - "delete_characters" => Ok(crate::module_bindings::RpgAgentOperationType::DeleteCharacters), - "delete_landmarks" => Ok(crate::module_bindings::RpgAgentOperationType::DeleteLandmarks), - other => Err(SpacetimeClientError::Runtime(format!( - "未知 rpg agent operation type: {other}" - ))), - } -} - -pub(crate) fn format_rpg_agent_operation_status( - value: crate::module_bindings::RpgAgentOperationStatus, -) -> &'static str { - match value { - crate::module_bindings::RpgAgentOperationStatus::Queued => "queued", - crate::module_bindings::RpgAgentOperationStatus::Running => "running", - crate::module_bindings::RpgAgentOperationStatus::Completed => "completed", - crate::module_bindings::RpgAgentOperationStatus::Failed => "failed", - } -} - -pub(crate) fn parse_rpg_agent_operation_status_record( - value: &str, -) -> Result { - match value.trim() { - "queued" => Ok(crate::module_bindings::RpgAgentOperationStatus::Queued), - "running" => Ok(crate::module_bindings::RpgAgentOperationStatus::Running), - "completed" => Ok(crate::module_bindings::RpgAgentOperationStatus::Completed), - "failed" => Ok(crate::module_bindings::RpgAgentOperationStatus::Failed), - other => Err(SpacetimeClientError::Runtime(format!( - "未知 rpg agent operation status: {other}" - ))), - } -} - -pub(crate) fn format_rpg_agent_draft_card_kind( - value: crate::module_bindings::RpgAgentDraftCardKind, -) -> &'static str { - match value { - crate::module_bindings::RpgAgentDraftCardKind::World => "world", - crate::module_bindings::RpgAgentDraftCardKind::Camp => "camp", - crate::module_bindings::RpgAgentDraftCardKind::Faction => "faction", - crate::module_bindings::RpgAgentDraftCardKind::Character => "character", - crate::module_bindings::RpgAgentDraftCardKind::Landmark => "landmark", - crate::module_bindings::RpgAgentDraftCardKind::Thread => "thread", - crate::module_bindings::RpgAgentDraftCardKind::Chapter => "chapter", - crate::module_bindings::RpgAgentDraftCardKind::SceneChapter => "scene_chapter", - crate::module_bindings::RpgAgentDraftCardKind::Carrier => "carrier", - crate::module_bindings::RpgAgentDraftCardKind::SidequestSeed => "sidequest_seed", - } -} - -pub(crate) fn format_rpg_agent_draft_card_status( - value: crate::module_bindings::RpgAgentDraftCardStatus, -) -> &'static str { - match value { - crate::module_bindings::RpgAgentDraftCardStatus::Suggested => "suggested", - crate::module_bindings::RpgAgentDraftCardStatus::Confirmed => "confirmed", - crate::module_bindings::RpgAgentDraftCardStatus::Locked => "locked", - crate::module_bindings::RpgAgentDraftCardStatus::Warning => "warning", - } -} - -pub(crate) fn format_custom_world_role_asset_status_back( - value: crate::module_bindings::CustomWorldRoleAssetStatus, -) -> String { - match value { - crate::module_bindings::CustomWorldRoleAssetStatus::Missing => "missing", - crate::module_bindings::CustomWorldRoleAssetStatus::VisualReady => "visual_ready", - crate::module_bindings::CustomWorldRoleAssetStatus::AnimationsReady => "animations_ready", - crate::module_bindings::CustomWorldRoleAssetStatus::Complete => "complete", - } - .to_string() -} - -impl TryFrom<&str> for BigFishAssetKind { - type Error = SpacetimeClientError; - - fn try_from(value: &str) -> Result { - match value.trim() { - "level_main_image" => Ok(Self::LevelMainImage), - "level_motion" => Ok(Self::LevelMotion), - "stage_background" => Ok(Self::StageBackground), - other => Err(SpacetimeClientError::Runtime(format!( - "big fish asset kind `{other}` 当前尚未支持" - ))), - } - } -} - -pub(crate) fn parse_big_fish_creation_stage( - value: &str, -) -> Result { - match value.trim() { - "collecting_anchors" => Ok(BigFishCreationStage::CollectingAnchors), - "draft_ready" => Ok(BigFishCreationStage::DraftReady), - "asset_refining" => Ok(BigFishCreationStage::AssetRefining), - "ready_to_publish" => Ok(BigFishCreationStage::ReadyToPublish), - "published" => Ok(BigFishCreationStage::Published), - other => Err(SpacetimeClientError::Runtime(format!( - "big fish creation stage `{other}` 当前尚未支持" - ))), - } -} - -pub(crate) fn format_big_fish_creation_stage(value: BigFishCreationStage) -> &'static str { - match value { - BigFishCreationStage::CollectingAnchors => "collecting_anchors", - BigFishCreationStage::DraftReady => "draft_ready", - BigFishCreationStage::AssetRefining => "asset_refining", - BigFishCreationStage::ReadyToPublish => "ready_to_publish", - BigFishCreationStage::Published => "published", - } -} - -pub(crate) fn format_big_fish_anchor_status(value: BigFishAnchorStatus) -> &'static str { - match value { - BigFishAnchorStatus::Confirmed => "confirmed", - BigFishAnchorStatus::Inferred => "inferred", - BigFishAnchorStatus::Missing => "missing", - BigFishAnchorStatus::Locked => "locked", - } -} - -pub(crate) fn format_big_fish_agent_message_role(value: BigFishAgentMessageRole) -> &'static str { - match value { - BigFishAgentMessageRole::User => "user", - BigFishAgentMessageRole::Assistant => "assistant", - BigFishAgentMessageRole::System => "system", - } -} - -pub(crate) fn format_big_fish_agent_message_kind(value: BigFishAgentMessageKind) -> &'static str { - match value { - BigFishAgentMessageKind::Chat => "chat", - BigFishAgentMessageKind::Summary => "summary", - BigFishAgentMessageKind::ActionResult => "action_result", - BigFishAgentMessageKind::Warning => "warning", - } -} - -pub(crate) fn format_big_fish_asset_kind(value: BigFishAssetKind) -> &'static str { - match value { - BigFishAssetKind::LevelMainImage => "level_main_image", - BigFishAssetKind::LevelMotion => "level_motion", - BigFishAssetKind::StageBackground => "stage_background", - } -} - -pub(crate) fn format_big_fish_asset_status(value: BigFishAssetStatus) -> &'static str { - match value { - BigFishAssetStatus::Missing => "missing", - BigFishAssetStatus::Ready => "ready", - } -} - -pub(crate) fn format_big_fish_run_status(value: BigFishRunStatus) -> &'static str { - match value { - BigFishRunStatus::Running => "running", - BigFishRunStatus::Won => "won", - BigFishRunStatus::Failed => "failed", - } -} - -pub(crate) fn format_custom_world_theme_mode(value: DomainCustomWorldThemeMode) -> &'static str { - match value { - DomainCustomWorldThemeMode::Martial => "martial", - DomainCustomWorldThemeMode::Arcane => "arcane", - DomainCustomWorldThemeMode::Machina => "machina", - DomainCustomWorldThemeMode::Tide => "tide", - DomainCustomWorldThemeMode::Rift => "rift", - DomainCustomWorldThemeMode::Mythic => "mythic", - } -} - -pub(crate) fn map_battle_mode_back(value: BattleMode) -> DomainBattleMode { - match value { - BattleMode::Fight => DomainBattleMode::Fight, - BattleMode::Spar => DomainBattleMode::Spar, - } -} - -pub(crate) fn map_runtime_browse_history_theme_mode_back( - value: crate::module_bindings::RuntimeBrowseHistoryThemeMode, -) -> module_runtime::RuntimeBrowseHistoryThemeMode { - match value { - crate::module_bindings::RuntimeBrowseHistoryThemeMode::Martial => { - module_runtime::RuntimeBrowseHistoryThemeMode::Martial - } - crate::module_bindings::RuntimeBrowseHistoryThemeMode::Arcane => { - module_runtime::RuntimeBrowseHistoryThemeMode::Arcane - } - crate::module_bindings::RuntimeBrowseHistoryThemeMode::Machina => { - module_runtime::RuntimeBrowseHistoryThemeMode::Machina - } - crate::module_bindings::RuntimeBrowseHistoryThemeMode::Tide => { - module_runtime::RuntimeBrowseHistoryThemeMode::Tide - } - crate::module_bindings::RuntimeBrowseHistoryThemeMode::Rift => { - module_runtime::RuntimeBrowseHistoryThemeMode::Rift - } - crate::module_bindings::RuntimeBrowseHistoryThemeMode::Mythic => { - module_runtime::RuntimeBrowseHistoryThemeMode::Mythic - } - } -} - -pub(crate) fn map_runtime_profile_wallet_ledger_source_type_back( - value: crate::module_bindings::RuntimeProfileWalletLedgerSourceType, -) -> module_runtime::RuntimeProfileWalletLedgerSourceType { - match value { - crate::module_bindings::RuntimeProfileWalletLedgerSourceType::SnapshotSync => { - module_runtime::RuntimeProfileWalletLedgerSourceType::SnapshotSync - } - crate::module_bindings::RuntimeProfileWalletLedgerSourceType::NewUserRegistrationReward => { - module_runtime::RuntimeProfileWalletLedgerSourceType::NewUserRegistrationReward - } - crate::module_bindings::RuntimeProfileWalletLedgerSourceType::InviteInviterReward => { - module_runtime::RuntimeProfileWalletLedgerSourceType::InviteInviterReward - } - crate::module_bindings::RuntimeProfileWalletLedgerSourceType::InviteInviteeReward => { - module_runtime::RuntimeProfileWalletLedgerSourceType::InviteInviteeReward - } - crate::module_bindings::RuntimeProfileWalletLedgerSourceType::PointsRecharge => { - module_runtime::RuntimeProfileWalletLedgerSourceType::PointsRecharge - } - crate::module_bindings::RuntimeProfileWalletLedgerSourceType::AssetOperationConsume => { - module_runtime::RuntimeProfileWalletLedgerSourceType::AssetOperationConsume - } - crate::module_bindings::RuntimeProfileWalletLedgerSourceType::AssetOperationRefund => { - module_runtime::RuntimeProfileWalletLedgerSourceType::AssetOperationRefund - } - crate::module_bindings::RuntimeProfileWalletLedgerSourceType::RedeemCodeReward => { - module_runtime::RuntimeProfileWalletLedgerSourceType::RedeemCodeReward - } - crate::module_bindings::RuntimeProfileWalletLedgerSourceType::PuzzleAuthorIncentiveClaim => { - module_runtime::RuntimeProfileWalletLedgerSourceType::PuzzleAuthorIncentiveClaim - } - crate::module_bindings::RuntimeProfileWalletLedgerSourceType::DailyTaskReward => { - module_runtime::RuntimeProfileWalletLedgerSourceType::DailyTaskReward - } - } -} - -pub(crate) fn map_analytics_granularity( - granularity: module_runtime::AnalyticsGranularity, -) -> AnalyticsGranularity { - match granularity { - module_runtime::AnalyticsGranularity::Day => AnalyticsGranularity::Day, - module_runtime::AnalyticsGranularity::Week => AnalyticsGranularity::Week, - module_runtime::AnalyticsGranularity::Month => AnalyticsGranularity::Month, - module_runtime::AnalyticsGranularity::Quarter => AnalyticsGranularity::Quarter, - module_runtime::AnalyticsGranularity::Year => AnalyticsGranularity::Year, - } -} - -pub(crate) fn map_runtime_tracking_scope_kind( - value: DomainRuntimeTrackingScopeKind, -) -> crate::module_bindings::RuntimeTrackingScopeKind { - match value { - DomainRuntimeTrackingScopeKind::Site => { - crate::module_bindings::RuntimeTrackingScopeKind::Site - } - DomainRuntimeTrackingScopeKind::Work => { - crate::module_bindings::RuntimeTrackingScopeKind::Work - } - DomainRuntimeTrackingScopeKind::Module => { - crate::module_bindings::RuntimeTrackingScopeKind::Module - } - DomainRuntimeTrackingScopeKind::User => { - crate::module_bindings::RuntimeTrackingScopeKind::User - } - } -} - -pub(crate) fn map_runtime_tracking_scope_kind_back( - value: crate::module_bindings::RuntimeTrackingScopeKind, -) -> DomainRuntimeTrackingScopeKind { - match value { - crate::module_bindings::RuntimeTrackingScopeKind::Site => { - DomainRuntimeTrackingScopeKind::Site - } - crate::module_bindings::RuntimeTrackingScopeKind::Work => { - DomainRuntimeTrackingScopeKind::Work - } - crate::module_bindings::RuntimeTrackingScopeKind::Module => { - DomainRuntimeTrackingScopeKind::Module - } - crate::module_bindings::RuntimeTrackingScopeKind::User => { - DomainRuntimeTrackingScopeKind::User - } - } -} - -pub(crate) fn map_runtime_profile_task_cycle( - value: DomainRuntimeProfileTaskCycle, -) -> crate::module_bindings::RuntimeProfileTaskCycle { - match value { - DomainRuntimeProfileTaskCycle::Daily => { - crate::module_bindings::RuntimeProfileTaskCycle::Daily - } - } -} - -pub(crate) fn map_runtime_profile_task_cycle_back( - value: crate::module_bindings::RuntimeProfileTaskCycle, -) -> DomainRuntimeProfileTaskCycle { - match value { - crate::module_bindings::RuntimeProfileTaskCycle::Daily => { - DomainRuntimeProfileTaskCycle::Daily - } - } -} - -pub(crate) fn map_runtime_profile_task_status_back( - value: crate::module_bindings::RuntimeProfileTaskStatus, -) -> DomainRuntimeProfileTaskStatus { - match value { - crate::module_bindings::RuntimeProfileTaskStatus::Incomplete => { - DomainRuntimeProfileTaskStatus::Incomplete - } - crate::module_bindings::RuntimeProfileTaskStatus::Claimable => { - DomainRuntimeProfileTaskStatus::Claimable - } - crate::module_bindings::RuntimeProfileTaskStatus::Claimed => { - DomainRuntimeProfileTaskStatus::Claimed - } - crate::module_bindings::RuntimeProfileTaskStatus::Disabled => { - DomainRuntimeProfileTaskStatus::Disabled - } - } -} - -pub(crate) fn map_runtime_profile_redeem_code_mode( - value: module_runtime::RuntimeProfileRedeemCodeMode, -) -> crate::module_bindings::RuntimeProfileRedeemCodeMode { - match value { - module_runtime::RuntimeProfileRedeemCodeMode::Public => { - crate::module_bindings::RuntimeProfileRedeemCodeMode::Public - } - module_runtime::RuntimeProfileRedeemCodeMode::Unique => { - crate::module_bindings::RuntimeProfileRedeemCodeMode::Unique - } - module_runtime::RuntimeProfileRedeemCodeMode::Private => { - crate::module_bindings::RuntimeProfileRedeemCodeMode::Private - } - } -} - -pub(crate) fn map_runtime_profile_redeem_code_mode_back( - value: crate::module_bindings::RuntimeProfileRedeemCodeMode, -) -> module_runtime::RuntimeProfileRedeemCodeMode { - match value { - crate::module_bindings::RuntimeProfileRedeemCodeMode::Public => { - module_runtime::RuntimeProfileRedeemCodeMode::Public - } - crate::module_bindings::RuntimeProfileRedeemCodeMode::Unique => { - module_runtime::RuntimeProfileRedeemCodeMode::Unique - } - crate::module_bindings::RuntimeProfileRedeemCodeMode::Private => { - module_runtime::RuntimeProfileRedeemCodeMode::Private - } - } -} - -pub(crate) fn map_runtime_profile_recharge_product_kind( - value: module_runtime::RuntimeProfileRechargeProductKind, -) -> crate::module_bindings::RuntimeProfileRechargeProductKind { - match value { - module_runtime::RuntimeProfileRechargeProductKind::Points => { - crate::module_bindings::RuntimeProfileRechargeProductKind::Points - } - module_runtime::RuntimeProfileRechargeProductKind::Membership => { - crate::module_bindings::RuntimeProfileRechargeProductKind::Membership - } - } -} - -pub(crate) fn map_runtime_profile_recharge_product_kind_back( - value: crate::module_bindings::RuntimeProfileRechargeProductKind, -) -> module_runtime::RuntimeProfileRechargeProductKind { - match value { - crate::module_bindings::RuntimeProfileRechargeProductKind::Points => { - module_runtime::RuntimeProfileRechargeProductKind::Points - } - crate::module_bindings::RuntimeProfileRechargeProductKind::Membership => { - module_runtime::RuntimeProfileRechargeProductKind::Membership - } - } -} - -pub(crate) fn map_runtime_profile_membership_tier( - value: module_runtime::RuntimeProfileMembershipTier, -) -> crate::module_bindings::RuntimeProfileMembershipTier { - match value { - module_runtime::RuntimeProfileMembershipTier::Normal => { - crate::module_bindings::RuntimeProfileMembershipTier::Normal - } - module_runtime::RuntimeProfileMembershipTier::Month => { - crate::module_bindings::RuntimeProfileMembershipTier::Month - } - module_runtime::RuntimeProfileMembershipTier::Season => { - crate::module_bindings::RuntimeProfileMembershipTier::Season - } - module_runtime::RuntimeProfileMembershipTier::Year => { - crate::module_bindings::RuntimeProfileMembershipTier::Year - } - } -} - -pub(crate) fn map_runtime_profile_membership_status_back( - value: crate::module_bindings::RuntimeProfileMembershipStatus, -) -> module_runtime::RuntimeProfileMembershipStatus { - match value { - crate::module_bindings::RuntimeProfileMembershipStatus::Normal => { - module_runtime::RuntimeProfileMembershipStatus::Normal - } - crate::module_bindings::RuntimeProfileMembershipStatus::Active => { - module_runtime::RuntimeProfileMembershipStatus::Active - } - } -} - -pub(crate) fn map_runtime_profile_membership_tier_back( - value: crate::module_bindings::RuntimeProfileMembershipTier, -) -> module_runtime::RuntimeProfileMembershipTier { - match value { - crate::module_bindings::RuntimeProfileMembershipTier::Normal => { - module_runtime::RuntimeProfileMembershipTier::Normal - } - crate::module_bindings::RuntimeProfileMembershipTier::Month => { - module_runtime::RuntimeProfileMembershipTier::Month - } - crate::module_bindings::RuntimeProfileMembershipTier::Season => { - module_runtime::RuntimeProfileMembershipTier::Season - } - crate::module_bindings::RuntimeProfileMembershipTier::Year => { - module_runtime::RuntimeProfileMembershipTier::Year - } - } -} - -pub(crate) fn map_runtime_profile_recharge_order_status_back( - value: crate::module_bindings::RuntimeProfileRechargeOrderStatus, -) -> module_runtime::RuntimeProfileRechargeOrderStatus { - match value { - crate::module_bindings::RuntimeProfileRechargeOrderStatus::Pending => { - module_runtime::RuntimeProfileRechargeOrderStatus::Pending - } - crate::module_bindings::RuntimeProfileRechargeOrderStatus::Paid => { - module_runtime::RuntimeProfileRechargeOrderStatus::Paid - } - crate::module_bindings::RuntimeProfileRechargeOrderStatus::Failed => { - module_runtime::RuntimeProfileRechargeOrderStatus::Failed - } - crate::module_bindings::RuntimeProfileRechargeOrderStatus::Closed => { - module_runtime::RuntimeProfileRechargeOrderStatus::Closed - } - crate::module_bindings::RuntimeProfileRechargeOrderStatus::Refunded => { - module_runtime::RuntimeProfileRechargeOrderStatus::Refunded - } - } -} - -pub(crate) fn map_runtime_profile_feedback_status_back( - value: crate::module_bindings::RuntimeProfileFeedbackStatus, -) -> module_runtime::RuntimeProfileFeedbackStatus { - match value { - crate::module_bindings::RuntimeProfileFeedbackStatus::Open => { - module_runtime::RuntimeProfileFeedbackStatus::Open - } - } -} - -pub(crate) fn map_story_session_status(value: StorySessionStatus) -> DomainStorySessionStatus { - match value { - StorySessionStatus::Active => DomainStorySessionStatus::Active, - StorySessionStatus::Completed => DomainStorySessionStatus::Completed, - StorySessionStatus::Archived => DomainStorySessionStatus::Archived, - } -} - -pub(crate) fn map_battle_status(value: BattleStatus) -> DomainBattleStatus { - match value { - BattleStatus::Ongoing => DomainBattleStatus::Ongoing, - BattleStatus::Resolved => DomainBattleStatus::Resolved, - BattleStatus::Aborted => DomainBattleStatus::Aborted, - } -} - -pub(crate) fn map_story_event_kind(value: StoryEventKind) -> DomainStoryEventKind { - match value { - StoryEventKind::SessionStarted => DomainStoryEventKind::SessionStarted, - StoryEventKind::StoryContinued => DomainStoryEventKind::StoryContinued, - } -} - -pub(crate) fn map_ai_task_kind(value: DomainAiTaskKind) -> AiTaskKind { - match value { - DomainAiTaskKind::StoryGeneration => AiTaskKind::StoryGeneration, - DomainAiTaskKind::CharacterChat => AiTaskKind::CharacterChat, - DomainAiTaskKind::NpcChat => AiTaskKind::NpcChat, - DomainAiTaskKind::CustomWorldGeneration => AiTaskKind::CustomWorldGeneration, - DomainAiTaskKind::QuestIntent => AiTaskKind::QuestIntent, - DomainAiTaskKind::RuntimeItemIntent => AiTaskKind::RuntimeItemIntent, - } -} - -pub(crate) fn map_ai_task_stage_kind(value: DomainAiTaskStageKind) -> AiTaskStageKind { - match value { - DomainAiTaskStageKind::PreparePrompt => AiTaskStageKind::PreparePrompt, - DomainAiTaskStageKind::RequestModel => AiTaskStageKind::RequestModel, - DomainAiTaskStageKind::RepairResponse => AiTaskStageKind::RepairResponse, - DomainAiTaskStageKind::NormalizeResult => AiTaskStageKind::NormalizeResult, - DomainAiTaskStageKind::PersistResult => AiTaskStageKind::PersistResult, - } -} - -pub(crate) fn map_ai_result_reference_kind( - value: DomainAiResultReferenceKind, -) -> AiResultReferenceKind { - match value { - DomainAiResultReferenceKind::StorySession => AiResultReferenceKind::StorySession, - DomainAiResultReferenceKind::StoryEvent => AiResultReferenceKind::StoryEvent, - DomainAiResultReferenceKind::CustomWorldProfile => { - AiResultReferenceKind::CustomWorldProfile - } - DomainAiResultReferenceKind::QuestRecord => AiResultReferenceKind::QuestRecord, - DomainAiResultReferenceKind::RuntimeItemRecord => AiResultReferenceKind::RuntimeItemRecord, - DomainAiResultReferenceKind::AssetObject => AiResultReferenceKind::AssetObject, - } -} - -pub(crate) fn format_ai_task_kind(value: AiTaskKind) -> &'static str { - match value { - AiTaskKind::StoryGeneration => "story_generation", - AiTaskKind::CharacterChat => "character_chat", - AiTaskKind::NpcChat => "npc_chat", - AiTaskKind::CustomWorldGeneration => "custom_world_generation", - AiTaskKind::QuestIntent => "quest_intent", - AiTaskKind::RuntimeItemIntent => "runtime_item_intent", - } -} - -pub(crate) fn format_ai_task_status(value: AiTaskStatus) -> &'static str { - match value { - AiTaskStatus::Pending => "pending", - AiTaskStatus::Running => "running", - AiTaskStatus::Completed => "completed", - AiTaskStatus::Failed => "failed", - AiTaskStatus::Cancelled => "cancelled", - } -} - -pub(crate) fn format_ai_task_stage_kind(value: AiTaskStageKind) -> &'static str { - match value { - AiTaskStageKind::PreparePrompt => "prepare_prompt", - AiTaskStageKind::RequestModel => "request_model", - AiTaskStageKind::RepairResponse => "repair_response", - AiTaskStageKind::NormalizeResult => "normalize_result", - AiTaskStageKind::PersistResult => "persist_result", - } -} - -pub(crate) fn format_ai_task_stage_status(value: AiTaskStageStatus) -> &'static str { - match value { - AiTaskStageStatus::Pending => "pending", - AiTaskStageStatus::Running => "running", - AiTaskStageStatus::Completed => "completed", - AiTaskStageStatus::Skipped => "skipped", - } -} - -pub(crate) fn format_ai_result_reference_kind(value: AiResultReferenceKind) -> &'static str { - match value { - AiResultReferenceKind::StorySession => "story_session", - AiResultReferenceKind::StoryEvent => "story_event", - AiResultReferenceKind::CustomWorldProfile => "custom_world_profile", - AiResultReferenceKind::QuestRecord => "quest_record", - AiResultReferenceKind::RuntimeItemRecord => "runtime_item_record", - AiResultReferenceKind::AssetObject => "asset_object", - } -} - -pub(crate) fn map_combat_outcome(value: CombatOutcome) -> DomainCombatOutcome { - match value { - CombatOutcome::Ongoing => DomainCombatOutcome::Ongoing, - CombatOutcome::Victory => DomainCombatOutcome::Victory, - CombatOutcome::SparComplete => DomainCombatOutcome::SparComplete, - CombatOutcome::Escaped => DomainCombatOutcome::Escaped, - } -} - -pub(crate) fn map_runtime_item_reward_item_snapshot( - snapshot: DomainRuntimeItemRewardItemSnapshot, -) -> RuntimeItemRewardItemSnapshot { - RuntimeItemRewardItemSnapshot { - item_id: snapshot.item_id, - category: snapshot.category, - item_name: snapshot.item_name, - description: snapshot.description, - quantity: snapshot.quantity, - rarity: map_runtime_item_reward_item_rarity(snapshot.rarity), - tags: snapshot.tags, - stackable: snapshot.stackable, - stack_key: snapshot.stack_key, - equipment_slot_id: snapshot - .equipment_slot_id - .map(map_runtime_item_equipment_slot), - } -} - -pub(crate) fn map_runtime_item_reward_item_snapshot_back( - snapshot: RuntimeItemRewardItemSnapshot, -) -> DomainRuntimeItemRewardItemSnapshot { - DomainRuntimeItemRewardItemSnapshot { - item_id: snapshot.item_id, - category: snapshot.category, - item_name: snapshot.item_name, - description: snapshot.description, - quantity: snapshot.quantity, - rarity: map_runtime_item_reward_item_rarity_back(snapshot.rarity), - tags: snapshot.tags, - stackable: snapshot.stackable, - stack_key: snapshot.stack_key, - equipment_slot_id: snapshot - .equipment_slot_id - .map(map_runtime_item_equipment_slot_back), - } -} - -pub(crate) fn parse_json_value( - value: &str, - label: &str, -) -> Result { - serde_json::from_str::(value) - .map_err(|error| SpacetimeClientError::Runtime(format!("{label} 非法: {error}"))) -} - -pub(crate) fn parse_optional_json_value( - value: Option<&str>, - fallback: serde_json::Value, - label: &str, -) -> Result { - match value.map(str::trim).filter(|value| !value.is_empty()) { - Some(value) => parse_json_value(value, label), - None => Ok(fallback), - } -} - -pub(crate) fn parse_json_array( - value: &str, - label: &str, -) -> Result, SpacetimeClientError> { - match parse_json_value(value, label)? { - serde_json::Value::Array(entries) => Ok(entries), - _ => Err(SpacetimeClientError::Runtime(format!( - "{label} 必须是 JSON array" - ))), - } -} - -pub(crate) fn parse_json_string_array( - value: &str, - label: &str, -) -> Result, SpacetimeClientError> { - parse_json_array(value, label)? - .into_iter() - .map(|entry| match entry { - serde_json::Value::String(value) => Ok(value), - _ => Err(SpacetimeClientError::Runtime(format!( - "{label} 必须是 string array" - ))), - }) - .collect() -} - -pub(crate) fn map_custom_world_checkpoint_record( - value: serde_json::Value, -) -> Result { - let object = value.as_object().ok_or_else(|| { - SpacetimeClientError::Runtime("custom world checkpoint 必须是 JSON object".to_string()) - })?; - let checkpoint_id = object - .get("checkpointId") - .and_then(serde_json::Value::as_str) - .map(str::trim) - .filter(|value| !value.is_empty()) - .ok_or_else(|| { - SpacetimeClientError::Runtime("custom world checkpoint.checkpointId 缺失".to_string()) - })?; - let created_at = object - .get("createdAt") - .and_then(serde_json::Value::as_str) - .map(str::trim) - .filter(|value| !value.is_empty()) - .ok_or_else(|| { - SpacetimeClientError::Runtime("custom world checkpoint.createdAt 缺失".to_string()) - })?; - let label = object - .get("label") - .and_then(serde_json::Value::as_str) - .map(str::trim) - .filter(|value| !value.is_empty()) - .ok_or_else(|| { - SpacetimeClientError::Runtime("custom world checkpoint.label 缺失".to_string()) - })?; - - Ok(CustomWorldCheckpointRecord { - checkpoint_id: checkpoint_id.to_string(), - created_at: created_at.to_string(), - label: label.to_string(), - }) -} - -pub(crate) fn parse_supported_actions_json( - value: &str, -) -> Result, SpacetimeClientError> { - parse_json_array(value, "custom world agent supported_actions_json")? - .into_iter() - .map(|entry| { - let object = entry.as_object().ok_or_else(|| { - SpacetimeClientError::Runtime( - "custom world supported action 必须是 JSON object".to_string(), - ) - })?; - let action = object - .get("action") - .and_then(serde_json::Value::as_str) - .map(str::trim) - .filter(|value| !value.is_empty()) - .ok_or_else(|| { - SpacetimeClientError::Runtime( - "custom world supported action.action 缺失".to_string(), - ) - })?; - let enabled = object - .get("enabled") - .and_then(serde_json::Value::as_bool) - .ok_or_else(|| { - SpacetimeClientError::Runtime( - "custom world supported action.enabled 缺失".to_string(), - ) - })?; - - Ok(CustomWorldSupportedActionRecord { - action: action.to_string(), - enabled, - reason: object - .get("reason") - .and_then(serde_json::Value::as_str) - .map(str::trim) - .filter(|value| !value.is_empty()) - .map(ToOwned::to_owned), - }) - }) - .collect() -} - -pub(crate) fn parse_custom_world_publish_gate_record( - value: &str, -) -> Result { - let object = parse_json_value(value, "custom world publish_gate_json")? - .as_object() - .cloned() - .ok_or_else(|| { - SpacetimeClientError::Runtime( - "custom world publish_gate_json 必须是 JSON object".to_string(), - ) - })?; - - let profile_id = object - .get("profileId") - .and_then(serde_json::Value::as_str) - .map(str::trim) - .filter(|value| !value.is_empty()) - .ok_or_else(|| { - SpacetimeClientError::Runtime("custom world publish_gate.profileId 缺失".to_string()) - })?; - let blockers = object - .get("blockers") - .and_then(serde_json::Value::as_array) - .ok_or_else(|| { - SpacetimeClientError::Runtime("custom world publish_gate.blockers 缺失".to_string()) - })? - .iter() - .cloned() - .map(|entry| { - let object = entry.as_object().ok_or_else(|| { - SpacetimeClientError::Runtime( - "custom world publish gate blocker 必须是 JSON object".to_string(), - ) - })?; - let id = object - .get("id") - .and_then(serde_json::Value::as_str) - .map(str::trim) - .filter(|value| !value.is_empty()) - .ok_or_else(|| { - SpacetimeClientError::Runtime( - "custom world publish gate blocker.id 缺失".to_string(), - ) - })?; - let code = object - .get("code") - .and_then(serde_json::Value::as_str) - .map(str::trim) - .filter(|value| !value.is_empty()) - .ok_or_else(|| { - SpacetimeClientError::Runtime( - "custom world publish gate blocker.code 缺失".to_string(), - ) - })?; - let message = object - .get("message") - .and_then(serde_json::Value::as_str) - .map(str::trim) - .filter(|value| !value.is_empty()) - .ok_or_else(|| { - SpacetimeClientError::Runtime( - "custom world publish gate blocker.message 缺失".to_string(), - ) - })?; - - Ok(CustomWorldResultPreviewBlockerRecord { - id: id.to_string(), - code: code.to_string(), - message: message.to_string(), - }) - }) - .collect::, _>>()?; - let blocker_count = object - .get("blockerCount") - .and_then(serde_json::Value::as_u64) - .and_then(|value| u32::try_from(value).ok()) - .ok_or_else(|| { - SpacetimeClientError::Runtime("custom world publish_gate.blockerCount 缺失".to_string()) - })?; - let publish_ready = object - .get("publishReady") - .and_then(serde_json::Value::as_bool) - .ok_or_else(|| { - SpacetimeClientError::Runtime("custom world publish_gate.publishReady 缺失".to_string()) - })?; - let can_enter_world = object - .get("canEnterWorld") - .and_then(serde_json::Value::as_bool) - .ok_or_else(|| { - SpacetimeClientError::Runtime( - "custom world publish_gate.canEnterWorld 缺失".to_string(), - ) - })?; - - Ok(CustomWorldPublishGateRecord { - profile_id: profile_id.to_string(), - blockers, - blocker_count, - publish_ready, - can_enter_world, - }) -} - -#[derive(Clone, Debug, PartialEq, Eq)] -pub struct BattleStateRecord { - pub battle_state_id: String, - pub story_session_id: String, - pub runtime_session_id: String, - pub actor_user_id: String, - pub chapter_id: Option, - pub target_npc_id: String, - pub target_name: String, - pub battle_mode: String, - pub status: String, - pub player_hp: i32, - pub player_max_hp: i32, - pub player_mana: i32, - pub player_max_mana: i32, - pub target_hp: i32, - pub target_max_hp: i32, - pub experience_reward: u32, - pub reward_items: Vec, - pub turn_index: u32, - pub last_action_function_id: Option, - pub last_action_text: Option, - pub last_result_text: Option, - pub last_damage_dealt: i32, - pub last_damage_taken: i32, - pub last_outcome: String, - pub version: u32, - pub created_at: String, - pub updated_at: String, -} - -#[derive(Clone, Debug, PartialEq, Eq)] -pub struct ResolveCombatActionRecord { - pub battle_state: BattleStateRecord, - pub damage_dealt: i32, - pub damage_taken: i32, - pub outcome: String, -} - -#[derive(Clone, Debug, PartialEq)] -pub struct CustomWorldLibraryEntryRecord { - pub owner_user_id: String, - pub profile_id: String, - pub public_work_code: Option, - pub author_public_user_code: Option, - pub profile: serde_json::Value, - pub visibility: String, - pub published_at: Option, - pub updated_at: String, - pub author_display_name: String, - pub world_name: String, - pub subtitle: String, - pub summary_text: String, - pub cover_image_src: Option, - pub theme_mode: String, - pub playable_npc_count: u32, - pub landmark_count: u32, - pub play_count: u32, - pub remix_count: u32, - pub like_count: u32, - pub recent_play_count_7d: u32, -} - -#[derive(Clone, Debug, PartialEq, Eq)] -pub struct CustomWorldGalleryEntryRecord { - pub owner_user_id: String, - pub profile_id: String, - pub public_work_code: String, - pub author_public_user_code: String, - pub visibility: String, - pub published_at: Option, - pub updated_at: String, - pub author_display_name: String, - pub world_name: String, - pub subtitle: String, - pub summary_text: String, - pub cover_image_src: Option, - pub theme_mode: String, - pub playable_npc_count: u32, - pub landmark_count: u32, - pub play_count: u32, - pub remix_count: u32, - pub like_count: u32, - pub recent_play_count_7d: u32, -} - -#[derive(Clone, Debug, PartialEq)] -pub struct CustomWorldLibraryMutationRecord { - pub entry: CustomWorldLibraryEntryRecord, - pub gallery_entry: Option, -} - -#[derive(Clone, Debug, PartialEq)] -pub struct CustomWorldPublishedProfileCompileRecord { - pub profile_id: String, - pub owner_user_id: String, - pub world_name: String, - pub subtitle: String, - pub summary_text: String, - pub theme_mode: String, - pub cover_image_src: Option, - pub playable_npc_count: u32, - pub landmark_count: u32, - pub author_display_name: String, - pub compiled_profile: serde_json::Value, - pub updated_at: String, -} - -#[derive(Clone, Debug, PartialEq)] -pub struct CustomWorldPublishWorldRecord { - pub compiled_record: CustomWorldPublishedProfileCompileRecord, - pub entry: CustomWorldLibraryEntryRecord, - pub gallery_entry: Option, - pub session_stage: String, -} - -#[derive(Clone, Debug, PartialEq)] -pub struct CustomWorldAgentMessageRecord { - pub message_id: String, - pub role: String, - pub kind: String, - pub text: String, - pub created_at: String, - pub related_operation_id: Option, -} - -#[derive(Clone, Debug, PartialEq)] -pub struct CustomWorldAgentOperationRecord { - pub operation_id: String, - pub operation_type: String, - pub status: String, - pub phase_label: String, - pub phase_detail: String, - pub progress: u32, - pub error_message: Option, - pub started_at_micros: i64, - pub updated_at_micros: i64, -} - -#[derive(Clone, Debug, PartialEq, Eq)] -pub struct CustomWorldAgentOperationProgressRecordInput { - pub session_id: String, - pub owner_user_id: String, - pub operation_id: String, - // SpacetimeDB 模块侧使用枚举存储操作类型,这里保留字符串给 API 层做轻量传参。 - pub operation_type: String, - pub operation_status: String, - pub phase_label: String, - pub phase_detail: String, - pub operation_progress: u32, - pub error_message: Option, - pub updated_at_micros: i64, -} - -#[derive(Clone, Debug, PartialEq)] -pub struct CustomWorldDraftCardRecord { - pub card_id: String, - pub kind: String, - pub title: String, - pub subtitle: String, - pub summary: String, - pub status: String, - pub linked_ids: Vec, - pub warning_count: u32, - pub asset_status: Option, - pub asset_status_label: Option, - pub detail_payload: Option, -} - -#[derive(Clone, Debug, PartialEq)] -pub struct CustomWorldSupportedActionRecord { - pub action: String, - pub enabled: bool, - pub reason: Option, -} - -#[derive(Clone, Debug, PartialEq)] -pub struct CustomWorldCheckpointRecord { - pub checkpoint_id: String, - pub created_at: String, - pub label: String, -} - -// 兼容并行 custom world facade 中仍在使用的旧命名,避免本轮 module-npc 收口被无关改动阻塞。 -pub type CustomWorldAgentCheckpointRecord = CustomWorldCheckpointRecord; - -#[derive(Clone, Debug, PartialEq, Eq)] -pub struct CustomWorldResultPreviewBlockerRecord { - pub id: String, - pub code: String, - pub message: String, -} - -#[derive(Clone, Debug, PartialEq, Eq)] -pub struct CustomWorldPublishGateRecord { - pub profile_id: String, - pub blockers: Vec, - pub blocker_count: u32, - pub publish_ready: bool, - pub can_enter_world: bool, -} - -#[derive(Clone, Debug, PartialEq, Eq)] -pub struct CustomWorldWorkSummaryRecord { - pub work_id: String, - pub source_type: String, - pub status: String, - pub title: String, - pub subtitle: String, - pub summary: String, - pub cover_image_src: Option, - pub cover_render_mode: Option, - pub cover_character_image_srcs: Vec, - pub updated_at: String, - pub published_at: Option, - pub stage: Option, - pub stage_label: Option, - pub playable_npc_count: u32, - pub landmark_count: u32, - pub role_visual_ready_count: Option, - pub role_animation_ready_count: Option, - pub role_asset_summary_label: Option, - pub session_id: Option, - pub profile_id: Option, - pub can_resume: bool, - pub can_enter_world: bool, - pub blocker_count: u32, - pub publish_ready: bool, -} - -#[derive(Clone, Debug, PartialEq, Eq)] -pub struct CustomWorldDraftCardDetailSectionRecord { - pub section_id: String, - pub label: String, - pub value: String, -} - -#[derive(Clone, Debug, PartialEq, Eq)] -pub struct CustomWorldDraftCardDetailRecord { - pub card_id: String, - pub kind: String, - pub title: String, - pub sections: Vec, - pub linked_ids: Vec, - pub locked: bool, - pub editable: bool, - pub editable_section_ids: Vec, - pub warning_messages: Vec, - pub asset_status: Option, - pub asset_status_label: Option, -} - -#[derive(Clone, Debug, PartialEq)] -pub struct CustomWorldAgentSessionRecord { - pub session_id: String, - pub seed_text: String, - pub current_turn: u32, - pub anchor_content: serde_json::Value, - pub progress_percent: u32, - pub last_assistant_reply: Option, - pub stage: String, - pub focus_card_id: Option, - pub creator_intent: serde_json::Value, - pub creator_intent_readiness: serde_json::Value, - pub anchor_pack: serde_json::Value, - pub lock_state: serde_json::Value, - pub draft_profile: serde_json::Value, - pub messages: Vec, - pub draft_cards: Vec, - pub pending_clarifications: Vec, - pub suggested_actions: Vec, - pub recommended_replies: Vec, - pub quality_findings: Vec, - pub asset_coverage: serde_json::Value, - pub checkpoints: Vec, - pub supported_actions: Vec, - pub publish_gate: Option, - pub result_preview: Option, - pub updated_at: String, -} - -#[derive(Clone, Debug, PartialEq, Eq)] -pub struct CustomWorldProfileUpsertRecordInput { - pub profile_id: String, - pub owner_user_id: String, - pub public_work_code: Option, - pub author_public_user_code: Option, - pub source_agent_session_id: Option, - pub world_name: String, - pub subtitle: String, - pub summary_text: String, - pub theme_mode: DomainCustomWorldThemeMode, - pub cover_image_src: Option, - pub profile_payload_json: String, - pub playable_npc_count: u32, - pub landmark_count: u32, - pub author_display_name: String, - pub updated_at_micros: i64, -} - -#[derive(Clone, Debug, PartialEq, Eq)] -pub struct CustomWorldProfileRemixRecordInput { - pub source_owner_user_id: String, - pub source_profile_id: String, - pub target_owner_user_id: String, - pub target_profile_id: String, - pub author_display_name: String, - pub remixed_at_micros: i64, -} - -#[derive(Clone, Debug, PartialEq, Eq)] -pub struct CustomWorldProfilePlayReportRecordInput { - pub owner_user_id: String, - pub profile_id: String, - pub played_at_micros: i64, -} - -#[derive(Clone, Debug, PartialEq, Eq)] -pub struct CustomWorldProfileLikeReportRecordInput { - pub owner_user_id: String, - pub profile_id: String, - pub user_id: String, - pub liked_at_micros: i64, -} - -#[derive(Clone, Debug, PartialEq, Eq)] -pub struct CustomWorldPublishWorldRecordInput { - pub session_id: String, - pub profile_id: String, - pub owner_user_id: String, - pub public_work_code: Option, - pub author_public_user_code: String, - pub draft_profile_json: String, - pub legacy_result_profile_json: Option, - pub setting_text: String, - pub author_display_name: String, - pub published_at_micros: i64, -} - -#[derive(Clone, Debug, PartialEq, Eq)] -pub struct CustomWorldAgentSessionCreateRecordInput { - pub session_id: String, - pub owner_user_id: String, - pub seed_text: String, - pub welcome_message_id: String, - pub welcome_message_text: String, - pub anchor_content_json: String, - pub creator_intent_json: Option, - pub creator_intent_readiness_json: String, - pub anchor_pack_json: Option, - pub lock_state_json: Option, - pub draft_profile_json: Option, - pub pending_clarifications_json: String, - pub suggested_actions_json: String, - pub recommended_replies_json: String, - pub quality_findings_json: String, - pub asset_coverage_json: String, - pub checkpoints_json: String, - pub created_at_micros: i64, -} - -#[derive(Clone, Debug, PartialEq, Eq)] -pub struct CustomWorldAgentMessageSubmitRecordInput { - pub session_id: String, - pub owner_user_id: String, - pub user_message_id: String, - pub user_message_text: String, - pub operation_id: String, - pub submitted_at_micros: i64, -} - -#[derive(Clone, Debug, PartialEq, Eq)] -pub struct CustomWorldAgentMessageFinalizeRecordInput { - pub session_id: String, - pub owner_user_id: String, - pub operation_id: String, - pub assistant_message_id: Option, - pub assistant_reply_text: Option, - pub phase_label: String, - pub phase_detail: String, - pub operation_status: String, - pub operation_progress: u32, - pub stage: String, - pub progress_percent: u32, - pub focus_card_id: Option, - pub anchor_content_json: String, - pub creator_intent_json: Option, - pub creator_intent_readiness_json: String, - pub anchor_pack_json: Option, - pub draft_profile_json: Option, - pub pending_clarifications_json: String, - pub suggested_actions_json: String, - pub recommended_replies_json: String, - pub quality_findings_json: String, - pub asset_coverage_json: String, - pub error_message: Option, - pub updated_at_micros: i64, -} - -#[derive(Clone, Debug, PartialEq, Eq)] -pub struct CustomWorldAgentActionExecuteRecordInput { - pub session_id: String, - pub owner_user_id: String, - pub operation_id: String, - pub action: String, - pub payload_json: Option, - pub submitted_at_micros: i64, -} - -#[derive(Clone, Debug, PartialEq)] -pub struct CustomWorldAgentActionExecuteRecord { - pub operation: CustomWorldAgentOperationRecord, -} - -#[derive(Clone, Debug, PartialEq, Eq)] -pub struct PuzzleAgentSessionCreateRecordInput { - pub session_id: String, - pub owner_user_id: String, - pub seed_text: String, - pub welcome_message_id: String, - pub welcome_message_text: String, - pub created_at_micros: i64, -} - -#[derive(Clone, Debug, PartialEq, Eq)] -pub struct PuzzleFormDraftSaveRecordInput { - pub session_id: String, - pub owner_user_id: String, - pub seed_text: String, - pub saved_at_micros: i64, -} - -#[derive(Clone, Debug, PartialEq, Eq)] -pub struct PuzzleAgentMessageSubmitRecordInput { - pub session_id: String, - pub owner_user_id: String, - pub user_message_id: String, - pub user_message_text: String, - pub submitted_at_micros: i64, -} - -#[derive(Clone, Debug, PartialEq, Eq)] -pub struct PuzzleAgentMessageFinalizeRecordInput { - pub session_id: String, - pub owner_user_id: String, - pub assistant_message_id: Option, - pub assistant_reply_text: Option, - pub stage: String, - pub progress_percent: u32, - pub anchor_pack_json: String, - pub error_message: Option, - pub updated_at_micros: i64, -} - -#[derive(Clone, Debug, PartialEq, Eq)] -pub struct PuzzleGeneratedImagesSaveRecordInput { - pub session_id: String, - pub owner_user_id: String, - pub level_id: Option, - pub levels_json: Option, - pub candidates_json: String, - pub saved_at_micros: i64, -} - -#[derive(Clone, Debug, PartialEq, Eq)] -pub struct PuzzleUiBackgroundSaveRecordInput { - pub session_id: String, - pub owner_user_id: String, - pub level_id: Option, - pub levels_json: Option, - pub prompt: String, - pub image_src: String, - pub image_object_key: Option, - pub saved_at_micros: i64, -} - -#[derive(Clone, Debug, PartialEq, Eq)] -pub struct PuzzleSelectCoverImageRecordInput { - pub session_id: String, - pub owner_user_id: String, - pub level_id: Option, - pub candidate_id: String, - pub selected_at_micros: i64, -} - -#[derive(Clone, Debug, PartialEq, Eq)] -pub struct PuzzlePublishRecordInput { - pub session_id: String, - pub owner_user_id: String, - pub work_id: String, - pub profile_id: String, - pub author_display_name: String, - pub work_title: Option, - pub work_description: Option, - pub level_name: Option, - pub summary: Option, - pub theme_tags: Option>, - pub levels_json: Option, - pub published_at_micros: i64, -} - -#[derive(Clone, Debug, PartialEq, Eq)] -pub struct PuzzleWorkUpsertRecordInput { - pub profile_id: String, - pub owner_user_id: String, - pub work_title: String, - pub work_description: String, - pub level_name: String, - pub summary: String, - pub theme_tags: Vec, - pub cover_image_src: Option, - pub cover_asset_id: Option, - pub levels_json: Option, - pub updated_at_micros: i64, -} - -#[derive(Clone, Debug, PartialEq, Eq)] -pub struct PuzzleWorkRemixRecordInput { - pub source_profile_id: String, - pub target_owner_user_id: String, - pub target_session_id: String, - pub target_profile_id: String, - pub target_work_id: String, - pub author_display_name: String, - pub welcome_message_id: String, - pub remixed_at_micros: i64, -} - -#[derive(Clone, Debug, PartialEq, Eq)] -pub struct PuzzleWorkLikeReportRecordInput { - pub profile_id: String, - pub user_id: String, - pub liked_at_micros: i64, -} - -#[derive(Clone, Debug, PartialEq, Eq)] -pub struct PuzzleRunStartRecordInput { - pub run_id: String, - pub owner_user_id: String, - pub profile_id: String, - pub level_id: Option, - pub started_at_micros: i64, -} - -#[derive(Clone, Debug, PartialEq, Eq)] -pub struct PuzzleRunSwapRecordInput { - pub run_id: String, - pub owner_user_id: String, - pub first_piece_id: String, - pub second_piece_id: String, - pub swapped_at_micros: i64, -} - -#[derive(Clone, Debug, PartialEq, Eq)] -pub struct PuzzleRunDragRecordInput { - pub run_id: String, - pub owner_user_id: String, - pub piece_id: String, - pub target_row: u32, - pub target_col: u32, - pub dragged_at_micros: i64, -} - -#[derive(Clone, Debug, PartialEq, Eq)] -pub struct PuzzleRunNextLevelRecordInput { - pub run_id: String, - pub owner_user_id: String, - pub target_profile_id: Option, - pub advanced_at_micros: i64, -} - -#[derive(Clone, Debug, PartialEq, Eq)] -pub struct PuzzleRunPauseRecordInput { - pub run_id: String, - pub owner_user_id: String, - pub paused: bool, - pub updated_at_micros: i64, -} - -#[derive(Clone, Debug, PartialEq, Eq)] -pub struct PuzzleRunPropRecordInput { - pub run_id: String, - pub owner_user_id: String, - pub prop_kind: String, - pub used_at_micros: i64, - pub spent_points: u64, -} - -#[derive(Clone, Debug, PartialEq, Eq)] -pub struct BigFishPlayReportRecordInput { - pub session_id: String, - pub user_id: String, - pub elapsed_ms: u64, - pub reported_at_micros: i64, -} - -#[derive(Clone, Debug, PartialEq, Eq)] -pub struct BigFishRunStartRecordInput { - pub run_id: String, - pub session_id: String, - pub owner_user_id: String, - pub started_at_micros: i64, -} - -#[derive(Clone, Debug, PartialEq)] -pub struct BigFishInputSubmitRecordInput { - pub run_id: String, - pub owner_user_id: String, - pub x: f32, - pub y: f32, - pub submitted_at_micros: i64, -} - -#[derive(Clone, Debug, PartialEq, Eq)] -pub struct BigFishLikeReportRecordInput { - pub session_id: String, - pub user_id: String, - pub liked_at_micros: i64, -} - -#[derive(Clone, Debug, PartialEq, Eq)] -pub struct BigFishWorkRemixRecordInput { - pub source_session_id: String, - pub target_session_id: String, - pub target_owner_user_id: String, - pub welcome_message_id: String, - pub remixed_at_micros: i64, -} - -#[derive(Clone, Debug, PartialEq, Eq)] -pub struct Match3DAgentSessionCreateRecordInput { - pub session_id: String, - pub owner_user_id: String, - pub seed_text: String, - pub welcome_message_id: String, - pub welcome_message_text: String, - pub config_json: Option, - pub created_at_micros: i64, -} - -#[derive(Clone, Debug, PartialEq, Eq)] -pub struct Match3DAgentMessageSubmitRecordInput { - pub session_id: String, - pub owner_user_id: String, - pub user_message_id: String, - pub user_message_text: String, - pub submitted_at_micros: i64, -} - -#[derive(Clone, Debug, PartialEq, Eq)] -pub struct Match3DAgentMessageFinalizeRecordInput { - pub session_id: String, - pub owner_user_id: String, - pub assistant_message_id: Option, - pub assistant_reply_text: Option, - pub config_json: Option, - pub progress_percent: u32, - pub stage: String, - pub updated_at_micros: i64, - pub error_message: Option, -} - -#[derive(Clone, Debug, PartialEq, Eq)] -pub struct Match3DCompileDraftRecordInput { - pub session_id: String, - pub owner_user_id: String, - pub profile_id: String, - pub author_display_name: String, - pub game_name: Option, - pub summary_text: Option, - pub tags_json: Option, - pub cover_image_src: Option, - pub cover_asset_id: Option, - pub compiled_at_micros: i64, - pub generated_item_assets_json: Option, -} - -#[derive(Clone, Debug, PartialEq, Eq)] -pub struct Match3DWorkUpdateRecordInput { - pub profile_id: String, - pub owner_user_id: String, - pub game_name: String, - pub theme_text: String, - pub summary_text: String, - pub tags_json: String, - pub cover_image_src: String, - pub cover_asset_id: String, - pub clear_count: u32, - pub difficulty: u32, - pub updated_at_micros: i64, -} - -#[derive(Clone, Debug, PartialEq, Eq)] -pub struct Match3DRunStartRecordInput { - pub run_id: String, - pub owner_user_id: String, - pub profile_id: String, - pub started_at_ms: i64, - pub item_type_count_override: u32, -} - -#[derive(Clone, Debug, PartialEq, Eq)] -pub struct Match3DRunClickRecordInput { - pub run_id: String, - pub owner_user_id: String, - pub item_instance_id: String, - pub client_snapshot_version: u32, - pub client_event_id: String, - pub clicked_at_ms: i64, -} - -#[derive(Clone, Debug, PartialEq, Eq)] -pub struct Match3DRunStopRecordInput { - pub run_id: String, - pub owner_user_id: String, - pub stopped_at_ms: i64, -} - -#[derive(Clone, Debug, PartialEq, Eq)] -pub struct Match3DRunRestartRecordInput { - pub source_run_id: String, - pub next_run_id: String, - pub owner_user_id: String, - pub restarted_at_ms: i64, -} - -#[derive(Clone, Debug, PartialEq, Eq)] -pub struct Match3DRunTimeUpRecordInput { - pub run_id: String, - pub owner_user_id: String, - pub finished_at_ms: i64, -} - -#[derive(Clone, Debug, PartialEq, Eq)] -pub struct Match3DAnchorItemRecord { - pub key: String, - pub label: String, - pub value: String, - pub status: String, -} - -#[derive(Clone, Debug, PartialEq, Eq)] -pub struct Match3DAnchorPackRecord { - pub theme: Match3DAnchorItemRecord, - pub clear_count: Match3DAnchorItemRecord, - pub difficulty: Match3DAnchorItemRecord, -} - -#[derive(Clone, Debug, PartialEq, Eq)] -pub struct Match3DCreatorConfigRecord { - pub theme_text: String, - pub reference_image_src: Option, - pub clear_count: u32, - pub difficulty: u32, - pub asset_style_id: Option, - pub asset_style_label: Option, - pub asset_style_prompt: Option, - pub generate_click_sound: bool, -} - -#[derive(Clone, Debug, PartialEq, Eq)] -pub struct Match3DResultDraftRecord { - pub profile_id: String, - pub game_name: String, - pub theme_text: String, - pub summary_text: String, - pub tags: Vec, - pub cover_image_src: Option, - pub reference_image_src: Option, - pub clear_count: u32, - pub difficulty: u32, - pub generated_item_assets_json: Option, - pub total_item_count: u32, - pub publish_ready: bool, - pub blockers: Vec, -} - -#[derive(Clone, Debug, PartialEq, Eq)] -pub struct Match3DAgentMessageRecord { - pub message_id: String, - pub role: String, - pub kind: String, - pub text: String, - pub created_at: String, -} - -#[derive(Clone, Debug, PartialEq, Eq)] -pub struct Match3DAgentSessionRecord { - pub session_id: String, - pub current_turn: u32, - pub progress_percent: u32, - pub stage: String, - pub anchor_pack: Match3DAnchorPackRecord, - pub config: Option, - pub draft: Option, - pub messages: Vec, - pub last_assistant_reply: Option, - pub published_profile_id: Option, - pub updated_at: String, -} - -#[derive(Clone, Debug, PartialEq, Eq)] -pub struct Match3DWorkProfileRecord { - pub work_id: String, - pub profile_id: String, - pub owner_user_id: String, - pub source_session_id: Option, - pub author_display_name: String, - pub game_name: String, - pub theme_text: String, - pub summary: String, - pub tags: Vec, - pub cover_image_src: Option, - pub cover_asset_id: Option, - pub reference_image_src: Option, - pub clear_count: u32, - pub difficulty: u32, - pub publication_status: String, - pub play_count: u32, - pub updated_at: String, - pub published_at: Option, - pub publish_ready: bool, - pub generated_item_assets_json: Option, -} - -#[derive(Clone, Debug, PartialEq)] -pub struct Match3DItemSnapshotRecord { - pub item_instance_id: String, - pub item_type_id: String, - pub visual_key: String, - pub x: f32, - pub y: f32, - pub radius: f32, - pub layer: u32, - pub state: String, - pub clickable: bool, - pub tray_slot_index: Option, -} - -#[derive(Clone, Debug, PartialEq, Eq)] -pub struct Match3DTraySlotRecord { - pub slot_index: u32, - pub item_instance_id: Option, - pub item_type_id: Option, - pub visual_key: Option, -} - -#[derive(Clone, Debug, PartialEq)] -pub struct Match3DRunRecord { - pub run_id: String, - pub profile_id: String, - pub owner_user_id: String, - pub status: String, - pub snapshot_version: u64, - pub started_at_ms: u64, - pub duration_limit_ms: u64, - pub server_now_ms: Option, - pub remaining_ms: u64, - pub clear_count: u32, - pub total_item_count: u32, - pub cleared_item_count: u32, - pub items: Vec, - pub tray_slots: Vec, - pub failure_reason: Option, - pub last_confirmed_action_id: Option, -} - -#[derive(Clone, Debug, PartialEq)] -pub struct Match3DClickConfirmationRecord { - pub status: String, - pub accepted: bool, - pub reject_reason: Option, - pub accepted_item_instance_id: Option, - pub entered_slot_index: Option, - pub cleared_item_instance_ids: Vec, - pub failure_reason: Option, - pub run: Match3DRunRecord, -} - -#[derive(Clone, Debug, PartialEq, Eq)] -pub struct SquareHoleAgentSessionCreateRecordInput { - pub session_id: String, - pub owner_user_id: String, - pub seed_text: String, - pub welcome_message_id: String, - pub welcome_message_text: String, - pub config_json: Option, - pub created_at_micros: i64, -} - -#[derive(Clone, Debug, PartialEq, Eq)] -pub struct SquareHoleAgentMessageSubmitRecordInput { - pub session_id: String, - pub owner_user_id: String, - pub user_message_id: String, - pub user_message_text: String, - pub submitted_at_micros: i64, -} - -#[derive(Clone, Debug, PartialEq, Eq)] -pub struct SquareHoleAgentMessageFinalizeRecordInput { - pub session_id: String, - pub owner_user_id: String, - pub assistant_message_id: Option, - pub assistant_reply_text: Option, - pub config_json: Option, - pub progress_percent: u32, - pub stage: String, - pub updated_at_micros: i64, - pub error_message: Option, -} - -#[derive(Clone, Debug, PartialEq, Eq)] -pub struct SquareHoleCompileDraftRecordInput { - pub session_id: String, - pub owner_user_id: String, - pub profile_id: String, - pub author_display_name: String, - pub game_name: Option, - pub summary_text: Option, - pub tags_json: Option, - pub cover_image_src: Option, - pub compiled_at_micros: i64, -} - -#[derive(Clone, Debug, PartialEq, Eq)] -pub struct SquareHoleWorkUpdateRecordInput { - pub profile_id: String, - pub owner_user_id: String, - pub game_name: String, - pub theme_text: String, - pub twist_rule: String, - pub summary_text: String, - pub tags_json: String, - pub cover_image_src: String, - pub background_prompt: String, - pub background_image_src: String, - pub shape_options_json: String, - pub hole_options_json: String, - pub shape_count: u32, - pub difficulty: u32, - pub updated_at_micros: i64, -} - -#[derive(Clone, Debug, PartialEq, Eq)] -pub struct SquareHoleRunStartRecordInput { - pub run_id: String, - pub owner_user_id: String, - pub profile_id: String, - pub started_at_ms: i64, -} - -#[derive(Clone, Debug, PartialEq, Eq)] -pub struct SquareHoleRunDropRecordInput { - pub run_id: String, - pub owner_user_id: String, - pub hole_id: String, - pub client_snapshot_version: u64, - pub client_event_id: String, - pub dropped_at_ms: i64, -} - -#[derive(Clone, Debug, PartialEq, Eq)] -pub struct SquareHoleRunStopRecordInput { - pub run_id: String, - pub owner_user_id: String, - pub stopped_at_ms: i64, -} - -#[derive(Clone, Debug, PartialEq, Eq)] -pub struct SquareHoleRunRestartRecordInput { - pub source_run_id: String, - pub next_run_id: String, - pub owner_user_id: String, - pub restarted_at_ms: i64, -} - -#[derive(Clone, Debug, PartialEq, Eq)] -pub struct SquareHoleRunTimeUpRecordInput { - pub run_id: String, - pub owner_user_id: String, - pub finished_at_ms: i64, -} - -#[derive(Clone, Debug, PartialEq, Eq)] -pub struct VisualNovelAgentSessionCreateRecordInput { - pub session_id: String, - pub owner_user_id: String, - pub source_mode: String, - pub seed_text: String, - pub source_asset_ids_json: String, - pub welcome_message_id: String, - pub welcome_message_text: String, - pub draft_json: Option, - pub created_at_micros: i64, -} - -#[derive(Clone, Debug, PartialEq, Eq)] -pub struct VisualNovelAgentMessageSubmitRecordInput { - pub session_id: String, - pub owner_user_id: String, - pub user_message_id: String, - pub user_message_text: String, - pub submitted_at_micros: i64, -} - -#[derive(Clone, Debug, PartialEq, Eq)] -pub struct VisualNovelAgentMessageFinalizeRecordInput { - pub session_id: String, - pub owner_user_id: String, - pub assistant_message_id: Option, - pub assistant_reply_text: Option, - pub draft_json: Option, - pub pending_action_json: Option, - pub status: String, - pub progress_percent: u32, - pub updated_at_micros: i64, - pub error_message: Option, -} - -#[derive(Clone, Debug, PartialEq, Eq)] -pub struct VisualNovelWorkCompileRecordInput { - pub session_id: String, - pub owner_user_id: String, - pub profile_id: String, - pub work_id: Option, - pub author_display_name: String, - pub work_title: Option, - pub work_description: Option, - pub tags_json: Option, - pub cover_image_src: Option, - pub compiled_at_micros: i64, -} - -#[derive(Clone, Debug, PartialEq, Eq)] -pub struct VisualNovelWorkUpdateRecordInput { - pub profile_id: String, - pub owner_user_id: String, - pub work_title: String, - pub work_description: String, - pub tags_json: String, - pub cover_image_src: Option, - pub source_asset_ids_json: String, - pub draft_json: String, - pub publish_ready: bool, - pub updated_at_micros: i64, -} - -#[derive(Clone, Debug, PartialEq, Eq)] -pub struct VisualNovelRunStartRecordInput { - pub run_id: String, - pub owner_user_id: String, - pub profile_id: String, - pub mode: String, - pub snapshot_json: Option, - pub started_at_micros: i64, -} - -#[derive(Clone, Debug, PartialEq, Eq)] -pub struct VisualNovelRunSnapshotRecordInput { - pub run_id: String, - pub owner_user_id: String, - pub status: String, - pub current_scene_id: Option, - pub current_phase_id: Option, - pub visible_character_ids_json: String, - pub flags_json: String, - pub metrics_json: String, - pub available_choices_json: String, - pub text_mode_enabled: bool, - pub snapshot_json: Option, - pub updated_at_micros: i64, -} - -#[derive(Clone, Debug, PartialEq, Eq)] -pub struct VisualNovelHistoryEntryRecordInput { - pub entry_id: String, - pub run_id: String, - pub owner_user_id: String, - pub turn_index: u32, - pub source: String, - pub action_text: Option, - pub steps_json: String, - pub snapshot_before_hash: Option, - pub snapshot_after_hash: Option, - pub created_at_micros: i64, -} - -#[derive(Clone, Debug, PartialEq, Eq)] -pub struct VisualNovelRuntimeEventRecordInput { - pub event_id: String, - pub run_id: String, - pub owner_user_id: String, - pub profile_id: Option, - pub event_kind: String, - pub client_event_id: Option, - pub history_entry_id: Option, - pub payload_json: String, - pub occurred_at_micros: i64, -} - -#[derive(Clone, Debug, PartialEq)] -pub struct VisualNovelAgentMessageRecord { - pub message_id: String, - pub session_id: String, - pub role: String, - pub kind: String, - pub text: String, - pub created_at: String, -} - -#[derive(Clone, Debug, PartialEq)] -pub struct VisualNovelAgentSessionRecord { - pub session_id: String, - pub owner_user_id: String, - pub source_mode: String, - pub status: String, - pub seed_text: String, - pub source_asset_ids: Vec, - pub current_turn: u32, - pub progress_percent: u32, - pub messages: Vec, - pub draft: Option, - pub pending_action: Option, - pub last_assistant_reply: Option, - pub published_profile_id: Option, - pub created_at: String, - pub updated_at: String, -} - -#[derive(Clone, Debug, PartialEq)] -pub struct VisualNovelWorkProfileRecord { - pub work_id: String, - pub profile_id: String, - pub owner_user_id: String, - pub source_session_id: Option, - pub author_display_name: String, - pub work_title: String, - pub work_description: String, - pub tags: Vec, - pub cover_image_src: Option, - pub source_asset_ids: Vec, - pub draft: serde_json::Value, - pub publication_status: String, - pub publish_ready: bool, - pub play_count: u32, - pub created_at: String, - pub updated_at: String, - pub published_at: Option, -} - -#[derive(Clone, Debug, PartialEq)] -pub struct VisualNovelHistoryEntryRecord { - pub entry_id: String, - pub run_id: String, - pub owner_user_id: String, - pub profile_id: String, - pub turn_index: u32, - pub source: String, - pub action_text: Option, - pub steps: serde_json::Value, - pub snapshot_before_hash: Option, - pub snapshot_after_hash: Option, - pub created_at: String, -} - -#[derive(Clone, Debug, PartialEq)] -pub struct VisualNovelRunRecord { - pub run_id: String, - pub owner_user_id: String, - pub profile_id: String, - pub mode: String, - pub status: String, - pub current_scene_id: Option, - pub current_phase_id: Option, - pub visible_character_ids: Vec, - pub flags: serde_json::Value, - pub metrics: serde_json::Value, - pub history: Vec, - pub available_choices: serde_json::Value, - pub text_mode_enabled: bool, - pub created_at: String, - pub updated_at: String, -} - -#[derive(Clone, Debug, PartialEq)] -pub struct VisualNovelRuntimeEventRecord { - pub event_id: String, - pub run_id: Option, - pub owner_user_id: String, - pub profile_id: Option, - pub event_kind: String, - pub client_event_id: Option, - pub history_entry_id: Option, - pub payload: serde_json::Value, - pub occurred_at: String, -} - -#[derive(Clone, Debug, PartialEq, Eq)] -pub struct SquareHoleAnchorItemRecord { - pub key: String, - pub label: String, - pub value: String, - pub status: String, -} - -#[derive(Clone, Debug, PartialEq, Eq)] -pub struct SquareHoleAnchorPackRecord { - pub theme: SquareHoleAnchorItemRecord, - pub twist_rule: SquareHoleAnchorItemRecord, - pub shape_count: SquareHoleAnchorItemRecord, - pub difficulty: SquareHoleAnchorItemRecord, -} - -#[derive(Clone, Debug, PartialEq, Eq)] -pub struct SquareHoleCreatorConfigRecord { - pub theme_text: String, - pub twist_rule: String, - pub shape_count: u32, - pub difficulty: u32, - pub shape_options: Vec, - pub hole_options: Vec, - pub background_prompt: String, - pub cover_image_src: Option, - pub background_image_src: Option, -} - -#[derive(Clone, Debug, PartialEq, Eq)] -pub struct SquareHoleShapeOptionRecord { - pub option_id: String, - pub shape_kind: String, - pub label: String, - pub target_hole_id: String, - pub image_prompt: String, - pub image_src: Option, -} - -#[derive(Clone, Debug, PartialEq, Eq)] -pub struct SquareHoleHoleOptionRecord { - pub hole_id: String, - pub hole_kind: String, - pub label: String, - pub image_prompt: String, - pub image_src: Option, -} - -#[derive(Clone, Debug, PartialEq, Eq)] -pub struct SquareHoleResultDraftRecord { - pub profile_id: String, - pub game_name: String, - pub theme_text: String, - pub twist_rule: String, - pub summary: String, - pub tags: Vec, - pub cover_image_src: Option, - pub background_prompt: String, - pub background_image_src: Option, - pub shape_options: Vec, - pub hole_options: Vec, - pub shape_count: u32, - pub difficulty: u32, - pub publish_ready: bool, - pub blockers: Vec, -} - -#[derive(Clone, Debug, PartialEq, Eq)] -pub struct SquareHoleAgentMessageRecord { - pub id: String, - pub role: String, - pub kind: String, - pub text: String, - pub created_at: String, -} - -#[derive(Clone, Debug, PartialEq, Eq)] -pub struct SquareHoleAgentSessionRecord { - pub session_id: String, - pub current_turn: u32, - pub progress_percent: u32, - pub stage: String, - pub anchor_pack: SquareHoleAnchorPackRecord, - pub config: SquareHoleCreatorConfigRecord, - pub draft: Option, - pub messages: Vec, - pub last_assistant_reply: Option, - pub published_profile_id: Option, - pub updated_at: String, -} - -#[derive(Clone, Debug, PartialEq, Eq)] -pub struct SquareHoleWorkProfileRecord { - pub work_id: String, - pub profile_id: String, - pub owner_user_id: String, - pub source_session_id: Option, - pub author_display_name: String, - pub game_name: String, - pub theme_text: String, - pub twist_rule: String, - pub summary: String, - pub tags: Vec, - pub cover_image_src: Option, - pub background_prompt: String, - pub background_image_src: Option, - pub shape_options: Vec, - pub hole_options: Vec, - pub shape_count: u32, - pub difficulty: u32, - pub publication_status: String, - pub play_count: u32, - pub updated_at: String, - pub published_at: Option, - pub publish_ready: bool, -} - -#[derive(Clone, Debug, PartialEq, Eq)] -pub struct SquareHoleShapeSnapshotRecord { - pub shape_id: String, - pub shape_kind: String, - pub label: String, - pub target_hole_id: String, - pub color: String, - pub image_src: Option, -} - -#[derive(Clone, Debug, PartialEq)] -pub struct SquareHoleHoleSnapshotRecord { - pub hole_id: String, - pub hole_kind: String, - pub label: String, - pub x: f32, - pub y: f32, - pub image_src: Option, -} - -#[derive(Clone, Debug, PartialEq, Eq)] -pub struct SquareHoleDropFeedbackRecord { - pub accepted: bool, - pub reject_reason: Option, - pub message: String, -} - -#[derive(Clone, Debug, PartialEq)] -pub struct SquareHoleRunRecord { - pub run_id: String, - pub profile_id: String, - pub owner_user_id: String, - pub status: String, - pub snapshot_version: u64, - pub started_at_ms: u64, - pub duration_limit_ms: u64, - pub server_now_ms: Option, - pub remaining_ms: u64, - pub total_shape_count: u32, - pub completed_shape_count: u32, - pub combo: u32, - pub best_combo: u32, - pub score: u32, - pub rule_label: String, - pub background_image_src: Option, - pub current_shape: Option, - pub holes: Vec, - pub last_feedback: Option, - pub last_confirmed_action_id: Option, -} - -#[derive(Clone, Debug, PartialEq)] -pub struct SquareHoleDropConfirmationRecord { - pub status: String, - pub accepted: bool, - pub reject_reason: Option, - pub failure_reason: Option, - pub feedback: SquareHoleDropFeedbackRecord, - pub run: SquareHoleRunRecord, -} - -#[derive(Clone, Debug, PartialEq, Eq)] -pub struct PuzzleAnchorItemRecord { - pub key: String, - pub label: String, - pub value: String, - pub status: String, -} - -#[derive(Clone, Debug, PartialEq, Eq)] -pub struct PuzzleAnchorPackRecord { - pub theme_promise: PuzzleAnchorItemRecord, - pub visual_subject: PuzzleAnchorItemRecord, - pub visual_mood: PuzzleAnchorItemRecord, - pub composition_hooks: PuzzleAnchorItemRecord, - pub tags_and_forbidden: PuzzleAnchorItemRecord, -} - -#[derive(Clone, Debug, PartialEq, Eq)] -pub struct PuzzleCreatorIntentRecord { - pub source_mode: String, - pub raw_messages_summary: String, - pub theme_promise: String, - pub visual_subject: String, - pub visual_mood: Vec, - pub composition_hooks: Vec, - pub theme_tags: Vec, - pub forbidden_directives: Vec, -} - -#[derive(Clone, Debug, PartialEq, Eq)] -pub struct PuzzleGeneratedImageCandidateRecord { - pub candidate_id: String, - pub image_src: String, - pub asset_id: String, - pub prompt: String, - pub actual_prompt: Option, - pub source_type: String, - pub selected: bool, -} - -#[derive(Clone, Debug, PartialEq, Eq)] -pub struct PuzzleResultDraftRecord { - pub work_title: String, - pub work_description: String, - pub level_name: String, - pub summary: String, - pub theme_tags: Vec, - pub forbidden_directives: Vec, - pub creator_intent: Option, - pub anchor_pack: PuzzleAnchorPackRecord, - pub candidates: Vec, - pub selected_candidate_id: Option, - pub cover_image_src: Option, - pub cover_asset_id: Option, - pub generation_status: String, - pub levels: Vec, - pub form_draft: Option, -} - -#[derive(Clone, Debug, PartialEq, Eq)] -pub struct PuzzleFormDraftRecord { - pub work_title: Option, - pub work_description: Option, - pub picture_description: Option, -} - -#[derive(Clone, Debug, PartialEq, Eq)] -pub struct PuzzleDraftLevelRecord { - pub level_id: String, - pub level_name: String, - pub picture_description: String, - pub picture_reference: Option, - pub ui_background_prompt: Option, - pub ui_background_image_src: Option, - pub ui_background_image_object_key: Option, - pub background_music: Option, - pub candidates: Vec, - pub selected_candidate_id: Option, - pub cover_image_src: Option, - pub cover_asset_id: Option, - pub generation_status: String, -} - -#[derive(Clone, Debug, PartialEq, Eq)] -pub struct PuzzleAudioAssetRecord { - pub task_id: String, - pub provider: String, - pub asset_object_id: Option, - pub asset_kind: Option, - pub audio_src: String, - pub prompt: Option, - pub title: Option, - pub updated_at: Option, -} - -#[derive(Clone, Debug, PartialEq, Eq)] -pub struct PuzzleAgentMessageRecord { - pub message_id: String, - pub role: String, - pub kind: String, - pub text: String, - pub created_at: String, -} - -#[derive(Clone, Debug, PartialEq, Eq)] -pub struct PuzzleAgentSuggestedActionRecord { - pub action_id: String, - pub action_type: String, - pub label: String, -} - -#[derive(Clone, Debug, PartialEq, Eq)] -pub struct PuzzleResultPreviewBlockerRecord { - pub blocker_id: String, - pub code: String, - pub message: String, -} - -#[derive(Clone, Debug, PartialEq, Eq)] -pub struct PuzzleResultPreviewFindingRecord { - pub finding_id: String, - pub severity: String, - pub code: String, - pub message: String, -} - -#[derive(Clone, Debug, PartialEq, Eq)] -pub struct PuzzleResultPreviewRecord { - pub draft: PuzzleResultDraftRecord, - pub blockers: Vec, - pub quality_findings: Vec, - pub publish_ready: bool, -} - -#[derive(Clone, Debug, PartialEq, Eq)] -pub struct PuzzleAgentSessionRecord { - pub session_id: String, - pub seed_text: String, - pub current_turn: u32, - pub progress_percent: u32, - pub stage: String, - pub anchor_pack: PuzzleAnchorPackRecord, - pub draft: Option, - pub messages: Vec, - pub last_assistant_reply: Option, - pub published_profile_id: Option, - pub suggested_actions: Vec, - pub result_preview: Option, - pub updated_at: String, -} - -#[derive(Clone, Debug, PartialEq, Eq)] -pub struct PuzzleWorkProfileRecord { - pub work_id: String, - pub profile_id: String, - pub owner_user_id: String, - pub source_session_id: Option, - pub author_display_name: String, - pub work_title: String, - pub work_description: String, - pub level_name: String, - pub summary: String, - pub theme_tags: Vec, - pub cover_image_src: Option, - pub cover_asset_id: Option, - pub publication_status: String, - pub updated_at: String, - pub published_at: Option, - pub play_count: u32, - pub remix_count: u32, - pub like_count: u32, - pub recent_play_count_7d: u32, - pub point_incentive_total_half_points: u64, - pub point_incentive_claimed_points: u64, - pub publish_ready: bool, - pub anchor_pack: PuzzleAnchorPackRecord, - pub levels: Vec, -} - -#[derive(Clone, Debug, PartialEq, Eq)] -pub struct PuzzleGalleryCardRecord { - pub work_id: String, - pub profile_id: String, - pub owner_user_id: String, - pub source_session_id: Option, - pub author_display_name: String, - pub work_title: String, - pub work_description: String, - pub level_name: String, - pub summary: String, - pub theme_tags: Vec, - pub cover_image_src: Option, - pub cover_asset_id: Option, - pub publication_status: String, - pub updated_at: String, - pub published_at: Option, - pub play_count: u32, - pub remix_count: u32, - pub like_count: u32, - pub recent_play_count_7d: u32, - pub point_incentive_total_half_points: u64, - pub point_incentive_claimed_points: u64, - pub publish_ready: bool, - pub generation_status: Option, -} - -#[derive(Clone, Debug, PartialEq, Eq)] -pub struct PuzzleWorkPointIncentiveClaimRecordInput { - pub profile_id: String, - pub owner_user_id: String, - pub claimed_at_micros: i64, -} - -#[derive(Clone, Debug, PartialEq, Eq)] -pub struct PuzzleCellPositionRecord { - pub row: u32, - pub col: u32, -} - -#[derive(Clone, Debug, PartialEq, Eq)] -pub struct PuzzlePieceStateRecord { - pub piece_id: String, - pub correct_row: u32, - pub correct_col: u32, - pub current_row: u32, - pub current_col: u32, - pub merged_group_id: Option, -} - -#[derive(Clone, Debug, PartialEq, Eq)] -pub struct PuzzleMergedGroupRecord { - pub group_id: String, - pub piece_ids: Vec, - pub occupied_cells: Vec, -} - -#[derive(Clone, Debug, PartialEq, Eq)] -pub struct PuzzleLeaderboardEntryRecord { - pub rank: u32, - pub nickname: String, - pub elapsed_ms: u64, - pub visible_tags: Vec, - pub is_current_player: bool, -} - -#[derive(Clone, Debug, PartialEq, Eq)] -pub struct PuzzleBoardRecord { - pub rows: u32, - pub cols: u32, - pub pieces: Vec, - pub merged_groups: Vec, - pub selected_piece_id: Option, - pub all_tiles_resolved: bool, -} - -#[derive(Clone, Debug, PartialEq)] -pub struct PuzzleRecommendedNextWorkRecord { - pub profile_id: String, - pub level_name: String, - pub author_display_name: String, - pub theme_tags: Vec, - pub cover_image_src: Option, - pub similarity_score: f32, -} - -#[derive(Clone, Debug, PartialEq, Eq)] -pub struct PuzzleRuntimeLevelRecord { - pub run_id: String, - pub level_index: u32, - pub level_id: Option, - pub grid_size: u32, - pub profile_id: String, - pub level_name: String, - pub author_display_name: String, - pub theme_tags: Vec, - pub cover_image_src: Option, - pub ui_background_image_src: Option, - pub ui_background_image_object_key: Option, - pub background_music: Option, - pub board: PuzzleBoardRecord, - pub status: String, - pub started_at_ms: u64, - pub cleared_at_ms: Option, - pub elapsed_ms: Option, - pub time_limit_ms: u64, - pub remaining_ms: u64, - pub paused_accumulated_ms: u64, - pub pause_started_at_ms: Option, - pub freeze_accumulated_ms: u64, - pub freeze_started_at_ms: Option, - pub freeze_until_ms: Option, - pub leaderboard_entries: Vec, -} - -#[derive(Clone, Debug, PartialEq)] -pub struct PuzzleRunRecord { - pub run_id: String, - pub entry_profile_id: String, - pub cleared_level_count: u32, - pub current_level_index: u32, - pub current_grid_size: u32, - pub played_profile_ids: Vec, - pub previous_level_tags: Vec, - pub current_level: Option, - pub recommended_next_profile_id: Option, - pub next_level_mode: String, - pub next_level_profile_id: Option, - pub next_level_id: Option, - pub recommended_next_works: Vec, - pub leaderboard_entries: Vec, -} - -#[derive(Clone, Debug, PartialEq, Eq)] -pub struct PuzzleLeaderboardSubmitRecordInput { - pub run_id: String, - pub owner_user_id: String, - pub profile_id: String, - pub grid_size: u32, - pub elapsed_ms: u64, - pub nickname: String, - pub submitted_at_micros: i64, -} - -#[derive(Clone, Debug, PartialEq, Eq)] -pub struct BigFishSessionCreateRecordInput { - pub session_id: String, - pub owner_user_id: String, - pub seed_text: String, - pub welcome_message_id: String, - pub welcome_message_text: String, - pub created_at_micros: i64, -} - -#[derive(Clone, Debug, PartialEq, Eq)] -pub struct BigFishMessageSubmitRecordInput { - pub session_id: String, - pub owner_user_id: String, - pub user_message_id: String, - pub user_message_text: String, - pub assistant_message_id: String, - pub submitted_at_micros: i64, -} - -#[derive(Clone, Debug, PartialEq, Eq)] -pub struct BigFishMessageFinalizeRecordInput { - pub session_id: String, - pub owner_user_id: String, - pub assistant_message_id: Option, - pub assistant_reply_text: Option, - pub stage: String, - pub progress_percent: u32, - pub anchor_pack_json: String, - pub error_message: Option, - pub updated_at_micros: i64, -} - -#[derive(Clone, Debug, PartialEq, Eq)] -pub struct BigFishDraftCompileRecordInput { - pub session_id: String, - pub owner_user_id: String, - pub draft_json: Option, - pub compiled_at_micros: i64, -} - -#[derive(Clone, Debug, PartialEq, Eq)] -pub struct BigFishAssetGenerateRecordInput { - pub session_id: String, - pub owner_user_id: String, - pub asset_kind: String, - pub level: Option, - pub motion_key: Option, - pub asset_url: Option, - pub generated_at_micros: i64, -} - -#[derive(Clone, Debug, PartialEq, Eq)] -pub struct BigFishAnchorItemRecord { - pub key: String, - pub label: String, - pub value: String, - pub status: String, -} - -#[derive(Clone, Debug, PartialEq, Eq)] -pub struct BigFishAnchorPackRecord { - pub gameplay_promise: BigFishAnchorItemRecord, - pub ecology_visual_theme: BigFishAnchorItemRecord, - pub growth_ladder: BigFishAnchorItemRecord, - pub risk_tempo: BigFishAnchorItemRecord, -} - -#[derive(Clone, Debug, PartialEq)] -pub struct BigFishLevelBlueprintRecord { - pub level: u32, - pub name: String, - pub one_line_fantasy: String, - pub text_description: String, - pub silhouette_direction: String, - pub size_ratio: f32, - pub visual_description: String, - pub visual_prompt_seed: String, - pub idle_motion_description: String, - pub move_motion_description: String, - pub motion_prompt_seed: String, - pub merge_source_level: Option, - pub prey_window: Vec, - pub threat_window: Vec, - pub is_final_level: bool, -} - -#[derive(Clone, Debug, PartialEq, Eq)] -pub struct BigFishBackgroundBlueprintRecord { - pub theme: String, - pub color_mood: String, - pub foreground_hints: String, - pub midground_composition: String, - pub background_depth: String, - pub safe_play_area_hint: String, - pub spawn_edge_hint: String, - pub background_prompt_seed: String, -} - -#[derive(Clone, Debug, PartialEq)] -pub struct BigFishRuntimeParamsRecord { - pub level_count: u32, - pub merge_count_per_upgrade: u32, - pub spawn_target_count: u32, - pub leader_move_speed: f32, - pub follower_catch_up_speed: f32, - pub offscreen_cull_seconds: f32, - pub prey_spawn_delta_levels: Vec, - pub threat_spawn_delta_levels: Vec, - pub win_level: u32, -} - -#[derive(Clone, Debug, PartialEq)] -pub struct BigFishGameDraftRecord { - pub title: String, - pub subtitle: String, - pub core_fun: String, - pub ecology_theme: String, - pub levels: Vec, - pub background: BigFishBackgroundBlueprintRecord, - pub runtime_params: BigFishRuntimeParamsRecord, -} - -#[derive(Clone, Debug, PartialEq, Eq)] -pub struct BigFishAgentMessageRecord { - pub message_id: String, - pub role: String, - pub kind: String, - pub text: String, - pub created_at: String, -} - -#[derive(Clone, Debug, PartialEq, Eq)] -pub struct BigFishAssetSlotRecord { - pub slot_id: String, - pub asset_kind: String, - pub level: Option, - pub motion_key: Option, - pub status: String, - pub asset_url: Option, - pub prompt_snapshot: String, - pub updated_at: String, -} - -#[derive(Clone, Debug, PartialEq, Eq)] -pub struct BigFishAssetCoverageRecord { - pub level_main_image_ready_count: u32, - pub level_motion_ready_count: u32, - pub background_ready: bool, - pub required_level_count: u32, - pub publish_ready: bool, - pub blockers: Vec, -} - -#[derive(Clone, Debug, PartialEq)] -pub struct BigFishSessionRecord { - pub session_id: String, - pub current_turn: u32, - pub progress_percent: u32, - pub stage: String, - pub anchor_pack: BigFishAnchorPackRecord, - pub draft: Option, - pub asset_slots: Vec, - pub asset_coverage: BigFishAssetCoverageRecord, - pub messages: Vec, - pub last_assistant_reply: Option, - pub publish_ready: bool, - pub updated_at: String, -} - -#[derive(Clone, Debug, PartialEq)] -pub struct BigFishVector2Record { - pub x: f32, - pub y: f32, -} - -#[derive(Clone, Debug, PartialEq)] -pub struct BigFishRuntimeEntityRecord { - pub entity_id: String, - pub level: u32, - pub position: BigFishVector2Record, - pub radius: f32, - pub offscreen_seconds: f32, -} - -#[derive(Clone, Debug, PartialEq)] -pub struct BigFishRuntimeRunRecord { - pub run_id: String, - pub session_id: String, - pub status: String, - pub tick: u64, - pub player_level: u32, - pub win_level: u32, - pub leader_entity_id: Option, - pub owned_entities: Vec, - pub wild_entities: Vec, - pub camera_center: BigFishVector2Record, - pub last_input: BigFishVector2Record, - pub event_log: Vec, - pub updated_at: String, -} - -#[derive(Clone, Debug, PartialEq, Eq, serde::Serialize, serde::Deserialize)] -pub struct BigFishWorkSummaryRecord { - pub work_id: String, - pub source_session_id: String, - pub owner_user_id: String, - pub title: String, - pub subtitle: String, - pub summary: String, - pub cover_image_src: Option, - pub status: String, - pub updated_at_micros: i64, - pub published_at_micros: Option, - pub publish_ready: bool, - pub level_count: u32, - pub level_main_image_ready_count: u32, - pub level_motion_ready_count: u32, - pub background_ready: bool, - pub play_count: u32, - pub remix_count: u32, - pub like_count: u32, - pub recent_play_count_7d: u32, -} - -#[cfg(test)] -mod tests { - use super::*; - - #[test] - fn puzzle_works_mapper_keeps_typed_public_stat_fields() { - let result = PuzzleWorksProcedureResult { - ok: true, - items: vec![PuzzleWorkProfile { - work_id: "puzzle-work-1".to_string(), - profile_id: "puzzle-profile-1".to_string(), - owner_user_id: "user-1".to_string(), - source_session_id: None, - author_display_name: "测试作者".to_string(), - work_title: "雨夜拼图作品".to_string(), - work_description: "拼图作品说明".to_string(), - level_name: "雨夜拼图".to_string(), - summary: "公开作品摘要".to_string(), - theme_tags: vec!["雨夜".to_string(), "猫咪".to_string(), "神庙".to_string()], - cover_image_src: None, - cover_asset_id: None, - levels: Vec::new(), - publication_status: PuzzlePublicationStatus::Published, - updated_at_micros: 123000000, - published_at_micros: Some(123000000), - play_count: 11, - remix_count: 7, - like_count: 5, - recent_play_count_7_d: 3, - point_incentive_total_half_points: 4, - point_incentive_claimed_points: 2, - publish_ready: true, - anchor_pack: test_puzzle_anchor_pack(), - }], - error_message: None, - }; - - let items = map_puzzle_works_procedure_result(result) - .expect("typed puzzle works result 应能映射统计字段"); - - assert_eq!(items.len(), 1); - assert_eq!(items[0].play_count, 11); - assert_eq!(items[0].remix_count, 7); - assert_eq!(items[0].like_count, 5); - assert_eq!(items[0].recent_play_count_7d, 3); - } - - #[test] - fn puzzle_run_mapper_maps_typed_timer_fields() { - let result = PuzzleRunProcedureResult { - ok: true, - run: Some(PuzzleRunSnapshot { - run_id: "puzzle-run-1".to_string(), - entry_profile_id: "puzzle-profile-1".to_string(), - cleared_level_count: 0, - current_level_index: 1, - current_grid_size: 3, - played_profile_ids: vec!["puzzle-profile-1".to_string()], - previous_level_tags: vec![ - "雨夜".to_string(), - "猫咪".to_string(), - "神庙".to_string(), - ], - current_level: Some(PuzzleRuntimeLevelSnapshot { - run_id: "puzzle-run-1".to_string(), - level_index: 1, - level_id: None, - grid_size: 3, - profile_id: "puzzle-profile-1".to_string(), - level_name: "雨夜拼图".to_string(), - author_display_name: "测试作者".to_string(), - theme_tags: vec!["雨夜".to_string(), "猫咪".to_string(), "神庙".to_string()], - cover_image_src: None, - ui_background_image_src: None, - ui_background_image_object_key: None, - background_music: None, - board: PuzzleBoardSnapshot { - rows: 3, - cols: 3, - pieces: vec![PuzzlePieceState { - piece_id: "piece-1".to_string(), - correct_row: 0, - correct_col: 0, - current_row: 0, - current_col: 0, - merged_group_id: None, - }], - merged_groups: Vec::new(), - selected_piece_id: None, - all_tiles_resolved: false, - }, - status: PuzzleRuntimeLevelStatus::Playing, - started_at_ms: 0, - cleared_at_ms: None, - elapsed_ms: None, - time_limit_ms: 0, - remaining_ms: 0, - paused_accumulated_ms: 0, - pause_started_at_ms: None, - freeze_accumulated_ms: 0, - freeze_started_at_ms: None, - freeze_until_ms: None, - leaderboard_entries: Vec::new(), - }), - recommended_next_profile_id: None, - next_level_mode: "none".to_string(), - next_level_profile_id: None, - next_level_id: None, - recommended_next_works: Vec::new(), - leaderboard_entries: Vec::new(), - }), - error_message: None, - }; - - let run = map_puzzle_run_procedure_result(result) - .expect("typed puzzle run result 应能映射计时字段"); - let level = run.current_level.expect("兼容后仍应保留当前关卡"); - - assert_eq!(run.run_id, "puzzle-run-1"); - assert!(level.started_at_ms > 0); - assert_eq!(level.time_limit_ms, 0); - assert_eq!(level.remaining_ms, 0); - assert!(level.leaderboard_entries.is_empty()); - } - - #[test] - fn big_fish_works_mapper_uses_typed_owner_and_public_stats() { - let result = BigFishWorksProcedureResult { - ok: true, - items: vec![BigFishWorkSummarySnapshot { - work_id: "big-fish-work-session-1".to_string(), - source_session_id: "session-1".to_string(), - owner_user_id: "user-1".to_string(), - title: "深海草稿".to_string(), - subtitle: "副标题".to_string(), - summary: "摘要".to_string(), - cover_image_src: None, - status: "draft".to_string(), - updated_at_micros: 123, - publish_ready: false, - level_count: 8, - level_main_image_ready_count: 0, - level_motion_ready_count: 0, - background_ready: false, - play_count: 9, - remix_count: 4, - like_count: 2, - recent_play_count_7_d: 6, - published_at_micros: None, - }], - error_message: None, - }; - - let items = map_big_fish_works_procedure_result(result, Some("user-1")) - .expect("typed big fish works result 应能映射 owner 和统计字段"); - - assert_eq!(items.len(), 1); - assert_eq!(items[0].owner_user_id, "user-1"); - assert_eq!(items[0].published_at_micros, None); - assert_eq!(items[0].play_count, 9); - assert_eq!(items[0].remix_count, 4); - assert_eq!(items[0].like_count, 2); - assert_eq!(items[0].recent_play_count_7d, 6); - } - - #[test] - fn match3d_work_mapper_keeps_generated_item_assets_json() { - let result = Match3DWorkProcedureResult { - ok: true, - work: Some(Match3DWorkSnapshot { - profile_id: "match3d-profile-1".to_string(), - owner_user_id: "user-1".to_string(), - source_session_id: "match3d-session-1".to_string(), - author_display_name: "测试作者".to_string(), - game_name: "水果抓大鹅".to_string(), - theme_text: "水果".to_string(), - summary_text: "水果主题".to_string(), - tags: vec!["水果".to_string()], - cover_image_src: String::new(), - cover_asset_id: String::new(), - clear_count: 3, - difficulty: 3, - config: Match3DCreatorConfigSnapshot { - theme_text: "水果".to_string(), - reference_image_src: None, - clear_count: 3, - difficulty: 3, - asset_style_id: None, - asset_style_label: None, - asset_style_prompt: None, - generate_click_sound: false, - }, - publication_status: "Draft".to_string(), - publish_ready: false, - play_count: 0, - updated_at_micros: 123000000, - published_at_micros: None, - generated_item_assets_json: Some( - r#"[{"itemId":"match3d-item-1","itemName":"草莓","imageSrc":"/generated-match3d-assets/session/profile/items/item/image.png","status":"image_ready"}]"# - .to_string(), - ), - }), - error_message: None, - }; - - let item = map_match3d_work_procedure_result(result) - .expect("typed match3d work result 应保留生成素材 JSON"); - - assert_eq!( - item.generated_item_assets_json.as_deref(), - Some( - r#"[{"itemId":"match3d-item-1","itemName":"草莓","imageSrc":"/generated-match3d-assets/session/profile/items/item/image.png","status":"image_ready"}]"# - ) - ); - } - - fn test_puzzle_anchor_pack() -> PuzzleAnchorPack { - PuzzleAnchorPack { - theme_promise: test_puzzle_anchor_item("themePromise", "题材承诺", "雨夜冒险"), - visual_subject: test_puzzle_anchor_item("visualSubject", "画面主体", "猫咪神庙"), - visual_mood: test_puzzle_anchor_item("visualMood", "视觉气质", "温暖"), - composition_hooks: test_puzzle_anchor_item("compositionHooks", "拼图记忆点", "灯光"), - tags_and_forbidden: test_puzzle_anchor_item( - "tagsAndForbidden", - "标签与禁忌", - "雨夜, 猫咪, 神庙", - ), - } - } - - fn test_puzzle_anchor_item(key: &str, label: &str, value: &str) -> PuzzleAnchorItem { - PuzzleAnchorItem { - key: key.to_string(), - label: label.to_string(), - value: value.to_string(), - status: PuzzleAnchorStatus::Inferred, - } - } -} - -#[derive(Clone, Debug, PartialEq, Eq)] -pub struct ResolveNpcBattleInteractionInput { - pub npc_interaction: DomainResolveNpcInteractionInput, - pub story_session_id: String, - pub actor_user_id: String, - pub battle_state_id: Option, - pub player_hp: i32, - pub player_max_hp: i32, - pub player_mana: i32, - pub player_max_mana: i32, - pub target_hp: i32, - pub target_max_hp: i32, - pub experience_reward: u32, - pub reward_items: Vec, -} - -#[derive(Clone, Debug, PartialEq, Eq)] -pub struct AiTaskStageRecord { - pub stage_kind: String, - pub label: String, - pub detail: String, - pub order: u32, - pub status: String, - pub text_output: Option, - pub structured_payload_json: Option, - pub warning_messages: Vec, - pub started_at: Option, - pub completed_at: Option, -} - -#[derive(Clone, Debug, PartialEq, Eq)] -pub struct AiResultReferenceRecord { - pub result_ref_id: String, - pub task_id: String, - pub reference_kind: String, - pub reference_id: String, - pub label: Option, - pub created_at: String, -} - -#[derive(Clone, Debug, PartialEq, Eq)] -pub struct AiTextChunkRecord { - pub chunk_id: String, - pub task_id: String, - pub stage_kind: String, - pub sequence: u32, - pub delta_text: String, - pub created_at: String, -} - -#[derive(Clone, Debug, PartialEq, Eq)] -pub struct AiTaskRecord { - pub task_id: String, - pub task_kind: String, - pub owner_user_id: String, - pub request_label: String, - pub source_module: String, - pub source_entity_id: Option, - pub request_payload_json: Option, - pub status: String, - pub failure_message: Option, - pub stages: Vec, - pub result_references: Vec, - pub latest_text_output: Option, - pub latest_structured_payload_json: Option, - pub version: u32, - pub created_at: String, - pub started_at: Option, - pub completed_at: Option, - pub updated_at: String, -} - -#[derive(Clone, Debug, PartialEq, Eq)] -pub struct AiTaskMutationRecord { - pub task: AiTaskRecord, - pub text_chunk: Option, -} - -#[derive(Clone, Debug, PartialEq, Eq)] -pub struct NpcStateRecord { - pub npc_state_id: String, - pub runtime_session_id: String, - pub npc_id: String, - pub npc_name: String, - pub affinity: i32, - pub relation_stance: String, - pub help_used: bool, - pub chatted_count: u32, - pub gifts_given: u32, - pub recruited: bool, - pub trade_stock_signature: Option, - pub revealed_facts: Vec, - pub known_attribute_rumors: Vec, - pub first_meaningful_contact_resolved: bool, - pub seen_backstory_chapter_ids: Vec, - pub trust: u8, - pub warmth: u8, - pub ideological_fit: u8, - pub fear_or_guard: u8, - pub loyalty: u8, - pub current_conflict_tag: Option, - pub recent_approvals: Vec, - pub recent_disapprovals: Vec, - pub created_at: String, - pub updated_at: String, -} - -#[derive(Clone, Debug, PartialEq, Eq)] -pub struct NpcInteractionRecord { - pub npc_state: NpcStateRecord, - pub interaction_status: String, - pub action_text: String, - pub result_text: String, - pub story_text: Option, - pub battle_mode: Option, - pub encounter_closed: bool, - pub affinity_changed: bool, - pub previous_affinity: i32, - pub next_affinity: i32, -} - -#[derive(Clone, Debug, PartialEq, Eq)] -pub struct NpcBattleInteractionRecord { - pub npc_interaction: NpcInteractionRecord, - pub battle_state: BattleStateRecord, -} - -#[derive(Clone, Debug, PartialEq, Eq)] -pub(crate) struct NpcBattleInteractionSnapshot { - interaction: DomainNpcInteractionResult, - battle_state: DomainBattleStateSnapshot, -} - -pub(crate) fn build_battle_state_record(snapshot: DomainBattleStateSnapshot) -> BattleStateRecord { - BattleStateRecord { - battle_state_id: snapshot.battle_state_id, - story_session_id: snapshot.story_session_id, - runtime_session_id: snapshot.runtime_session_id, - actor_user_id: snapshot.actor_user_id, - chapter_id: snapshot.chapter_id, - target_npc_id: snapshot.target_npc_id, - target_name: snapshot.target_name, - battle_mode: snapshot.battle_mode.as_str().to_string(), - status: snapshot.status.as_str().to_string(), - player_hp: snapshot.player_hp, - player_max_hp: snapshot.player_max_hp, - player_mana: snapshot.player_mana, - player_max_mana: snapshot.player_max_mana, - target_hp: snapshot.target_hp, - target_max_hp: snapshot.target_max_hp, - experience_reward: snapshot.experience_reward, - reward_items: snapshot.reward_items, - turn_index: snapshot.turn_index, - last_action_function_id: snapshot.last_action_function_id, - last_action_text: snapshot.last_action_text, - last_result_text: snapshot.last_result_text, - last_damage_dealt: snapshot.last_damage_dealt, - last_damage_taken: snapshot.last_damage_taken, - last_outcome: snapshot.last_outcome.as_str().to_string(), - version: snapshot.version, - created_at: format_timestamp_micros(snapshot.created_at_micros), - updated_at: format_timestamp_micros(snapshot.updated_at_micros), - } -} - -pub(crate) fn build_resolve_combat_action_record( - result: DomainResolveCombatActionResult, -) -> ResolveCombatActionRecord { - ResolveCombatActionRecord { - battle_state: build_battle_state_record(result.snapshot), - damage_dealt: result.damage_dealt, - damage_taken: result.damage_taken, - outcome: result.outcome.as_str().to_string(), - } -} - -impl From - for crate::module_bindings::ResolveNpcBattleInteractionInput -{ - fn from(input: ResolveNpcBattleInteractionInput) -> Self { - Self { - npc_interaction: crate::module_bindings::ResolveNpcInteractionInput { - runtime_session_id: input.npc_interaction.runtime_session_id, - npc_id: input.npc_interaction.npc_id, - npc_name: input.npc_interaction.npc_name, - interaction_function_id: input.npc_interaction.interaction_function_id, - release_npc_id: input.npc_interaction.release_npc_id, - updated_at_micros: input.npc_interaction.updated_at_micros, - }, - story_session_id: input.story_session_id, - actor_user_id: input.actor_user_id, - battle_state_id: input.battle_state_id, - player_hp: input.player_hp, - player_max_hp: input.player_max_hp, - player_mana: input.player_mana, - player_max_mana: input.player_max_mana, - target_hp: input.target_hp, - target_max_hp: input.target_max_hp, - experience_reward: input.experience_reward, - reward_items: input - .reward_items - .into_iter() - .map(map_runtime_item_reward_item_snapshot) - .collect(), - } - } -} - -pub(crate) fn validate_npc_battle_interaction_input( - input: &ResolveNpcBattleInteractionInput, -) -> Result<(), SpacetimeClientError> { - let battle_state_input = DomainBattleStateInput { - battle_state_id: input - .battle_state_id - .clone() - .unwrap_or_else(|| "battle_preview".to_string()), - story_session_id: input.story_session_id.clone(), - runtime_session_id: input.npc_interaction.runtime_session_id.clone(), - actor_user_id: input.actor_user_id.clone(), - chapter_id: None, - target_npc_id: input.npc_interaction.npc_id.clone(), - target_name: input.npc_interaction.npc_name.clone(), - battle_mode: DomainBattleMode::Fight, - player_hp: input.player_hp, - player_max_hp: input.player_max_hp, - player_mana: input.player_mana, - player_max_mana: input.player_max_mana, - target_hp: input.target_hp, - target_max_hp: input.target_max_hp, - experience_reward: input.experience_reward, - reward_items: input.reward_items.clone(), - created_at_micros: input.npc_interaction.updated_at_micros, - }; - validate_battle_state_input(&battle_state_input) - .map_err(|error| SpacetimeClientError::Runtime(error.to_string()))?; - for reward_item in input.reward_items.iter().cloned() { - normalize_reward_item_snapshot(reward_item) - .map_err(|error| SpacetimeClientError::Runtime(error.to_string()))?; - } - - Ok(()) -} - -pub(crate) fn build_npc_state_record(snapshot: DomainNpcStateSnapshot) -> NpcStateRecord { - NpcStateRecord { - npc_state_id: snapshot.npc_state_id, - runtime_session_id: snapshot.runtime_session_id, - npc_id: snapshot.npc_id, - npc_name: snapshot.npc_name, - affinity: snapshot.affinity, - relation_stance: format_npc_relation_stance(snapshot.relation_state.stance).to_string(), - help_used: snapshot.help_used, - chatted_count: snapshot.chatted_count, - gifts_given: snapshot.gifts_given, - recruited: snapshot.recruited, - trade_stock_signature: snapshot.trade_stock_signature, - revealed_facts: snapshot.revealed_facts, - known_attribute_rumors: snapshot.known_attribute_rumors, - first_meaningful_contact_resolved: snapshot.first_meaningful_contact_resolved, - seen_backstory_chapter_ids: snapshot.seen_backstory_chapter_ids, - trust: snapshot.stance_profile.trust, - warmth: snapshot.stance_profile.warmth, - ideological_fit: snapshot.stance_profile.ideological_fit, - fear_or_guard: snapshot.stance_profile.fear_or_guard, - loyalty: snapshot.stance_profile.loyalty, - current_conflict_tag: snapshot.stance_profile.current_conflict_tag, - recent_approvals: snapshot.stance_profile.recent_approvals, - recent_disapprovals: snapshot.stance_profile.recent_disapprovals, - created_at: format_timestamp_micros(snapshot.created_at_micros), - updated_at: format_timestamp_micros(snapshot.updated_at_micros), - } -} - -pub(crate) fn build_npc_interaction_record( - result: DomainNpcInteractionResult, -) -> NpcInteractionRecord { - NpcInteractionRecord { - npc_state: build_npc_state_record(result.npc_state), - interaction_status: format_npc_interaction_status(result.interaction_status).to_string(), - action_text: result.action_text, - result_text: result.result_text, - story_text: result.story_text, - battle_mode: result - .battle_mode - .map(|mode| format_npc_interaction_battle_mode(mode).to_string()), - encounter_closed: result.encounter_closed, - affinity_changed: result.affinity_changed, - previous_affinity: result.previous_affinity, - next_affinity: result.next_affinity, - } -} - -pub(crate) fn build_npc_battle_interaction_record( - result: NpcBattleInteractionSnapshot, -) -> NpcBattleInteractionRecord { - NpcBattleInteractionRecord { - npc_interaction: build_npc_interaction_record(result.interaction), - battle_state: build_battle_state_record(result.battle_state), - } -} - -pub(crate) fn format_npc_relation_stance(value: DomainNpcRelationStance) -> &'static str { - match value { - DomainNpcRelationStance::Hostile => "hostile", - DomainNpcRelationStance::Guarded => "guarded", - DomainNpcRelationStance::Neutral => "neutral", - DomainNpcRelationStance::Cooperative => "cooperative", - DomainNpcRelationStance::Bonded => "bonded", - } -} - -pub(crate) fn format_npc_interaction_status(value: DomainNpcInteractionStatus) -> &'static str { - match value { - DomainNpcInteractionStatus::Previewed => "previewed", - DomainNpcInteractionStatus::Dialogue => "dialogue", - DomainNpcInteractionStatus::Resolved => "resolved", - DomainNpcInteractionStatus::Recruited => "recruited", - DomainNpcInteractionStatus::BattlePending => "battle_pending", - DomainNpcInteractionStatus::Left => "left", - } -} - -pub(crate) fn format_npc_interaction_battle_mode( - value: DomainNpcInteractionBattleMode, -) -> &'static str { - match value { - DomainNpcInteractionBattleMode::Fight => "fight", - DomainNpcInteractionBattleMode::Spar => "spar", - } -} - -pub(crate) fn map_inventory_container_kind( - value: InventoryContainerKind, -) -> module_inventory::InventoryContainerKind { - match value { - InventoryContainerKind::Backpack => module_inventory::InventoryContainerKind::Backpack, - InventoryContainerKind::Equipment => module_inventory::InventoryContainerKind::Equipment, - } -} - -pub(crate) fn map_inventory_item_rarity( - value: InventoryItemRarity, -) -> module_inventory::InventoryItemRarity { - match value { - InventoryItemRarity::Common => module_inventory::InventoryItemRarity::Common, - InventoryItemRarity::Uncommon => module_inventory::InventoryItemRarity::Uncommon, - InventoryItemRarity::Rare => module_inventory::InventoryItemRarity::Rare, - InventoryItemRarity::Epic => module_inventory::InventoryItemRarity::Epic, - InventoryItemRarity::Legendary => module_inventory::InventoryItemRarity::Legendary, - } -} - -pub(crate) fn map_inventory_equipment_slot( - value: InventoryEquipmentSlot, -) -> module_inventory::InventoryEquipmentSlot { - match value { - InventoryEquipmentSlot::Weapon => module_inventory::InventoryEquipmentSlot::Weapon, - InventoryEquipmentSlot::Armor => module_inventory::InventoryEquipmentSlot::Armor, - InventoryEquipmentSlot::Relic => module_inventory::InventoryEquipmentSlot::Relic, - } -} - -pub(crate) fn map_inventory_item_source_kind( - value: InventoryItemSourceKind, -) -> module_inventory::InventoryItemSourceKind { - match value { - InventoryItemSourceKind::StoryReward => { - module_inventory::InventoryItemSourceKind::StoryReward - } - InventoryItemSourceKind::QuestReward => { - module_inventory::InventoryItemSourceKind::QuestReward - } - InventoryItemSourceKind::TreasureReward => { - module_inventory::InventoryItemSourceKind::TreasureReward - } - InventoryItemSourceKind::NpcGift => module_inventory::InventoryItemSourceKind::NpcGift, - InventoryItemSourceKind::NpcTrade => module_inventory::InventoryItemSourceKind::NpcTrade, - InventoryItemSourceKind::CombatDrop => { - module_inventory::InventoryItemSourceKind::CombatDrop - } - InventoryItemSourceKind::ForgeCraft => { - module_inventory::InventoryItemSourceKind::ForgeCraft - } - InventoryItemSourceKind::ForgeReforge => { - module_inventory::InventoryItemSourceKind::ForgeReforge - } - InventoryItemSourceKind::ManualPatch => { - module_inventory::InventoryItemSourceKind::ManualPatch - } - } -} +mod ai; +mod assets; +mod auth; +mod bark_battle; +mod big_fish; +mod combat; +mod common; +mod custom_world; +mod inventory; +mod match3d; +mod npc; +mod puzzle; +mod runtime; +mod runtime_profile; +mod square_hole; +mod story; +mod visual_novel; + +pub use self::ai::{ + AiResultReferenceRecord, AiTaskMutationRecord, AiTaskRecord, AiTaskStageRecord, + AiTextChunkRecord, +}; +pub use self::assets::{ + BigFishAssetCoverageRecord, BigFishAssetGenerateRecordInput, BigFishAssetSlotRecord, + BigFishSessionRecord, CustomWorldAgentMessageFinalizeRecordInput, + CustomWorldAgentSessionCreateRecordInput, CustomWorldAgentSessionRecord, + CustomWorldDraftCardDetailRecord, CustomWorldDraftCardRecord, + VisualNovelAgentSessionCreateRecordInput, VisualNovelAgentSessionRecord, + VisualNovelWorkProfileRecord, VisualNovelWorkUpdateRecordInput, +}; +pub use self::big_fish::BigFishWorkSummaryRecord; +pub use self::combat::{ + BarkBattleDraftConfigRecord, BarkBattleRunRecord, BarkBattleRuntimeConfigRecord, + ResolveCombatActionRecord, +}; +pub use self::common::{ + BigFishAgentMessageRecord, BigFishAnchorItemRecord, BigFishAnchorPackRecord, + BigFishBackgroundBlueprintRecord, BigFishDraftCompileRecordInput, + BigFishInputSubmitRecordInput, BigFishLevelBlueprintRecord, BigFishLikeReportRecordInput, + BigFishMessageFinalizeRecordInput, BigFishMessageSubmitRecordInput, + BigFishPlayReportRecordInput, BigFishRunStartRecordInput, BigFishSessionCreateRecordInput, + BigFishVector2Record, BigFishWorkRemixRecordInput, CustomWorldAgentActionExecuteRecord, + CustomWorldAgentActionExecuteRecordInput, CustomWorldAgentCheckpointRecord, + CustomWorldAgentMessageRecord, CustomWorldAgentMessageSubmitRecordInput, + CustomWorldAgentOperationProgressRecordInput, CustomWorldAgentOperationRecord, + CustomWorldCheckpointRecord, CustomWorldDraftCardDetailSectionRecord, + CustomWorldLibraryMutationRecord, CustomWorldProfileLikeReportRecordInput, + CustomWorldProfilePlayReportRecordInput, CustomWorldProfileRemixRecordInput, + CustomWorldPublishGateRecord, CustomWorldPublishWorldRecord, + CustomWorldPublishWorldRecordInput, CustomWorldResultPreviewBlockerRecord, + CustomWorldSupportedActionRecord, SquareHoleAgentMessageFinalizeRecordInput, + SquareHoleAgentMessageRecord, SquareHoleAgentMessageSubmitRecordInput, + SquareHoleAgentSessionCreateRecordInput, SquareHoleAgentSessionRecord, + SquareHoleAnchorItemRecord, SquareHoleAnchorPackRecord, SquareHoleCompileDraftRecordInput, + SquareHoleCreatorConfigRecord, SquareHoleHoleOptionRecord, SquareHoleHoleSnapshotRecord, + SquareHoleResultDraftRecord, SquareHoleRunDropRecordInput, SquareHoleRunRestartRecordInput, + SquareHoleRunStartRecordInput, SquareHoleRunStopRecordInput, SquareHoleRunTimeUpRecordInput, + SquareHoleShapeOptionRecord, SquareHoleShapeSnapshotRecord, SquareHoleWorkProfileRecord, + SquareHoleWorkUpdateRecordInput, VisualNovelAgentMessageFinalizeRecordInput, + VisualNovelAgentMessageRecord, VisualNovelAgentMessageSubmitRecordInput, + VisualNovelHistoryEntryRecord, VisualNovelHistoryEntryRecordInput, VisualNovelRunRecord, + VisualNovelRunSnapshotRecordInput, VisualNovelRunStartRecordInput, + VisualNovelWorkCompileRecordInput, +}; +pub use self::match3d::{ + Match3DAgentMessageFinalizeRecordInput, Match3DAgentMessageRecord, + Match3DAgentMessageSubmitRecordInput, Match3DAgentSessionCreateRecordInput, + Match3DAgentSessionRecord, Match3DAnchorItemRecord, Match3DAnchorPackRecord, + Match3DClickConfirmationRecord, Match3DCompileDraftRecordInput, Match3DCreatorConfigRecord, + Match3DItemSnapshotRecord, Match3DResultDraftRecord, Match3DRunClickRecordInput, + Match3DRunRecord, Match3DRunRestartRecordInput, Match3DRunStartRecordInput, + Match3DRunStopRecordInput, Match3DRunTimeUpRecordInput, Match3DTraySlotRecord, + Match3DWorkProfileRecord, Match3DWorkUpdateRecordInput, +}; +pub use self::npc::{ + BattleStateRecord, CustomWorldGalleryEntryRecord, CustomWorldLibraryEntryRecord, + CustomWorldProfileUpsertRecordInput, CustomWorldPublishedProfileCompileRecord, + CustomWorldWorkSummaryRecord, NpcBattleInteractionRecord, NpcInteractionRecord, NpcStateRecord, + ResolveNpcBattleInteractionInput, +}; +pub use self::puzzle::{ + PuzzleAgentMessageFinalizeRecordInput, PuzzleAgentMessageRecord, + PuzzleAgentMessageSubmitRecordInput, PuzzleAgentSessionCreateRecordInput, + PuzzleAgentSessionRecord, PuzzleAgentSuggestedActionRecord, PuzzleAnchorItemRecord, + PuzzleAnchorPackRecord, PuzzleAudioAssetRecord, PuzzleBoardRecord, PuzzleCellPositionRecord, + PuzzleCreatorIntentRecord, PuzzleDraftLevelRecord, PuzzleFormDraftRecord, + PuzzleFormDraftSaveRecordInput, PuzzleGalleryCardRecord, PuzzleGeneratedImageCandidateRecord, + PuzzleGeneratedImagesSaveRecordInput, PuzzleLeaderboardEntryRecord, + PuzzleLeaderboardSubmitRecordInput, PuzzleMergedGroupRecord, PuzzlePieceStateRecord, + PuzzlePublishRecordInput, PuzzleRecommendedNextWorkRecord, PuzzleResultDraftRecord, + PuzzleResultPreviewBlockerRecord, PuzzleResultPreviewFindingRecord, PuzzleResultPreviewRecord, + PuzzleRunDragRecordInput, PuzzleRunNextLevelRecordInput, PuzzleRunPauseRecordInput, + PuzzleRunPropRecordInput, PuzzleRunRecord, PuzzleRunStartRecordInput, PuzzleRunSwapRecordInput, + PuzzleRuntimeLevelRecord, PuzzleSelectCoverImageRecordInput, PuzzleUiBackgroundSaveRecordInput, + PuzzleWorkLikeReportRecordInput, PuzzleWorkPointIncentiveClaimRecordInput, + PuzzleWorkProfileRecord, PuzzleWorkRemixRecordInput, PuzzleWorkUpsertRecordInput, +}; +pub use self::runtime::{ + BigFishGameDraftRecord, BigFishRuntimeEntityRecord, BigFishRuntimeParamsRecord, + BigFishRuntimeRunRecord, CreationEntryConfigRecord, +}; +pub use self::runtime_profile::{ + SquareHoleDropConfirmationRecord, SquareHoleDropFeedbackRecord, SquareHoleRunRecord, +}; +pub use self::story::{VisualNovelRuntimeEventRecord, VisualNovelRuntimeEventRecordInput}; + +pub(crate) use self::ai::map_ai_task_procedure_result; +pub(crate) use self::assets::{map_entity_binding_procedure_result, map_procedure_result}; +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, +}; +pub(crate) use self::big_fish::{ + map_big_fish_gallery_view_row, map_big_fish_run_procedure_result, + map_big_fish_session_procedure_result, map_big_fish_works_procedure_result, + parse_big_fish_creation_stage, +}; +pub(crate) use self::combat::{ + map_battle_mode, map_battle_mode_back, map_battle_state_procedure_result, map_battle_status, + map_combat_outcome, map_resolve_combat_action_procedure_result, +}; +pub(crate) use self::common::{empty_string_to_none, i64_to_u64_ms, parse_optional_json_value}; +pub(crate) use self::custom_world::{ + map_custom_world_agent_action_execute_result, + map_custom_world_agent_operation_procedure_result, + map_custom_world_agent_session_procedure_result, map_custom_world_draft_card_detail_result, + map_custom_world_gallery_entry_row, map_custom_world_library_detail_result, + map_custom_world_library_mutation_result, map_custom_world_profile_list_result, + map_custom_world_publish_world_result, map_custom_world_works_list_result, + parse_rpg_agent_operation_status_record, parse_rpg_agent_operation_type_record, + parse_rpg_agent_stage_record, +}; +pub(crate) use self::inventory::{ + map_runtime_inventory_state_procedure_result, map_runtime_item_reward_item_snapshot, + map_runtime_item_reward_item_snapshot_back, +}; +pub(crate) use self::match3d::{ + map_match3d_agent_session_procedure_result, map_match3d_click_item_procedure_result, + map_match3d_gallery_view_row, map_match3d_run_procedure_result, + map_match3d_work_procedure_result, map_match3d_works_procedure_result, +}; +pub(crate) use self::npc::{ + build_battle_state_record, map_battle_state_snapshot, map_inventory_item_source_kind, + map_npc_battle_interaction_procedure_result, validate_npc_battle_interaction_input, +}; +pub(crate) use self::puzzle::{ + map_puzzle_agent_session_procedure_result, map_puzzle_gallery_card_view_row, + map_puzzle_run_procedure_result, map_puzzle_work_procedure_result, + map_puzzle_works_procedure_result, map_runtime_profile_wallet_ledger_source_type_back, + parse_puzzle_agent_stage_record, +}; +pub(crate) use self::runtime::{ + build_creation_entry_config_record_from_rows, map_creation_entry_config_procedure_result, + map_runtime_setting_procedure_result, map_runtime_snapshot_delete_procedure_result, + map_runtime_snapshot_procedure_result, map_runtime_snapshot_required_procedure_result, + map_runtime_tracking_event_procedure_result, map_runtime_tracking_scope_kind, + map_runtime_tracking_scope_kind_back, parse_json_array, parse_json_string_array, + parse_json_value, parse_supported_actions_json, +}; +pub(crate) use self::runtime_profile::{ + map_analytics_metric_query_procedure_result, map_runtime_profile_dashboard_procedure_result, + map_runtime_profile_feedback_submission_procedure_result, + map_runtime_profile_invite_code_admin_list_procedure_result, + map_runtime_profile_invite_code_admin_procedure_result, + map_runtime_profile_play_stats_procedure_result, + map_runtime_profile_recharge_center_procedure_result, + map_runtime_profile_recharge_order_procedure_result, + map_runtime_profile_recharge_product_admin_list_procedure_result, + map_runtime_profile_recharge_product_admin_procedure_result, + map_runtime_profile_redeem_code_admin_list_procedure_result, + map_runtime_profile_redeem_code_admin_procedure_result, + map_runtime_profile_reward_code_redeem_procedure_result, + map_runtime_profile_save_archive_list_procedure_result, + map_runtime_profile_save_archive_resume_procedure_result, + map_runtime_profile_task_center_procedure_result, + map_runtime_profile_task_claim_procedure_result, + map_runtime_profile_task_config_admin_list_procedure_result, + map_runtime_profile_task_config_admin_procedure_result, + map_runtime_profile_wallet_adjustment_procedure_result, + map_runtime_profile_wallet_ledger_procedure_result, + map_runtime_referral_invite_center_procedure_result, + map_runtime_referral_redeem_procedure_result, +}; +pub(crate) use self::square_hole::{ + map_square_hole_agent_session_procedure_result, map_square_hole_drop_shape_procedure_result, + map_square_hole_gallery_view_row, map_square_hole_run_procedure_result, + map_square_hole_work_procedure_result, map_square_hole_works_procedure_result, +}; +pub(crate) use self::story::{ + map_asset_history_list_result, map_runtime_browse_history_procedure_result, + map_runtime_profile_save_archive_snapshot, map_runtime_snapshot_snapshot, + map_story_session_procedure_result, map_story_session_state_procedure_result, +}; +pub(crate) use self::visual_novel::{ + map_visual_novel_agent_session_procedure_result, map_visual_novel_gallery_view_row, + map_visual_novel_history_procedure_result, map_visual_novel_run_procedure_result, + map_visual_novel_runtime_event_procedure_result, map_visual_novel_work_procedure_result, + map_visual_novel_works_procedure_result, +}; diff --git a/server-rs/crates/spacetime-client/src/mapper/ai.rs b/server-rs/crates/spacetime-client/src/mapper/ai.rs new file mode 100644 index 00000000..91122fc4 --- /dev/null +++ b/server-rs/crates/spacetime-client/src/mapper/ai.rs @@ -0,0 +1,306 @@ +use super::*; + +use crate::mapper::{ + custom_world::{format_ai_result_reference_kind, format_ai_task_kind}, + inventory::map_ai_result_reference_kind, + npc::map_ai_task_kind, +}; + +impl From for AiTaskCreateInput { + fn from(input: DomainAiTaskCreateInput) -> Self { + Self { + task_id: input.task_id, + task_kind: map_ai_task_kind(input.task_kind), + owner_user_id: input.owner_user_id, + request_label: input.request_label, + source_module: input.source_module, + source_entity_id: input.source_entity_id, + request_payload_json: input.request_payload_json, + stages: input.stages.into_iter().map(Into::into).collect(), + created_at_micros: input.created_at_micros, + } + } +} + +impl From for AiTaskStartInput { + fn from(input: DomainAiTaskStartInput) -> Self { + Self { + task_id: input.task_id, + started_at_micros: input.started_at_micros, + } + } +} + +impl From for AiTaskStageStartInput { + fn from(input: DomainAiTaskStageStartInput) -> Self { + Self { + task_id: input.task_id, + stage_kind: map_ai_task_stage_kind(input.stage_kind), + started_at_micros: input.started_at_micros, + } + } +} + +impl From for AiTextChunkAppendInput { + fn from(input: DomainAiTextChunkAppendInput) -> Self { + Self { + task_id: input.task_id, + stage_kind: map_ai_task_stage_kind(input.stage_kind), + sequence: input.sequence, + delta_text: input.delta_text, + created_at_micros: input.created_at_micros, + } + } +} + +impl From for AiStageCompletionInput { + fn from(input: DomainAiStageCompletionInput) -> Self { + Self { + task_id: input.task_id, + stage_kind: map_ai_task_stage_kind(input.stage_kind), + text_output: input.text_output, + structured_payload_json: input.structured_payload_json, + warning_messages: input.warning_messages, + completed_at_micros: input.completed_at_micros, + } + } +} + +impl From for AiResultReferenceInput { + fn from(input: DomainAiResultReferenceInput) -> Self { + Self { + task_id: input.task_id, + reference_kind: map_ai_result_reference_kind(input.reference_kind), + reference_id: input.reference_id, + label: input.label, + created_at_micros: input.created_at_micros, + } + } +} + +impl From for AiTaskFinishInput { + fn from(input: DomainAiTaskFinishInput) -> Self { + Self { + task_id: input.task_id, + completed_at_micros: input.completed_at_micros, + } + } +} + +impl From for AiTaskFailureInput { + fn from(input: DomainAiTaskFailureInput) -> Self { + Self { + task_id: input.task_id, + failure_message: input.failure_message, + completed_at_micros: input.completed_at_micros, + } + } +} + +impl From for AiTaskCancelInput { + fn from(input: DomainAiTaskCancelInput) -> Self { + Self { + task_id: input.task_id, + completed_at_micros: input.completed_at_micros, + } + } +} + +impl From for AiTaskStageBlueprint { + fn from(blueprint: DomainAiTaskStageBlueprint) -> Self { + Self { + stage_kind: map_ai_task_stage_kind(blueprint.stage_kind), + label: blueprint.label, + detail: blueprint.detail, + order: blueprint.order, + } + } +} + +pub(crate) fn map_ai_task_procedure_result( + result: AiTaskProcedureResult, +) -> Result { + if !result.ok { + return Err(SpacetimeClientError::procedure_failed(result.error_message)); + } + + let task = result + .task + .ok_or_else(|| SpacetimeClientError::missing_snapshot("ai_task 快照"))?; + + Ok(AiTaskMutationRecord { + task: map_ai_task_snapshot(task), + text_chunk: result.text_chunk.map(map_ai_text_chunk_snapshot), + }) +} + +pub(crate) fn map_ai_task_snapshot(snapshot: AiTaskSnapshot) -> AiTaskRecord { + AiTaskRecord { + task_id: snapshot.task_id, + task_kind: format_ai_task_kind(snapshot.task_kind).to_string(), + owner_user_id: snapshot.owner_user_id, + request_label: snapshot.request_label, + source_module: snapshot.source_module, + source_entity_id: snapshot.source_entity_id, + request_payload_json: snapshot.request_payload_json, + status: format_ai_task_status(snapshot.status).to_string(), + failure_message: snapshot.failure_message, + stages: snapshot + .stages + .into_iter() + .map(map_ai_task_stage_snapshot) + .collect(), + result_references: snapshot + .result_references + .into_iter() + .map(map_ai_result_reference_snapshot) + .collect(), + latest_text_output: snapshot.latest_text_output, + latest_structured_payload_json: snapshot.latest_structured_payload_json, + version: snapshot.version, + created_at: format_timestamp_micros(snapshot.created_at_micros), + started_at: snapshot.started_at_micros.map(format_timestamp_micros), + completed_at: snapshot.completed_at_micros.map(format_timestamp_micros), + updated_at: format_timestamp_micros(snapshot.updated_at_micros), + } +} + +pub(crate) fn map_ai_task_stage_snapshot(snapshot: AiTaskStageSnapshot) -> AiTaskStageRecord { + AiTaskStageRecord { + stage_kind: format_ai_task_stage_kind(snapshot.stage_kind).to_string(), + label: snapshot.label, + detail: snapshot.detail, + order: snapshot.order, + status: format_ai_task_stage_status(snapshot.status).to_string(), + text_output: snapshot.text_output, + structured_payload_json: snapshot.structured_payload_json, + warning_messages: snapshot.warning_messages, + started_at: snapshot.started_at_micros.map(format_timestamp_micros), + completed_at: snapshot.completed_at_micros.map(format_timestamp_micros), + } +} + +pub(crate) fn map_ai_text_chunk_snapshot(snapshot: AiTextChunkSnapshot) -> AiTextChunkRecord { + AiTextChunkRecord { + chunk_id: snapshot.chunk_id, + task_id: snapshot.task_id, + stage_kind: format_ai_task_stage_kind(snapshot.stage_kind).to_string(), + sequence: snapshot.sequence, + delta_text: snapshot.delta_text, + created_at: format_timestamp_micros(snapshot.created_at_micros), + } +} + +pub(crate) fn map_ai_result_reference_snapshot( + snapshot: AiResultReferenceSnapshot, +) -> AiResultReferenceRecord { + AiResultReferenceRecord { + result_ref_id: snapshot.result_ref_id, + task_id: snapshot.task_id, + reference_kind: format_ai_result_reference_kind(snapshot.reference_kind).to_string(), + reference_id: snapshot.reference_id, + label: snapshot.label, + created_at: format_timestamp_micros(snapshot.created_at_micros), + } +} + +pub(crate) fn map_ai_task_stage_kind(value: DomainAiTaskStageKind) -> AiTaskStageKind { + match value { + DomainAiTaskStageKind::PreparePrompt => AiTaskStageKind::PreparePrompt, + DomainAiTaskStageKind::RequestModel => AiTaskStageKind::RequestModel, + DomainAiTaskStageKind::RepairResponse => AiTaskStageKind::RepairResponse, + DomainAiTaskStageKind::NormalizeResult => AiTaskStageKind::NormalizeResult, + DomainAiTaskStageKind::PersistResult => AiTaskStageKind::PersistResult, + } +} + +pub(crate) fn format_ai_task_status(value: AiTaskStatus) -> &'static str { + match value { + AiTaskStatus::Pending => "pending", + AiTaskStatus::Running => "running", + AiTaskStatus::Completed => "completed", + AiTaskStatus::Failed => "failed", + AiTaskStatus::Cancelled => "cancelled", + } +} + +pub(crate) fn format_ai_task_stage_kind(value: AiTaskStageKind) -> &'static str { + match value { + AiTaskStageKind::PreparePrompt => "prepare_prompt", + AiTaskStageKind::RequestModel => "request_model", + AiTaskStageKind::RepairResponse => "repair_response", + AiTaskStageKind::NormalizeResult => "normalize_result", + AiTaskStageKind::PersistResult => "persist_result", + } +} + +pub(crate) fn format_ai_task_stage_status(value: AiTaskStageStatus) -> &'static str { + match value { + AiTaskStageStatus::Pending => "pending", + AiTaskStageStatus::Running => "running", + AiTaskStageStatus::Completed => "completed", + AiTaskStageStatus::Skipped => "skipped", + } +} + +#[derive(Clone, Debug, PartialEq, Eq)] +pub struct AiTaskStageRecord { + pub stage_kind: String, + pub label: String, + pub detail: String, + pub order: u32, + pub status: String, + pub text_output: Option, + pub structured_payload_json: Option, + pub warning_messages: Vec, + pub started_at: Option, + pub completed_at: Option, +} + +#[derive(Clone, Debug, PartialEq, Eq)] +pub struct AiResultReferenceRecord { + pub result_ref_id: String, + pub task_id: String, + pub reference_kind: String, + pub reference_id: String, + pub label: Option, + pub created_at: String, +} + +#[derive(Clone, Debug, PartialEq, Eq)] +pub struct AiTextChunkRecord { + pub chunk_id: String, + pub task_id: String, + pub stage_kind: String, + pub sequence: u32, + pub delta_text: String, + pub created_at: String, +} + +#[derive(Clone, Debug, PartialEq, Eq)] +pub struct AiTaskRecord { + pub task_id: String, + pub task_kind: String, + pub owner_user_id: String, + pub request_label: String, + pub source_module: String, + pub source_entity_id: Option, + pub request_payload_json: Option, + pub status: String, + pub failure_message: Option, + pub stages: Vec, + pub result_references: Vec, + pub latest_text_output: Option, + pub latest_structured_payload_json: Option, + pub version: u32, + pub created_at: String, + pub started_at: Option, + pub completed_at: Option, + pub updated_at: String, +} + +#[derive(Clone, Debug, PartialEq, Eq)] +pub struct AiTaskMutationRecord { + pub task: AiTaskRecord, + pub text_chunk: Option, +} diff --git a/server-rs/crates/spacetime-client/src/mapper/assets.rs b/server-rs/crates/spacetime-client/src/mapper/assets.rs new file mode 100644 index 00000000..0e9586f3 --- /dev/null +++ b/server-rs/crates/spacetime-client/src/mapper/assets.rs @@ -0,0 +1,382 @@ +use super::*; + +impl From for AssetEntityBindingInput { + fn from(input: module_assets::AssetEntityBindingInput) -> Self { + Self { + binding_id: input.binding_id, + asset_object_id: input.asset_object_id, + entity_kind: input.entity_kind, + entity_id: input.entity_id, + slot: input.slot, + asset_kind: input.asset_kind, + owner_user_id: input.owner_user_id, + profile_id: input.profile_id, + updated_at_micros: input.updated_at_micros, + } + } +} + +impl From for AssetObjectUpsertInput { + fn from(input: module_assets::AssetObjectUpsertInput) -> Self { + Self { + asset_object_id: input.asset_object_id, + bucket: input.bucket, + object_key: input.object_key, + access_policy: map_access_policy(input.access_policy), + content_type: input.content_type, + content_length: input.content_length, + content_hash: input.content_hash, + version: input.version, + source_job_id: input.source_job_id, + owner_user_id: input.owner_user_id, + profile_id: input.profile_id, + entity_id: input.entity_id, + asset_kind: input.asset_kind, + updated_at_micros: input.updated_at_micros, + } + } +} + +impl From for AssetHistoryListInput { + fn from(input: module_assets::AssetHistoryListInput) -> Self { + Self { + asset_kind: input.asset_kind, + limit: input.limit, + } + } +} + +pub(crate) fn map_procedure_result( + result: AssetObjectProcedureResult, +) -> Result { + if !result.ok { + return Err(SpacetimeClientError::procedure_failed(result.error_message)); + } + + let snapshot = result + .record + .ok_or_else(|| SpacetimeClientError::missing_snapshot("对象快照"))?; + + Ok(build_asset_object_record(map_snapshot(snapshot))) +} + +pub(crate) fn map_entity_binding_procedure_result( + result: AssetEntityBindingProcedureResult, +) -> Result { + if !result.ok { + return Err(SpacetimeClientError::procedure_failed(result.error_message)); + } + + let snapshot = result + .record + .ok_or_else(|| SpacetimeClientError::missing_snapshot("绑定快照"))?; + + Ok(build_asset_entity_binding_record( + map_entity_binding_snapshot(snapshot), + )) +} + +pub(crate) fn map_entity_binding_snapshot( + snapshot: AssetEntityBindingSnapshot, +) -> module_assets::AssetEntityBindingSnapshot { + module_assets::AssetEntityBindingSnapshot { + binding_id: snapshot.binding_id, + asset_object_id: snapshot.asset_object_id, + entity_kind: snapshot.entity_kind, + entity_id: snapshot.entity_id, + slot: snapshot.slot, + asset_kind: snapshot.asset_kind, + owner_user_id: snapshot.owner_user_id, + profile_id: snapshot.profile_id, + created_at_micros: snapshot.created_at_micros, + updated_at_micros: snapshot.updated_at_micros, + } +} + +pub(crate) fn map_snapshot( + snapshot: AssetObjectUpsertSnapshot, +) -> module_assets::AssetObjectUpsertSnapshot { + module_assets::AssetObjectUpsertSnapshot { + asset_object_id: snapshot.asset_object_id, + bucket: snapshot.bucket, + object_key: snapshot.object_key, + access_policy: map_access_policy_back(snapshot.access_policy), + content_type: snapshot.content_type, + content_length: snapshot.content_length, + content_hash: snapshot.content_hash, + version: snapshot.version, + source_job_id: snapshot.source_job_id, + owner_user_id: snapshot.owner_user_id, + profile_id: snapshot.profile_id, + entity_id: snapshot.entity_id, + asset_kind: snapshot.asset_kind, + created_at_micros: snapshot.created_at_micros, + updated_at_micros: snapshot.updated_at_micros, + } +} + +pub(crate) fn map_access_policy( + value: AssetObjectAccessPolicy, +) -> crate::module_bindings::AssetObjectAccessPolicy { + match value { + AssetObjectAccessPolicy::Private => { + crate::module_bindings::AssetObjectAccessPolicy::Private + } + AssetObjectAccessPolicy::PublicRead => { + crate::module_bindings::AssetObjectAccessPolicy::PublicRead + } + } +} + +pub(crate) fn map_access_policy_back( + value: crate::module_bindings::AssetObjectAccessPolicy, +) -> AssetObjectAccessPolicy { + match value { + crate::module_bindings::AssetObjectAccessPolicy::Private => { + AssetObjectAccessPolicy::Private + } + crate::module_bindings::AssetObjectAccessPolicy::PublicRead => { + AssetObjectAccessPolicy::PublicRead + } + } +} + +impl TryFrom<&str> for BigFishAssetKind { + type Error = SpacetimeClientError; + + fn try_from(value: &str) -> Result { + match value.trim() { + "level_main_image" => Ok(Self::LevelMainImage), + "level_motion" => Ok(Self::LevelMotion), + "stage_background" => Ok(Self::StageBackground), + other => Err(SpacetimeClientError::Runtime(format!( + "big fish asset kind `{other}` 当前尚未支持" + ))), + } + } +} + +#[derive(Clone, Debug, PartialEq)] +pub struct CustomWorldDraftCardRecord { + pub card_id: String, + pub kind: String, + pub title: String, + pub subtitle: String, + pub summary: String, + pub status: String, + pub linked_ids: Vec, + pub warning_count: u32, + pub asset_status: Option, + pub asset_status_label: Option, + pub detail_payload: Option, +} + +#[derive(Clone, Debug, PartialEq, Eq)] +pub struct CustomWorldDraftCardDetailRecord { + pub card_id: String, + pub kind: String, + pub title: String, + pub sections: Vec, + pub linked_ids: Vec, + pub locked: bool, + pub editable: bool, + pub editable_section_ids: Vec, + pub warning_messages: Vec, + pub asset_status: Option, + pub asset_status_label: Option, +} + +#[derive(Clone, Debug, PartialEq)] +pub struct CustomWorldAgentSessionRecord { + pub session_id: String, + pub seed_text: String, + pub current_turn: u32, + pub anchor_content: serde_json::Value, + pub progress_percent: u32, + pub last_assistant_reply: Option, + pub stage: String, + pub focus_card_id: Option, + pub creator_intent: serde_json::Value, + pub creator_intent_readiness: serde_json::Value, + pub anchor_pack: serde_json::Value, + pub lock_state: serde_json::Value, + pub draft_profile: serde_json::Value, + pub messages: Vec, + pub draft_cards: Vec, + pub pending_clarifications: Vec, + pub suggested_actions: Vec, + pub recommended_replies: Vec, + pub quality_findings: Vec, + pub asset_coverage: serde_json::Value, + pub checkpoints: Vec, + pub supported_actions: Vec, + pub publish_gate: Option, + pub result_preview: Option, + pub updated_at: String, +} + +#[derive(Clone, Debug, PartialEq, Eq)] +pub struct CustomWorldAgentSessionCreateRecordInput { + pub session_id: String, + pub owner_user_id: String, + pub seed_text: String, + pub welcome_message_id: String, + pub welcome_message_text: String, + pub anchor_content_json: String, + pub creator_intent_json: Option, + pub creator_intent_readiness_json: String, + pub anchor_pack_json: Option, + pub lock_state_json: Option, + pub draft_profile_json: Option, + pub pending_clarifications_json: String, + pub suggested_actions_json: String, + pub recommended_replies_json: String, + pub quality_findings_json: String, + pub asset_coverage_json: String, + pub checkpoints_json: String, + pub created_at_micros: i64, +} + +#[derive(Clone, Debug, PartialEq, Eq)] +pub struct CustomWorldAgentMessageFinalizeRecordInput { + pub session_id: String, + pub owner_user_id: String, + pub operation_id: String, + pub assistant_message_id: Option, + pub assistant_reply_text: Option, + pub phase_label: String, + pub phase_detail: String, + pub operation_status: String, + pub operation_progress: u32, + pub stage: String, + pub progress_percent: u32, + pub focus_card_id: Option, + pub anchor_content_json: String, + pub creator_intent_json: Option, + pub creator_intent_readiness_json: String, + pub anchor_pack_json: Option, + pub draft_profile_json: Option, + pub pending_clarifications_json: String, + pub suggested_actions_json: String, + pub recommended_replies_json: String, + pub quality_findings_json: String, + pub asset_coverage_json: String, + pub error_message: Option, + pub updated_at_micros: i64, +} + +#[derive(Clone, Debug, PartialEq, Eq)] +pub struct VisualNovelAgentSessionCreateRecordInput { + pub session_id: String, + pub owner_user_id: String, + pub source_mode: String, + pub seed_text: String, + pub source_asset_ids_json: String, + pub welcome_message_id: String, + pub welcome_message_text: String, + pub draft_json: Option, + pub created_at_micros: i64, +} + +#[derive(Clone, Debug, PartialEq, Eq)] +pub struct VisualNovelWorkUpdateRecordInput { + pub profile_id: String, + pub owner_user_id: String, + pub work_title: String, + pub work_description: String, + pub tags_json: String, + pub cover_image_src: Option, + pub source_asset_ids_json: String, + pub draft_json: String, + pub publish_ready: bool, + pub updated_at_micros: i64, +} + +#[derive(Clone, Debug, PartialEq)] +pub struct VisualNovelAgentSessionRecord { + pub session_id: String, + pub owner_user_id: String, + pub source_mode: String, + pub status: String, + pub seed_text: String, + pub source_asset_ids: Vec, + pub current_turn: u32, + pub progress_percent: u32, + pub messages: Vec, + pub draft: Option, + pub pending_action: Option, + pub last_assistant_reply: Option, + pub published_profile_id: Option, + pub created_at: String, + pub updated_at: String, +} + +#[derive(Clone, Debug, PartialEq)] +pub struct VisualNovelWorkProfileRecord { + pub work_id: String, + pub profile_id: String, + pub owner_user_id: String, + pub source_session_id: Option, + pub author_display_name: String, + pub work_title: String, + pub work_description: String, + pub tags: Vec, + pub cover_image_src: Option, + pub source_asset_ids: Vec, + pub draft: serde_json::Value, + pub publication_status: String, + pub publish_ready: bool, + pub play_count: u32, + pub created_at: String, + pub updated_at: String, + pub published_at: Option, +} + +#[derive(Clone, Debug, PartialEq, Eq)] +pub struct BigFishAssetGenerateRecordInput { + pub session_id: String, + pub owner_user_id: String, + pub asset_kind: String, + pub level: Option, + pub motion_key: Option, + pub asset_url: Option, + pub generated_at_micros: i64, +} + +#[derive(Clone, Debug, PartialEq, Eq)] +pub struct BigFishAssetSlotRecord { + pub slot_id: String, + pub asset_kind: String, + pub level: Option, + pub motion_key: Option, + pub status: String, + pub asset_url: Option, + pub prompt_snapshot: String, + pub updated_at: String, +} + +#[derive(Clone, Debug, PartialEq, Eq)] +pub struct BigFishAssetCoverageRecord { + pub level_main_image_ready_count: u32, + pub level_motion_ready_count: u32, + pub background_ready: bool, + pub required_level_count: u32, + pub publish_ready: bool, + pub blockers: Vec, +} + +#[derive(Clone, Debug, PartialEq)] +pub struct BigFishSessionRecord { + pub session_id: String, + pub current_turn: u32, + pub progress_percent: u32, + pub stage: String, + pub anchor_pack: BigFishAnchorPackRecord, + pub draft: Option, + pub asset_slots: Vec, + pub asset_coverage: BigFishAssetCoverageRecord, + pub messages: Vec, + pub last_assistant_reply: Option, + pub publish_ready: bool, + pub updated_at: String, +} diff --git a/server-rs/crates/spacetime-client/src/mapper/auth.rs b/server-rs/crates/spacetime-client/src/mapper/auth.rs new file mode 100644 index 00000000..1012acc2 --- /dev/null +++ b/server-rs/crates/spacetime-client/src/mapper/auth.rs @@ -0,0 +1,42 @@ +use super::*; + +pub(crate) fn map_auth_store_snapshot_procedure_result( + result: AuthStoreSnapshotProcedureResult, +) -> Result { + if !result.ok { + return Err(SpacetimeClientError::procedure_failed(result.error_message)); + } + + let record = result + .record + .ok_or_else(|| SpacetimeClientError::missing_snapshot("认证快照"))?; + + Ok(map_auth_store_snapshot_record(record)) +} + +pub(crate) fn map_auth_store_snapshot_record( + record: crate::module_bindings::AuthStoreSnapshotRecord, +) -> crate::AuthStoreSnapshotRecord { + crate::AuthStoreSnapshotRecord { + snapshot_json: record.snapshot_json, + updated_at_micros: record.updated_at_micros, + } +} + +pub(crate) fn map_auth_store_snapshot_import_procedure_result( + result: AuthStoreSnapshotImportProcedureResult, +) -> Result { + if !result.ok { + return Err(SpacetimeClientError::procedure_failed(result.error_message)); + } + + let record = result + .record + .ok_or_else(|| SpacetimeClientError::missing_snapshot("认证快照导入结果"))?; + + Ok(AuthStoreSnapshotImportRecord { + imported_user_count: record.imported_user_count, + imported_identity_count: record.imported_identity_count, + imported_refresh_session_count: record.imported_refresh_session_count, + }) +} diff --git a/server-rs/crates/spacetime-client/src/mapper/bark_battle.rs b/server-rs/crates/spacetime-client/src/mapper/bark_battle.rs new file mode 100644 index 00000000..b8a5c090 --- /dev/null +++ b/server-rs/crates/spacetime-client/src/mapper/bark_battle.rs @@ -0,0 +1,94 @@ +use super::*; + +pub(crate) fn map_bark_battle_draft_config_procedure_result( + result: BarkBattleProcedureResult, +) -> Result { + if !result.ok { + return Err(SpacetimeClientError::procedure_failed(result.error_message)); + } + result + .draft_config + .ok_or_else(|| SpacetimeClientError::missing_snapshot("Bark Battle draft config")) + .map(bark_battle_draft_config_to_value) +} + +pub(crate) fn map_bark_battle_runtime_config_procedure_result( + result: BarkBattleProcedureResult, +) -> Result { + if !result.ok { + return Err(SpacetimeClientError::procedure_failed(result.error_message)); + } + result + .runtime_config + .ok_or_else(|| SpacetimeClientError::missing_snapshot("Bark Battle runtime config")) + .map(bark_battle_runtime_config_to_value) +} + +pub(crate) fn map_bark_battle_run_procedure_result( + result: BarkBattleProcedureResult, +) -> Result { + if !result.ok { + return Err(SpacetimeClientError::procedure_failed(result.error_message)); + } + result + .run + .ok_or_else(|| SpacetimeClientError::missing_snapshot("Bark Battle run")) + .map(bark_battle_run_to_value) +} + +fn bark_battle_draft_config_to_value(snapshot: BarkBattleDraftConfigSnapshot) -> serde_json::Value { + serde_json::json!({ + "draftId": snapshot.draft_id, + "ownerUserId": snapshot.owner_user_id, + "workId": snapshot.work_id, + "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, + "updatedAtMicros": snapshot.updated_at_micros, + }) +} + +fn bark_battle_runtime_config_to_value( + snapshot: BarkBattleRuntimeConfigSnapshot, +) -> serde_json::Value { + serde_json::json!({ + "workId": snapshot.work_id, + "ownerUserId": snapshot.owner_user_id, + "sourceDraftId": snapshot.source_draft_id, + "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, + "updatedAtMicros": snapshot.updated_at_micros, + }) +} + +fn bark_battle_run_to_value(snapshot: BarkBattleRunSnapshot) -> serde_json::Value { + serde_json::json!({ + "runId": snapshot.run_id, + "ownerUserId": snapshot.owner_user_id, + "workId": snapshot.work_id, + "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, + "clientFinishedAtMicros": snapshot.client_finished_at_micros, + "serverFinishedAtMicros": snapshot.server_finished_at_micros, + "metricsJson": snapshot.metrics_json, + "serverResult": snapshot.server_result, + "validationStatus": snapshot.validation_status, + "antiCheatFlagsJson": snapshot.anti_cheat_flags_json, + "leaderboardScore": snapshot.leaderboard_score, + "scoreId": snapshot.score_id, + }) +} diff --git a/server-rs/crates/spacetime-client/src/mapper/big_fish.rs b/server-rs/crates/spacetime-client/src/mapper/big_fish.rs new file mode 100644 index 00000000..8fb549c2 --- /dev/null +++ b/server-rs/crates/spacetime-client/src/mapper/big_fish.rs @@ -0,0 +1,616 @@ +use super::*; + +pub(crate) fn map_big_fish_session_procedure_result( + result: BigFishSessionProcedureResult, +) -> Result { + if !result.ok { + return Err(SpacetimeClientError::procedure_failed(result.error_message)); + } + + let session = result + .session + .ok_or_else(|| SpacetimeClientError::missing_snapshot("big fish session 快照"))?; + + Ok(map_big_fish_session_snapshot(session)) +} + +pub(crate) fn map_big_fish_works_procedure_result( + result: BigFishWorksProcedureResult, + _fallback_owner_user_id: Option<&str>, +) -> Result, SpacetimeClientError> { + if !result.ok { + return Err(SpacetimeClientError::procedure_failed(result.error_message)); + } + + Ok(result + .items + .into_iter() + .map(map_big_fish_work_summary_snapshot) + .collect()) +} + +pub(crate) fn map_big_fish_run_procedure_result( + result: BigFishRunProcedureResult, +) -> Result { + if !result.ok { + return Err(SpacetimeClientError::procedure_failed(result.error_message)); + } + + let run = result + .run + .ok_or_else(|| SpacetimeClientError::missing_snapshot("big fish run 快照"))?; + Ok(map_big_fish_runtime_snapshot(run)) +} + +pub(crate) fn map_big_fish_session_snapshot( + snapshot: BigFishSessionSnapshot, +) -> BigFishSessionRecord { + BigFishSessionRecord { + session_id: snapshot.session_id, + current_turn: snapshot.current_turn, + progress_percent: snapshot.progress_percent, + stage: format_big_fish_creation_stage(snapshot.stage).to_string(), + anchor_pack: map_big_fish_anchor_pack(snapshot.anchor_pack), + draft: snapshot.draft.map(map_big_fish_game_draft), + asset_slots: snapshot + .asset_slots + .into_iter() + .map(map_big_fish_asset_slot_snapshot) + .collect(), + asset_coverage: map_big_fish_asset_coverage(snapshot.asset_coverage), + messages: snapshot + .messages + .into_iter() + .map(map_big_fish_agent_message_snapshot) + .collect(), + last_assistant_reply: snapshot.last_assistant_reply, + publish_ready: snapshot.publish_ready, + updated_at: format_timestamp_micros(snapshot.updated_at_micros), + } +} + +pub(crate) fn map_big_fish_anchor_pack(snapshot: BigFishAnchorPack) -> BigFishAnchorPackRecord { + BigFishAnchorPackRecord { + gameplay_promise: map_big_fish_anchor_item(snapshot.gameplay_promise), + ecology_visual_theme: map_big_fish_anchor_item(snapshot.ecology_visual_theme), + growth_ladder: map_big_fish_anchor_item(snapshot.growth_ladder), + risk_tempo: map_big_fish_anchor_item(snapshot.risk_tempo), + } +} + +pub(crate) fn map_big_fish_anchor_item(snapshot: BigFishAnchorItem) -> BigFishAnchorItemRecord { + BigFishAnchorItemRecord { + key: snapshot.key, + label: snapshot.label, + value: snapshot.value, + status: format_big_fish_anchor_status(snapshot.status).to_string(), + } +} + +pub(crate) fn map_big_fish_game_draft(snapshot: BigFishGameDraft) -> BigFishGameDraftRecord { + BigFishGameDraftRecord { + title: snapshot.title, + subtitle: snapshot.subtitle, + core_fun: snapshot.core_fun, + ecology_theme: snapshot.ecology_theme, + levels: snapshot + .levels + .into_iter() + .map(map_big_fish_level_blueprint) + .collect(), + background: map_big_fish_background_blueprint(snapshot.background), + runtime_params: map_big_fish_runtime_params(snapshot.runtime_params), + } +} + +pub(crate) fn map_big_fish_level_blueprint( + snapshot: BigFishLevelBlueprint, +) -> BigFishLevelBlueprintRecord { + BigFishLevelBlueprintRecord { + level: snapshot.level, + name: snapshot.name, + one_line_fantasy: snapshot.one_line_fantasy, + text_description: snapshot.text_description, + silhouette_direction: snapshot.silhouette_direction, + size_ratio: snapshot.size_ratio, + visual_description: snapshot.visual_description, + visual_prompt_seed: snapshot.visual_prompt_seed, + idle_motion_description: snapshot.idle_motion_description, + move_motion_description: snapshot.move_motion_description, + motion_prompt_seed: snapshot.motion_prompt_seed, + merge_source_level: snapshot.merge_source_level, + prey_window: snapshot.prey_window, + threat_window: snapshot.threat_window, + is_final_level: snapshot.is_final_level, + } +} + +pub(crate) fn map_big_fish_background_blueprint( + snapshot: BigFishBackgroundBlueprint, +) -> BigFishBackgroundBlueprintRecord { + BigFishBackgroundBlueprintRecord { + theme: snapshot.theme, + color_mood: snapshot.color_mood, + foreground_hints: snapshot.foreground_hints, + midground_composition: snapshot.midground_composition, + background_depth: snapshot.background_depth, + safe_play_area_hint: snapshot.safe_play_area_hint, + spawn_edge_hint: snapshot.spawn_edge_hint, + background_prompt_seed: snapshot.background_prompt_seed, + } +} + +pub(crate) fn map_big_fish_runtime_params( + snapshot: BigFishRuntimeParams, +) -> BigFishRuntimeParamsRecord { + BigFishRuntimeParamsRecord { + level_count: snapshot.level_count, + merge_count_per_upgrade: snapshot.merge_count_per_upgrade, + spawn_target_count: snapshot.spawn_target_count, + leader_move_speed: snapshot.leader_move_speed, + follower_catch_up_speed: snapshot.follower_catch_up_speed, + offscreen_cull_seconds: snapshot.offscreen_cull_seconds, + prey_spawn_delta_levels: snapshot.prey_spawn_delta_levels, + threat_spawn_delta_levels: snapshot.threat_spawn_delta_levels, + win_level: snapshot.win_level, + } +} + +pub(crate) fn map_big_fish_asset_slot_snapshot( + snapshot: BigFishAssetSlotSnapshot, +) -> BigFishAssetSlotRecord { + BigFishAssetSlotRecord { + slot_id: snapshot.slot_id, + asset_kind: format_big_fish_asset_kind(snapshot.asset_kind).to_string(), + level: snapshot.level, + motion_key: snapshot.motion_key, + status: format_big_fish_asset_status(snapshot.status).to_string(), + asset_url: snapshot.asset_url, + prompt_snapshot: snapshot.prompt_snapshot, + updated_at: format_timestamp_micros(snapshot.updated_at_micros), + } +} + +pub(crate) fn map_big_fish_asset_coverage( + snapshot: BigFishAssetCoverage, +) -> BigFishAssetCoverageRecord { + BigFishAssetCoverageRecord { + level_main_image_ready_count: snapshot.level_main_image_ready_count, + level_motion_ready_count: snapshot.level_motion_ready_count, + background_ready: snapshot.background_ready, + required_level_count: snapshot.required_level_count, + publish_ready: snapshot.publish_ready, + blockers: snapshot.blockers, + } +} + +pub(crate) fn map_big_fish_agent_message_snapshot( + snapshot: BigFishAgentMessageSnapshot, +) -> BigFishAgentMessageRecord { + BigFishAgentMessageRecord { + message_id: snapshot.message_id, + role: format_big_fish_agent_message_role(snapshot.role).to_string(), + kind: format_big_fish_agent_message_kind(snapshot.kind).to_string(), + text: snapshot.text, + created_at: format_timestamp_micros(snapshot.created_at_micros), + } +} + +pub(crate) fn map_big_fish_work_summary_snapshot( + snapshot: BigFishWorkSummarySnapshot, +) -> BigFishWorkSummaryRecord { + BigFishWorkSummaryRecord { + work_id: snapshot.work_id, + source_session_id: snapshot.source_session_id, + owner_user_id: snapshot.owner_user_id, + title: snapshot.title, + subtitle: snapshot.subtitle, + summary: snapshot.summary, + cover_image_src: snapshot.cover_image_src, + status: snapshot.status, + updated_at_micros: snapshot.updated_at_micros, + published_at_micros: snapshot.published_at_micros, + publish_ready: snapshot.publish_ready, + level_count: snapshot.level_count, + level_main_image_ready_count: snapshot.level_main_image_ready_count, + level_motion_ready_count: snapshot.level_motion_ready_count, + background_ready: snapshot.background_ready, + play_count: snapshot.play_count, + remix_count: snapshot.remix_count, + like_count: snapshot.like_count, + recent_play_count_7d: snapshot.recent_play_count_7_d, + } +} + +pub(crate) fn map_big_fish_gallery_view_row( + row: BigFishWorkSummarySnapshot, + recent_play_count_7d: u32, +) -> BigFishWorkSummaryRecord { + let mut record = map_big_fish_work_summary_snapshot(row); + record.recent_play_count_7d = recent_play_count_7d; + record +} + +pub(crate) fn map_big_fish_runtime_snapshot( + snapshot: BigFishRuntimeSnapshot, +) -> BigFishRuntimeRunRecord { + BigFishRuntimeRunRecord { + run_id: snapshot.run_id, + session_id: snapshot.session_id, + status: format_big_fish_run_status(snapshot.status).to_string(), + tick: snapshot.tick, + player_level: snapshot.player_level, + win_level: snapshot.win_level, + leader_entity_id: snapshot.leader_entity_id, + owned_entities: snapshot + .owned_entities + .into_iter() + .map(map_big_fish_runtime_entity_snapshot) + .collect(), + wild_entities: snapshot + .wild_entities + .into_iter() + .map(map_big_fish_runtime_entity_snapshot) + .collect(), + camera_center: map_big_fish_vector2(snapshot.camera_center), + last_input: map_big_fish_vector2(snapshot.last_input), + event_log: snapshot.event_log, + updated_at: format_timestamp_micros(snapshot.updated_at_micros), + } +} + +fn map_big_fish_runtime_entity_snapshot( + snapshot: BigFishRuntimeEntitySnapshot, +) -> BigFishRuntimeEntityRecord { + BigFishRuntimeEntityRecord { + entity_id: snapshot.entity_id, + level: snapshot.level, + position: map_big_fish_vector2(snapshot.position), + radius: snapshot.radius, + offscreen_seconds: snapshot.offscreen_seconds, + } +} + +fn map_big_fish_vector2(snapshot: BigFishVector2) -> BigFishVector2Record { + BigFishVector2Record { + x: snapshot.x, + y: snapshot.y, + } +} + +pub(crate) fn parse_big_fish_creation_stage( + value: &str, +) -> Result { + match value.trim() { + "collecting_anchors" => Ok(BigFishCreationStage::CollectingAnchors), + "draft_ready" => Ok(BigFishCreationStage::DraftReady), + "asset_refining" => Ok(BigFishCreationStage::AssetRefining), + "ready_to_publish" => Ok(BigFishCreationStage::ReadyToPublish), + "published" => Ok(BigFishCreationStage::Published), + other => Err(SpacetimeClientError::Runtime(format!( + "big fish creation stage `{other}` 当前尚未支持" + ))), + } +} + +pub(crate) fn format_big_fish_creation_stage(value: BigFishCreationStage) -> &'static str { + match value { + BigFishCreationStage::CollectingAnchors => "collecting_anchors", + BigFishCreationStage::DraftReady => "draft_ready", + BigFishCreationStage::AssetRefining => "asset_refining", + BigFishCreationStage::ReadyToPublish => "ready_to_publish", + BigFishCreationStage::Published => "published", + } +} + +pub(crate) fn format_big_fish_anchor_status(value: BigFishAnchorStatus) -> &'static str { + match value { + BigFishAnchorStatus::Confirmed => "confirmed", + BigFishAnchorStatus::Inferred => "inferred", + BigFishAnchorStatus::Missing => "missing", + BigFishAnchorStatus::Locked => "locked", + } +} + +pub(crate) fn format_big_fish_agent_message_role(value: BigFishAgentMessageRole) -> &'static str { + match value { + BigFishAgentMessageRole::User => "user", + BigFishAgentMessageRole::Assistant => "assistant", + BigFishAgentMessageRole::System => "system", + } +} + +pub(crate) fn format_big_fish_agent_message_kind(value: BigFishAgentMessageKind) -> &'static str { + match value { + BigFishAgentMessageKind::Chat => "chat", + BigFishAgentMessageKind::Summary => "summary", + BigFishAgentMessageKind::ActionResult => "action_result", + BigFishAgentMessageKind::Warning => "warning", + } +} + +pub(crate) fn format_big_fish_asset_kind(value: BigFishAssetKind) -> &'static str { + match value { + BigFishAssetKind::LevelMainImage => "level_main_image", + BigFishAssetKind::LevelMotion => "level_motion", + BigFishAssetKind::StageBackground => "stage_background", + } +} + +pub(crate) fn format_big_fish_asset_status(value: BigFishAssetStatus) -> &'static str { + match value { + BigFishAssetStatus::Missing => "missing", + BigFishAssetStatus::Ready => "ready", + } +} + +pub(crate) fn format_big_fish_run_status(value: BigFishRunStatus) -> &'static str { + match value { + BigFishRunStatus::Running => "running", + BigFishRunStatus::Won => "won", + BigFishRunStatus::Failed => "failed", + } +} + +#[derive(Clone, Debug, PartialEq, Eq, serde::Serialize, serde::Deserialize)] +pub struct BigFishWorkSummaryRecord { + pub work_id: String, + pub source_session_id: String, + pub owner_user_id: String, + pub title: String, + pub subtitle: String, + pub summary: String, + pub cover_image_src: Option, + pub status: String, + pub updated_at_micros: i64, + pub published_at_micros: Option, + pub publish_ready: bool, + pub level_count: u32, + pub level_main_image_ready_count: u32, + pub level_motion_ready_count: u32, + pub background_ready: bool, + pub play_count: u32, + pub remix_count: u32, + pub like_count: u32, + pub recent_play_count_7d: u32, +} + +#[cfg(test)] +mod tests { + use super::*; + + #[test] + fn puzzle_works_mapper_keeps_typed_public_stat_fields() { + let result = PuzzleWorksProcedureResult { + ok: true, + items: vec![PuzzleWorkProfile { + work_id: "puzzle-work-1".to_string(), + profile_id: "puzzle-profile-1".to_string(), + owner_user_id: "user-1".to_string(), + source_session_id: None, + author_display_name: "测试作者".to_string(), + work_title: "雨夜拼图作品".to_string(), + work_description: "拼图作品说明".to_string(), + level_name: "雨夜拼图".to_string(), + summary: "公开作品摘要".to_string(), + theme_tags: vec!["雨夜".to_string(), "猫咪".to_string(), "神庙".to_string()], + cover_image_src: None, + cover_asset_id: None, + levels: Vec::new(), + publication_status: PuzzlePublicationStatus::Published, + updated_at_micros: 123000000, + published_at_micros: Some(123000000), + play_count: 11, + remix_count: 7, + like_count: 5, + recent_play_count_7_d: 3, + point_incentive_total_half_points: 4, + point_incentive_claimed_points: 2, + publish_ready: true, + anchor_pack: test_puzzle_anchor_pack(), + }], + error_message: None, + }; + + let items = map_puzzle_works_procedure_result(result) + .expect("typed puzzle works result 应能映射统计字段"); + + assert_eq!(items.len(), 1); + assert_eq!(items[0].play_count, 11); + assert_eq!(items[0].remix_count, 7); + assert_eq!(items[0].like_count, 5); + assert_eq!(items[0].recent_play_count_7d, 3); + } + + #[test] + fn puzzle_run_mapper_maps_typed_timer_fields() { + let result = PuzzleRunProcedureResult { + ok: true, + run: Some(PuzzleRunSnapshot { + run_id: "puzzle-run-1".to_string(), + entry_profile_id: "puzzle-profile-1".to_string(), + cleared_level_count: 0, + current_level_index: 1, + current_grid_size: 3, + played_profile_ids: vec!["puzzle-profile-1".to_string()], + previous_level_tags: vec![ + "雨夜".to_string(), + "猫咪".to_string(), + "神庙".to_string(), + ], + current_level: Some(PuzzleRuntimeLevelSnapshot { + run_id: "puzzle-run-1".to_string(), + level_index: 1, + level_id: None, + grid_size: 3, + profile_id: "puzzle-profile-1".to_string(), + level_name: "雨夜拼图".to_string(), + author_display_name: "测试作者".to_string(), + theme_tags: vec!["雨夜".to_string(), "猫咪".to_string(), "神庙".to_string()], + cover_image_src: None, + ui_background_image_src: None, + ui_background_image_object_key: None, + background_music: None, + board: PuzzleBoardSnapshot { + rows: 3, + cols: 3, + pieces: vec![PuzzlePieceState { + piece_id: "piece-1".to_string(), + correct_row: 0, + correct_col: 0, + current_row: 0, + current_col: 0, + merged_group_id: None, + }], + merged_groups: Vec::new(), + selected_piece_id: None, + all_tiles_resolved: false, + }, + status: PuzzleRuntimeLevelStatus::Playing, + started_at_ms: 0, + cleared_at_ms: None, + elapsed_ms: None, + time_limit_ms: 0, + remaining_ms: 0, + paused_accumulated_ms: 0, + pause_started_at_ms: None, + freeze_accumulated_ms: 0, + freeze_started_at_ms: None, + freeze_until_ms: None, + leaderboard_entries: Vec::new(), + }), + recommended_next_profile_id: None, + next_level_mode: "none".to_string(), + next_level_profile_id: None, + next_level_id: None, + recommended_next_works: Vec::new(), + leaderboard_entries: Vec::new(), + }), + error_message: None, + }; + + let run = map_puzzle_run_procedure_result(result) + .expect("typed puzzle run result 应能映射计时字段"); + let level = run.current_level.expect("兼容后仍应保留当前关卡"); + + assert_eq!(run.run_id, "puzzle-run-1"); + assert!(level.started_at_ms > 0); + assert_eq!(level.time_limit_ms, 0); + assert_eq!(level.remaining_ms, 0); + assert!(level.leaderboard_entries.is_empty()); + } + + #[test] + fn big_fish_works_mapper_uses_typed_owner_and_public_stats() { + let result = BigFishWorksProcedureResult { + ok: true, + items: vec![BigFishWorkSummarySnapshot { + work_id: "big-fish-work-session-1".to_string(), + source_session_id: "session-1".to_string(), + owner_user_id: "user-1".to_string(), + title: "深海草稿".to_string(), + subtitle: "副标题".to_string(), + summary: "摘要".to_string(), + cover_image_src: None, + status: "draft".to_string(), + updated_at_micros: 123, + publish_ready: false, + level_count: 8, + level_main_image_ready_count: 0, + level_motion_ready_count: 0, + background_ready: false, + play_count: 9, + remix_count: 4, + like_count: 2, + recent_play_count_7_d: 6, + published_at_micros: None, + }], + error_message: None, + }; + + let items = map_big_fish_works_procedure_result(result, Some("user-1")) + .expect("typed big fish works result 应能映射 owner 和统计字段"); + + assert_eq!(items.len(), 1); + assert_eq!(items[0].owner_user_id, "user-1"); + assert_eq!(items[0].published_at_micros, None); + assert_eq!(items[0].play_count, 9); + assert_eq!(items[0].remix_count, 4); + assert_eq!(items[0].like_count, 2); + assert_eq!(items[0].recent_play_count_7d, 6); + } + + #[test] + fn match3d_work_mapper_keeps_generated_item_assets_json() { + let result = Match3DWorkProcedureResult { + ok: true, + work: Some(Match3DWorkSnapshot { + profile_id: "match3d-profile-1".to_string(), + owner_user_id: "user-1".to_string(), + source_session_id: "match3d-session-1".to_string(), + author_display_name: "测试作者".to_string(), + game_name: "水果抓大鹅".to_string(), + theme_text: "水果".to_string(), + summary_text: "水果主题".to_string(), + tags: vec!["水果".to_string()], + cover_image_src: String::new(), + cover_asset_id: String::new(), + clear_count: 3, + difficulty: 3, + config: Match3DCreatorConfigSnapshot { + theme_text: "水果".to_string(), + reference_image_src: None, + clear_count: 3, + difficulty: 3, + asset_style_id: None, + asset_style_label: None, + asset_style_prompt: None, + generate_click_sound: false, + }, + publication_status: "Draft".to_string(), + publish_ready: false, + play_count: 0, + updated_at_micros: 123000000, + published_at_micros: None, + generated_item_assets_json: Some( + r#"[{"itemId":"match3d-item-1","itemName":"草莓","imageSrc":"/generated-match3d-assets/session/profile/items/item/image.png","status":"image_ready"}]"# + .to_string(), + ), + }), + error_message: None, + }; + + let item = map_match3d_work_procedure_result(result) + .expect("typed match3d work result 应保留生成素材 JSON"); + + assert_eq!( + item.generated_item_assets_json.as_deref(), + Some( + r#"[{"itemId":"match3d-item-1","itemName":"草莓","imageSrc":"/generated-match3d-assets/session/profile/items/item/image.png","status":"image_ready"}]"# + ) + ); + } + + fn test_puzzle_anchor_pack() -> PuzzleAnchorPack { + PuzzleAnchorPack { + theme_promise: test_puzzle_anchor_item("themePromise", "题材承诺", "雨夜冒险"), + visual_subject: test_puzzle_anchor_item("visualSubject", "画面主体", "猫咪神庙"), + visual_mood: test_puzzle_anchor_item("visualMood", "视觉气质", "温暖"), + composition_hooks: test_puzzle_anchor_item("compositionHooks", "拼图记忆点", "灯光"), + tags_and_forbidden: test_puzzle_anchor_item( + "tagsAndForbidden", + "标签与禁忌", + "雨夜, 猫咪, 神庙", + ), + } + } + + fn test_puzzle_anchor_item(key: &str, label: &str, value: &str) -> PuzzleAnchorItem { + PuzzleAnchorItem { + key: key.to_string(), + label: label.to_string(), + value: value.to_string(), + status: PuzzleAnchorStatus::Inferred, + } + } +} diff --git a/server-rs/crates/spacetime-client/src/mapper/combat.rs b/server-rs/crates/spacetime-client/src/mapper/combat.rs new file mode 100644 index 00000000..94cb44fe --- /dev/null +++ b/server-rs/crates/spacetime-client/src/mapper/combat.rs @@ -0,0 +1,124 @@ +use super::*; + +impl From for BattleStateQueryInput { + fn from(input: DomainBattleStateQueryInput) -> Self { + Self { + battle_state_id: input.battle_state_id, + } + } +} + +impl From for ResolveCombatActionInput { + fn from(input: DomainResolveCombatActionInput) -> Self { + Self { + battle_state_id: input.battle_state_id, + function_id: input.function_id, + action_text: input.action_text, + base_damage: input.base_damage, + mana_cost: input.mana_cost, + heal: input.heal, + mana_restore: input.mana_restore, + counter_multiplier_basis_points: input.counter_multiplier_basis_points, + updated_at_micros: input.updated_at_micros, + } + } +} + +pub type BarkBattleDraftConfigRecord = serde_json::Value; + +pub type BarkBattleRuntimeConfigRecord = serde_json::Value; + +pub type BarkBattleRunRecord = serde_json::Value; + +pub(crate) fn map_battle_state_procedure_result( + result: BattleStateProcedureResult, +) -> Result { + if !result.ok { + return Err(SpacetimeClientError::procedure_failed(result.error_message)); + } + + let snapshot = result + .snapshot + .ok_or_else(|| SpacetimeClientError::missing_snapshot("battle_state 快照"))?; + + Ok(build_battle_state_record(map_battle_state_snapshot( + snapshot, + ))) +} + +pub(crate) fn map_resolve_combat_action_procedure_result( + result: ResolveCombatActionProcedureResult, +) -> Result { + if !result.ok { + return Err(SpacetimeClientError::procedure_failed(result.error_message)); + } + + let action_result = result + .result + .ok_or_else(|| SpacetimeClientError::missing_snapshot("战斗结算结果"))?; + + Ok(build_resolve_combat_action_record( + map_resolve_combat_action_result(action_result), + )) +} + +pub(crate) fn map_resolve_combat_action_result( + result: ResolveCombatActionResult, +) -> DomainResolveCombatActionResult { + DomainResolveCombatActionResult { + snapshot: map_battle_state_snapshot(result.snapshot), + damage_dealt: result.damage_dealt, + damage_taken: result.damage_taken, + outcome: map_combat_outcome(result.outcome), + } +} + +pub(crate) fn map_battle_mode(value: DomainBattleMode) -> BattleMode { + match value { + DomainBattleMode::Fight => BattleMode::Fight, + DomainBattleMode::Spar => BattleMode::Spar, + } +} + +pub(crate) fn map_battle_mode_back(value: BattleMode) -> DomainBattleMode { + match value { + BattleMode::Fight => DomainBattleMode::Fight, + BattleMode::Spar => DomainBattleMode::Spar, + } +} + +pub(crate) fn map_battle_status(value: BattleStatus) -> DomainBattleStatus { + match value { + BattleStatus::Ongoing => DomainBattleStatus::Ongoing, + BattleStatus::Resolved => DomainBattleStatus::Resolved, + BattleStatus::Aborted => DomainBattleStatus::Aborted, + } +} + +pub(crate) fn map_combat_outcome(value: CombatOutcome) -> DomainCombatOutcome { + match value { + CombatOutcome::Ongoing => DomainCombatOutcome::Ongoing, + CombatOutcome::Victory => DomainCombatOutcome::Victory, + CombatOutcome::SparComplete => DomainCombatOutcome::SparComplete, + CombatOutcome::Escaped => DomainCombatOutcome::Escaped, + } +} + +#[derive(Clone, Debug, PartialEq, Eq)] +pub struct ResolveCombatActionRecord { + pub battle_state: BattleStateRecord, + pub damage_dealt: i32, + pub damage_taken: i32, + pub outcome: String, +} + +pub(crate) fn build_resolve_combat_action_record( + result: DomainResolveCombatActionResult, +) -> ResolveCombatActionRecord { + ResolveCombatActionRecord { + battle_state: build_battle_state_record(result.snapshot), + damage_dealt: result.damage_dealt, + damage_taken: result.damage_taken, + outcome: result.outcome.as_str().to_string(), + } +} diff --git a/server-rs/crates/spacetime-client/src/mapper/common.rs b/server-rs/crates/spacetime-client/src/mapper/common.rs new file mode 100644 index 00000000..5fea18aa --- /dev/null +++ b/server-rs/crates/spacetime-client/src/mapper/common.rs @@ -0,0 +1,706 @@ +use super::*; + +impl From for CustomWorldPublishWorldInput { + fn from(input: CustomWorldPublishWorldRecordInput) -> Self { + Self { + session_id: input.session_id, + profile_id: input.profile_id, + owner_user_id: input.owner_user_id, + public_work_code: input.public_work_code, + author_public_user_code: input.author_public_user_code, + draft_profile_json: input.draft_profile_json, + legacy_result_profile_json: input.legacy_result_profile_json, + setting_text: input.setting_text, + author_display_name: input.author_display_name, + published_at_micros: input.published_at_micros, + } + } +} + +pub(crate) fn empty_string_to_none(value: String) -> Option { + let trimmed = value.trim(); + if trimmed.is_empty() { + None + } else { + Some(trimmed.to_string()) + } +} + +pub(crate) fn i64_to_u64_ms(value: i64) -> u64 { + value.max(0) as u64 +} + +pub(crate) fn parse_optional_json_value( + value: Option<&str>, + fallback: serde_json::Value, + label: &str, +) -> Result { + match value.map(str::trim).filter(|value| !value.is_empty()) { + Some(value) => parse_json_value(value, label), + None => Ok(fallback), + } +} + +#[derive(Clone, Debug, PartialEq)] +pub struct CustomWorldLibraryMutationRecord { + pub entry: CustomWorldLibraryEntryRecord, + pub gallery_entry: Option, +} + +#[derive(Clone, Debug, PartialEq)] +pub struct CustomWorldPublishWorldRecord { + pub compiled_record: CustomWorldPublishedProfileCompileRecord, + pub entry: CustomWorldLibraryEntryRecord, + pub gallery_entry: Option, + pub session_stage: String, +} + +#[derive(Clone, Debug, PartialEq)] +pub struct CustomWorldAgentMessageRecord { + pub message_id: String, + pub role: String, + pub kind: String, + pub text: String, + pub created_at: String, + pub related_operation_id: Option, +} + +#[derive(Clone, Debug, PartialEq)] +pub struct CustomWorldAgentOperationRecord { + pub operation_id: String, + pub operation_type: String, + pub status: String, + pub phase_label: String, + pub phase_detail: String, + pub progress: u32, + pub error_message: Option, + pub started_at_micros: i64, + pub updated_at_micros: i64, +} + +#[derive(Clone, Debug, PartialEq, Eq)] +pub struct CustomWorldAgentOperationProgressRecordInput { + pub session_id: String, + pub owner_user_id: String, + pub operation_id: String, + // SpacetimeDB 模块侧使用枚举存储操作类型,这里保留字符串给 API 层做轻量传参。 + pub operation_type: String, + pub operation_status: String, + pub phase_label: String, + pub phase_detail: String, + pub operation_progress: u32, + pub error_message: Option, + pub updated_at_micros: i64, +} + +#[derive(Clone, Debug, PartialEq)] +pub struct CustomWorldSupportedActionRecord { + pub action: String, + pub enabled: bool, + pub reason: Option, +} + +#[derive(Clone, Debug, PartialEq)] +pub struct CustomWorldCheckpointRecord { + pub checkpoint_id: String, + pub created_at: String, + pub label: String, +} + +// 兼容并行 custom world facade 中仍在使用的旧命名,避免本轮 module-npc 收口被无关改动阻塞。 + +pub type CustomWorldAgentCheckpointRecord = CustomWorldCheckpointRecord; + +#[derive(Clone, Debug, PartialEq, Eq)] +pub struct CustomWorldResultPreviewBlockerRecord { + pub id: String, + pub code: String, + pub message: String, +} + +#[derive(Clone, Debug, PartialEq, Eq)] +pub struct CustomWorldPublishGateRecord { + pub profile_id: String, + pub blockers: Vec, + pub blocker_count: u32, + pub publish_ready: bool, + pub can_enter_world: bool, +} + +#[derive(Clone, Debug, PartialEq, Eq)] +pub struct CustomWorldDraftCardDetailSectionRecord { + pub section_id: String, + pub label: String, + pub value: String, +} + +#[derive(Clone, Debug, PartialEq, Eq)] +pub struct CustomWorldProfileRemixRecordInput { + pub source_owner_user_id: String, + pub source_profile_id: String, + pub target_owner_user_id: String, + pub target_profile_id: String, + pub author_display_name: String, + pub remixed_at_micros: i64, +} + +#[derive(Clone, Debug, PartialEq, Eq)] +pub struct CustomWorldProfilePlayReportRecordInput { + pub owner_user_id: String, + pub profile_id: String, + pub played_at_micros: i64, +} + +#[derive(Clone, Debug, PartialEq, Eq)] +pub struct CustomWorldProfileLikeReportRecordInput { + pub owner_user_id: String, + pub profile_id: String, + pub user_id: String, + pub liked_at_micros: i64, +} + +#[derive(Clone, Debug, PartialEq, Eq)] +pub struct CustomWorldPublishWorldRecordInput { + pub session_id: String, + pub profile_id: String, + pub owner_user_id: String, + pub public_work_code: Option, + pub author_public_user_code: String, + pub draft_profile_json: String, + pub legacy_result_profile_json: Option, + pub setting_text: String, + pub author_display_name: String, + pub published_at_micros: i64, +} + +#[derive(Clone, Debug, PartialEq, Eq)] +pub struct CustomWorldAgentMessageSubmitRecordInput { + pub session_id: String, + pub owner_user_id: String, + pub user_message_id: String, + pub user_message_text: String, + pub operation_id: String, + pub submitted_at_micros: i64, +} + +#[derive(Clone, Debug, PartialEq, Eq)] +pub struct CustomWorldAgentActionExecuteRecordInput { + pub session_id: String, + pub owner_user_id: String, + pub operation_id: String, + pub action: String, + pub payload_json: Option, + pub submitted_at_micros: i64, +} + +#[derive(Clone, Debug, PartialEq)] +pub struct CustomWorldAgentActionExecuteRecord { + pub operation: CustomWorldAgentOperationRecord, +} + +#[derive(Clone, Debug, PartialEq, Eq)] +pub struct BigFishPlayReportRecordInput { + pub session_id: String, + pub user_id: String, + pub elapsed_ms: u64, + pub reported_at_micros: i64, +} + +#[derive(Clone, Debug, PartialEq, Eq)] +pub struct BigFishRunStartRecordInput { + pub run_id: String, + pub session_id: String, + pub owner_user_id: String, + pub started_at_micros: i64, +} + +#[derive(Clone, Debug, PartialEq)] +pub struct BigFishInputSubmitRecordInput { + pub run_id: String, + pub owner_user_id: String, + pub x: f32, + pub y: f32, + pub submitted_at_micros: i64, +} + +#[derive(Clone, Debug, PartialEq, Eq)] +pub struct BigFishLikeReportRecordInput { + pub session_id: String, + pub user_id: String, + pub liked_at_micros: i64, +} + +#[derive(Clone, Debug, PartialEq, Eq)] +pub struct BigFishWorkRemixRecordInput { + pub source_session_id: String, + pub target_session_id: String, + pub target_owner_user_id: String, + pub welcome_message_id: String, + pub remixed_at_micros: i64, +} + +#[derive(Clone, Debug, PartialEq, Eq)] +pub struct SquareHoleAgentSessionCreateRecordInput { + pub session_id: String, + pub owner_user_id: String, + pub seed_text: String, + pub welcome_message_id: String, + pub welcome_message_text: String, + pub config_json: Option, + pub created_at_micros: i64, +} + +#[derive(Clone, Debug, PartialEq, Eq)] +pub struct SquareHoleAgentMessageSubmitRecordInput { + pub session_id: String, + pub owner_user_id: String, + pub user_message_id: String, + pub user_message_text: String, + pub submitted_at_micros: i64, +} + +#[derive(Clone, Debug, PartialEq, Eq)] +pub struct SquareHoleAgentMessageFinalizeRecordInput { + pub session_id: String, + pub owner_user_id: String, + pub assistant_message_id: Option, + pub assistant_reply_text: Option, + pub config_json: Option, + pub progress_percent: u32, + pub stage: String, + pub updated_at_micros: i64, + pub error_message: Option, +} + +#[derive(Clone, Debug, PartialEq, Eq)] +pub struct SquareHoleCompileDraftRecordInput { + pub session_id: String, + pub owner_user_id: String, + pub profile_id: String, + pub author_display_name: String, + pub game_name: Option, + pub summary_text: Option, + pub tags_json: Option, + pub cover_image_src: Option, + pub compiled_at_micros: i64, +} + +#[derive(Clone, Debug, PartialEq, Eq)] +pub struct SquareHoleWorkUpdateRecordInput { + pub profile_id: String, + pub owner_user_id: String, + pub game_name: String, + pub theme_text: String, + pub twist_rule: String, + pub summary_text: String, + pub tags_json: String, + pub cover_image_src: String, + pub background_prompt: String, + pub background_image_src: String, + pub shape_options_json: String, + pub hole_options_json: String, + pub shape_count: u32, + pub difficulty: u32, + pub updated_at_micros: i64, +} + +#[derive(Clone, Debug, PartialEq, Eq)] +pub struct SquareHoleRunStartRecordInput { + pub run_id: String, + pub owner_user_id: String, + pub profile_id: String, + pub started_at_ms: i64, +} + +#[derive(Clone, Debug, PartialEq, Eq)] +pub struct SquareHoleRunDropRecordInput { + pub run_id: String, + pub owner_user_id: String, + pub hole_id: String, + pub client_snapshot_version: u64, + pub client_event_id: String, + pub dropped_at_ms: i64, +} + +#[derive(Clone, Debug, PartialEq, Eq)] +pub struct SquareHoleRunStopRecordInput { + pub run_id: String, + pub owner_user_id: String, + pub stopped_at_ms: i64, +} + +#[derive(Clone, Debug, PartialEq, Eq)] +pub struct SquareHoleRunRestartRecordInput { + pub source_run_id: String, + pub next_run_id: String, + pub owner_user_id: String, + pub restarted_at_ms: i64, +} + +#[derive(Clone, Debug, PartialEq, Eq)] +pub struct SquareHoleRunTimeUpRecordInput { + pub run_id: String, + pub owner_user_id: String, + pub finished_at_ms: i64, +} + +#[derive(Clone, Debug, PartialEq, Eq)] +pub struct VisualNovelAgentMessageSubmitRecordInput { + pub session_id: String, + pub owner_user_id: String, + pub user_message_id: String, + pub user_message_text: String, + pub submitted_at_micros: i64, +} + +#[derive(Clone, Debug, PartialEq, Eq)] +pub struct VisualNovelAgentMessageFinalizeRecordInput { + pub session_id: String, + pub owner_user_id: String, + pub assistant_message_id: Option, + pub assistant_reply_text: Option, + pub draft_json: Option, + pub pending_action_json: Option, + pub status: String, + pub progress_percent: u32, + pub updated_at_micros: i64, + pub error_message: Option, +} + +#[derive(Clone, Debug, PartialEq, Eq)] +pub struct VisualNovelWorkCompileRecordInput { + pub session_id: String, + pub owner_user_id: String, + pub profile_id: String, + pub work_id: Option, + pub author_display_name: String, + pub work_title: Option, + pub work_description: Option, + pub tags_json: Option, + pub cover_image_src: Option, + pub compiled_at_micros: i64, +} + +#[derive(Clone, Debug, PartialEq, Eq)] +pub struct VisualNovelRunStartRecordInput { + pub run_id: String, + pub owner_user_id: String, + pub profile_id: String, + pub mode: String, + pub snapshot_json: Option, + pub started_at_micros: i64, +} + +#[derive(Clone, Debug, PartialEq, Eq)] +pub struct VisualNovelRunSnapshotRecordInput { + pub run_id: String, + pub owner_user_id: String, + pub status: String, + pub current_scene_id: Option, + pub current_phase_id: Option, + pub visible_character_ids_json: String, + pub flags_json: String, + pub metrics_json: String, + pub available_choices_json: String, + pub text_mode_enabled: bool, + pub snapshot_json: Option, + pub updated_at_micros: i64, +} + +#[derive(Clone, Debug, PartialEq, Eq)] +pub struct VisualNovelHistoryEntryRecordInput { + pub entry_id: String, + pub run_id: String, + pub owner_user_id: String, + pub turn_index: u32, + pub source: String, + pub action_text: Option, + pub steps_json: String, + pub snapshot_before_hash: Option, + pub snapshot_after_hash: Option, + pub created_at_micros: i64, +} + +#[derive(Clone, Debug, PartialEq)] +pub struct VisualNovelAgentMessageRecord { + pub message_id: String, + pub session_id: String, + pub role: String, + pub kind: String, + pub text: String, + pub created_at: String, +} + +#[derive(Clone, Debug, PartialEq)] +pub struct VisualNovelHistoryEntryRecord { + pub entry_id: String, + pub run_id: String, + pub owner_user_id: String, + pub profile_id: String, + pub turn_index: u32, + pub source: String, + pub action_text: Option, + pub steps: serde_json::Value, + pub snapshot_before_hash: Option, + pub snapshot_after_hash: Option, + pub created_at: String, +} + +#[derive(Clone, Debug, PartialEq)] +pub struct VisualNovelRunRecord { + pub run_id: String, + pub owner_user_id: String, + pub profile_id: String, + pub mode: String, + pub status: String, + pub current_scene_id: Option, + pub current_phase_id: Option, + pub visible_character_ids: Vec, + pub flags: serde_json::Value, + pub metrics: serde_json::Value, + pub history: Vec, + pub available_choices: serde_json::Value, + pub text_mode_enabled: bool, + pub created_at: String, + pub updated_at: String, +} + +#[derive(Clone, Debug, PartialEq, Eq)] +pub struct SquareHoleAnchorItemRecord { + pub key: String, + pub label: String, + pub value: String, + pub status: String, +} + +#[derive(Clone, Debug, PartialEq, Eq)] +pub struct SquareHoleAnchorPackRecord { + pub theme: SquareHoleAnchorItemRecord, + pub twist_rule: SquareHoleAnchorItemRecord, + pub shape_count: SquareHoleAnchorItemRecord, + pub difficulty: SquareHoleAnchorItemRecord, +} + +#[derive(Clone, Debug, PartialEq, Eq)] +pub struct SquareHoleCreatorConfigRecord { + pub theme_text: String, + pub twist_rule: String, + pub shape_count: u32, + pub difficulty: u32, + pub shape_options: Vec, + pub hole_options: Vec, + pub background_prompt: String, + pub cover_image_src: Option, + pub background_image_src: Option, +} + +#[derive(Clone, Debug, PartialEq, Eq)] +pub struct SquareHoleShapeOptionRecord { + pub option_id: String, + pub shape_kind: String, + pub label: String, + pub target_hole_id: String, + pub image_prompt: String, + pub image_src: Option, +} + +#[derive(Clone, Debug, PartialEq, Eq)] +pub struct SquareHoleHoleOptionRecord { + pub hole_id: String, + pub hole_kind: String, + pub label: String, + pub image_prompt: String, + pub image_src: Option, +} + +#[derive(Clone, Debug, PartialEq, Eq)] +pub struct SquareHoleResultDraftRecord { + pub profile_id: String, + pub game_name: String, + pub theme_text: String, + pub twist_rule: String, + pub summary: String, + pub tags: Vec, + pub cover_image_src: Option, + pub background_prompt: String, + pub background_image_src: Option, + pub shape_options: Vec, + pub hole_options: Vec, + pub shape_count: u32, + pub difficulty: u32, + pub publish_ready: bool, + pub blockers: Vec, +} + +#[derive(Clone, Debug, PartialEq, Eq)] +pub struct SquareHoleAgentMessageRecord { + pub id: String, + pub role: String, + pub kind: String, + pub text: String, + pub created_at: String, +} + +#[derive(Clone, Debug, PartialEq, Eq)] +pub struct SquareHoleAgentSessionRecord { + pub session_id: String, + pub current_turn: u32, + pub progress_percent: u32, + pub stage: String, + pub anchor_pack: SquareHoleAnchorPackRecord, + pub config: SquareHoleCreatorConfigRecord, + pub draft: Option, + pub messages: Vec, + pub last_assistant_reply: Option, + pub published_profile_id: Option, + pub updated_at: String, +} + +#[derive(Clone, Debug, PartialEq, Eq)] +pub struct SquareHoleWorkProfileRecord { + pub work_id: String, + pub profile_id: String, + pub owner_user_id: String, + pub source_session_id: Option, + pub author_display_name: String, + pub game_name: String, + pub theme_text: String, + pub twist_rule: String, + pub summary: String, + pub tags: Vec, + pub cover_image_src: Option, + pub background_prompt: String, + pub background_image_src: Option, + pub shape_options: Vec, + pub hole_options: Vec, + pub shape_count: u32, + pub difficulty: u32, + pub publication_status: String, + pub play_count: u32, + pub updated_at: String, + pub published_at: Option, + pub publish_ready: bool, +} + +#[derive(Clone, Debug, PartialEq, Eq)] +pub struct SquareHoleShapeSnapshotRecord { + pub shape_id: String, + pub shape_kind: String, + pub label: String, + pub target_hole_id: String, + pub color: String, + pub image_src: Option, +} + +#[derive(Clone, Debug, PartialEq)] +pub struct SquareHoleHoleSnapshotRecord { + pub hole_id: String, + pub hole_kind: String, + pub label: String, + pub x: f32, + pub y: f32, + pub image_src: Option, +} + +#[derive(Clone, Debug, PartialEq, Eq)] +pub struct BigFishSessionCreateRecordInput { + pub session_id: String, + pub owner_user_id: String, + pub seed_text: String, + pub welcome_message_id: String, + pub welcome_message_text: String, + pub created_at_micros: i64, +} + +#[derive(Clone, Debug, PartialEq, Eq)] +pub struct BigFishMessageSubmitRecordInput { + pub session_id: String, + pub owner_user_id: String, + pub user_message_id: String, + pub user_message_text: String, + pub assistant_message_id: String, + pub submitted_at_micros: i64, +} + +#[derive(Clone, Debug, PartialEq, Eq)] +pub struct BigFishMessageFinalizeRecordInput { + pub session_id: String, + pub owner_user_id: String, + pub assistant_message_id: Option, + pub assistant_reply_text: Option, + pub stage: String, + pub progress_percent: u32, + pub anchor_pack_json: String, + pub error_message: Option, + pub updated_at_micros: i64, +} + +#[derive(Clone, Debug, PartialEq, Eq)] +pub struct BigFishDraftCompileRecordInput { + pub session_id: String, + pub owner_user_id: String, + pub draft_json: Option, + pub compiled_at_micros: i64, +} + +#[derive(Clone, Debug, PartialEq, Eq)] +pub struct BigFishAnchorItemRecord { + pub key: String, + pub label: String, + pub value: String, + pub status: String, +} + +#[derive(Clone, Debug, PartialEq, Eq)] +pub struct BigFishAnchorPackRecord { + pub gameplay_promise: BigFishAnchorItemRecord, + pub ecology_visual_theme: BigFishAnchorItemRecord, + pub growth_ladder: BigFishAnchorItemRecord, + pub risk_tempo: BigFishAnchorItemRecord, +} + +#[derive(Clone, Debug, PartialEq)] +pub struct BigFishLevelBlueprintRecord { + pub level: u32, + pub name: String, + pub one_line_fantasy: String, + pub text_description: String, + pub silhouette_direction: String, + pub size_ratio: f32, + pub visual_description: String, + pub visual_prompt_seed: String, + pub idle_motion_description: String, + pub move_motion_description: String, + pub motion_prompt_seed: String, + pub merge_source_level: Option, + pub prey_window: Vec, + pub threat_window: Vec, + pub is_final_level: bool, +} + +#[derive(Clone, Debug, PartialEq, Eq)] +pub struct BigFishBackgroundBlueprintRecord { + pub theme: String, + pub color_mood: String, + pub foreground_hints: String, + pub midground_composition: String, + pub background_depth: String, + pub safe_play_area_hint: String, + pub spawn_edge_hint: String, + pub background_prompt_seed: String, +} + +#[derive(Clone, Debug, PartialEq, Eq)] +pub struct BigFishAgentMessageRecord { + pub message_id: String, + pub role: String, + pub kind: String, + pub text: String, + pub created_at: String, +} + +#[derive(Clone, Debug, PartialEq)] +pub struct BigFishVector2Record { + pub x: f32, + pub y: f32, +} diff --git a/server-rs/crates/spacetime-client/src/mapper/custom_world.rs b/server-rs/crates/spacetime-client/src/mapper/custom_world.rs new file mode 100644 index 00000000..6b084df0 --- /dev/null +++ b/server-rs/crates/spacetime-client/src/mapper/custom_world.rs @@ -0,0 +1,957 @@ +use super::*; + +impl From for CustomWorldProfileUpsertInput { + fn from(input: CustomWorldProfileUpsertRecordInput) -> Self { + Self { + profile_id: input.profile_id, + owner_user_id: input.owner_user_id, + public_work_code: input.public_work_code, + author_public_user_code: input.author_public_user_code, + source_agent_session_id: input.source_agent_session_id, + world_name: input.world_name, + subtitle: input.subtitle, + summary_text: input.summary_text, + theme_mode: map_custom_world_theme_mode(input.theme_mode), + cover_image_src: input.cover_image_src, + profile_payload_json: input.profile_payload_json, + playable_npc_count: input.playable_npc_count, + landmark_count: input.landmark_count, + author_display_name: input.author_display_name, + updated_at_micros: input.updated_at_micros, + } + } +} + +pub(crate) fn map_custom_world_profile_list_result( + result: CustomWorldProfileListResult, +) -> Result, SpacetimeClientError> { + if !result.ok { + return Err(SpacetimeClientError::procedure_failed(result.error_message)); + } + + result + .entries + .into_iter() + .map(map_custom_world_library_entry_from_profile_snapshot) + .collect() +} + +pub(crate) fn map_custom_world_library_detail_result( + result: CustomWorldLibraryMutationResult, +) -> Result { + if !result.ok { + return Err(SpacetimeClientError::procedure_failed(result.error_message)); + } + + let entry = result + .entry + .ok_or_else(|| SpacetimeClientError::Procedure("custom_world_profile 不存在".to_string())) + .and_then(map_custom_world_library_entry_from_profile_snapshot)?; + let gallery_entry = result + .gallery_entry + .map(map_custom_world_gallery_entry_snapshot) + .transpose()?; + + Ok(CustomWorldLibraryMutationRecord { + entry, + gallery_entry, + }) +} + +pub(crate) fn map_custom_world_gallery_list_result( + result: CustomWorldGalleryListResult, +) -> Result, SpacetimeClientError> { + if !result.ok { + return Err(SpacetimeClientError::procedure_failed(result.error_message)); + } + + Ok(result + .entries + .into_iter() + .map(map_custom_world_gallery_entry_snapshot) + .collect::, _>>()?) +} + +pub(crate) fn map_custom_world_library_mutation_result( + result: CustomWorldLibraryMutationResult, +) -> Result { + if !result.ok { + return Err(SpacetimeClientError::procedure_failed(result.error_message)); + } + + let entry = result + .entry + .ok_or_else(|| SpacetimeClientError::missing_snapshot("custom world entry")) + .and_then(map_custom_world_library_entry_from_profile_snapshot)?; + let gallery_entry = result + .gallery_entry + .map(map_custom_world_gallery_entry_snapshot) + .transpose()?; + + Ok(CustomWorldLibraryMutationRecord { + entry, + gallery_entry, + }) +} + +pub(crate) fn map_custom_world_publish_world_result( + result: CustomWorldPublishWorldResult, +) -> Result { + if !result.ok { + return Err(SpacetimeClientError::procedure_failed(result.error_message)); + } + + let compiled_record = result + .compiled_record + .ok_or_else(|| SpacetimeClientError::missing_snapshot("published profile compile 快照")) + .and_then(map_custom_world_published_profile_compile_snapshot)?; + let entry = result + .entry + .ok_or_else(|| SpacetimeClientError::missing_snapshot("custom world entry")) + .and_then(map_custom_world_library_entry_from_profile_snapshot)?; + let gallery_entry = result + .gallery_entry + .map(map_custom_world_gallery_entry_snapshot) + .transpose()?; + let session_stage = result + .session_stage + .ok_or_else(|| SpacetimeClientError::missing_snapshot("session stage")) + .map(map_rpg_agent_stage)?; + + Ok(CustomWorldPublishWorldRecord { + compiled_record, + entry, + gallery_entry, + session_stage, + }) +} + +pub(crate) fn map_custom_world_agent_session_procedure_result( + result: CustomWorldAgentSessionProcedureResult, +) -> Result { + if !result.ok { + return Err(SpacetimeClientError::procedure_failed(result.error_message)); + } + + let session = result + .session + .ok_or_else(|| SpacetimeClientError::missing_snapshot("custom world agent session 快照"))?; + + map_custom_world_agent_session_snapshot(session) +} + +pub(crate) fn map_custom_world_agent_operation_procedure_result( + result: CustomWorldAgentOperationProcedureResult, +) -> Result { + if !result.ok { + return Err(SpacetimeClientError::procedure_failed(result.error_message)); + } + + let operation = result.operation.ok_or_else(|| { + SpacetimeClientError::missing_snapshot("custom world agent operation 快照") + })?; + + Ok(map_custom_world_agent_operation_snapshot(operation)) +} + +pub(crate) fn map_custom_world_works_list_result( + result: CustomWorldWorksListResult, +) -> Result, SpacetimeClientError> { + if !result.ok { + return Err(SpacetimeClientError::procedure_failed(result.error_message)); + } + + result + .items + .into_iter() + .map(map_custom_world_work_summary_snapshot) + .collect() +} + +pub(crate) fn map_custom_world_draft_card_detail_result( + result: CustomWorldDraftCardDetailResult, +) -> Result { + if !result.ok { + return Err(SpacetimeClientError::procedure_failed(result.error_message)); + } + + let card = result + .card + .ok_or_else(|| SpacetimeClientError::missing_snapshot("custom world card detail 快照"))?; + + map_custom_world_draft_card_detail_snapshot(card) +} + +pub(crate) fn map_custom_world_agent_action_execute_result( + result: CustomWorldAgentActionExecuteResult, +) -> Result { + if !result.ok { + return Err(SpacetimeClientError::procedure_failed(result.error_message)); + } + + let operation = result.operation.ok_or_else(|| { + SpacetimeClientError::missing_snapshot("custom world action operation 快照") + })?; + + Ok(CustomWorldAgentActionExecuteRecord { + operation: map_custom_world_agent_operation_snapshot(operation), + }) +} + +pub(crate) fn map_custom_world_library_entry_from_profile_snapshot( + snapshot: CustomWorldProfileSnapshot, +) -> Result { + let profile = serde_json::from_str::(&snapshot.profile_payload_json) + .map_err(|error| { + SpacetimeClientError::Runtime(format!( + "custom world profile payload JSON 非法: {error}" + )) + })?; + + Ok(CustomWorldLibraryEntryRecord { + owner_user_id: snapshot.owner_user_id, + profile_id: snapshot.profile_id, + public_work_code: snapshot.public_work_code, + author_public_user_code: snapshot.author_public_user_code, + profile, + visibility: map_custom_world_publication_status(snapshot.publication_status).to_string(), + published_at: snapshot.published_at_micros.map(format_timestamp_micros), + updated_at: format_timestamp_micros(snapshot.updated_at_micros), + author_display_name: snapshot.author_display_name, + world_name: snapshot.world_name, + subtitle: snapshot.subtitle, + summary_text: snapshot.summary_text, + cover_image_src: snapshot.cover_image_src, + theme_mode: format_custom_world_theme_mode(map_custom_world_theme_mode_back( + snapshot.theme_mode, + )) + .to_string(), + playable_npc_count: snapshot.playable_npc_count, + landmark_count: snapshot.landmark_count, + play_count: snapshot.play_count, + remix_count: snapshot.remix_count, + like_count: snapshot.like_count, + recent_play_count_7d: 0, + }) +} + +pub(crate) fn map_custom_world_gallery_entry_snapshot( + snapshot: CustomWorldGalleryEntrySnapshot, +) -> Result { + Ok(CustomWorldGalleryEntryRecord { + owner_user_id: snapshot.owner_user_id, + profile_id: snapshot.profile_id, + public_work_code: snapshot.public_work_code, + author_public_user_code: snapshot.author_public_user_code, + visibility: "published".to_string(), + published_at: Some(format_timestamp_micros(snapshot.published_at_micros)), + updated_at: format_timestamp_micros(snapshot.updated_at_micros), + author_display_name: snapshot.author_display_name, + world_name: snapshot.world_name, + subtitle: snapshot.subtitle, + summary_text: snapshot.summary_text, + cover_image_src: snapshot.cover_image_src, + theme_mode: format_custom_world_theme_mode(map_custom_world_theme_mode_back( + snapshot.theme_mode, + )) + .to_string(), + playable_npc_count: snapshot.playable_npc_count, + landmark_count: snapshot.landmark_count, + play_count: snapshot.play_count, + remix_count: snapshot.remix_count, + like_count: snapshot.like_count, + recent_play_count_7d: snapshot.recent_play_count_7_d, + }) +} + +pub(crate) fn map_custom_world_gallery_entry_row( + row: CustomWorldGalleryEntry, + recent_play_count_7d: u32, +) -> CustomWorldGalleryEntryRecord { + CustomWorldGalleryEntryRecord { + owner_user_id: row.owner_user_id, + profile_id: row.profile_id, + public_work_code: row.public_work_code, + author_public_user_code: row.author_public_user_code, + visibility: "published".to_string(), + published_at: Some(format_timestamp_micros( + row.published_at.to_micros_since_unix_epoch(), + )), + updated_at: format_timestamp_micros(row.updated_at.to_micros_since_unix_epoch()), + author_display_name: row.author_display_name, + world_name: row.world_name, + subtitle: row.subtitle, + summary_text: row.summary_text, + cover_image_src: row.cover_image_src, + theme_mode: format_custom_world_theme_mode(map_custom_world_theme_mode_back( + row.theme_mode, + )) + .to_string(), + playable_npc_count: row.playable_npc_count, + landmark_count: row.landmark_count, + play_count: row.play_count, + remix_count: row.remix_count, + like_count: row.like_count, + recent_play_count_7d, + } +} + +pub(crate) fn map_custom_world_published_profile_compile_snapshot( + snapshot: CustomWorldPublishedProfileCompileSnapshot, +) -> Result { + let compiled_profile = + serde_json::from_str::(&snapshot.compiled_profile_payload_json) + .map_err(|error| { + SpacetimeClientError::Runtime(format!( + "published profile compile JSON 非法: {error}" + )) + })?; + + Ok(CustomWorldPublishedProfileCompileRecord { + profile_id: snapshot.profile_id, + owner_user_id: snapshot.owner_user_id, + world_name: snapshot.world_name, + subtitle: snapshot.subtitle, + summary_text: snapshot.summary_text, + theme_mode: format_custom_world_theme_mode(map_custom_world_theme_mode_back( + snapshot.theme_mode, + )) + .to_string(), + cover_image_src: snapshot.cover_image_src, + playable_npc_count: snapshot.playable_npc_count, + landmark_count: snapshot.landmark_count, + author_display_name: snapshot.author_display_name, + compiled_profile: compiled_profile, + updated_at: format_timestamp_micros(snapshot.updated_at_micros), + }) +} + +pub(crate) fn map_custom_world_work_summary_snapshot( + snapshot: CustomWorldWorkSummarySnapshot, +) -> Result { + Ok(CustomWorldWorkSummaryRecord { + work_id: snapshot.work_id, + source_type: snapshot.source_type, + status: snapshot.status, + title: snapshot.title, + subtitle: snapshot.subtitle, + summary: snapshot.summary, + cover_image_src: snapshot.cover_image_src, + cover_render_mode: snapshot.cover_render_mode, + cover_character_image_srcs: parse_json_string_array( + &snapshot.cover_character_image_srcs_json, + "custom world work cover_character_image_srcs_json", + )?, + updated_at: format_timestamp_micros(snapshot.updated_at_micros), + published_at: snapshot.published_at_micros.map(format_timestamp_micros), + stage: snapshot.stage.map(map_rpg_agent_stage), + stage_label: snapshot.stage_label, + playable_npc_count: snapshot.playable_npc_count, + landmark_count: snapshot.landmark_count, + role_visual_ready_count: snapshot.role_visual_ready_count, + role_animation_ready_count: snapshot.role_animation_ready_count, + role_asset_summary_label: snapshot.role_asset_summary_label, + session_id: snapshot.session_id, + profile_id: snapshot.profile_id, + can_resume: snapshot.can_resume, + can_enter_world: snapshot.can_enter_world, + blocker_count: snapshot.blocker_count, + publish_ready: snapshot.publish_ready, + }) +} + +pub(crate) fn map_custom_world_agent_session_snapshot( + snapshot: CustomWorldAgentSessionSnapshot, +) -> Result { + let anchor_content = parse_json_value( + &snapshot.anchor_content_json, + "custom world agent anchor_content_json", + )?; + let creator_intent = parse_optional_json_value( + snapshot.creator_intent_json.as_deref(), + serde_json::json!({}), + "custom world agent creator_intent_json", + )?; + let creator_intent_readiness = parse_json_value( + &snapshot.creator_intent_readiness_json, + "custom world agent creator_intent_readiness_json", + )?; + let anchor_pack = parse_optional_json_value( + snapshot.anchor_pack_json.as_deref(), + serde_json::json!({}), + "custom world agent anchor_pack_json", + )?; + let lock_state = parse_optional_json_value( + snapshot.lock_state_json.as_deref(), + serde_json::json!({}), + "custom world agent lock_state_json", + )?; + let draft_profile = parse_optional_json_value( + snapshot.draft_profile_json.as_deref(), + serde_json::json!({}), + "custom world agent draft_profile_json", + )?; + let pending_clarifications = parse_json_array( + &snapshot.pending_clarifications_json, + "custom world agent pending_clarifications_json", + )?; + let suggested_actions = parse_json_array( + &snapshot.suggested_actions_json, + "custom world agent suggested_actions_json", + )?; + let recommended_replies = parse_json_string_array( + &snapshot.recommended_replies_json, + "custom world agent recommended_replies_json", + )?; + let quality_findings = parse_json_array( + &snapshot.quality_findings_json, + "custom world agent quality_findings_json", + )?; + let asset_coverage = parse_json_value( + &snapshot.asset_coverage_json, + "custom world agent asset_coverage_json", + )?; + let checkpoints_json = parse_json_array( + &snapshot.checkpoints_json, + "custom world agent checkpoints_json", + )?; + let checkpoints = checkpoints_json + .into_iter() + .map(map_custom_world_checkpoint_record) + .collect::, _>>()?; + let supported_actions = parse_supported_actions_json(&snapshot.supported_actions_json)?; + let publish_gate = snapshot + .publish_gate_json + .as_deref() + .map(parse_custom_world_publish_gate_record) + .transpose()?; + + Ok(CustomWorldAgentSessionRecord { + session_id: snapshot.session_id, + seed_text: snapshot.seed_text, + current_turn: snapshot.current_turn, + anchor_content, + progress_percent: snapshot.progress_percent, + last_assistant_reply: snapshot.last_assistant_reply, + stage: map_rpg_agent_stage(snapshot.stage), + focus_card_id: snapshot.focus_card_id, + creator_intent, + creator_intent_readiness, + anchor_pack, + lock_state, + draft_profile, + messages: snapshot + .messages + .into_iter() + .map(map_custom_world_agent_message_snapshot) + .collect(), + draft_cards: snapshot + .draft_cards + .into_iter() + .map(map_custom_world_draft_card_snapshot) + .collect::, _>>()?, + pending_clarifications, + suggested_actions, + recommended_replies, + quality_findings, + asset_coverage, + checkpoints, + supported_actions, + publish_gate, + result_preview: snapshot + .result_preview_json + .as_deref() + .map(|value| parse_json_value(value, "custom world agent result_preview_json")) + .transpose()?, + updated_at: format_timestamp_micros(snapshot.updated_at_micros), + }) +} + +pub(crate) fn map_custom_world_agent_message_snapshot( + snapshot: CustomWorldAgentMessageSnapshot, +) -> CustomWorldAgentMessageRecord { + CustomWorldAgentMessageRecord { + message_id: snapshot.message_id, + role: format_rpg_agent_message_role(snapshot.role).to_string(), + kind: format_rpg_agent_message_kind(snapshot.kind).to_string(), + text: snapshot.text, + created_at: format_timestamp_micros(snapshot.created_at_micros), + related_operation_id: snapshot.related_operation_id, + } +} + +pub(crate) fn map_custom_world_agent_operation_snapshot( + snapshot: CustomWorldAgentOperationSnapshot, +) -> CustomWorldAgentOperationRecord { + CustomWorldAgentOperationRecord { + operation_id: snapshot.operation_id, + operation_type: format_rpg_agent_operation_type(snapshot.operation_type).to_string(), + status: format_rpg_agent_operation_status(snapshot.status).to_string(), + phase_label: snapshot.phase_label, + phase_detail: snapshot.phase_detail, + progress: snapshot.progress, + error_message: snapshot.error_message, + started_at_micros: snapshot.created_at_micros, + updated_at_micros: snapshot.updated_at_micros, + } +} + +pub(crate) fn map_custom_world_draft_card_snapshot( + snapshot: CustomWorldDraftCardSnapshot, +) -> Result { + Ok(CustomWorldDraftCardRecord { + card_id: snapshot.card_id, + kind: format_rpg_agent_draft_card_kind(snapshot.kind).to_string(), + title: snapshot.title, + subtitle: snapshot.subtitle, + summary: snapshot.summary, + status: format_rpg_agent_draft_card_status(snapshot.status).to_string(), + linked_ids: parse_json_string_array( + &snapshot.linked_ids_json, + "custom world draft_card linked_ids_json", + )?, + warning_count: snapshot.warning_count, + asset_status: snapshot + .asset_status + .map(format_custom_world_role_asset_status_back), + asset_status_label: snapshot.asset_status_label, + detail_payload: snapshot + .detail_payload_json + .as_deref() + .map(|value| parse_json_value(value, "custom world draft_card detail_payload_json")) + .transpose()?, + }) +} + +pub(crate) fn map_custom_world_draft_card_detail_snapshot( + snapshot: CustomWorldDraftCardDetailSnapshot, +) -> Result { + Ok(CustomWorldDraftCardDetailRecord { + card_id: snapshot.card_id, + kind: format_rpg_agent_draft_card_kind(snapshot.kind).to_string(), + title: snapshot.title, + sections: snapshot + .sections + .into_iter() + .map(map_custom_world_draft_card_detail_section_snapshot) + .collect(), + linked_ids: parse_json_string_array( + &snapshot.linked_ids_json, + "custom world card detail linked_ids_json", + )?, + locked: snapshot.locked, + editable: snapshot.editable, + editable_section_ids: parse_json_string_array( + &snapshot.editable_section_ids_json, + "custom world card detail editable_section_ids_json", + )?, + warning_messages: parse_json_string_array( + &snapshot.warning_messages_json, + "custom world card detail warning_messages_json", + )?, + asset_status: snapshot + .asset_status + .map(format_custom_world_role_asset_status_back), + asset_status_label: snapshot.asset_status_label, + }) +} + +pub(crate) fn map_custom_world_draft_card_detail_section_snapshot( + snapshot: CustomWorldDraftCardDetailSectionSnapshot, +) -> CustomWorldDraftCardDetailSectionRecord { + CustomWorldDraftCardDetailSectionRecord { + section_id: snapshot.section_id, + label: snapshot.label, + value: snapshot.value, + } +} + +pub(crate) fn map_custom_world_theme_mode( + value: DomainCustomWorldThemeMode, +) -> CustomWorldThemeMode { + match value { + DomainCustomWorldThemeMode::Martial => CustomWorldThemeMode::Martial, + DomainCustomWorldThemeMode::Arcane => CustomWorldThemeMode::Arcane, + DomainCustomWorldThemeMode::Machina => CustomWorldThemeMode::Machina, + DomainCustomWorldThemeMode::Tide => CustomWorldThemeMode::Tide, + DomainCustomWorldThemeMode::Rift => CustomWorldThemeMode::Rift, + DomainCustomWorldThemeMode::Mythic => CustomWorldThemeMode::Mythic, + } +} + +pub(crate) fn map_custom_world_theme_mode_back( + value: CustomWorldThemeMode, +) -> DomainCustomWorldThemeMode { + match value { + CustomWorldThemeMode::Martial => DomainCustomWorldThemeMode::Martial, + CustomWorldThemeMode::Arcane => DomainCustomWorldThemeMode::Arcane, + CustomWorldThemeMode::Machina => DomainCustomWorldThemeMode::Machina, + CustomWorldThemeMode::Tide => DomainCustomWorldThemeMode::Tide, + CustomWorldThemeMode::Rift => DomainCustomWorldThemeMode::Rift, + CustomWorldThemeMode::Mythic => DomainCustomWorldThemeMode::Mythic, + } +} + +pub(crate) fn map_custom_world_publication_status( + value: CustomWorldPublicationStatus, +) -> &'static str { + match value { + CustomWorldPublicationStatus::Draft => "draft", + CustomWorldPublicationStatus::Published => "published", + } +} + +pub(crate) fn map_rpg_agent_stage(value: crate::module_bindings::RpgAgentStage) -> String { + match value { + crate::module_bindings::RpgAgentStage::CollectingIntent => "collecting_intent", + crate::module_bindings::RpgAgentStage::Clarifying => "clarifying", + crate::module_bindings::RpgAgentStage::FoundationReview => "foundation_review", + crate::module_bindings::RpgAgentStage::ObjectRefining => "object_refining", + crate::module_bindings::RpgAgentStage::VisualRefining => "visual_refining", + crate::module_bindings::RpgAgentStage::LongTailReview => "long_tail_review", + crate::module_bindings::RpgAgentStage::ReadyToPublish => "ready_to_publish", + crate::module_bindings::RpgAgentStage::Published => "published", + crate::module_bindings::RpgAgentStage::Error => "error", + } + .to_string() +} + +pub(crate) fn parse_rpg_agent_stage_record( + value: &str, +) -> Result { + match value.trim() { + "collecting_intent" => Ok(crate::module_bindings::RpgAgentStage::CollectingIntent), + "clarifying" => Ok(crate::module_bindings::RpgAgentStage::Clarifying), + "foundation_review" => Ok(crate::module_bindings::RpgAgentStage::FoundationReview), + "object_refining" => Ok(crate::module_bindings::RpgAgentStage::ObjectRefining), + "visual_refining" => Ok(crate::module_bindings::RpgAgentStage::VisualRefining), + "long_tail_review" => Ok(crate::module_bindings::RpgAgentStage::LongTailReview), + "ready_to_publish" => Ok(crate::module_bindings::RpgAgentStage::ReadyToPublish), + "published" => Ok(crate::module_bindings::RpgAgentStage::Published), + "error" => Ok(crate::module_bindings::RpgAgentStage::Error), + other => Err(SpacetimeClientError::Runtime(format!( + "未知 rpg agent stage: {other}" + ))), + } +} + +pub(crate) fn format_rpg_agent_message_role( + value: crate::module_bindings::RpgAgentMessageRole, +) -> &'static str { + match value { + crate::module_bindings::RpgAgentMessageRole::User => "user", + crate::module_bindings::RpgAgentMessageRole::Assistant => "assistant", + crate::module_bindings::RpgAgentMessageRole::System => "system", + } +} + +pub(crate) fn format_rpg_agent_message_kind( + value: crate::module_bindings::RpgAgentMessageKind, +) -> &'static str { + match value { + crate::module_bindings::RpgAgentMessageKind::Chat => "chat", + crate::module_bindings::RpgAgentMessageKind::Clarification => "clarification", + crate::module_bindings::RpgAgentMessageKind::Summary => "summary", + crate::module_bindings::RpgAgentMessageKind::Checkpoint => "checkpoint", + crate::module_bindings::RpgAgentMessageKind::Warning => "warning", + crate::module_bindings::RpgAgentMessageKind::ActionResult => "action_result", + } +} + +pub(crate) fn format_rpg_agent_operation_type( + value: crate::module_bindings::RpgAgentOperationType, +) -> &'static str { + match value { + crate::module_bindings::RpgAgentOperationType::ProcessMessage => "process_message", + crate::module_bindings::RpgAgentOperationType::DraftFoundation => "draft_foundation", + crate::module_bindings::RpgAgentOperationType::UpdateDraftCard => "update_draft_card", + crate::module_bindings::RpgAgentOperationType::SyncResultProfile => "sync_result_profile", + crate::module_bindings::RpgAgentOperationType::GenerateCharacters => "generate_characters", + crate::module_bindings::RpgAgentOperationType::GenerateLandmarks => "generate_landmarks", + crate::module_bindings::RpgAgentOperationType::GenerateRoleAssets => "generate_role_assets", + crate::module_bindings::RpgAgentOperationType::SyncRoleAssets => "sync_role_assets", + crate::module_bindings::RpgAgentOperationType::GenerateSceneAssets => { + "generate_scene_assets" + } + crate::module_bindings::RpgAgentOperationType::SyncSceneAssets => "sync_scene_assets", + crate::module_bindings::RpgAgentOperationType::ExpandLongTail => "expand_long_tail", + crate::module_bindings::RpgAgentOperationType::PublishWorld => "publish_world", + crate::module_bindings::RpgAgentOperationType::RevertCheckpoint => "revert_checkpoint", + crate::module_bindings::RpgAgentOperationType::DeleteCharacters => "delete_characters", + crate::module_bindings::RpgAgentOperationType::DeleteLandmarks => "delete_landmarks", + } +} + +pub(crate) fn parse_rpg_agent_operation_type_record( + value: &str, +) -> Result { + match value.trim() { + "process_message" => Ok(crate::module_bindings::RpgAgentOperationType::ProcessMessage), + "draft_foundation" => Ok(crate::module_bindings::RpgAgentOperationType::DraftFoundation), + "update_draft_card" => Ok(crate::module_bindings::RpgAgentOperationType::UpdateDraftCard), + "sync_result_profile" => { + Ok(crate::module_bindings::RpgAgentOperationType::SyncResultProfile) + } + "generate_characters" => { + Ok(crate::module_bindings::RpgAgentOperationType::GenerateCharacters) + } + "generate_landmarks" => { + Ok(crate::module_bindings::RpgAgentOperationType::GenerateLandmarks) + } + "generate_role_assets" => { + Ok(crate::module_bindings::RpgAgentOperationType::GenerateRoleAssets) + } + "sync_role_assets" => Ok(crate::module_bindings::RpgAgentOperationType::SyncRoleAssets), + "generate_scene_assets" => { + Ok(crate::module_bindings::RpgAgentOperationType::GenerateSceneAssets) + } + "sync_scene_assets" => Ok(crate::module_bindings::RpgAgentOperationType::SyncSceneAssets), + "expand_long_tail" => Ok(crate::module_bindings::RpgAgentOperationType::ExpandLongTail), + "publish_world" => Ok(crate::module_bindings::RpgAgentOperationType::PublishWorld), + "revert_checkpoint" => Ok(crate::module_bindings::RpgAgentOperationType::RevertCheckpoint), + "delete_characters" => Ok(crate::module_bindings::RpgAgentOperationType::DeleteCharacters), + "delete_landmarks" => Ok(crate::module_bindings::RpgAgentOperationType::DeleteLandmarks), + other => Err(SpacetimeClientError::Runtime(format!( + "未知 rpg agent operation type: {other}" + ))), + } +} + +pub(crate) fn format_rpg_agent_operation_status( + value: crate::module_bindings::RpgAgentOperationStatus, +) -> &'static str { + match value { + crate::module_bindings::RpgAgentOperationStatus::Queued => "queued", + crate::module_bindings::RpgAgentOperationStatus::Running => "running", + crate::module_bindings::RpgAgentOperationStatus::Completed => "completed", + crate::module_bindings::RpgAgentOperationStatus::Failed => "failed", + } +} + +pub(crate) fn parse_rpg_agent_operation_status_record( + value: &str, +) -> Result { + match value.trim() { + "queued" => Ok(crate::module_bindings::RpgAgentOperationStatus::Queued), + "running" => Ok(crate::module_bindings::RpgAgentOperationStatus::Running), + "completed" => Ok(crate::module_bindings::RpgAgentOperationStatus::Completed), + "failed" => Ok(crate::module_bindings::RpgAgentOperationStatus::Failed), + other => Err(SpacetimeClientError::Runtime(format!( + "未知 rpg agent operation status: {other}" + ))), + } +} + +pub(crate) fn format_rpg_agent_draft_card_kind( + value: crate::module_bindings::RpgAgentDraftCardKind, +) -> &'static str { + match value { + crate::module_bindings::RpgAgentDraftCardKind::World => "world", + crate::module_bindings::RpgAgentDraftCardKind::Camp => "camp", + crate::module_bindings::RpgAgentDraftCardKind::Faction => "faction", + crate::module_bindings::RpgAgentDraftCardKind::Character => "character", + crate::module_bindings::RpgAgentDraftCardKind::Landmark => "landmark", + crate::module_bindings::RpgAgentDraftCardKind::Thread => "thread", + crate::module_bindings::RpgAgentDraftCardKind::Chapter => "chapter", + crate::module_bindings::RpgAgentDraftCardKind::SceneChapter => "scene_chapter", + crate::module_bindings::RpgAgentDraftCardKind::Carrier => "carrier", + crate::module_bindings::RpgAgentDraftCardKind::SidequestSeed => "sidequest_seed", + } +} + +pub(crate) fn format_rpg_agent_draft_card_status( + value: crate::module_bindings::RpgAgentDraftCardStatus, +) -> &'static str { + match value { + crate::module_bindings::RpgAgentDraftCardStatus::Suggested => "suggested", + crate::module_bindings::RpgAgentDraftCardStatus::Confirmed => "confirmed", + crate::module_bindings::RpgAgentDraftCardStatus::Locked => "locked", + crate::module_bindings::RpgAgentDraftCardStatus::Warning => "warning", + } +} + +pub(crate) fn format_custom_world_role_asset_status_back( + value: crate::module_bindings::CustomWorldRoleAssetStatus, +) -> String { + match value { + crate::module_bindings::CustomWorldRoleAssetStatus::Missing => "missing", + crate::module_bindings::CustomWorldRoleAssetStatus::VisualReady => "visual_ready", + crate::module_bindings::CustomWorldRoleAssetStatus::AnimationsReady => "animations_ready", + crate::module_bindings::CustomWorldRoleAssetStatus::Complete => "complete", + } + .to_string() +} + +pub(crate) fn format_custom_world_theme_mode(value: DomainCustomWorldThemeMode) -> &'static str { + match value { + DomainCustomWorldThemeMode::Martial => "martial", + DomainCustomWorldThemeMode::Arcane => "arcane", + DomainCustomWorldThemeMode::Machina => "machina", + DomainCustomWorldThemeMode::Tide => "tide", + DomainCustomWorldThemeMode::Rift => "rift", + DomainCustomWorldThemeMode::Mythic => "mythic", + } +} + +pub(crate) fn format_ai_task_kind(value: AiTaskKind) -> &'static str { + match value { + AiTaskKind::StoryGeneration => "story_generation", + AiTaskKind::CharacterChat => "character_chat", + AiTaskKind::NpcChat => "npc_chat", + AiTaskKind::CustomWorldGeneration => "custom_world_generation", + AiTaskKind::QuestIntent => "quest_intent", + AiTaskKind::RuntimeItemIntent => "runtime_item_intent", + } +} + +pub(crate) fn format_ai_result_reference_kind(value: AiResultReferenceKind) -> &'static str { + match value { + AiResultReferenceKind::StorySession => "story_session", + AiResultReferenceKind::StoryEvent => "story_event", + AiResultReferenceKind::CustomWorldProfile => "custom_world_profile", + AiResultReferenceKind::QuestRecord => "quest_record", + AiResultReferenceKind::RuntimeItemRecord => "runtime_item_record", + AiResultReferenceKind::AssetObject => "asset_object", + } +} + +pub(crate) fn map_custom_world_checkpoint_record( + value: serde_json::Value, +) -> Result { + let object = value.as_object().ok_or_else(|| { + SpacetimeClientError::Runtime("custom world checkpoint 必须是 JSON object".to_string()) + })?; + let checkpoint_id = object + .get("checkpointId") + .and_then(serde_json::Value::as_str) + .map(str::trim) + .filter(|value| !value.is_empty()) + .ok_or_else(|| { + SpacetimeClientError::Runtime("custom world checkpoint.checkpointId 缺失".to_string()) + })?; + let created_at = object + .get("createdAt") + .and_then(serde_json::Value::as_str) + .map(str::trim) + .filter(|value| !value.is_empty()) + .ok_or_else(|| { + SpacetimeClientError::Runtime("custom world checkpoint.createdAt 缺失".to_string()) + })?; + let label = object + .get("label") + .and_then(serde_json::Value::as_str) + .map(str::trim) + .filter(|value| !value.is_empty()) + .ok_or_else(|| { + SpacetimeClientError::Runtime("custom world checkpoint.label 缺失".to_string()) + })?; + + Ok(CustomWorldCheckpointRecord { + checkpoint_id: checkpoint_id.to_string(), + created_at: created_at.to_string(), + label: label.to_string(), + }) +} + +pub(crate) fn parse_custom_world_publish_gate_record( + value: &str, +) -> Result { + let object = parse_json_value(value, "custom world publish_gate_json")? + .as_object() + .cloned() + .ok_or_else(|| { + SpacetimeClientError::Runtime( + "custom world publish_gate_json 必须是 JSON object".to_string(), + ) + })?; + + let profile_id = object + .get("profileId") + .and_then(serde_json::Value::as_str) + .map(str::trim) + .filter(|value| !value.is_empty()) + .ok_or_else(|| { + SpacetimeClientError::Runtime("custom world publish_gate.profileId 缺失".to_string()) + })?; + let blockers = object + .get("blockers") + .and_then(serde_json::Value::as_array) + .ok_or_else(|| { + SpacetimeClientError::Runtime("custom world publish_gate.blockers 缺失".to_string()) + })? + .iter() + .cloned() + .map(|entry| { + let object = entry.as_object().ok_or_else(|| { + SpacetimeClientError::Runtime( + "custom world publish gate blocker 必须是 JSON object".to_string(), + ) + })?; + let id = object + .get("id") + .and_then(serde_json::Value::as_str) + .map(str::trim) + .filter(|value| !value.is_empty()) + .ok_or_else(|| { + SpacetimeClientError::Runtime( + "custom world publish gate blocker.id 缺失".to_string(), + ) + })?; + let code = object + .get("code") + .and_then(serde_json::Value::as_str) + .map(str::trim) + .filter(|value| !value.is_empty()) + .ok_or_else(|| { + SpacetimeClientError::Runtime( + "custom world publish gate blocker.code 缺失".to_string(), + ) + })?; + let message = object + .get("message") + .and_then(serde_json::Value::as_str) + .map(str::trim) + .filter(|value| !value.is_empty()) + .ok_or_else(|| { + SpacetimeClientError::Runtime( + "custom world publish gate blocker.message 缺失".to_string(), + ) + })?; + + Ok(CustomWorldResultPreviewBlockerRecord { + id: id.to_string(), + code: code.to_string(), + message: message.to_string(), + }) + }) + .collect::, _>>()?; + let blocker_count = object + .get("blockerCount") + .and_then(serde_json::Value::as_u64) + .and_then(|value| u32::try_from(value).ok()) + .ok_or_else(|| { + SpacetimeClientError::Runtime("custom world publish_gate.blockerCount 缺失".to_string()) + })?; + let publish_ready = object + .get("publishReady") + .and_then(serde_json::Value::as_bool) + .ok_or_else(|| { + SpacetimeClientError::Runtime("custom world publish_gate.publishReady 缺失".to_string()) + })?; + let can_enter_world = object + .get("canEnterWorld") + .and_then(serde_json::Value::as_bool) + .ok_or_else(|| { + SpacetimeClientError::Runtime( + "custom world publish_gate.canEnterWorld 缺失".to_string(), + ) + })?; + + Ok(CustomWorldPublishGateRecord { + profile_id: profile_id.to_string(), + blockers, + blocker_count, + publish_ready, + can_enter_world, + }) +} diff --git a/server-rs/crates/spacetime-client/src/mapper/inventory.rs b/server-rs/crates/spacetime-client/src/mapper/inventory.rs new file mode 100644 index 00000000..ebf11863 --- /dev/null +++ b/server-rs/crates/spacetime-client/src/mapper/inventory.rs @@ -0,0 +1,200 @@ +use super::*; + +impl From for RuntimeInventoryStateQueryInput { + fn from(input: DomainRuntimeInventoryStateQueryInput) -> Self { + Self { + runtime_session_id: input.runtime_session_id, + actor_user_id: input.actor_user_id, + } + } +} + +pub(crate) fn map_runtime_inventory_state_procedure_result( + result: RuntimeInventoryStateProcedureResult, +) -> Result { + if !result.ok { + return Err(SpacetimeClientError::procedure_failed(result.error_message)); + } + + let snapshot = result + .snapshot + .ok_or_else(|| SpacetimeClientError::missing_snapshot("runtime inventory state 快照"))?; + + Ok(build_runtime_inventory_state_record( + map_runtime_inventory_state_snapshot(snapshot), + )) +} + +pub(crate) fn map_runtime_inventory_state_snapshot( + snapshot: RuntimeInventoryStateSnapshot, +) -> DomainRuntimeInventoryStateSnapshot { + DomainRuntimeInventoryStateSnapshot { + runtime_session_id: snapshot.runtime_session_id, + actor_user_id: snapshot.actor_user_id, + backpack_items: snapshot + .backpack_items + .into_iter() + .map(map_inventory_slot_snapshot) + .collect(), + equipment_items: snapshot + .equipment_items + .into_iter() + .map(map_inventory_slot_snapshot) + .collect(), + } +} + +pub(crate) fn map_inventory_slot_snapshot( + snapshot: InventorySlotSnapshot, +) -> module_inventory::InventorySlotSnapshot { + module_inventory::InventorySlotSnapshot { + slot_id: snapshot.slot_id, + runtime_session_id: snapshot.runtime_session_id, + story_session_id: snapshot.story_session_id, + actor_user_id: snapshot.actor_user_id, + container_kind: map_inventory_container_kind(snapshot.container_kind), + slot_key: snapshot.slot_key, + item_id: snapshot.item_id, + category: snapshot.category, + name: snapshot.name, + description: snapshot.description, + quantity: snapshot.quantity, + rarity: map_inventory_item_rarity(snapshot.rarity), + tags: snapshot.tags, + stackable: snapshot.stackable, + stack_key: snapshot.stack_key, + equipment_slot_id: snapshot.equipment_slot_id.map(map_inventory_equipment_slot), + source_kind: map_inventory_item_source_kind(snapshot.source_kind), + source_reference_id: snapshot.source_reference_id, + created_at_micros: snapshot.created_at_micros, + updated_at_micros: snapshot.updated_at_micros, + } +} + +pub(crate) fn map_runtime_item_reward_item_rarity( + value: DomainRuntimeItemRewardItemRarity, +) -> RuntimeItemRewardItemRarity { + match value { + DomainRuntimeItemRewardItemRarity::Common => RuntimeItemRewardItemRarity::Common, + DomainRuntimeItemRewardItemRarity::Uncommon => RuntimeItemRewardItemRarity::Uncommon, + DomainRuntimeItemRewardItemRarity::Rare => RuntimeItemRewardItemRarity::Rare, + DomainRuntimeItemRewardItemRarity::Epic => RuntimeItemRewardItemRarity::Epic, + DomainRuntimeItemRewardItemRarity::Legendary => RuntimeItemRewardItemRarity::Legendary, + } +} + +pub(crate) fn map_runtime_item_equipment_slot( + value: DomainRuntimeItemEquipmentSlot, +) -> RuntimeItemEquipmentSlot { + match value { + DomainRuntimeItemEquipmentSlot::Weapon => RuntimeItemEquipmentSlot::Weapon, + DomainRuntimeItemEquipmentSlot::Armor => RuntimeItemEquipmentSlot::Armor, + DomainRuntimeItemEquipmentSlot::Relic => RuntimeItemEquipmentSlot::Relic, + } +} + +pub(crate) fn map_runtime_item_reward_item_rarity_back( + value: RuntimeItemRewardItemRarity, +) -> DomainRuntimeItemRewardItemRarity { + match value { + RuntimeItemRewardItemRarity::Common => DomainRuntimeItemRewardItemRarity::Common, + RuntimeItemRewardItemRarity::Uncommon => DomainRuntimeItemRewardItemRarity::Uncommon, + RuntimeItemRewardItemRarity::Rare => DomainRuntimeItemRewardItemRarity::Rare, + RuntimeItemRewardItemRarity::Epic => DomainRuntimeItemRewardItemRarity::Epic, + RuntimeItemRewardItemRarity::Legendary => DomainRuntimeItemRewardItemRarity::Legendary, + } +} + +pub(crate) fn map_runtime_item_equipment_slot_back( + value: RuntimeItemEquipmentSlot, +) -> DomainRuntimeItemEquipmentSlot { + match value { + RuntimeItemEquipmentSlot::Weapon => DomainRuntimeItemEquipmentSlot::Weapon, + RuntimeItemEquipmentSlot::Armor => DomainRuntimeItemEquipmentSlot::Armor, + RuntimeItemEquipmentSlot::Relic => DomainRuntimeItemEquipmentSlot::Relic, + } +} + +pub(crate) fn map_ai_result_reference_kind( + value: DomainAiResultReferenceKind, +) -> AiResultReferenceKind { + match value { + DomainAiResultReferenceKind::StorySession => AiResultReferenceKind::StorySession, + DomainAiResultReferenceKind::StoryEvent => AiResultReferenceKind::StoryEvent, + DomainAiResultReferenceKind::CustomWorldProfile => { + AiResultReferenceKind::CustomWorldProfile + } + DomainAiResultReferenceKind::QuestRecord => AiResultReferenceKind::QuestRecord, + DomainAiResultReferenceKind::RuntimeItemRecord => AiResultReferenceKind::RuntimeItemRecord, + DomainAiResultReferenceKind::AssetObject => AiResultReferenceKind::AssetObject, + } +} + +pub(crate) fn map_runtime_item_reward_item_snapshot( + snapshot: DomainRuntimeItemRewardItemSnapshot, +) -> RuntimeItemRewardItemSnapshot { + RuntimeItemRewardItemSnapshot { + item_id: snapshot.item_id, + category: snapshot.category, + item_name: snapshot.item_name, + description: snapshot.description, + quantity: snapshot.quantity, + rarity: map_runtime_item_reward_item_rarity(snapshot.rarity), + tags: snapshot.tags, + stackable: snapshot.stackable, + stack_key: snapshot.stack_key, + equipment_slot_id: snapshot + .equipment_slot_id + .map(map_runtime_item_equipment_slot), + } +} + +pub(crate) fn map_runtime_item_reward_item_snapshot_back( + snapshot: RuntimeItemRewardItemSnapshot, +) -> DomainRuntimeItemRewardItemSnapshot { + DomainRuntimeItemRewardItemSnapshot { + item_id: snapshot.item_id, + category: snapshot.category, + item_name: snapshot.item_name, + description: snapshot.description, + quantity: snapshot.quantity, + rarity: map_runtime_item_reward_item_rarity_back(snapshot.rarity), + tags: snapshot.tags, + stackable: snapshot.stackable, + stack_key: snapshot.stack_key, + equipment_slot_id: snapshot + .equipment_slot_id + .map(map_runtime_item_equipment_slot_back), + } +} + +pub(crate) fn map_inventory_container_kind( + value: InventoryContainerKind, +) -> module_inventory::InventoryContainerKind { + match value { + InventoryContainerKind::Backpack => module_inventory::InventoryContainerKind::Backpack, + InventoryContainerKind::Equipment => module_inventory::InventoryContainerKind::Equipment, + } +} + +pub(crate) fn map_inventory_item_rarity( + value: InventoryItemRarity, +) -> module_inventory::InventoryItemRarity { + match value { + InventoryItemRarity::Common => module_inventory::InventoryItemRarity::Common, + InventoryItemRarity::Uncommon => module_inventory::InventoryItemRarity::Uncommon, + InventoryItemRarity::Rare => module_inventory::InventoryItemRarity::Rare, + InventoryItemRarity::Epic => module_inventory::InventoryItemRarity::Epic, + InventoryItemRarity::Legendary => module_inventory::InventoryItemRarity::Legendary, + } +} + +pub(crate) fn map_inventory_equipment_slot( + value: InventoryEquipmentSlot, +) -> module_inventory::InventoryEquipmentSlot { + match value { + InventoryEquipmentSlot::Weapon => module_inventory::InventoryEquipmentSlot::Weapon, + InventoryEquipmentSlot::Armor => module_inventory::InventoryEquipmentSlot::Armor, + InventoryEquipmentSlot::Relic => module_inventory::InventoryEquipmentSlot::Relic, + } +} diff --git a/server-rs/crates/spacetime-client/src/mapper/match3d.rs b/server-rs/crates/spacetime-client/src/mapper/match3d.rs new file mode 100644 index 00000000..608b5cf6 --- /dev/null +++ b/server-rs/crates/spacetime-client/src/mapper/match3d.rs @@ -0,0 +1,606 @@ +use super::*; + +pub(crate) fn map_match3d_agent_session_procedure_result( + result: Match3DAgentSessionProcedureResult, +) -> Result { + if !result.ok { + return Err(SpacetimeClientError::Procedure( + result + .error_message + .unwrap_or_else(|| "SpacetimeDB procedure 返回未知错误".to_string()), + )); + } + + let session = result.session.ok_or_else(|| { + SpacetimeClientError::Procedure( + "SpacetimeDB procedure 未返回 match3d agent session 快照".to_string(), + ) + })?; + + Ok(map_match3d_agent_session_snapshot(session)) +} + +pub(crate) fn map_match3d_work_procedure_result( + result: Match3DWorkProcedureResult, +) -> Result { + if !result.ok { + return Err(SpacetimeClientError::Procedure( + result + .error_message + .unwrap_or_else(|| "SpacetimeDB procedure 返回未知错误".to_string()), + )); + } + + let work = result.work.ok_or_else(|| { + SpacetimeClientError::Procedure( + "SpacetimeDB procedure 未返回 match3d work 快照".to_string(), + ) + })?; + + Ok(map_match3d_work_snapshot(work)) +} + +pub(crate) fn map_match3d_works_procedure_result( + result: Match3DWorksProcedureResult, +) -> Result, SpacetimeClientError> { + if !result.ok { + return Err(SpacetimeClientError::Procedure( + result + .error_message + .unwrap_or_else(|| "SpacetimeDB procedure 返回未知错误".to_string()), + )); + } + + Ok(result + .items + .into_iter() + .map(map_match3d_work_snapshot) + .collect()) +} + +pub(crate) fn map_match3d_run_procedure_result( + result: Match3DRunProcedureResult, +) -> Result { + if !result.ok { + return Err(SpacetimeClientError::Procedure( + result + .error_message + .unwrap_or_else(|| "SpacetimeDB procedure 返回未知错误".to_string()), + )); + } + + let run = result.run.ok_or_else(|| { + SpacetimeClientError::Procedure("SpacetimeDB procedure 未返回 match3d run 快照".to_string()) + })?; + Ok(map_match3d_run_snapshot(run)) +} + +pub(crate) fn map_match3d_click_item_procedure_result( + result: Match3DClickItemProcedureResult, +) -> Result { + if !result.ok { + return Err(SpacetimeClientError::Procedure( + result + .error_message + .unwrap_or_else(|| "SpacetimeDB procedure 返回未知错误".to_string()), + )); + } + + let run = result.run.ok_or_else(|| { + SpacetimeClientError::Procedure( + "SpacetimeDB procedure 未返回 match3d click run 快照".to_string(), + ) + })?; + let run = map_match3d_run_snapshot(run); + let accepted = result.status == "Accepted"; + let accepted_item_instance_id = result.accepted_item_instance_id.clone(); + let entered_slot_index = accepted_item_instance_id.as_deref().and_then(|item_id| { + run.items + .iter() + .find(|item| item.item_instance_id == item_id) + .and_then(|item| item.tray_slot_index) + }); + + Ok(Match3DClickConfirmationRecord { + status: result.status.clone(), + accepted, + reject_reason: if accepted { None } else { Some(result.status) }, + accepted_item_instance_id, + entered_slot_index, + cleared_item_instance_ids: result.cleared_item_instance_ids, + failure_reason: result.failure_reason, + run, + }) +} + +fn map_match3d_agent_session_snapshot( + snapshot: Match3DAgentSessionSnapshot, +) -> Match3DAgentSessionRecord { + let config = map_match3d_creator_config(snapshot.config); + Match3DAgentSessionRecord { + session_id: snapshot.session_id, + current_turn: snapshot.current_turn, + progress_percent: snapshot.progress_percent, + stage: normalize_match3d_stage(&snapshot.stage).to_string(), + anchor_pack: build_match3d_anchor_pack(&config), + draft: snapshot + .draft + .map(|draft| map_match3d_result_draft(draft, config.reference_image_src.clone())), + config: Some(config), + messages: snapshot + .messages + .into_iter() + .map(map_match3d_agent_message_snapshot) + .collect(), + last_assistant_reply: empty_string_to_none(snapshot.last_assistant_reply), + published_profile_id: snapshot.published_profile_id, + updated_at: format_timestamp_micros(snapshot.updated_at_micros), + } +} + +fn map_match3d_creator_config( + snapshot: Match3DCreatorConfigSnapshot, +) -> Match3DCreatorConfigRecord { + Match3DCreatorConfigRecord { + theme_text: snapshot.theme_text, + reference_image_src: snapshot.reference_image_src, + clear_count: snapshot.clear_count, + difficulty: snapshot.difficulty, + asset_style_id: snapshot.asset_style_id, + asset_style_label: snapshot.asset_style_label, + asset_style_prompt: snapshot.asset_style_prompt, + generate_click_sound: snapshot.generate_click_sound, + } +} + +fn map_match3d_result_draft( + snapshot: Match3DDraftSnapshot, + reference_image_src: Option, +) -> Match3DResultDraftRecord { + Match3DResultDraftRecord { + profile_id: snapshot.profile_id, + game_name: snapshot.game_name, + theme_text: snapshot.theme_text, + summary_text: snapshot.summary_text, + tags: snapshot.tags, + cover_image_src: None, + reference_image_src, + clear_count: snapshot.clear_count, + difficulty: snapshot.difficulty, + generated_item_assets_json: snapshot.generated_item_assets_json, + total_item_count: snapshot.clear_count.saturating_mul(3), + publish_ready: false, + blockers: Vec::new(), + } +} + +fn map_match3d_agent_message_snapshot( + snapshot: Match3DAgentMessageSnapshot, +) -> Match3DAgentMessageRecord { + Match3DAgentMessageRecord { + message_id: snapshot.message_id, + role: snapshot.role, + kind: normalize_match3d_message_kind(&snapshot.kind).to_string(), + text: snapshot.text, + created_at: format_timestamp_micros(snapshot.created_at_micros), + } +} + +fn map_match3d_work_snapshot(snapshot: Match3DWorkSnapshot) -> Match3DWorkProfileRecord { + let config = map_match3d_creator_config(snapshot.config); + Match3DWorkProfileRecord { + work_id: snapshot.profile_id.clone(), + profile_id: snapshot.profile_id, + owner_user_id: snapshot.owner_user_id, + source_session_id: empty_string_to_none(snapshot.source_session_id), + author_display_name: snapshot.author_display_name, + game_name: snapshot.game_name, + theme_text: snapshot.theme_text, + summary: snapshot.summary_text, + tags: snapshot.tags, + cover_image_src: empty_string_to_none(snapshot.cover_image_src), + cover_asset_id: empty_string_to_none(snapshot.cover_asset_id), + reference_image_src: config.reference_image_src, + clear_count: snapshot.clear_count, + difficulty: snapshot.difficulty, + publication_status: normalize_match3d_publication_status(&snapshot.publication_status) + .to_string(), + play_count: snapshot.play_count, + updated_at: format_timestamp_micros(snapshot.updated_at_micros), + published_at: snapshot.published_at_micros.map(format_timestamp_micros), + publish_ready: snapshot.publish_ready, + generated_item_assets_json: snapshot.generated_item_assets_json, + } +} + +pub(crate) fn map_match3d_gallery_view_row(row: Match3DGalleryViewRow) -> Match3DWorkProfileRecord { + Match3DWorkProfileRecord { + work_id: row.profile_id.clone(), + profile_id: row.profile_id, + owner_user_id: row.owner_user_id, + source_session_id: empty_string_to_none(row.source_session_id), + author_display_name: row.author_display_name, + game_name: row.game_name, + theme_text: row.theme_text, + summary: row.summary_text, + tags: row.tags, + cover_image_src: empty_string_to_none(row.cover_image_src), + cover_asset_id: empty_string_to_none(row.cover_asset_id), + reference_image_src: row.reference_image_src, + clear_count: row.clear_count, + difficulty: row.difficulty, + publication_status: normalize_match3d_publication_status(&row.publication_status) + .to_string(), + play_count: row.play_count, + updated_at: format_timestamp_micros(row.updated_at_micros), + published_at: row.published_at_micros.map(format_timestamp_micros), + publish_ready: row.publish_ready, + generated_item_assets_json: row.generated_item_assets_json, + } +} + +fn map_match3d_run_snapshot(snapshot: Match3DRunSnapshot) -> Match3DRunRecord { + let tray_slots = snapshot + .tray_slots + .into_iter() + .map(map_match3d_tray_slot_snapshot) + .collect::>(); + let items = snapshot + .items + .into_iter() + .map(|item| { + let tray_slot_index = tray_slots + .iter() + .find(|slot| { + slot.item_instance_id.as_deref() == Some(item.item_instance_id.as_str()) + }) + .map(|slot| slot.slot_index); + map_match3d_item_snapshot(item, tray_slot_index) + }) + .collect(); + + Match3DRunRecord { + run_id: snapshot.run_id, + profile_id: snapshot.profile_id, + owner_user_id: String::new(), + status: snapshot.status, + snapshot_version: u64::from(snapshot.snapshot_version), + started_at_ms: i64_to_u64_ms(snapshot.started_at_ms), + duration_limit_ms: i64_to_u64_ms(snapshot.duration_limit_ms), + server_now_ms: Some(i64_to_u64_ms(snapshot.server_now_ms)), + remaining_ms: i64_to_u64_ms(snapshot.remaining_ms), + clear_count: snapshot.clear_count, + total_item_count: snapshot.total_item_count, + cleared_item_count: snapshot.cleared_item_count, + items, + tray_slots, + failure_reason: snapshot.failure_reason, + last_confirmed_action_id: None, + } +} + +fn map_match3d_item_snapshot( + snapshot: Match3DItemSnapshot, + tray_slot_index: Option, +) -> Match3DItemSnapshotRecord { + Match3DItemSnapshotRecord { + item_instance_id: snapshot.item_instance_id, + item_type_id: snapshot.item_type_id, + visual_key: snapshot.visual_key, + x: snapshot.x, + y: snapshot.y, + radius: snapshot.radius, + layer: snapshot.layer, + state: snapshot.state, + clickable: snapshot.clickable, + tray_slot_index, + } +} + +fn map_match3d_tray_slot_snapshot(snapshot: Match3DTraySlotSnapshot) -> Match3DTraySlotRecord { + Match3DTraySlotRecord { + slot_index: snapshot.slot_index, + item_instance_id: snapshot.item_instance_id, + item_type_id: snapshot.item_type_id, + visual_key: snapshot.visual_key, + } +} + +fn build_match3d_anchor_pack(config: &Match3DCreatorConfigRecord) -> Match3DAnchorPackRecord { + let clear_count = config.clear_count.to_string(); + let difficulty = config.difficulty.to_string(); + Match3DAnchorPackRecord { + theme: build_match3d_anchor_item("theme", "题材主题", config.theme_text.as_str()), + clear_count: build_match3d_anchor_item("clearCount", "需要消除次数", clear_count.as_str()), + difficulty: build_match3d_anchor_item("difficulty", "难度", difficulty.as_str()), + } +} + +fn build_match3d_anchor_item(key: &str, label: &str, value: &str) -> Match3DAnchorItemRecord { + Match3DAnchorItemRecord { + key: key.to_string(), + label: label.to_string(), + value: value.to_string(), + status: if value.trim().is_empty() { + "missing" + } else { + "confirmed" + } + .to_string(), + } +} + +fn normalize_match3d_stage(value: &str) -> &str { + match value { + "Collecting" | "collecting" | "collecting_config" => "collecting_config", + "ReadyToCompile" | "ready_to_compile" => "ready_to_compile", + "DraftCompiled" | "draft_compiled" | "draft_ready" => "draft_ready", + "Published" | "published" => "published", + _ => value, + } +} + +fn normalize_match3d_publication_status(value: &str) -> &str { + match value { + "Draft" | "draft" => "draft", + "Published" | "published" => "published", + _ => value, + } +} + +fn normalize_match3d_message_kind(value: &str) -> &str { + match value { + "text" => "chat", + _ => value, + } +} + +#[derive(Clone, Debug, PartialEq, Eq)] +pub struct Match3DAgentSessionCreateRecordInput { + pub session_id: String, + pub owner_user_id: String, + pub seed_text: String, + pub welcome_message_id: String, + pub welcome_message_text: String, + pub config_json: Option, + pub created_at_micros: i64, +} + +#[derive(Clone, Debug, PartialEq, Eq)] +pub struct Match3DAgentMessageSubmitRecordInput { + pub session_id: String, + pub owner_user_id: String, + pub user_message_id: String, + pub user_message_text: String, + pub submitted_at_micros: i64, +} + +#[derive(Clone, Debug, PartialEq, Eq)] +pub struct Match3DAgentMessageFinalizeRecordInput { + pub session_id: String, + pub owner_user_id: String, + pub assistant_message_id: Option, + pub assistant_reply_text: Option, + pub config_json: Option, + pub progress_percent: u32, + pub stage: String, + pub updated_at_micros: i64, + pub error_message: Option, +} + +#[derive(Clone, Debug, PartialEq, Eq)] +pub struct Match3DCompileDraftRecordInput { + pub session_id: String, + pub owner_user_id: String, + pub profile_id: String, + pub author_display_name: String, + pub game_name: Option, + pub summary_text: Option, + pub tags_json: Option, + pub cover_image_src: Option, + pub cover_asset_id: Option, + pub compiled_at_micros: i64, + pub generated_item_assets_json: Option, +} + +#[derive(Clone, Debug, PartialEq, Eq)] +pub struct Match3DWorkUpdateRecordInput { + pub profile_id: String, + pub owner_user_id: String, + pub game_name: String, + pub theme_text: String, + pub summary_text: String, + pub tags_json: String, + pub cover_image_src: String, + pub cover_asset_id: String, + pub clear_count: u32, + pub difficulty: u32, + pub updated_at_micros: i64, +} + +#[derive(Clone, Debug, PartialEq, Eq)] +pub struct Match3DRunStartRecordInput { + pub run_id: String, + pub owner_user_id: String, + pub profile_id: String, + pub started_at_ms: i64, + pub item_type_count_override: u32, +} + +#[derive(Clone, Debug, PartialEq, Eq)] +pub struct Match3DRunClickRecordInput { + pub run_id: String, + pub owner_user_id: String, + pub item_instance_id: String, + pub client_snapshot_version: u32, + pub client_event_id: String, + pub clicked_at_ms: i64, +} + +#[derive(Clone, Debug, PartialEq, Eq)] +pub struct Match3DRunStopRecordInput { + pub run_id: String, + pub owner_user_id: String, + pub stopped_at_ms: i64, +} + +#[derive(Clone, Debug, PartialEq, Eq)] +pub struct Match3DRunRestartRecordInput { + pub source_run_id: String, + pub next_run_id: String, + pub owner_user_id: String, + pub restarted_at_ms: i64, +} + +#[derive(Clone, Debug, PartialEq, Eq)] +pub struct Match3DRunTimeUpRecordInput { + pub run_id: String, + pub owner_user_id: String, + pub finished_at_ms: i64, +} + +#[derive(Clone, Debug, PartialEq, Eq)] +pub struct Match3DAnchorItemRecord { + pub key: String, + pub label: String, + pub value: String, + pub status: String, +} + +#[derive(Clone, Debug, PartialEq, Eq)] +pub struct Match3DAnchorPackRecord { + pub theme: Match3DAnchorItemRecord, + pub clear_count: Match3DAnchorItemRecord, + pub difficulty: Match3DAnchorItemRecord, +} + +#[derive(Clone, Debug, PartialEq, Eq)] +pub struct Match3DCreatorConfigRecord { + pub theme_text: String, + pub reference_image_src: Option, + pub clear_count: u32, + pub difficulty: u32, + pub asset_style_id: Option, + pub asset_style_label: Option, + pub asset_style_prompt: Option, + pub generate_click_sound: bool, +} + +#[derive(Clone, Debug, PartialEq, Eq)] +pub struct Match3DResultDraftRecord { + pub profile_id: String, + pub game_name: String, + pub theme_text: String, + pub summary_text: String, + pub tags: Vec, + pub cover_image_src: Option, + pub reference_image_src: Option, + pub clear_count: u32, + pub difficulty: u32, + pub generated_item_assets_json: Option, + pub total_item_count: u32, + pub publish_ready: bool, + pub blockers: Vec, +} + +#[derive(Clone, Debug, PartialEq, Eq)] +pub struct Match3DAgentMessageRecord { + pub message_id: String, + pub role: String, + pub kind: String, + pub text: String, + pub created_at: String, +} + +#[derive(Clone, Debug, PartialEq, Eq)] +pub struct Match3DAgentSessionRecord { + pub session_id: String, + pub current_turn: u32, + pub progress_percent: u32, + pub stage: String, + pub anchor_pack: Match3DAnchorPackRecord, + pub config: Option, + pub draft: Option, + pub messages: Vec, + pub last_assistant_reply: Option, + pub published_profile_id: Option, + pub updated_at: String, +} + +#[derive(Clone, Debug, PartialEq, Eq)] +pub struct Match3DWorkProfileRecord { + pub work_id: String, + pub profile_id: String, + pub owner_user_id: String, + pub source_session_id: Option, + pub author_display_name: String, + pub game_name: String, + pub theme_text: String, + pub summary: String, + pub tags: Vec, + pub cover_image_src: Option, + pub cover_asset_id: Option, + pub reference_image_src: Option, + pub clear_count: u32, + pub difficulty: u32, + pub publication_status: String, + pub play_count: u32, + pub updated_at: String, + pub published_at: Option, + pub publish_ready: bool, + pub generated_item_assets_json: Option, +} + +#[derive(Clone, Debug, PartialEq)] +pub struct Match3DItemSnapshotRecord { + pub item_instance_id: String, + pub item_type_id: String, + pub visual_key: String, + pub x: f32, + pub y: f32, + pub radius: f32, + pub layer: u32, + pub state: String, + pub clickable: bool, + pub tray_slot_index: Option, +} + +#[derive(Clone, Debug, PartialEq, Eq)] +pub struct Match3DTraySlotRecord { + pub slot_index: u32, + pub item_instance_id: Option, + pub item_type_id: Option, + pub visual_key: Option, +} + +#[derive(Clone, Debug, PartialEq)] +pub struct Match3DRunRecord { + pub run_id: String, + pub profile_id: String, + pub owner_user_id: String, + pub status: String, + pub snapshot_version: u64, + pub started_at_ms: u64, + pub duration_limit_ms: u64, + pub server_now_ms: Option, + pub remaining_ms: u64, + pub clear_count: u32, + pub total_item_count: u32, + pub cleared_item_count: u32, + pub items: Vec, + pub tray_slots: Vec, + pub failure_reason: Option, + pub last_confirmed_action_id: Option, +} + +#[derive(Clone, Debug, PartialEq)] +pub struct Match3DClickConfirmationRecord { + pub status: String, + pub accepted: bool, + pub reject_reason: Option, + pub accepted_item_instance_id: Option, + pub entered_slot_index: Option, + pub cleared_item_instance_ids: Vec, + pub failure_reason: Option, + pub run: Match3DRunRecord, +} diff --git a/server-rs/crates/spacetime-client/src/mapper/npc.rs b/server-rs/crates/spacetime-client/src/mapper/npc.rs new file mode 100644 index 00000000..cc2f1fa0 --- /dev/null +++ b/server-rs/crates/spacetime-client/src/mapper/npc.rs @@ -0,0 +1,624 @@ +use super::*; + +impl From for BattleStateInput { + fn from(input: DomainBattleStateInput) -> Self { + Self { + battle_state_id: input.battle_state_id, + story_session_id: input.story_session_id, + runtime_session_id: input.runtime_session_id, + actor_user_id: input.actor_user_id, + chapter_id: input.chapter_id, + target_npc_id: input.target_npc_id, + target_name: input.target_name, + battle_mode: map_battle_mode(input.battle_mode), + player_hp: input.player_hp, + player_max_hp: input.player_max_hp, + player_mana: input.player_mana, + player_max_mana: input.player_max_mana, + target_hp: input.target_hp, + target_max_hp: input.target_max_hp, + experience_reward: input.experience_reward, + reward_items: input + .reward_items + .into_iter() + .map(map_runtime_item_reward_item_snapshot) + .collect(), + created_at_micros: input.created_at_micros, + } + } +} + +pub(crate) fn map_npc_battle_interaction_procedure_result( + result: NpcBattleInteractionProcedureResult, +) -> Result { + if !result.ok { + return Err(SpacetimeClientError::procedure_failed(result.error_message)); + } + + let interaction_result = result + .result + .ok_or_else(|| SpacetimeClientError::missing_snapshot("NPC 开战结果"))?; + + Ok(build_npc_battle_interaction_record( + map_npc_battle_interaction_result(interaction_result), + )) +} + +pub(crate) fn map_battle_state_snapshot( + snapshot: BattleStateSnapshot, +) -> DomainBattleStateSnapshot { + DomainBattleStateSnapshot { + battle_state_id: snapshot.battle_state_id, + story_session_id: snapshot.story_session_id, + runtime_session_id: snapshot.runtime_session_id, + actor_user_id: snapshot.actor_user_id, + chapter_id: snapshot.chapter_id, + target_npc_id: snapshot.target_npc_id, + target_name: snapshot.target_name, + battle_mode: map_battle_mode_back(snapshot.battle_mode), + status: map_battle_status(snapshot.status), + player_hp: snapshot.player_hp, + player_max_hp: snapshot.player_max_hp, + player_mana: snapshot.player_mana, + player_max_mana: snapshot.player_max_mana, + target_hp: snapshot.target_hp, + target_max_hp: snapshot.target_max_hp, + experience_reward: snapshot.experience_reward, + reward_items: snapshot + .reward_items + .into_iter() + .map(map_runtime_item_reward_item_snapshot_back) + .collect(), + turn_index: snapshot.turn_index, + last_action_function_id: snapshot.last_action_function_id, + last_action_text: snapshot.last_action_text, + last_result_text: snapshot.last_result_text, + last_damage_dealt: snapshot.last_damage_dealt, + last_damage_taken: snapshot.last_damage_taken, + last_outcome: map_combat_outcome(snapshot.last_outcome), + version: snapshot.version, + created_at_micros: snapshot.created_at_micros, + updated_at_micros: snapshot.updated_at_micros, + } +} + +pub(crate) fn map_npc_battle_interaction_result( + result: NpcBattleInteractionResult, +) -> NpcBattleInteractionSnapshot { + NpcBattleInteractionSnapshot { + interaction: map_npc_interaction_result(result.interaction), + battle_state: map_battle_state_snapshot(result.battle_state), + } +} + +pub(crate) fn map_npc_interaction_result( + result: NpcInteractionResult, +) -> DomainNpcInteractionResult { + DomainNpcInteractionResult { + npc_state: map_npc_state_snapshot(result.npc_state), + interaction_status: map_npc_interaction_status(result.interaction_status), + action_text: result.action_text, + result_text: result.result_text, + story_text: result.story_text, + battle_mode: result.battle_mode.map(map_npc_interaction_battle_mode), + encounter_closed: result.encounter_closed, + affinity_changed: result.affinity_changed, + previous_affinity: result.previous_affinity, + next_affinity: result.next_affinity, + } +} + +pub(crate) fn map_npc_state_snapshot(snapshot: NpcStateSnapshot) -> DomainNpcStateSnapshot { + DomainNpcStateSnapshot { + npc_state_id: snapshot.npc_state_id, + runtime_session_id: snapshot.runtime_session_id, + npc_id: snapshot.npc_id, + npc_name: snapshot.npc_name, + affinity: snapshot.affinity, + relation_state: map_npc_relation_state(snapshot.relation_state), + help_used: snapshot.help_used, + chatted_count: snapshot.chatted_count, + gifts_given: snapshot.gifts_given, + recruited: snapshot.recruited, + trade_stock_signature: snapshot.trade_stock_signature, + revealed_facts: snapshot.revealed_facts, + known_attribute_rumors: snapshot.known_attribute_rumors, + first_meaningful_contact_resolved: snapshot.first_meaningful_contact_resolved, + seen_backstory_chapter_ids: snapshot.seen_backstory_chapter_ids, + stance_profile: map_npc_stance_profile(snapshot.stance_profile), + created_at_micros: snapshot.created_at_micros, + updated_at_micros: snapshot.updated_at_micros, + } +} + +pub(crate) fn map_npc_relation_state(value: NpcRelationState) -> DomainNpcRelationState { + DomainNpcRelationState { + affinity: value.affinity, + stance: map_npc_relation_stance(value.stance), + } +} + +pub(crate) fn map_npc_stance_profile(value: NpcStanceProfile) -> DomainNpcStanceProfile { + DomainNpcStanceProfile { + trust: value.trust, + warmth: value.warmth, + ideological_fit: value.ideological_fit, + fear_or_guard: value.fear_or_guard, + loyalty: value.loyalty, + current_conflict_tag: value.current_conflict_tag, + recent_approvals: value.recent_approvals, + recent_disapprovals: value.recent_disapprovals, + } +} + +pub(crate) fn map_npc_interaction_status( + value: NpcInteractionStatus, +) -> DomainNpcInteractionStatus { + match value { + NpcInteractionStatus::Previewed => DomainNpcInteractionStatus::Previewed, + NpcInteractionStatus::Dialogue => DomainNpcInteractionStatus::Dialogue, + NpcInteractionStatus::Resolved => DomainNpcInteractionStatus::Resolved, + NpcInteractionStatus::Recruited => DomainNpcInteractionStatus::Recruited, + NpcInteractionStatus::BattlePending => DomainNpcInteractionStatus::BattlePending, + NpcInteractionStatus::Left => DomainNpcInteractionStatus::Left, + } +} + +pub(crate) fn map_npc_interaction_battle_mode( + value: NpcInteractionBattleMode, +) -> DomainNpcInteractionBattleMode { + match value { + NpcInteractionBattleMode::Fight => DomainNpcInteractionBattleMode::Fight, + NpcInteractionBattleMode::Spar => DomainNpcInteractionBattleMode::Spar, + } +} + +pub(crate) fn map_npc_relation_stance(value: NpcRelationStance) -> DomainNpcRelationStance { + match value { + NpcRelationStance::Hostile => DomainNpcRelationStance::Hostile, + NpcRelationStance::Guarded => DomainNpcRelationStance::Guarded, + NpcRelationStance::Neutral => DomainNpcRelationStance::Neutral, + NpcRelationStance::Cooperative => DomainNpcRelationStance::Cooperative, + NpcRelationStance::Bonded => DomainNpcRelationStance::Bonded, + } +} + +pub(crate) fn map_ai_task_kind(value: DomainAiTaskKind) -> AiTaskKind { + match value { + DomainAiTaskKind::StoryGeneration => AiTaskKind::StoryGeneration, + DomainAiTaskKind::CharacterChat => AiTaskKind::CharacterChat, + DomainAiTaskKind::NpcChat => AiTaskKind::NpcChat, + DomainAiTaskKind::CustomWorldGeneration => AiTaskKind::CustomWorldGeneration, + DomainAiTaskKind::QuestIntent => AiTaskKind::QuestIntent, + DomainAiTaskKind::RuntimeItemIntent => AiTaskKind::RuntimeItemIntent, + } +} + +#[derive(Clone, Debug, PartialEq, Eq)] +pub struct BattleStateRecord { + pub battle_state_id: String, + pub story_session_id: String, + pub runtime_session_id: String, + pub actor_user_id: String, + pub chapter_id: Option, + pub target_npc_id: String, + pub target_name: String, + pub battle_mode: String, + pub status: String, + pub player_hp: i32, + pub player_max_hp: i32, + pub player_mana: i32, + pub player_max_mana: i32, + pub target_hp: i32, + pub target_max_hp: i32, + pub experience_reward: u32, + pub reward_items: Vec, + pub turn_index: u32, + pub last_action_function_id: Option, + pub last_action_text: Option, + pub last_result_text: Option, + pub last_damage_dealt: i32, + pub last_damage_taken: i32, + pub last_outcome: String, + pub version: u32, + pub created_at: String, + pub updated_at: String, +} + +#[derive(Clone, Debug, PartialEq)] +pub struct CustomWorldLibraryEntryRecord { + pub owner_user_id: String, + pub profile_id: String, + pub public_work_code: Option, + pub author_public_user_code: Option, + pub profile: serde_json::Value, + pub visibility: String, + pub published_at: Option, + pub updated_at: String, + pub author_display_name: String, + pub world_name: String, + pub subtitle: String, + pub summary_text: String, + pub cover_image_src: Option, + pub theme_mode: String, + pub playable_npc_count: u32, + pub landmark_count: u32, + pub play_count: u32, + pub remix_count: u32, + pub like_count: u32, + pub recent_play_count_7d: u32, +} + +#[derive(Clone, Debug, PartialEq, Eq)] +pub struct CustomWorldGalleryEntryRecord { + pub owner_user_id: String, + pub profile_id: String, + pub public_work_code: String, + pub author_public_user_code: String, + pub visibility: String, + pub published_at: Option, + pub updated_at: String, + pub author_display_name: String, + pub world_name: String, + pub subtitle: String, + pub summary_text: String, + pub cover_image_src: Option, + pub theme_mode: String, + pub playable_npc_count: u32, + pub landmark_count: u32, + pub play_count: u32, + pub remix_count: u32, + pub like_count: u32, + pub recent_play_count_7d: u32, +} + +#[derive(Clone, Debug, PartialEq)] +pub struct CustomWorldPublishedProfileCompileRecord { + pub profile_id: String, + pub owner_user_id: String, + pub world_name: String, + pub subtitle: String, + pub summary_text: String, + pub theme_mode: String, + pub cover_image_src: Option, + pub playable_npc_count: u32, + pub landmark_count: u32, + pub author_display_name: String, + pub compiled_profile: serde_json::Value, + pub updated_at: String, +} + +#[derive(Clone, Debug, PartialEq, Eq)] +pub struct CustomWorldWorkSummaryRecord { + pub work_id: String, + pub source_type: String, + pub status: String, + pub title: String, + pub subtitle: String, + pub summary: String, + pub cover_image_src: Option, + pub cover_render_mode: Option, + pub cover_character_image_srcs: Vec, + pub updated_at: String, + pub published_at: Option, + pub stage: Option, + pub stage_label: Option, + pub playable_npc_count: u32, + pub landmark_count: u32, + pub role_visual_ready_count: Option, + pub role_animation_ready_count: Option, + pub role_asset_summary_label: Option, + pub session_id: Option, + pub profile_id: Option, + pub can_resume: bool, + pub can_enter_world: bool, + pub blocker_count: u32, + pub publish_ready: bool, +} + +#[derive(Clone, Debug, PartialEq, Eq)] +pub struct CustomWorldProfileUpsertRecordInput { + pub profile_id: String, + pub owner_user_id: String, + pub public_work_code: Option, + pub author_public_user_code: Option, + pub source_agent_session_id: Option, + pub world_name: String, + pub subtitle: String, + pub summary_text: String, + pub theme_mode: DomainCustomWorldThemeMode, + pub cover_image_src: Option, + pub profile_payload_json: String, + pub playable_npc_count: u32, + pub landmark_count: u32, + pub author_display_name: String, + pub updated_at_micros: i64, +} + +#[derive(Clone, Debug, PartialEq, Eq)] +pub struct ResolveNpcBattleInteractionInput { + pub npc_interaction: DomainResolveNpcInteractionInput, + pub story_session_id: String, + pub actor_user_id: String, + pub battle_state_id: Option, + pub player_hp: i32, + pub player_max_hp: i32, + pub player_mana: i32, + pub player_max_mana: i32, + pub target_hp: i32, + pub target_max_hp: i32, + pub experience_reward: u32, + pub reward_items: Vec, +} + +#[derive(Clone, Debug, PartialEq, Eq)] +pub struct NpcStateRecord { + pub npc_state_id: String, + pub runtime_session_id: String, + pub npc_id: String, + pub npc_name: String, + pub affinity: i32, + pub relation_stance: String, + pub help_used: bool, + pub chatted_count: u32, + pub gifts_given: u32, + pub recruited: bool, + pub trade_stock_signature: Option, + pub revealed_facts: Vec, + pub known_attribute_rumors: Vec, + pub first_meaningful_contact_resolved: bool, + pub seen_backstory_chapter_ids: Vec, + pub trust: u8, + pub warmth: u8, + pub ideological_fit: u8, + pub fear_or_guard: u8, + pub loyalty: u8, + pub current_conflict_tag: Option, + pub recent_approvals: Vec, + pub recent_disapprovals: Vec, + pub created_at: String, + pub updated_at: String, +} + +#[derive(Clone, Debug, PartialEq, Eq)] +pub struct NpcInteractionRecord { + pub npc_state: NpcStateRecord, + pub interaction_status: String, + pub action_text: String, + pub result_text: String, + pub story_text: Option, + pub battle_mode: Option, + pub encounter_closed: bool, + pub affinity_changed: bool, + pub previous_affinity: i32, + pub next_affinity: i32, +} + +#[derive(Clone, Debug, PartialEq, Eq)] +pub struct NpcBattleInteractionRecord { + pub npc_interaction: NpcInteractionRecord, + pub battle_state: BattleStateRecord, +} + +#[derive(Clone, Debug, PartialEq, Eq)] +pub(crate) struct NpcBattleInteractionSnapshot { + interaction: DomainNpcInteractionResult, + battle_state: DomainBattleStateSnapshot, +} + +pub(crate) fn build_battle_state_record(snapshot: DomainBattleStateSnapshot) -> BattleStateRecord { + BattleStateRecord { + battle_state_id: snapshot.battle_state_id, + story_session_id: snapshot.story_session_id, + runtime_session_id: snapshot.runtime_session_id, + actor_user_id: snapshot.actor_user_id, + chapter_id: snapshot.chapter_id, + target_npc_id: snapshot.target_npc_id, + target_name: snapshot.target_name, + battle_mode: snapshot.battle_mode.as_str().to_string(), + status: snapshot.status.as_str().to_string(), + player_hp: snapshot.player_hp, + player_max_hp: snapshot.player_max_hp, + player_mana: snapshot.player_mana, + player_max_mana: snapshot.player_max_mana, + target_hp: snapshot.target_hp, + target_max_hp: snapshot.target_max_hp, + experience_reward: snapshot.experience_reward, + reward_items: snapshot.reward_items, + turn_index: snapshot.turn_index, + last_action_function_id: snapshot.last_action_function_id, + last_action_text: snapshot.last_action_text, + last_result_text: snapshot.last_result_text, + last_damage_dealt: snapshot.last_damage_dealt, + last_damage_taken: snapshot.last_damage_taken, + last_outcome: snapshot.last_outcome.as_str().to_string(), + version: snapshot.version, + created_at: format_timestamp_micros(snapshot.created_at_micros), + updated_at: format_timestamp_micros(snapshot.updated_at_micros), + } +} + +impl From + for crate::module_bindings::ResolveNpcBattleInteractionInput +{ + fn from(input: ResolveNpcBattleInteractionInput) -> Self { + Self { + npc_interaction: crate::module_bindings::ResolveNpcInteractionInput { + runtime_session_id: input.npc_interaction.runtime_session_id, + npc_id: input.npc_interaction.npc_id, + npc_name: input.npc_interaction.npc_name, + interaction_function_id: input.npc_interaction.interaction_function_id, + release_npc_id: input.npc_interaction.release_npc_id, + updated_at_micros: input.npc_interaction.updated_at_micros, + }, + story_session_id: input.story_session_id, + actor_user_id: input.actor_user_id, + battle_state_id: input.battle_state_id, + player_hp: input.player_hp, + player_max_hp: input.player_max_hp, + player_mana: input.player_mana, + player_max_mana: input.player_max_mana, + target_hp: input.target_hp, + target_max_hp: input.target_max_hp, + experience_reward: input.experience_reward, + reward_items: input + .reward_items + .into_iter() + .map(map_runtime_item_reward_item_snapshot) + .collect(), + } + } +} + +pub(crate) fn validate_npc_battle_interaction_input( + input: &ResolveNpcBattleInteractionInput, +) -> Result<(), SpacetimeClientError> { + let battle_state_input = DomainBattleStateInput { + battle_state_id: input + .battle_state_id + .clone() + .unwrap_or_else(|| "battle_preview".to_string()), + story_session_id: input.story_session_id.clone(), + runtime_session_id: input.npc_interaction.runtime_session_id.clone(), + actor_user_id: input.actor_user_id.clone(), + chapter_id: None, + target_npc_id: input.npc_interaction.npc_id.clone(), + target_name: input.npc_interaction.npc_name.clone(), + battle_mode: DomainBattleMode::Fight, + player_hp: input.player_hp, + player_max_hp: input.player_max_hp, + player_mana: input.player_mana, + player_max_mana: input.player_max_mana, + target_hp: input.target_hp, + target_max_hp: input.target_max_hp, + experience_reward: input.experience_reward, + reward_items: input.reward_items.clone(), + created_at_micros: input.npc_interaction.updated_at_micros, + }; + validate_battle_state_input(&battle_state_input) + .map_err(|error| SpacetimeClientError::Runtime(error.to_string()))?; + for reward_item in input.reward_items.iter().cloned() { + normalize_reward_item_snapshot(reward_item) + .map_err(|error| SpacetimeClientError::Runtime(error.to_string()))?; + } + + Ok(()) +} + +pub(crate) fn build_npc_state_record(snapshot: DomainNpcStateSnapshot) -> NpcStateRecord { + NpcStateRecord { + npc_state_id: snapshot.npc_state_id, + runtime_session_id: snapshot.runtime_session_id, + npc_id: snapshot.npc_id, + npc_name: snapshot.npc_name, + affinity: snapshot.affinity, + relation_stance: format_npc_relation_stance(snapshot.relation_state.stance).to_string(), + help_used: snapshot.help_used, + chatted_count: snapshot.chatted_count, + gifts_given: snapshot.gifts_given, + recruited: snapshot.recruited, + trade_stock_signature: snapshot.trade_stock_signature, + revealed_facts: snapshot.revealed_facts, + known_attribute_rumors: snapshot.known_attribute_rumors, + first_meaningful_contact_resolved: snapshot.first_meaningful_contact_resolved, + seen_backstory_chapter_ids: snapshot.seen_backstory_chapter_ids, + trust: snapshot.stance_profile.trust, + warmth: snapshot.stance_profile.warmth, + ideological_fit: snapshot.stance_profile.ideological_fit, + fear_or_guard: snapshot.stance_profile.fear_or_guard, + loyalty: snapshot.stance_profile.loyalty, + current_conflict_tag: snapshot.stance_profile.current_conflict_tag, + recent_approvals: snapshot.stance_profile.recent_approvals, + recent_disapprovals: snapshot.stance_profile.recent_disapprovals, + created_at: format_timestamp_micros(snapshot.created_at_micros), + updated_at: format_timestamp_micros(snapshot.updated_at_micros), + } +} + +pub(crate) fn build_npc_interaction_record( + result: DomainNpcInteractionResult, +) -> NpcInteractionRecord { + NpcInteractionRecord { + npc_state: build_npc_state_record(result.npc_state), + interaction_status: format_npc_interaction_status(result.interaction_status).to_string(), + action_text: result.action_text, + result_text: result.result_text, + story_text: result.story_text, + battle_mode: result + .battle_mode + .map(|mode| format_npc_interaction_battle_mode(mode).to_string()), + encounter_closed: result.encounter_closed, + affinity_changed: result.affinity_changed, + previous_affinity: result.previous_affinity, + next_affinity: result.next_affinity, + } +} + +pub(crate) fn build_npc_battle_interaction_record( + result: NpcBattleInteractionSnapshot, +) -> NpcBattleInteractionRecord { + NpcBattleInteractionRecord { + npc_interaction: build_npc_interaction_record(result.interaction), + battle_state: build_battle_state_record(result.battle_state), + } +} + +pub(crate) fn format_npc_relation_stance(value: DomainNpcRelationStance) -> &'static str { + match value { + DomainNpcRelationStance::Hostile => "hostile", + DomainNpcRelationStance::Guarded => "guarded", + DomainNpcRelationStance::Neutral => "neutral", + DomainNpcRelationStance::Cooperative => "cooperative", + DomainNpcRelationStance::Bonded => "bonded", + } +} + +pub(crate) fn format_npc_interaction_status(value: DomainNpcInteractionStatus) -> &'static str { + match value { + DomainNpcInteractionStatus::Previewed => "previewed", + DomainNpcInteractionStatus::Dialogue => "dialogue", + DomainNpcInteractionStatus::Resolved => "resolved", + DomainNpcInteractionStatus::Recruited => "recruited", + DomainNpcInteractionStatus::BattlePending => "battle_pending", + DomainNpcInteractionStatus::Left => "left", + } +} + +pub(crate) fn format_npc_interaction_battle_mode( + value: DomainNpcInteractionBattleMode, +) -> &'static str { + match value { + DomainNpcInteractionBattleMode::Fight => "fight", + DomainNpcInteractionBattleMode::Spar => "spar", + } +} + +pub(crate) fn map_inventory_item_source_kind( + value: InventoryItemSourceKind, +) -> module_inventory::InventoryItemSourceKind { + match value { + InventoryItemSourceKind::StoryReward => { + module_inventory::InventoryItemSourceKind::StoryReward + } + InventoryItemSourceKind::QuestReward => { + module_inventory::InventoryItemSourceKind::QuestReward + } + InventoryItemSourceKind::TreasureReward => { + module_inventory::InventoryItemSourceKind::TreasureReward + } + InventoryItemSourceKind::NpcGift => module_inventory::InventoryItemSourceKind::NpcGift, + InventoryItemSourceKind::NpcTrade => module_inventory::InventoryItemSourceKind::NpcTrade, + InventoryItemSourceKind::CombatDrop => { + module_inventory::InventoryItemSourceKind::CombatDrop + } + InventoryItemSourceKind::ForgeCraft => { + module_inventory::InventoryItemSourceKind::ForgeCraft + } + InventoryItemSourceKind::ForgeReforge => { + module_inventory::InventoryItemSourceKind::ForgeReforge + } + InventoryItemSourceKind::ManualPatch => { + module_inventory::InventoryItemSourceKind::ManualPatch + } + } +} diff --git a/server-rs/crates/spacetime-client/src/mapper/puzzle.rs b/server-rs/crates/spacetime-client/src/mapper/puzzle.rs new file mode 100644 index 00000000..ae67fd65 --- /dev/null +++ b/server-rs/crates/spacetime-client/src/mapper/puzzle.rs @@ -0,0 +1,1084 @@ +use super::*; + +pub(crate) fn map_puzzle_agent_session_procedure_result( + result: PuzzleAgentSessionProcedureResult, +) -> Result { + if !result.ok { + return Err(SpacetimeClientError::procedure_failed(result.error_message)); + } + + let session = result + .session + .ok_or_else(|| SpacetimeClientError::missing_snapshot("puzzle agent session 快照"))?; + Ok(map_puzzle_agent_session_snapshot(session)) +} + +pub(crate) fn map_puzzle_work_procedure_result( + result: PuzzleWorkProcedureResult, +) -> Result { + if !result.ok { + return Err(SpacetimeClientError::procedure_failed(result.error_message)); + } + + let item = result + .item + .ok_or_else(|| SpacetimeClientError::missing_snapshot("puzzle work 快照"))?; + Ok(map_puzzle_work_profile(item)) +} + +pub(crate) fn map_puzzle_works_procedure_result( + result: PuzzleWorksProcedureResult, +) -> Result, SpacetimeClientError> { + if !result.ok { + return Err(SpacetimeClientError::procedure_failed(result.error_message)); + } + + Ok(result + .items + .into_iter() + .map(map_puzzle_work_profile) + .collect()) +} + +pub(crate) fn map_puzzle_run_procedure_result( + result: PuzzleRunProcedureResult, +) -> Result { + if !result.ok { + return Err(SpacetimeClientError::procedure_failed(result.error_message)); + } + + let run = result + .run + .ok_or_else(|| SpacetimeClientError::missing_snapshot("puzzle run 快照"))?; + Ok(map_puzzle_run_snapshot(run)) +} + +pub(crate) fn map_puzzle_agent_session_snapshot( + snapshot: PuzzleAgentSessionSnapshot, +) -> PuzzleAgentSessionRecord { + PuzzleAgentSessionRecord { + session_id: snapshot.session_id, + seed_text: snapshot.seed_text, + current_turn: snapshot.current_turn, + progress_percent: snapshot.progress_percent, + stage: format_puzzle_agent_stage(snapshot.stage).to_string(), + anchor_pack: map_puzzle_anchor_pack(snapshot.anchor_pack), + draft: snapshot.draft.map(map_puzzle_result_draft), + messages: snapshot + .messages + .into_iter() + .map(map_puzzle_agent_message_snapshot) + .collect(), + last_assistant_reply: snapshot.last_assistant_reply, + published_profile_id: snapshot.published_profile_id, + suggested_actions: snapshot + .suggested_actions + .into_iter() + .map(map_puzzle_suggested_action) + .collect(), + result_preview: snapshot.result_preview.map(map_puzzle_result_preview), + updated_at: format_timestamp_micros(snapshot.updated_at_micros), + } +} + +pub(crate) fn map_puzzle_anchor_pack(snapshot: PuzzleAnchorPack) -> PuzzleAnchorPackRecord { + PuzzleAnchorPackRecord { + theme_promise: map_puzzle_anchor_item(snapshot.theme_promise), + visual_subject: map_puzzle_anchor_item(snapshot.visual_subject), + visual_mood: map_puzzle_anchor_item(snapshot.visual_mood), + composition_hooks: map_puzzle_anchor_item(snapshot.composition_hooks), + tags_and_forbidden: map_puzzle_anchor_item(snapshot.tags_and_forbidden), + } +} + +pub(crate) fn map_puzzle_anchor_item(snapshot: PuzzleAnchorItem) -> PuzzleAnchorItemRecord { + PuzzleAnchorItemRecord { + key: snapshot.key, + label: snapshot.label, + value: snapshot.value, + status: format_puzzle_anchor_status(snapshot.status).to_string(), + } +} + +pub(crate) fn map_puzzle_result_draft(snapshot: PuzzleResultDraft) -> PuzzleResultDraftRecord { + PuzzleResultDraftRecord { + work_title: snapshot.work_title, + work_description: snapshot.work_description, + level_name: snapshot.level_name, + summary: snapshot.summary, + theme_tags: snapshot.theme_tags, + forbidden_directives: snapshot.forbidden_directives, + creator_intent: snapshot.creator_intent.map(map_puzzle_creator_intent), + anchor_pack: map_puzzle_anchor_pack(snapshot.anchor_pack), + candidates: snapshot + .candidates + .into_iter() + .map(map_puzzle_generated_image_candidate) + .collect(), + selected_candidate_id: snapshot.selected_candidate_id, + cover_image_src: snapshot.cover_image_src, + cover_asset_id: snapshot.cover_asset_id, + generation_status: snapshot.generation_status, + levels: snapshot + .levels + .into_iter() + .map(map_puzzle_draft_level) + .collect(), + form_draft: snapshot.form_draft.map(map_puzzle_form_draft), + } +} + +pub(crate) fn map_puzzle_form_draft(snapshot: PuzzleFormDraft) -> PuzzleFormDraftRecord { + PuzzleFormDraftRecord { + work_title: snapshot.work_title, + work_description: snapshot.work_description, + picture_description: snapshot.picture_description, + } +} + +pub(crate) fn map_puzzle_draft_level(snapshot: PuzzleDraftLevel) -> PuzzleDraftLevelRecord { + PuzzleDraftLevelRecord { + level_id: snapshot.level_id, + level_name: snapshot.level_name, + picture_description: snapshot.picture_description, + picture_reference: snapshot.picture_reference, + ui_background_prompt: snapshot.ui_background_prompt, + ui_background_image_src: snapshot.ui_background_image_src, + ui_background_image_object_key: snapshot.ui_background_image_object_key, + background_music: snapshot.background_music.map(map_puzzle_audio_asset), + candidates: snapshot + .candidates + .into_iter() + .map(map_puzzle_generated_image_candidate) + .collect(), + selected_candidate_id: snapshot.selected_candidate_id, + cover_image_src: snapshot.cover_image_src, + cover_asset_id: snapshot.cover_asset_id, + generation_status: snapshot.generation_status, + } +} + +pub(crate) fn map_puzzle_audio_asset(asset: PuzzleAudioAsset) -> PuzzleAudioAssetRecord { + PuzzleAudioAssetRecord { + task_id: asset.task_id, + provider: asset.provider, + asset_object_id: asset.asset_object_id, + asset_kind: asset.asset_kind, + audio_src: asset.audio_src, + prompt: asset.prompt, + title: asset.title, + updated_at: asset.updated_at, + } +} + +pub(crate) fn map_puzzle_creator_intent( + snapshot: PuzzleCreatorIntent, +) -> PuzzleCreatorIntentRecord { + PuzzleCreatorIntentRecord { + source_mode: snapshot.source_mode, + raw_messages_summary: snapshot.raw_messages_summary, + theme_promise: snapshot.theme_promise, + visual_subject: snapshot.visual_subject, + visual_mood: snapshot.visual_mood, + composition_hooks: snapshot.composition_hooks, + theme_tags: snapshot.theme_tags, + forbidden_directives: snapshot.forbidden_directives, + } +} + +pub(crate) fn map_puzzle_generated_image_candidate( + snapshot: PuzzleGeneratedImageCandidate, +) -> PuzzleGeneratedImageCandidateRecord { + PuzzleGeneratedImageCandidateRecord { + candidate_id: snapshot.candidate_id, + image_src: snapshot.image_src, + asset_id: snapshot.asset_id, + prompt: snapshot.prompt, + actual_prompt: snapshot.actual_prompt, + source_type: snapshot.source_type, + selected: snapshot.selected, + } +} + +pub(crate) fn map_puzzle_agent_message_snapshot( + snapshot: PuzzleAgentMessageSnapshot, +) -> PuzzleAgentMessageRecord { + PuzzleAgentMessageRecord { + message_id: snapshot.message_id, + role: format_puzzle_agent_message_role(snapshot.role).to_string(), + kind: format_puzzle_agent_message_kind(snapshot.kind).to_string(), + text: snapshot.text, + created_at: format_timestamp_micros(snapshot.created_at_micros), + } +} + +pub(crate) fn map_puzzle_suggested_action( + snapshot: PuzzleAgentSuggestedAction, +) -> PuzzleAgentSuggestedActionRecord { + PuzzleAgentSuggestedActionRecord { + action_id: snapshot.id, + action_type: snapshot.action_type, + label: snapshot.label, + } +} + +pub(crate) fn map_puzzle_result_preview( + snapshot: PuzzleResultPreviewEnvelope, +) -> PuzzleResultPreviewRecord { + PuzzleResultPreviewRecord { + draft: map_puzzle_result_draft(snapshot.draft), + blockers: snapshot + .blockers + .into_iter() + .map(map_puzzle_result_preview_blocker) + .collect(), + quality_findings: snapshot + .quality_findings + .into_iter() + .map(map_puzzle_result_preview_finding) + .collect(), + publish_ready: snapshot.publish_ready, + } +} + +pub(crate) fn map_puzzle_result_preview_blocker( + snapshot: PuzzleResultPreviewBlocker, +) -> PuzzleResultPreviewBlockerRecord { + PuzzleResultPreviewBlockerRecord { + blocker_id: snapshot.id, + code: snapshot.code, + message: snapshot.message, + } +} + +pub(crate) fn map_puzzle_result_preview_finding( + snapshot: PuzzleResultPreviewFinding, +) -> PuzzleResultPreviewFindingRecord { + PuzzleResultPreviewFindingRecord { + finding_id: snapshot.id, + severity: snapshot.severity, + code: snapshot.code, + message: snapshot.message, + } +} + +pub(crate) fn map_puzzle_work_profile(snapshot: PuzzleWorkProfile) -> PuzzleWorkProfileRecord { + PuzzleWorkProfileRecord { + work_id: snapshot.work_id, + profile_id: snapshot.profile_id, + owner_user_id: snapshot.owner_user_id, + source_session_id: snapshot.source_session_id, + author_display_name: snapshot.author_display_name, + work_title: snapshot.work_title, + work_description: snapshot.work_description, + level_name: snapshot.level_name, + summary: snapshot.summary, + theme_tags: snapshot.theme_tags, + cover_image_src: snapshot.cover_image_src, + cover_asset_id: snapshot.cover_asset_id, + publication_status: format_puzzle_publication_status(snapshot.publication_status) + .to_string(), + updated_at: format_timestamp_micros(snapshot.updated_at_micros), + published_at: snapshot.published_at_micros.map(format_timestamp_micros), + play_count: snapshot.play_count, + remix_count: snapshot.remix_count, + like_count: snapshot.like_count, + recent_play_count_7d: snapshot.recent_play_count_7_d, + point_incentive_total_half_points: snapshot.point_incentive_total_half_points, + point_incentive_claimed_points: snapshot.point_incentive_claimed_points, + publish_ready: snapshot.publish_ready, + anchor_pack: map_puzzle_anchor_pack(snapshot.anchor_pack), + levels: snapshot + .levels + .into_iter() + .map(map_puzzle_draft_level) + .collect(), + } +} + +pub(crate) fn map_puzzle_gallery_card_view_row( + snapshot: PuzzleGalleryCardViewRow, + recent_play_count_7d: u32, +) -> PuzzleGalleryCardRecord { + PuzzleGalleryCardRecord { + work_id: snapshot.work_id, + profile_id: snapshot.profile_id, + owner_user_id: snapshot.owner_user_id, + source_session_id: snapshot.source_session_id, + author_display_name: snapshot.author_display_name, + work_title: snapshot.work_title, + work_description: snapshot.work_description, + level_name: snapshot.level_name, + summary: snapshot.summary, + theme_tags: snapshot.theme_tags, + cover_image_src: snapshot.cover_image_src, + cover_asset_id: snapshot.cover_asset_id, + publication_status: format_puzzle_publication_status(snapshot.publication_status) + .to_string(), + updated_at: format_timestamp_micros(snapshot.updated_at_micros), + published_at: snapshot.published_at_micros.map(format_timestamp_micros), + play_count: snapshot.play_count, + remix_count: snapshot.remix_count, + like_count: snapshot.like_count, + recent_play_count_7d, + point_incentive_total_half_points: snapshot.point_incentive_total_half_points, + point_incentive_claimed_points: snapshot.point_incentive_claimed_points, + publish_ready: snapshot.publish_ready, + generation_status: snapshot.generation_status, + } +} + +pub(crate) fn map_puzzle_run_snapshot(snapshot: PuzzleRunSnapshot) -> PuzzleRunRecord { + PuzzleRunRecord { + run_id: snapshot.run_id, + entry_profile_id: snapshot.entry_profile_id, + cleared_level_count: snapshot.cleared_level_count, + current_level_index: snapshot.current_level_index, + current_grid_size: snapshot.current_grid_size, + played_profile_ids: snapshot.played_profile_ids, + previous_level_tags: snapshot.previous_level_tags, + current_level: snapshot + .current_level + .map(map_puzzle_runtime_level_snapshot), + recommended_next_profile_id: snapshot.recommended_next_profile_id, + next_level_mode: snapshot.next_level_mode, + next_level_profile_id: snapshot.next_level_profile_id, + next_level_id: snapshot.next_level_id, + recommended_next_works: snapshot + .recommended_next_works + .into_iter() + .map(map_puzzle_recommended_next_work) + .collect(), + leaderboard_entries: snapshot + .leaderboard_entries + .into_iter() + .map(map_puzzle_leaderboard_entry) + .collect(), + } +} + +fn map_puzzle_recommended_next_work( + snapshot: PuzzleRecommendedNextWork, +) -> PuzzleRecommendedNextWorkRecord { + PuzzleRecommendedNextWorkRecord { + profile_id: snapshot.profile_id, + level_name: snapshot.level_name, + author_display_name: snapshot.author_display_name, + theme_tags: snapshot.theme_tags, + cover_image_src: snapshot.cover_image_src, + similarity_score: snapshot.similarity_score, + } +} + +pub(crate) fn map_puzzle_runtime_level_snapshot( + snapshot: PuzzleRuntimeLevelSnapshot, +) -> PuzzleRuntimeLevelRecord { + let started_at_ms = if snapshot.started_at_ms == 0 { + // 中文注释:运行态快照缺少可用开始时间时只补一个可用值,其余限时字段保持快照原值。 + current_unix_millis_for_legacy_puzzle_snapshot() + } else { + snapshot.started_at_ms + }; + + PuzzleRuntimeLevelRecord { + run_id: snapshot.run_id, + level_index: snapshot.level_index, + level_id: snapshot.level_id, + grid_size: snapshot.grid_size, + profile_id: snapshot.profile_id, + level_name: snapshot.level_name, + author_display_name: snapshot.author_display_name, + theme_tags: snapshot.theme_tags, + cover_image_src: snapshot.cover_image_src, + ui_background_image_src: snapshot.ui_background_image_src, + ui_background_image_object_key: snapshot.ui_background_image_object_key, + background_music: snapshot.background_music.map(map_puzzle_audio_asset), + board: map_puzzle_board_snapshot(snapshot.board), + status: format_puzzle_runtime_level_status(snapshot.status).to_string(), + started_at_ms, + cleared_at_ms: snapshot.cleared_at_ms, + elapsed_ms: snapshot.elapsed_ms, + time_limit_ms: snapshot.time_limit_ms, + remaining_ms: snapshot.remaining_ms, + paused_accumulated_ms: snapshot.paused_accumulated_ms, + pause_started_at_ms: snapshot.pause_started_at_ms, + freeze_accumulated_ms: snapshot.freeze_accumulated_ms, + freeze_started_at_ms: snapshot.freeze_started_at_ms, + freeze_until_ms: snapshot.freeze_until_ms, + leaderboard_entries: snapshot + .leaderboard_entries + .into_iter() + .map(map_puzzle_leaderboard_entry) + .collect(), + } +} + +fn current_unix_millis_for_legacy_puzzle_snapshot() -> u64 { + std::time::SystemTime::now() + .duration_since(std::time::UNIX_EPOCH) + .map(|duration| duration.as_millis().min(u128::from(u64::MAX)) as u64) + .unwrap_or(1) +} + +pub(crate) fn map_puzzle_leaderboard_entry( + snapshot: PuzzleLeaderboardEntry, +) -> PuzzleLeaderboardEntryRecord { + PuzzleLeaderboardEntryRecord { + rank: snapshot.rank, + nickname: snapshot.nickname, + elapsed_ms: snapshot.elapsed_ms, + visible_tags: snapshot.visible_tags, + is_current_player: snapshot.is_current_player, + } +} + +pub(crate) fn map_puzzle_board_snapshot(snapshot: PuzzleBoardSnapshot) -> PuzzleBoardRecord { + PuzzleBoardRecord { + rows: snapshot.rows, + cols: snapshot.cols, + pieces: snapshot + .pieces + .into_iter() + .map(map_puzzle_piece_state) + .collect(), + merged_groups: snapshot + .merged_groups + .into_iter() + .map(map_puzzle_merged_group_state) + .collect(), + selected_piece_id: snapshot.selected_piece_id, + all_tiles_resolved: snapshot.all_tiles_resolved, + } +} + +pub(crate) fn map_puzzle_piece_state(snapshot: PuzzlePieceState) -> PuzzlePieceStateRecord { + PuzzlePieceStateRecord { + piece_id: snapshot.piece_id, + correct_row: snapshot.correct_row, + correct_col: snapshot.correct_col, + current_row: snapshot.current_row, + current_col: snapshot.current_col, + merged_group_id: snapshot.merged_group_id, + } +} + +pub(crate) fn map_puzzle_merged_group_state( + snapshot: PuzzleMergedGroupState, +) -> PuzzleMergedGroupRecord { + PuzzleMergedGroupRecord { + group_id: snapshot.group_id, + piece_ids: snapshot.piece_ids, + occupied_cells: snapshot + .occupied_cells + .into_iter() + .map(map_puzzle_cell_position) + .collect(), + } +} + +pub(crate) fn map_puzzle_cell_position(snapshot: PuzzleCellPosition) -> PuzzleCellPositionRecord { + PuzzleCellPositionRecord { + row: snapshot.row, + col: snapshot.col, + } +} + +pub(crate) fn parse_puzzle_agent_stage_record( + value: &str, +) -> Result { + match value.trim() { + "collecting_anchors" => Ok(crate::module_bindings::PuzzleAgentStage::CollectingAnchors), + "draft_ready" => Ok(crate::module_bindings::PuzzleAgentStage::DraftReady), + "image_refining" => Ok(crate::module_bindings::PuzzleAgentStage::ImageRefining), + "ready_to_publish" => Ok(crate::module_bindings::PuzzleAgentStage::ReadyToPublish), + "published" => Ok(crate::module_bindings::PuzzleAgentStage::Published), + other => Err(SpacetimeClientError::Runtime(format!( + "未知 puzzle agent stage: {other}" + ))), + } +} + +pub(crate) fn format_puzzle_agent_stage(value: PuzzleAgentStage) -> &'static str { + match value { + PuzzleAgentStage::CollectingAnchors => "collecting_anchors", + PuzzleAgentStage::DraftReady => "draft_ready", + PuzzleAgentStage::ImageRefining => "image_refining", + PuzzleAgentStage::ReadyToPublish => "ready_to_publish", + PuzzleAgentStage::Published => "published", + } +} + +pub(crate) fn format_puzzle_anchor_status(value: PuzzleAnchorStatus) -> &'static str { + match value { + PuzzleAnchorStatus::Missing => "missing", + PuzzleAnchorStatus::Inferred => "inferred", + PuzzleAnchorStatus::Confirmed => "confirmed", + PuzzleAnchorStatus::Locked => "locked", + } +} + +pub(crate) fn format_puzzle_agent_message_role(value: PuzzleAgentMessageRole) -> &'static str { + match value { + PuzzleAgentMessageRole::User => "user", + PuzzleAgentMessageRole::Assistant => "assistant", + PuzzleAgentMessageRole::System => "system", + } +} + +pub(crate) fn format_puzzle_agent_message_kind(value: PuzzleAgentMessageKind) -> &'static str { + match value { + PuzzleAgentMessageKind::Chat => "chat", + PuzzleAgentMessageKind::Summary => "summary", + PuzzleAgentMessageKind::ActionResult => "action_result", + PuzzleAgentMessageKind::Warning => "warning", + } +} + +pub(crate) fn format_puzzle_publication_status(value: PuzzlePublicationStatus) -> &'static str { + match value { + PuzzlePublicationStatus::Draft => "draft", + PuzzlePublicationStatus::Published => "published", + } +} + +pub(crate) fn format_puzzle_runtime_level_status(value: PuzzleRuntimeLevelStatus) -> &'static str { + match value { + PuzzleRuntimeLevelStatus::Playing => "playing", + PuzzleRuntimeLevelStatus::Cleared => "cleared", + PuzzleRuntimeLevelStatus::Failed => "failed", + } +} + +pub(crate) fn map_runtime_profile_wallet_ledger_source_type_back( + value: crate::module_bindings::RuntimeProfileWalletLedgerSourceType, +) -> module_runtime::RuntimeProfileWalletLedgerSourceType { + match value { + crate::module_bindings::RuntimeProfileWalletLedgerSourceType::SnapshotSync => { + module_runtime::RuntimeProfileWalletLedgerSourceType::SnapshotSync + } + crate::module_bindings::RuntimeProfileWalletLedgerSourceType::NewUserRegistrationReward => { + module_runtime::RuntimeProfileWalletLedgerSourceType::NewUserRegistrationReward + } + crate::module_bindings::RuntimeProfileWalletLedgerSourceType::InviteInviterReward => { + module_runtime::RuntimeProfileWalletLedgerSourceType::InviteInviterReward + } + crate::module_bindings::RuntimeProfileWalletLedgerSourceType::InviteInviteeReward => { + module_runtime::RuntimeProfileWalletLedgerSourceType::InviteInviteeReward + } + crate::module_bindings::RuntimeProfileWalletLedgerSourceType::PointsRecharge => { + module_runtime::RuntimeProfileWalletLedgerSourceType::PointsRecharge + } + crate::module_bindings::RuntimeProfileWalletLedgerSourceType::AssetOperationConsume => { + module_runtime::RuntimeProfileWalletLedgerSourceType::AssetOperationConsume + } + crate::module_bindings::RuntimeProfileWalletLedgerSourceType::AssetOperationRefund => { + module_runtime::RuntimeProfileWalletLedgerSourceType::AssetOperationRefund + } + crate::module_bindings::RuntimeProfileWalletLedgerSourceType::RedeemCodeReward => { + module_runtime::RuntimeProfileWalletLedgerSourceType::RedeemCodeReward + } + crate::module_bindings::RuntimeProfileWalletLedgerSourceType::PuzzleAuthorIncentiveClaim => { + module_runtime::RuntimeProfileWalletLedgerSourceType::PuzzleAuthorIncentiveClaim + } + crate::module_bindings::RuntimeProfileWalletLedgerSourceType::DailyTaskReward => { + module_runtime::RuntimeProfileWalletLedgerSourceType::DailyTaskReward + } + } +} + +#[derive(Clone, Debug, PartialEq, Eq)] +pub struct PuzzleAgentSessionCreateRecordInput { + pub session_id: String, + pub owner_user_id: String, + pub seed_text: String, + pub welcome_message_id: String, + pub welcome_message_text: String, + pub created_at_micros: i64, +} + +#[derive(Clone, Debug, PartialEq, Eq)] +pub struct PuzzleFormDraftSaveRecordInput { + pub session_id: String, + pub owner_user_id: String, + pub seed_text: String, + pub saved_at_micros: i64, +} + +#[derive(Clone, Debug, PartialEq, Eq)] +pub struct PuzzleAgentMessageSubmitRecordInput { + pub session_id: String, + pub owner_user_id: String, + pub user_message_id: String, + pub user_message_text: String, + pub submitted_at_micros: i64, +} + +#[derive(Clone, Debug, PartialEq, Eq)] +pub struct PuzzleAgentMessageFinalizeRecordInput { + pub session_id: String, + pub owner_user_id: String, + pub assistant_message_id: Option, + pub assistant_reply_text: Option, + pub stage: String, + pub progress_percent: u32, + pub anchor_pack_json: String, + pub error_message: Option, + pub updated_at_micros: i64, +} + +#[derive(Clone, Debug, PartialEq, Eq)] +pub struct PuzzleGeneratedImagesSaveRecordInput { + pub session_id: String, + pub owner_user_id: String, + pub level_id: Option, + pub levels_json: Option, + pub candidates_json: String, + pub saved_at_micros: i64, +} + +#[derive(Clone, Debug, PartialEq, Eq)] +pub struct PuzzleUiBackgroundSaveRecordInput { + pub session_id: String, + pub owner_user_id: String, + pub level_id: Option, + pub levels_json: Option, + pub prompt: String, + pub image_src: String, + pub image_object_key: Option, + pub saved_at_micros: i64, +} + +#[derive(Clone, Debug, PartialEq, Eq)] +pub struct PuzzleSelectCoverImageRecordInput { + pub session_id: String, + pub owner_user_id: String, + pub level_id: Option, + pub candidate_id: String, + pub selected_at_micros: i64, +} + +#[derive(Clone, Debug, PartialEq, Eq)] +pub struct PuzzlePublishRecordInput { + pub session_id: String, + pub owner_user_id: String, + pub work_id: String, + pub profile_id: String, + pub author_display_name: String, + pub work_title: Option, + pub work_description: Option, + pub level_name: Option, + pub summary: Option, + pub theme_tags: Option>, + pub levels_json: Option, + pub published_at_micros: i64, +} + +#[derive(Clone, Debug, PartialEq, Eq)] +pub struct PuzzleWorkUpsertRecordInput { + pub profile_id: String, + pub owner_user_id: String, + pub work_title: String, + pub work_description: String, + pub level_name: String, + pub summary: String, + pub theme_tags: Vec, + pub cover_image_src: Option, + pub cover_asset_id: Option, + pub levels_json: Option, + pub updated_at_micros: i64, +} + +#[derive(Clone, Debug, PartialEq, Eq)] +pub struct PuzzleWorkRemixRecordInput { + pub source_profile_id: String, + pub target_owner_user_id: String, + pub target_session_id: String, + pub target_profile_id: String, + pub target_work_id: String, + pub author_display_name: String, + pub welcome_message_id: String, + pub remixed_at_micros: i64, +} + +#[derive(Clone, Debug, PartialEq, Eq)] +pub struct PuzzleWorkLikeReportRecordInput { + pub profile_id: String, + pub user_id: String, + pub liked_at_micros: i64, +} + +#[derive(Clone, Debug, PartialEq, Eq)] +pub struct PuzzleRunStartRecordInput { + pub run_id: String, + pub owner_user_id: String, + pub profile_id: String, + pub level_id: Option, + pub started_at_micros: i64, +} + +#[derive(Clone, Debug, PartialEq, Eq)] +pub struct PuzzleRunSwapRecordInput { + pub run_id: String, + pub owner_user_id: String, + pub first_piece_id: String, + pub second_piece_id: String, + pub swapped_at_micros: i64, +} + +#[derive(Clone, Debug, PartialEq, Eq)] +pub struct PuzzleRunDragRecordInput { + pub run_id: String, + pub owner_user_id: String, + pub piece_id: String, + pub target_row: u32, + pub target_col: u32, + pub dragged_at_micros: i64, +} + +#[derive(Clone, Debug, PartialEq, Eq)] +pub struct PuzzleRunNextLevelRecordInput { + pub run_id: String, + pub owner_user_id: String, + pub target_profile_id: Option, + pub advanced_at_micros: i64, +} + +#[derive(Clone, Debug, PartialEq, Eq)] +pub struct PuzzleRunPauseRecordInput { + pub run_id: String, + pub owner_user_id: String, + pub paused: bool, + pub updated_at_micros: i64, +} + +#[derive(Clone, Debug, PartialEq, Eq)] +pub struct PuzzleRunPropRecordInput { + pub run_id: String, + pub owner_user_id: String, + pub prop_kind: String, + pub used_at_micros: i64, + pub spent_points: u64, +} + +#[derive(Clone, Debug, PartialEq, Eq)] +pub struct PuzzleAnchorItemRecord { + pub key: String, + pub label: String, + pub value: String, + pub status: String, +} + +#[derive(Clone, Debug, PartialEq, Eq)] +pub struct PuzzleAnchorPackRecord { + pub theme_promise: PuzzleAnchorItemRecord, + pub visual_subject: PuzzleAnchorItemRecord, + pub visual_mood: PuzzleAnchorItemRecord, + pub composition_hooks: PuzzleAnchorItemRecord, + pub tags_and_forbidden: PuzzleAnchorItemRecord, +} + +#[derive(Clone, Debug, PartialEq, Eq)] +pub struct PuzzleCreatorIntentRecord { + pub source_mode: String, + pub raw_messages_summary: String, + pub theme_promise: String, + pub visual_subject: String, + pub visual_mood: Vec, + pub composition_hooks: Vec, + pub theme_tags: Vec, + pub forbidden_directives: Vec, +} + +#[derive(Clone, Debug, PartialEq, Eq)] +pub struct PuzzleGeneratedImageCandidateRecord { + pub candidate_id: String, + pub image_src: String, + pub asset_id: String, + pub prompt: String, + pub actual_prompt: Option, + pub source_type: String, + pub selected: bool, +} + +#[derive(Clone, Debug, PartialEq, Eq)] +pub struct PuzzleResultDraftRecord { + pub work_title: String, + pub work_description: String, + pub level_name: String, + pub summary: String, + pub theme_tags: Vec, + pub forbidden_directives: Vec, + pub creator_intent: Option, + pub anchor_pack: PuzzleAnchorPackRecord, + pub candidates: Vec, + pub selected_candidate_id: Option, + pub cover_image_src: Option, + pub cover_asset_id: Option, + pub generation_status: String, + pub levels: Vec, + pub form_draft: Option, +} + +#[derive(Clone, Debug, PartialEq, Eq)] +pub struct PuzzleFormDraftRecord { + pub work_title: Option, + pub work_description: Option, + pub picture_description: Option, +} + +#[derive(Clone, Debug, PartialEq, Eq)] +pub struct PuzzleDraftLevelRecord { + pub level_id: String, + pub level_name: String, + pub picture_description: String, + pub picture_reference: Option, + pub ui_background_prompt: Option, + pub ui_background_image_src: Option, + pub ui_background_image_object_key: Option, + pub background_music: Option, + pub candidates: Vec, + pub selected_candidate_id: Option, + pub cover_image_src: Option, + pub cover_asset_id: Option, + pub generation_status: String, +} + +#[derive(Clone, Debug, PartialEq, Eq)] +pub struct PuzzleAudioAssetRecord { + pub task_id: String, + pub provider: String, + pub asset_object_id: Option, + pub asset_kind: Option, + pub audio_src: String, + pub prompt: Option, + pub title: Option, + pub updated_at: Option, +} + +#[derive(Clone, Debug, PartialEq, Eq)] +pub struct PuzzleAgentMessageRecord { + pub message_id: String, + pub role: String, + pub kind: String, + pub text: String, + pub created_at: String, +} + +#[derive(Clone, Debug, PartialEq, Eq)] +pub struct PuzzleAgentSuggestedActionRecord { + pub action_id: String, + pub action_type: String, + pub label: String, +} + +#[derive(Clone, Debug, PartialEq, Eq)] +pub struct PuzzleResultPreviewBlockerRecord { + pub blocker_id: String, + pub code: String, + pub message: String, +} + +#[derive(Clone, Debug, PartialEq, Eq)] +pub struct PuzzleResultPreviewFindingRecord { + pub finding_id: String, + pub severity: String, + pub code: String, + pub message: String, +} + +#[derive(Clone, Debug, PartialEq, Eq)] +pub struct PuzzleResultPreviewRecord { + pub draft: PuzzleResultDraftRecord, + pub blockers: Vec, + pub quality_findings: Vec, + pub publish_ready: bool, +} + +#[derive(Clone, Debug, PartialEq, Eq)] +pub struct PuzzleAgentSessionRecord { + pub session_id: String, + pub seed_text: String, + pub current_turn: u32, + pub progress_percent: u32, + pub stage: String, + pub anchor_pack: PuzzleAnchorPackRecord, + pub draft: Option, + pub messages: Vec, + pub last_assistant_reply: Option, + pub published_profile_id: Option, + pub suggested_actions: Vec, + pub result_preview: Option, + pub updated_at: String, +} + +#[derive(Clone, Debug, PartialEq, Eq)] +pub struct PuzzleWorkProfileRecord { + pub work_id: String, + pub profile_id: String, + pub owner_user_id: String, + pub source_session_id: Option, + pub author_display_name: String, + pub work_title: String, + pub work_description: String, + pub level_name: String, + pub summary: String, + pub theme_tags: Vec, + pub cover_image_src: Option, + pub cover_asset_id: Option, + pub publication_status: String, + pub updated_at: String, + pub published_at: Option, + pub play_count: u32, + pub remix_count: u32, + pub like_count: u32, + pub recent_play_count_7d: u32, + pub point_incentive_total_half_points: u64, + pub point_incentive_claimed_points: u64, + pub publish_ready: bool, + pub anchor_pack: PuzzleAnchorPackRecord, + pub levels: Vec, +} + +#[derive(Clone, Debug, PartialEq, Eq)] +pub struct PuzzleGalleryCardRecord { + pub work_id: String, + pub profile_id: String, + pub owner_user_id: String, + pub source_session_id: Option, + pub author_display_name: String, + pub work_title: String, + pub work_description: String, + pub level_name: String, + pub summary: String, + pub theme_tags: Vec, + pub cover_image_src: Option, + pub cover_asset_id: Option, + pub publication_status: String, + pub updated_at: String, + pub published_at: Option, + pub play_count: u32, + pub remix_count: u32, + pub like_count: u32, + pub recent_play_count_7d: u32, + pub point_incentive_total_half_points: u64, + pub point_incentive_claimed_points: u64, + pub publish_ready: bool, + pub generation_status: Option, +} + +#[derive(Clone, Debug, PartialEq, Eq)] +pub struct PuzzleWorkPointIncentiveClaimRecordInput { + pub profile_id: String, + pub owner_user_id: String, + pub claimed_at_micros: i64, +} + +#[derive(Clone, Debug, PartialEq, Eq)] +pub struct PuzzleCellPositionRecord { + pub row: u32, + pub col: u32, +} + +#[derive(Clone, Debug, PartialEq, Eq)] +pub struct PuzzlePieceStateRecord { + pub piece_id: String, + pub correct_row: u32, + pub correct_col: u32, + pub current_row: u32, + pub current_col: u32, + pub merged_group_id: Option, +} + +#[derive(Clone, Debug, PartialEq, Eq)] +pub struct PuzzleMergedGroupRecord { + pub group_id: String, + pub piece_ids: Vec, + pub occupied_cells: Vec, +} + +#[derive(Clone, Debug, PartialEq, Eq)] +pub struct PuzzleLeaderboardEntryRecord { + pub rank: u32, + pub nickname: String, + pub elapsed_ms: u64, + pub visible_tags: Vec, + pub is_current_player: bool, +} + +#[derive(Clone, Debug, PartialEq, Eq)] +pub struct PuzzleBoardRecord { + pub rows: u32, + pub cols: u32, + pub pieces: Vec, + pub merged_groups: Vec, + pub selected_piece_id: Option, + pub all_tiles_resolved: bool, +} + +#[derive(Clone, Debug, PartialEq)] +pub struct PuzzleRecommendedNextWorkRecord { + pub profile_id: String, + pub level_name: String, + pub author_display_name: String, + pub theme_tags: Vec, + pub cover_image_src: Option, + pub similarity_score: f32, +} + +#[derive(Clone, Debug, PartialEq, Eq)] +pub struct PuzzleRuntimeLevelRecord { + pub run_id: String, + pub level_index: u32, + pub level_id: Option, + pub grid_size: u32, + pub profile_id: String, + pub level_name: String, + pub author_display_name: String, + pub theme_tags: Vec, + pub cover_image_src: Option, + pub ui_background_image_src: Option, + pub ui_background_image_object_key: Option, + pub background_music: Option, + pub board: PuzzleBoardRecord, + pub status: String, + pub started_at_ms: u64, + pub cleared_at_ms: Option, + pub elapsed_ms: Option, + pub time_limit_ms: u64, + pub remaining_ms: u64, + pub paused_accumulated_ms: u64, + pub pause_started_at_ms: Option, + pub freeze_accumulated_ms: u64, + pub freeze_started_at_ms: Option, + pub freeze_until_ms: Option, + pub leaderboard_entries: Vec, +} + +#[derive(Clone, Debug, PartialEq)] +pub struct PuzzleRunRecord { + pub run_id: String, + pub entry_profile_id: String, + pub cleared_level_count: u32, + pub current_level_index: u32, + pub current_grid_size: u32, + pub played_profile_ids: Vec, + pub previous_level_tags: Vec, + pub current_level: Option, + pub recommended_next_profile_id: Option, + pub next_level_mode: String, + pub next_level_profile_id: Option, + pub next_level_id: Option, + pub recommended_next_works: Vec, + pub leaderboard_entries: Vec, +} + +#[derive(Clone, Debug, PartialEq, Eq)] +pub struct PuzzleLeaderboardSubmitRecordInput { + pub run_id: String, + pub owner_user_id: String, + pub profile_id: String, + pub grid_size: u32, + pub elapsed_ms: u64, + pub nickname: String, + pub submitted_at_micros: i64, +} diff --git a/server-rs/crates/spacetime-client/src/mapper/runtime.rs b/server-rs/crates/spacetime-client/src/mapper/runtime.rs new file mode 100644 index 00000000..4b268707 --- /dev/null +++ b/server-rs/crates/spacetime-client/src/mapper/runtime.rs @@ -0,0 +1,440 @@ +use super::*; + +impl From for CreationEntryTypeAdminUpsertInput { + fn from(input: module_runtime::CreationEntryTypeAdminUpsertInput) -> Self { + Self { + id: input.id, + title: input.title, + subtitle: input.subtitle, + badge: input.badge, + image_src: input.image_src, + visible: input.visible, + open: input.open, + sort_order: input.sort_order, + } + } +} + +impl From for RuntimeSettingGetInput { + fn from(input: module_runtime::RuntimeSettingGetInput) -> Self { + Self { + user_id: input.user_id, + } + } +} + +impl From for RuntimeSettingUpsertInput { + fn from(input: module_runtime::RuntimeSettingUpsertInput) -> Self { + Self { + user_id: input.user_id, + music_volume: input.music_volume, + platform_theme: map_runtime_platform_theme(input.platform_theme), + updated_at_micros: input.updated_at_micros, + } + } +} + +impl From for RuntimeBrowseHistoryListInput { + fn from(input: module_runtime::RuntimeBrowseHistoryListInput) -> Self { + Self { + user_id: input.user_id, + } + } +} + +impl From for RuntimeBrowseHistoryClearInput { + fn from(input: module_runtime::RuntimeBrowseHistoryClearInput) -> Self { + Self { + user_id: input.user_id, + } + } +} + +impl From for RuntimeBrowseHistorySyncInput { + fn from(input: module_runtime::RuntimeBrowseHistorySyncInput) -> Self { + Self { + user_id: input.user_id, + entries: input.entries.into_iter().map(Into::into).collect(), + updated_at_micros: input.updated_at_micros, + } + } +} + +impl From for RuntimeBrowseHistoryWriteInput { + fn from(input: module_runtime::RuntimeBrowseHistoryWriteInput) -> Self { + Self { + owner_user_id: input.owner_user_id, + profile_id: input.profile_id, + world_name: input.world_name, + subtitle: input.subtitle, + summary_text: input.summary_text, + cover_image_src: input.cover_image_src, + theme_mode: input.theme_mode, + author_display_name: input.author_display_name, + visited_at: input.visited_at, + } + } +} + +impl From for RuntimeSnapshotGetInput { + fn from(input: module_runtime::RuntimeSnapshotGetInput) -> Self { + Self { + user_id: input.user_id, + } + } +} + +impl From for RuntimeSnapshotDeleteInput { + fn from(input: module_runtime::RuntimeSnapshotDeleteInput) -> Self { + Self { + user_id: input.user_id, + } + } +} + +pub type CreationEntryConfigRecord = + shared_contracts::creation_entry_config::CreationEntryConfigResponse; + +pub(crate) fn map_creation_entry_config_procedure_result( + result: CreationEntryConfigProcedureResult, +) -> Result { + if !result.ok { + return Err(SpacetimeClientError::procedure_failed(result.error_message)); + } + + let snapshot = result + .record + .ok_or_else(|| SpacetimeClientError::missing_snapshot("创作入口配置快照"))?; + + Ok(module_runtime::build_creation_entry_config_response( + map_creation_entry_config_snapshot(snapshot), + )) +} + +pub(crate) fn build_creation_entry_config_record_from_rows( + header: CreationEntryConfig, + mut creation_types: Vec, +) -> CreationEntryConfigRecord { + creation_types.sort_by(|left, right| { + left.sort_order + .cmp(&right.sort_order) + .then_with(|| left.id.cmp(&right.id)) + }); + + module_runtime::build_creation_entry_config_response( + module_runtime::CreationEntryConfigSnapshot { + config_id: header.config_id, + start_card: module_runtime::CreationEntryStartCardSnapshot { + title: header.start_title, + description: header.start_description, + idle_badge: header.start_idle_badge, + busy_badge: header.start_busy_badge, + }, + type_modal: module_runtime::CreationEntryTypeModalSnapshot { + title: header.modal_title, + description: header.modal_description, + }, + creation_types: creation_types + .into_iter() + .map(|item| module_runtime::CreationEntryTypeSnapshot { + id: item.id, + title: item.title, + subtitle: item.subtitle, + badge: item.badge, + image_src: item.image_src, + visible: item.visible, + open: item.open, + sort_order: item.sort_order, + updated_at_micros: item.updated_at.to_micros_since_unix_epoch(), + }) + .collect(), + updated_at_micros: header.updated_at.to_micros_since_unix_epoch(), + }, + ) +} + +fn map_creation_entry_config_snapshot( + snapshot: CreationEntryConfigSnapshot, +) -> module_runtime::CreationEntryConfigSnapshot { + module_runtime::CreationEntryConfigSnapshot { + config_id: snapshot.config_id, + start_card: module_runtime::CreationEntryStartCardSnapshot { + title: snapshot.start_card.title, + description: snapshot.start_card.description, + idle_badge: snapshot.start_card.idle_badge, + busy_badge: snapshot.start_card.busy_badge, + }, + type_modal: module_runtime::CreationEntryTypeModalSnapshot { + title: snapshot.type_modal.title, + description: snapshot.type_modal.description, + }, + creation_types: snapshot + .creation_types + .into_iter() + .map(|item| module_runtime::CreationEntryTypeSnapshot { + id: item.id, + title: item.title, + subtitle: item.subtitle, + badge: item.badge, + image_src: item.image_src, + visible: item.visible, + open: item.open, + sort_order: item.sort_order, + updated_at_micros: item.updated_at_micros, + }) + .collect(), + updated_at_micros: snapshot.updated_at_micros, + } +} + +pub(crate) fn map_runtime_setting_procedure_result( + result: RuntimeSettingProcedureResult, +) -> Result { + if !result.ok { + return Err(SpacetimeClientError::procedure_failed(result.error_message)); + } + + let snapshot = result + .record + .ok_or_else(|| SpacetimeClientError::missing_snapshot("runtime settings 快照"))?; + + Ok(build_runtime_setting_record(map_runtime_setting_snapshot( + snapshot, + ))) +} + +pub(crate) fn map_runtime_tracking_event_procedure_result( + result: RuntimeTrackingEventProcedureResult, +) -> Result<(), SpacetimeClientError> { + if !result.ok { + return Err(SpacetimeClientError::procedure_failed(result.error_message)); + } + + Ok(()) +} + +pub(crate) fn map_runtime_snapshot_procedure_result( + result: RuntimeSnapshotProcedureResult, +) -> Result, SpacetimeClientError> { + if !result.ok { + return Err(SpacetimeClientError::procedure_failed(result.error_message)); + } + + result + .record + .map(|snapshot| { + build_runtime_snapshot_record(map_runtime_snapshot_snapshot(snapshot)) + .map_err(|error| SpacetimeClientError::Runtime(error.to_string())) + }) + .transpose() +} + +pub(crate) fn map_runtime_snapshot_required_procedure_result( + result: RuntimeSnapshotProcedureResult, +) -> Result { + map_runtime_snapshot_procedure_result(result)? + .ok_or_else(|| SpacetimeClientError::missing_snapshot("runtime snapshot 快照")) +} + +pub(crate) fn map_runtime_snapshot_delete_procedure_result( + result: RuntimeSnapshotProcedureResult, +) -> Result { + map_runtime_snapshot_procedure_result(result).map(|record| record.is_some()) +} + +pub(crate) fn map_runtime_setting_snapshot( + snapshot: RuntimeSettingSnapshot, +) -> module_runtime::RuntimeSettingSnapshot { + module_runtime::RuntimeSettingSnapshot { + user_id: snapshot.user_id, + music_volume: snapshot.music_volume, + platform_theme: map_runtime_platform_theme_back(snapshot.platform_theme), + created_at_micros: snapshot.created_at_micros, + updated_at_micros: snapshot.updated_at_micros, + } +} + +pub(crate) fn map_runtime_platform_theme( + value: DomainRuntimePlatformTheme, +) -> crate::module_bindings::RuntimePlatformTheme { + match value { + DomainRuntimePlatformTheme::Light => crate::module_bindings::RuntimePlatformTheme::Light, + DomainRuntimePlatformTheme::Dark => crate::module_bindings::RuntimePlatformTheme::Dark, + } +} + +pub(crate) fn map_runtime_platform_theme_back( + value: crate::module_bindings::RuntimePlatformTheme, +) -> DomainRuntimePlatformTheme { + match value { + crate::module_bindings::RuntimePlatformTheme::Light => DomainRuntimePlatformTheme::Light, + crate::module_bindings::RuntimePlatformTheme::Dark => DomainRuntimePlatformTheme::Dark, + } +} + +pub(crate) fn map_runtime_tracking_scope_kind( + value: DomainRuntimeTrackingScopeKind, +) -> crate::module_bindings::RuntimeTrackingScopeKind { + match value { + DomainRuntimeTrackingScopeKind::Site => { + crate::module_bindings::RuntimeTrackingScopeKind::Site + } + DomainRuntimeTrackingScopeKind::Work => { + crate::module_bindings::RuntimeTrackingScopeKind::Work + } + DomainRuntimeTrackingScopeKind::Module => { + crate::module_bindings::RuntimeTrackingScopeKind::Module + } + DomainRuntimeTrackingScopeKind::User => { + crate::module_bindings::RuntimeTrackingScopeKind::User + } + } +} + +pub(crate) fn map_runtime_tracking_scope_kind_back( + value: crate::module_bindings::RuntimeTrackingScopeKind, +) -> DomainRuntimeTrackingScopeKind { + match value { + crate::module_bindings::RuntimeTrackingScopeKind::Site => { + DomainRuntimeTrackingScopeKind::Site + } + crate::module_bindings::RuntimeTrackingScopeKind::Work => { + DomainRuntimeTrackingScopeKind::Work + } + crate::module_bindings::RuntimeTrackingScopeKind::Module => { + DomainRuntimeTrackingScopeKind::Module + } + crate::module_bindings::RuntimeTrackingScopeKind::User => { + DomainRuntimeTrackingScopeKind::User + } + } +} + +pub(crate) fn parse_json_value( + value: &str, + label: &str, +) -> Result { + serde_json::from_str::(value) + .map_err(|error| SpacetimeClientError::Runtime(format!("{label} 非法: {error}"))) +} + +pub(crate) fn parse_json_array( + value: &str, + label: &str, +) -> Result, SpacetimeClientError> { + match parse_json_value(value, label)? { + serde_json::Value::Array(entries) => Ok(entries), + _ => Err(SpacetimeClientError::Runtime(format!( + "{label} 必须是 JSON array" + ))), + } +} + +pub(crate) fn parse_json_string_array( + value: &str, + label: &str, +) -> Result, SpacetimeClientError> { + parse_json_array(value, label)? + .into_iter() + .map(|entry| match entry { + serde_json::Value::String(value) => Ok(value), + _ => Err(SpacetimeClientError::Runtime(format!( + "{label} 必须是 string array" + ))), + }) + .collect() +} + +pub(crate) fn parse_supported_actions_json( + value: &str, +) -> Result, SpacetimeClientError> { + parse_json_array(value, "custom world agent supported_actions_json")? + .into_iter() + .map(|entry| { + let object = entry.as_object().ok_or_else(|| { + SpacetimeClientError::Runtime( + "custom world supported action 必须是 JSON object".to_string(), + ) + })?; + let action = object + .get("action") + .and_then(serde_json::Value::as_str) + .map(str::trim) + .filter(|value| !value.is_empty()) + .ok_or_else(|| { + SpacetimeClientError::Runtime( + "custom world supported action.action 缺失".to_string(), + ) + })?; + let enabled = object + .get("enabled") + .and_then(serde_json::Value::as_bool) + .ok_or_else(|| { + SpacetimeClientError::Runtime( + "custom world supported action.enabled 缺失".to_string(), + ) + })?; + + Ok(CustomWorldSupportedActionRecord { + action: action.to_string(), + enabled, + reason: object + .get("reason") + .and_then(serde_json::Value::as_str) + .map(str::trim) + .filter(|value| !value.is_empty()) + .map(ToOwned::to_owned), + }) + }) + .collect() +} + +#[derive(Clone, Debug, PartialEq)] +pub struct BigFishRuntimeParamsRecord { + pub level_count: u32, + pub merge_count_per_upgrade: u32, + pub spawn_target_count: u32, + pub leader_move_speed: f32, + pub follower_catch_up_speed: f32, + pub offscreen_cull_seconds: f32, + pub prey_spawn_delta_levels: Vec, + pub threat_spawn_delta_levels: Vec, + pub win_level: u32, +} + +#[derive(Clone, Debug, PartialEq)] +pub struct BigFishGameDraftRecord { + pub title: String, + pub subtitle: String, + pub core_fun: String, + pub ecology_theme: String, + pub levels: Vec, + pub background: BigFishBackgroundBlueprintRecord, + pub runtime_params: BigFishRuntimeParamsRecord, +} + +#[derive(Clone, Debug, PartialEq)] +pub struct BigFishRuntimeEntityRecord { + pub entity_id: String, + pub level: u32, + pub position: BigFishVector2Record, + pub radius: f32, + pub offscreen_seconds: f32, +} + +#[derive(Clone, Debug, PartialEq)] +pub struct BigFishRuntimeRunRecord { + pub run_id: String, + pub session_id: String, + pub status: String, + pub tick: u64, + pub player_level: u32, + pub win_level: u32, + pub leader_entity_id: Option, + pub owned_entities: Vec, + pub wild_entities: Vec, + pub camera_center: BigFishVector2Record, + pub last_input: BigFishVector2Record, + pub event_log: Vec, + pub updated_at: String, +} diff --git a/server-rs/crates/spacetime-client/src/mapper/runtime_profile.rs b/server-rs/crates/spacetime-client/src/mapper/runtime_profile.rs new file mode 100644 index 00000000..3a94396b --- /dev/null +++ b/server-rs/crates/spacetime-client/src/mapper/runtime_profile.rs @@ -0,0 +1,1326 @@ +use super::*; + +impl From for RuntimeProfileDashboardGetInput { + fn from(input: module_runtime::RuntimeProfileDashboardGetInput) -> Self { + Self { + user_id: input.user_id, + } + } +} + +impl From + for RuntimeProfileWalletLedgerListInput +{ + fn from(input: module_runtime::RuntimeProfileWalletLedgerListInput) -> Self { + Self { + user_id: input.user_id, + } + } +} + +impl From + for RuntimeProfileWalletAdjustmentInput +{ + fn from(input: module_runtime::RuntimeProfileWalletAdjustmentInput) -> Self { + Self { + user_id: input.user_id, + amount: input.amount, + ledger_id: input.ledger_id, + created_at_micros: input.created_at_micros, + } + } +} + +impl From + for RuntimeProfileRechargeCenterGetInput +{ + fn from(input: module_runtime::RuntimeProfileRechargeCenterGetInput) -> Self { + Self { + user_id: input.user_id, + } + } +} + +impl From + for RuntimeProfileRechargeOrderGetInput +{ + fn from(input: module_runtime::RuntimeProfileRechargeOrderGetInput) -> Self { + Self { + order_id: input.order_id, + } + } +} + +impl From + for RuntimeProfileRechargeOrderCreateInput +{ + fn from(input: module_runtime::RuntimeProfileRechargeOrderCreateInput) -> Self { + Self { + user_id: input.user_id, + product_id: input.product_id, + payment_channel: input.payment_channel, + created_at_micros: input.created_at_micros, + } + } +} + +impl From + for RuntimeProfileRechargeOrderPaidInput +{ + fn from(input: module_runtime::RuntimeProfileRechargeOrderPaidInput) -> Self { + Self { + order_id: input.order_id, + paid_at_micros: input.paid_at_micros, + provider_transaction_id: input.provider_transaction_id, + } + } +} + +impl From + for RuntimeProfileFeedbackSubmissionInput +{ + fn from(input: module_runtime::RuntimeProfileFeedbackSubmissionInput) -> Self { + Self { + user_id: input.user_id, + description: input.description, + contact_phone: input.contact_phone, + evidence_items: input.evidence_items.into_iter().map(Into::into).collect(), + created_at_micros: input.created_at_micros, + } + } +} + +impl From + for RuntimeProfileFeedbackEvidenceSnapshot +{ + fn from(input: module_runtime::RuntimeProfileFeedbackEvidenceSnapshot) -> Self { + Self { + evidence_id: input.evidence_id, + file_name: input.file_name, + content_type: input.content_type, + size_bytes: input.size_bytes, + data_url: input.data_url, + } + } +} + +impl From + for RuntimeProfileRewardCodeRedeemInput +{ + fn from(input: module_runtime::RuntimeProfileRewardCodeRedeemInput) -> Self { + Self { + user_id: input.user_id, + code: input.code, + redeemed_at_micros: input.redeemed_at_micros, + } + } +} + +impl From for RuntimeProfileTaskCenterGetInput { + fn from(input: module_runtime::RuntimeProfileTaskCenterGetInput) -> Self { + Self { + user_id: input.user_id, + } + } +} + +impl From for AnalyticsMetricQueryInput { + fn from(input: module_runtime::AnalyticsMetricQueryInput) -> Self { + Self { + event_key: input.event_key, + scope_kind: map_runtime_tracking_scope_kind(input.scope_kind), + scope_id: input.scope_id, + granularity: map_analytics_granularity(input.granularity), + } + } +} + +impl From for RuntimeProfileTaskClaimInput { + fn from(input: module_runtime::RuntimeProfileTaskClaimInput) -> Self { + Self { + user_id: input.user_id, + task_id: input.task_id, + } + } +} + +impl From + for RuntimeProfileTaskConfigAdminListInput +{ + fn from(input: module_runtime::RuntimeProfileTaskConfigAdminListInput) -> Self { + Self { + admin_user_id: input.admin_user_id, + } + } +} + +impl From + for RuntimeProfileTaskConfigAdminUpsertInput +{ + fn from(input: module_runtime::RuntimeProfileTaskConfigAdminUpsertInput) -> Self { + Self { + admin_user_id: input.admin_user_id, + task_id: input.task_id, + title: input.title, + description: input.description, + event_key: input.event_key, + cycle: map_runtime_profile_task_cycle(input.cycle), + scope_kind: map_runtime_tracking_scope_kind(input.scope_kind), + threshold: input.threshold, + reward_points: input.reward_points, + enabled: input.enabled, + sort_order: input.sort_order, + updated_at_micros: input.updated_at_micros, + } + } +} + +impl From + for RuntimeProfileTaskConfigAdminDisableInput +{ + fn from(input: module_runtime::RuntimeProfileTaskConfigAdminDisableInput) -> Self { + Self { + admin_user_id: input.admin_user_id, + task_id: input.task_id, + updated_at_micros: input.updated_at_micros, + } + } +} + +impl From + for RuntimeProfileRechargeProductAdminListInput +{ + fn from(input: module_runtime::RuntimeProfileRechargeProductAdminListInput) -> Self { + Self { + admin_user_id: input.admin_user_id, + } + } +} + +impl From + for RuntimeProfileRechargeProductAdminUpsertInput +{ + fn from(input: module_runtime::RuntimeProfileRechargeProductAdminUpsertInput) -> Self { + Self { + admin_user_id: input.admin_user_id, + product_id: input.product_id, + title: input.title, + price_cents: input.price_cents, + kind: map_runtime_profile_recharge_product_kind(input.kind), + points_amount: input.points_amount, + bonus_points: input.bonus_points, + duration_days: input.duration_days, + badge_label: input.badge_label, + description: input.description, + tier: map_runtime_profile_membership_tier(input.tier), + enabled: input.enabled, + sort_order: input.sort_order, + updated_at_micros: input.updated_at_micros, + } + } +} + +impl From + for RuntimeProfileRedeemCodeAdminUpsertInput +{ + fn from(input: module_runtime::RuntimeProfileRedeemCodeAdminUpsertInput) -> Self { + Self { + admin_user_id: input.admin_user_id, + code: input.code, + mode: map_runtime_profile_redeem_code_mode(input.mode), + reward_points: input.reward_points, + max_uses: input.max_uses, + enabled: input.enabled, + allowed_user_ids: input.allowed_user_ids, + allowed_public_user_codes: input.allowed_public_user_codes, + updated_at_micros: input.updated_at_micros, + } + } +} + +impl From + for RuntimeProfileRedeemCodeAdminDisableInput +{ + fn from(input: module_runtime::RuntimeProfileRedeemCodeAdminDisableInput) -> Self { + Self { + admin_user_id: input.admin_user_id, + code: input.code, + updated_at_micros: input.updated_at_micros, + } + } +} + +impl From + for RuntimeProfileRedeemCodeAdminListInput +{ + fn from(input: module_runtime::RuntimeProfileRedeemCodeAdminListInput) -> Self { + Self { + admin_user_id: input.admin_user_id, + } + } +} + +impl From + for RuntimeProfileInviteCodeAdminUpsertInput +{ + fn from(input: module_runtime::RuntimeProfileInviteCodeAdminUpsertInput) -> Self { + Self { + admin_user_id: input.admin_user_id, + invite_code: input.invite_code, + metadata_json: input.metadata_json, + starts_at_micros: input.starts_at_micros, + expires_at_micros: input.expires_at_micros, + updated_at_micros: input.updated_at_micros, + } + } +} + +impl From + for RuntimeProfileInviteCodeAdminListInput +{ + fn from(input: module_runtime::RuntimeProfileInviteCodeAdminListInput) -> Self { + Self { + admin_user_id: input.admin_user_id, + } + } +} + +impl From + for RuntimeReferralInviteCenterGetInput +{ + fn from(input: module_runtime::RuntimeReferralInviteCenterGetInput) -> Self { + Self { + user_id: input.user_id, + } + } +} + +impl From for RuntimeReferralRedeemInput { + fn from(input: module_runtime::RuntimeReferralRedeemInput) -> Self { + Self { + user_id: input.user_id, + invite_code: input.invite_code, + updated_at_micros: input.updated_at_micros, + } + } +} + +impl From for RuntimeProfilePlayStatsGetInput { + fn from(input: module_runtime::RuntimeProfilePlayStatsGetInput) -> Self { + Self { + user_id: input.user_id, + } + } +} + +impl From + for RuntimeProfileSaveArchiveListInput +{ + fn from(input: module_runtime::RuntimeProfileSaveArchiveListInput) -> Self { + Self { + user_id: input.user_id, + } + } +} + +impl From + for RuntimeProfileSaveArchiveResumeInput +{ + fn from(input: module_runtime::RuntimeProfileSaveArchiveResumeInput) -> Self { + Self { + user_id: input.user_id, + world_key: input.world_key, + } + } +} + +pub(crate) fn map_runtime_profile_dashboard_procedure_result( + result: RuntimeProfileDashboardProcedureResult, +) -> Result { + if !result.ok { + return Err(SpacetimeClientError::procedure_failed(result.error_message)); + } + + let snapshot = result + .record + .ok_or_else(|| SpacetimeClientError::missing_snapshot("profile dashboard 快照"))?; + + Ok(build_runtime_profile_dashboard_record( + map_runtime_profile_dashboard_snapshot(snapshot), + )) +} + +pub(crate) fn map_runtime_profile_wallet_ledger_procedure_result( + result: RuntimeProfileWalletLedgerProcedureResult, +) -> Result, SpacetimeClientError> { + if !result.ok { + return Err(SpacetimeClientError::procedure_failed(result.error_message)); + } + + Ok(result + .entries + .into_iter() + .map(|snapshot| { + build_runtime_profile_wallet_ledger_entry_record( + map_runtime_profile_wallet_ledger_entry_snapshot(snapshot), + ) + }) + .collect()) +} + +pub(crate) fn map_runtime_profile_wallet_adjustment_procedure_result( + result: RuntimeProfileWalletAdjustmentProcedureResult, +) -> Result { + if !result.ok { + return Err(SpacetimeClientError::procedure_failed(result.error_message)); + } + + let snapshot = result + .record + .ok_or_else(|| SpacetimeClientError::missing_snapshot("profile dashboard 快照"))?; + + Ok(build_runtime_profile_dashboard_record( + map_runtime_profile_dashboard_snapshot(snapshot), + )) +} + +pub(crate) fn map_runtime_profile_recharge_center_procedure_result( + result: RuntimeProfileRechargeCenterProcedureResult, +) -> Result { + if !result.ok { + return Err(SpacetimeClientError::procedure_failed(result.error_message)); + } + + let snapshot = result + .record + .ok_or_else(|| SpacetimeClientError::missing_snapshot("profile recharge center 快照"))?; + + Ok(build_runtime_profile_recharge_center_record( + map_runtime_profile_recharge_center_snapshot(snapshot), + )) +} + +pub(crate) fn map_runtime_profile_recharge_order_procedure_result( + result: RuntimeProfileRechargeCenterProcedureResult, +) -> Result< + ( + RuntimeProfileRechargeCenterRecord, + RuntimeProfileRechargeOrderRecord, + ), + SpacetimeClientError, +> { + if !result.ok { + return Err(SpacetimeClientError::procedure_failed(result.error_message)); + } + + let center = result + .record + .ok_or_else(|| SpacetimeClientError::missing_snapshot("profile recharge center 快照"))?; + let order = result + .order + .ok_or_else(|| SpacetimeClientError::missing_snapshot("profile recharge order 快照"))?; + + Ok(( + build_runtime_profile_recharge_center_record(map_runtime_profile_recharge_center_snapshot( + center, + )), + module_runtime::build_runtime_profile_recharge_order_record( + map_runtime_profile_recharge_order_snapshot(order), + ), + )) +} + +pub(crate) fn map_runtime_profile_feedback_submission_procedure_result( + result: RuntimeProfileFeedbackSubmissionProcedureResult, +) -> Result { + if !result.ok { + return Err(SpacetimeClientError::procedure_failed(result.error_message)); + } + + let snapshot = result + .record + .ok_or_else(|| SpacetimeClientError::missing_snapshot("profile feedback 快照"))?; + + build_runtime_profile_feedback_submission_record( + map_runtime_profile_feedback_submission_snapshot(snapshot), + ) + .map_err(SpacetimeClientError::validation_failed) +} + +pub(crate) fn map_runtime_referral_invite_center_procedure_result( + result: RuntimeReferralInviteCenterProcedureResult, +) -> Result { + if !result.ok { + return Err(SpacetimeClientError::procedure_failed(result.error_message)); + } + + let snapshot = result + .record + .ok_or_else(|| SpacetimeClientError::missing_snapshot("referral invite center 快照"))?; + + Ok(build_runtime_referral_invite_center_record( + map_runtime_referral_invite_center_snapshot(snapshot), + )) +} + +pub(crate) fn map_runtime_referral_redeem_procedure_result( + result: RuntimeReferralRedeemProcedureResult, +) -> Result { + if !result.ok { + return Err(SpacetimeClientError::procedure_failed(result.error_message)); + } + + let snapshot = result + .record + .ok_or_else(|| SpacetimeClientError::missing_snapshot("referral redeem 快照"))?; + + Ok(build_runtime_referral_redeem_record( + map_runtime_referral_redeem_snapshot(snapshot), + )) +} + +pub(crate) fn map_runtime_profile_reward_code_redeem_procedure_result( + result: RuntimeProfileRewardCodeRedeemProcedureResult, +) -> Result { + if !result.ok { + return Err(SpacetimeClientError::procedure_failed(result.error_message)); + } + + let snapshot = result + .record + .ok_or_else(|| SpacetimeClientError::missing_snapshot("reward redeem 快照"))?; + + Ok(build_runtime_profile_reward_code_redeem_record( + map_runtime_profile_reward_code_redeem_snapshot(snapshot), + )) +} + +pub(crate) fn map_runtime_profile_task_center_procedure_result( + result: RuntimeProfileTaskCenterProcedureResult, +) -> Result { + if !result.ok { + return Err(SpacetimeClientError::procedure_failed(result.error_message)); + } + + let snapshot = result + .record + .ok_or_else(|| SpacetimeClientError::missing_snapshot("profile task center 快照"))?; + + Ok(build_runtime_profile_task_center_record( + map_runtime_profile_task_center_snapshot(snapshot), + )) +} + +pub(crate) fn map_analytics_metric_query_procedure_result( + result: AnalyticsMetricQueryProcedureResult, +) -> Result { + if !result.ok { + return Err(SpacetimeClientError::procedure_failed(result.error_message)); + } + + Ok(DomainAnalyticsMetricQueryResponse { + buckets: result + .buckets + .into_iter() + .map(map_analytics_bucket_metric) + .collect(), + }) +} + +pub(crate) fn map_runtime_profile_task_claim_procedure_result( + result: RuntimeProfileTaskClaimProcedureResult, +) -> Result { + if !result.ok { + return Err(SpacetimeClientError::procedure_failed(result.error_message)); + } + + let snapshot = result + .record + .ok_or_else(|| SpacetimeClientError::missing_snapshot("profile task claim 快照"))?; + + Ok(build_runtime_profile_task_claim_record( + map_runtime_profile_task_claim_snapshot(snapshot), + )) +} + +pub(crate) fn map_runtime_profile_task_config_admin_list_procedure_result( + result: RuntimeProfileTaskConfigAdminListProcedureResult, +) -> Result, SpacetimeClientError> { + if !result.ok { + return Err(SpacetimeClientError::procedure_failed(result.error_message)); + } + + Ok(result + .entries + .into_iter() + .map(|snapshot| { + build_runtime_profile_task_config_record(map_runtime_profile_task_config_snapshot( + snapshot, + )) + }) + .collect()) +} + +pub(crate) fn map_runtime_profile_task_config_admin_procedure_result( + result: RuntimeProfileTaskConfigAdminProcedureResult, +) -> Result { + if !result.ok { + return Err(SpacetimeClientError::procedure_failed(result.error_message)); + } + + let snapshot = result + .record + .ok_or_else(|| SpacetimeClientError::missing_snapshot("profile task config 快照"))?; + + Ok(build_runtime_profile_task_config_record( + map_runtime_profile_task_config_snapshot(snapshot), + )) +} + +pub(crate) fn map_runtime_profile_recharge_product_admin_list_procedure_result( + result: RuntimeProfileRechargeProductAdminListProcedureResult, +) -> Result, SpacetimeClientError> { + if !result.ok { + return Err(SpacetimeClientError::procedure_failed(result.error_message)); + } + + Ok(result + .entries + .into_iter() + .map(|snapshot| { + build_runtime_profile_recharge_product_config_record( + map_runtime_profile_recharge_product_config_snapshot(snapshot), + ) + }) + .collect()) +} + +pub(crate) fn map_runtime_profile_recharge_product_admin_procedure_result( + result: RuntimeProfileRechargeProductAdminProcedureResult, +) -> Result { + if !result.ok { + return Err(SpacetimeClientError::procedure_failed(result.error_message)); + } + + let snapshot = result + .record + .ok_or_else(|| SpacetimeClientError::missing_snapshot("recharge product config 快照"))?; + + Ok(build_runtime_profile_recharge_product_config_record( + map_runtime_profile_recharge_product_config_snapshot(snapshot), + )) +} + +pub(crate) fn map_runtime_profile_redeem_code_admin_procedure_result( + result: RuntimeProfileRedeemCodeAdminProcedureResult, +) -> Result { + if !result.ok { + return Err(SpacetimeClientError::procedure_failed(result.error_message)); + } + + let snapshot = result + .record + .ok_or_else(|| SpacetimeClientError::missing_snapshot("redeem code 快照"))?; + + Ok(build_runtime_profile_redeem_code_record( + map_runtime_profile_redeem_code_snapshot(snapshot), + )) +} + +pub(crate) fn map_runtime_profile_redeem_code_admin_list_procedure_result( + result: RuntimeProfileRedeemCodeAdminListProcedureResult, +) -> Result, SpacetimeClientError> { + if !result.ok { + return Err(SpacetimeClientError::procedure_failed(result.error_message)); + } + + Ok(result + .entries + .into_iter() + .map(|snapshot| { + build_runtime_profile_redeem_code_record(map_runtime_profile_redeem_code_snapshot( + snapshot, + )) + }) + .collect()) +} + +pub(crate) fn map_runtime_profile_invite_code_admin_procedure_result( + result: RuntimeProfileInviteCodeAdminProcedureResult, +) -> Result { + if !result.ok { + return Err(SpacetimeClientError::Procedure( + result + .error_message + .unwrap_or_else(|| "SpacetimeDB procedure 返回未知错误".to_string()), + )); + } + + let snapshot = result.record.ok_or_else(|| { + SpacetimeClientError::Procedure("SpacetimeDB procedure 未返回 invite code 快照".to_string()) + })?; + + Ok(build_runtime_profile_invite_code_record( + map_runtime_profile_invite_code_snapshot(snapshot), + )) +} + +pub(crate) fn map_runtime_profile_invite_code_admin_list_procedure_result( + result: RuntimeProfileInviteCodeAdminListProcedureResult, +) -> Result, SpacetimeClientError> { + if !result.ok { + return Err(SpacetimeClientError::procedure_failed(result.error_message)); + } + + Ok(result + .entries + .into_iter() + .map(|snapshot| { + build_runtime_profile_invite_code_record(map_runtime_profile_invite_code_snapshot( + snapshot, + )) + }) + .collect()) +} + +pub(crate) fn map_runtime_profile_play_stats_procedure_result( + result: RuntimeProfilePlayStatsProcedureResult, +) -> Result { + if !result.ok { + return Err(SpacetimeClientError::procedure_failed(result.error_message)); + } + + let snapshot = result + .record + .ok_or_else(|| SpacetimeClientError::missing_snapshot("profile play stats 快照"))?; + + Ok(build_runtime_profile_play_stats_record( + map_runtime_profile_play_stats_snapshot(snapshot), + )) +} + +pub(crate) fn map_runtime_profile_save_archive_list_procedure_result( + result: RuntimeProfileSaveArchiveProcedureResult, +) -> Result, SpacetimeClientError> { + if !result.ok { + return Err(SpacetimeClientError::procedure_failed(result.error_message)); + } + + result + .entries + .into_iter() + .map(|snapshot| { + build_runtime_profile_save_archive_record(map_runtime_profile_save_archive_snapshot( + snapshot, + )) + .map_err(|error| SpacetimeClientError::Runtime(error.to_string())) + }) + .collect() +} + +pub(crate) fn map_runtime_profile_save_archive_resume_procedure_result( + result: RuntimeProfileSaveArchiveProcedureResult, +) -> Result<(RuntimeProfileSaveArchiveRecord, RuntimeSnapshotRecord), SpacetimeClientError> { + if !result.ok { + return Err(SpacetimeClientError::procedure_failed(result.error_message)); + } + + let archive = result + .record + .ok_or_else(|| SpacetimeClientError::missing_snapshot("save archive 快照"))?; + let snapshot = result + .current_snapshot + .ok_or_else(|| SpacetimeClientError::missing_snapshot("恢复后的 runtime snapshot"))?; + + Ok(( + build_runtime_profile_save_archive_record(map_runtime_profile_save_archive_snapshot( + archive, + )) + .map_err(|error| SpacetimeClientError::Runtime(error.to_string()))?, + build_runtime_snapshot_record(map_runtime_snapshot_snapshot(snapshot)) + .map_err(|error| SpacetimeClientError::Runtime(error.to_string()))?, + )) +} + +pub(crate) fn map_runtime_profile_dashboard_snapshot( + snapshot: RuntimeProfileDashboardSnapshot, +) -> module_runtime::RuntimeProfileDashboardSnapshot { + module_runtime::RuntimeProfileDashboardSnapshot { + user_id: snapshot.user_id, + wallet_balance: snapshot.wallet_balance, + total_play_time_ms: snapshot.total_play_time_ms, + played_world_count: snapshot.played_world_count, + updated_at_micros: snapshot.updated_at_micros, + } +} + +pub(crate) fn map_analytics_bucket_metric( + bucket: AnalyticsBucketMetric, +) -> module_runtime::AnalyticsBucketMetric { + module_runtime::AnalyticsBucketMetric { + bucket_key: bucket.bucket_key, + bucket_start_date_key: bucket.bucket_start_date_key, + bucket_end_date_key: bucket.bucket_end_date_key, + value: bucket.value, + } +} + +pub(crate) fn map_runtime_profile_wallet_ledger_entry_snapshot( + snapshot: RuntimeProfileWalletLedgerEntrySnapshot, +) -> module_runtime::RuntimeProfileWalletLedgerEntrySnapshot { + module_runtime::RuntimeProfileWalletLedgerEntrySnapshot { + wallet_ledger_id: snapshot.wallet_ledger_id, + user_id: snapshot.user_id, + amount_delta: snapshot.amount_delta, + balance_after: snapshot.balance_after, + source_type: map_runtime_profile_wallet_ledger_source_type_back(snapshot.source_type), + created_at_micros: snapshot.created_at_micros, + } +} + +pub(crate) fn map_runtime_profile_recharge_center_snapshot( + snapshot: RuntimeProfileRechargeCenterSnapshot, +) -> module_runtime::RuntimeProfileRechargeCenterSnapshot { + module_runtime::RuntimeProfileRechargeCenterSnapshot { + user_id: snapshot.user_id, + wallet_balance: snapshot.wallet_balance, + membership: map_runtime_profile_membership_snapshot(snapshot.membership), + point_products: snapshot + .point_products + .into_iter() + .map(map_runtime_profile_recharge_product_snapshot) + .collect(), + membership_products: snapshot + .membership_products + .into_iter() + .map(map_runtime_profile_recharge_product_snapshot) + .collect(), + benefits: snapshot + .benefits + .into_iter() + .map(map_runtime_profile_membership_benefit_snapshot) + .collect(), + latest_order: snapshot + .latest_order + .map(map_runtime_profile_recharge_order_snapshot), + has_points_recharged: snapshot.has_points_recharged, + } +} + +pub(crate) fn map_runtime_profile_recharge_product_snapshot( + snapshot: RuntimeProfileRechargeProductSnapshot, +) -> module_runtime::RuntimeProfileRechargeProductSnapshot { + module_runtime::RuntimeProfileRechargeProductSnapshot { + product_id: snapshot.product_id, + title: snapshot.title, + price_cents: snapshot.price_cents, + kind: map_runtime_profile_recharge_product_kind_back(snapshot.kind), + points_amount: snapshot.points_amount, + bonus_points: snapshot.bonus_points, + duration_days: snapshot.duration_days, + badge_label: snapshot.badge_label, + description: snapshot.description, + tier: map_runtime_profile_membership_tier_back(snapshot.tier), + } +} + +pub(crate) fn map_runtime_profile_recharge_product_config_snapshot( + snapshot: RuntimeProfileRechargeProductConfigSnapshot, +) -> module_runtime::RuntimeProfileRechargeProductConfigSnapshot { + module_runtime::RuntimeProfileRechargeProductConfigSnapshot { + product_id: snapshot.product_id, + title: snapshot.title, + price_cents: snapshot.price_cents, + kind: map_runtime_profile_recharge_product_kind_back(snapshot.kind), + points_amount: snapshot.points_amount, + bonus_points: snapshot.bonus_points, + duration_days: snapshot.duration_days, + badge_label: snapshot.badge_label, + description: snapshot.description, + tier: map_runtime_profile_membership_tier_back(snapshot.tier), + enabled: snapshot.enabled, + sort_order: snapshot.sort_order, + created_by: snapshot.created_by, + created_at_micros: snapshot.created_at_micros, + updated_by: snapshot.updated_by, + updated_at_micros: snapshot.updated_at_micros, + } +} + +pub(crate) fn map_runtime_profile_membership_benefit_snapshot( + snapshot: RuntimeProfileMembershipBenefitSnapshot, +) -> module_runtime::RuntimeProfileMembershipBenefitSnapshot { + module_runtime::RuntimeProfileMembershipBenefitSnapshot { + benefit_name: snapshot.benefit_name, + normal_value: snapshot.normal_value, + month_value: snapshot.month_value, + season_value: snapshot.season_value, + year_value: snapshot.year_value, + } +} + +pub(crate) fn map_runtime_profile_membership_snapshot( + snapshot: RuntimeProfileMembershipSnapshot, +) -> module_runtime::RuntimeProfileMembershipSnapshot { + module_runtime::RuntimeProfileMembershipSnapshot { + user_id: snapshot.user_id, + status: map_runtime_profile_membership_status_back(snapshot.status), + tier: map_runtime_profile_membership_tier_back(snapshot.tier), + started_at_micros: snapshot.started_at_micros, + expires_at_micros: snapshot.expires_at_micros, + updated_at_micros: snapshot.updated_at_micros, + } +} + +pub(crate) fn map_runtime_profile_recharge_order_snapshot( + snapshot: RuntimeProfileRechargeOrderSnapshot, +) -> module_runtime::RuntimeProfileRechargeOrderSnapshot { + module_runtime::RuntimeProfileRechargeOrderSnapshot { + order_id: snapshot.order_id, + user_id: snapshot.user_id, + product_id: snapshot.product_id, + product_title: snapshot.product_title, + kind: map_runtime_profile_recharge_product_kind_back(snapshot.kind), + amount_cents: snapshot.amount_cents, + status: map_runtime_profile_recharge_order_status_back(snapshot.status), + payment_channel: snapshot.payment_channel, + paid_at_micros: snapshot.paid_at_micros, + provider_transaction_id: snapshot.provider_transaction_id, + created_at_micros: snapshot.created_at_micros, + points_delta: snapshot.points_delta, + membership_expires_at_micros: snapshot.membership_expires_at_micros, + } +} + +pub(crate) fn map_runtime_profile_feedback_submission_snapshot( + snapshot: RuntimeProfileFeedbackSubmissionSnapshot, +) -> module_runtime::RuntimeProfileFeedbackSubmissionSnapshot { + module_runtime::RuntimeProfileFeedbackSubmissionSnapshot { + feedback_id: snapshot.feedback_id, + user_id: snapshot.user_id, + description: snapshot.description, + contact_phone: snapshot.contact_phone, + evidence_json: snapshot.evidence_json, + status: map_runtime_profile_feedback_status_back(snapshot.status), + created_at_micros: snapshot.created_at_micros, + updated_at_micros: snapshot.updated_at_micros, + } +} + +pub(crate) fn map_runtime_referral_invite_center_snapshot( + snapshot: RuntimeReferralInviteCenterSnapshot, +) -> module_runtime::RuntimeReferralInviteCenterSnapshot { + module_runtime::RuntimeReferralInviteCenterSnapshot { + user_id: snapshot.user_id, + invite_code: snapshot.invite_code, + invite_link_path: snapshot.invite_link_path, + invited_count: snapshot.invited_count, + rewarded_invite_count: snapshot.rewarded_invite_count, + today_inviter_reward_count: snapshot.today_inviter_reward_count, + today_inviter_reward_remaining: snapshot.today_inviter_reward_remaining, + reward_points: snapshot.reward_points, + invited_users: snapshot + .invited_users + .into_iter() + .map(|user| module_runtime::RuntimeReferralInvitedUserSnapshot { + user_id: user.user_id, + display_name: user.display_name, + avatar_url: user.avatar_url, + bound_at_micros: user.bound_at_micros, + }) + .collect(), + has_redeemed_code: snapshot.has_redeemed_code, + bound_inviter_user_id: snapshot.bound_inviter_user_id, + bound_at_micros: snapshot.bound_at_micros, + updated_at_micros: snapshot.updated_at_micros, + } +} + +pub(crate) fn map_runtime_referral_redeem_snapshot( + snapshot: RuntimeReferralRedeemSnapshot, +) -> module_runtime::RuntimeReferralRedeemSnapshot { + module_runtime::RuntimeReferralRedeemSnapshot { + center: map_runtime_referral_invite_center_snapshot(snapshot.center), + invitee_reward_granted: snapshot.invitee_reward_granted, + inviter_reward_granted: snapshot.inviter_reward_granted, + invitee_balance_after: snapshot.invitee_balance_after, + inviter_balance_after: snapshot.inviter_balance_after, + } +} + +pub(crate) fn map_runtime_profile_reward_code_redeem_snapshot( + snapshot: RuntimeProfileRewardCodeRedeemSnapshot, +) -> module_runtime::RuntimeProfileRewardCodeRedeemSnapshot { + module_runtime::RuntimeProfileRewardCodeRedeemSnapshot { + wallet_balance: snapshot.wallet_balance, + amount_granted: snapshot.amount_granted, + ledger_entry: map_runtime_profile_wallet_ledger_entry_snapshot(snapshot.ledger_entry), + } +} + +pub(crate) fn map_runtime_profile_task_config_snapshot( + snapshot: RuntimeProfileTaskConfigSnapshot, +) -> module_runtime::RuntimeProfileTaskConfigSnapshot { + module_runtime::RuntimeProfileTaskConfigSnapshot { + task_id: snapshot.task_id, + title: snapshot.title, + description: snapshot.description, + event_key: snapshot.event_key, + cycle: map_runtime_profile_task_cycle_back(snapshot.cycle), + scope_kind: map_runtime_tracking_scope_kind_back(snapshot.scope_kind), + threshold: snapshot.threshold, + reward_points: snapshot.reward_points, + enabled: snapshot.enabled, + sort_order: snapshot.sort_order, + created_by: snapshot.created_by, + created_at_micros: snapshot.created_at_micros, + updated_by: snapshot.updated_by, + updated_at_micros: snapshot.updated_at_micros, + } +} + +pub(crate) fn map_runtime_profile_task_item_snapshot( + snapshot: RuntimeProfileTaskItemSnapshot, +) -> module_runtime::RuntimeProfileTaskItemSnapshot { + module_runtime::RuntimeProfileTaskItemSnapshot { + task_id: snapshot.task_id, + title: snapshot.title, + description: snapshot.description, + event_key: snapshot.event_key, + cycle: map_runtime_profile_task_cycle_back(snapshot.cycle), + threshold: snapshot.threshold, + progress_count: snapshot.progress_count, + reward_points: snapshot.reward_points, + status: map_runtime_profile_task_status_back(snapshot.status), + day_key: snapshot.day_key, + claimed_at_micros: snapshot.claimed_at_micros, + updated_at_micros: snapshot.updated_at_micros, + } +} + +pub(crate) fn map_runtime_profile_task_center_snapshot( + snapshot: RuntimeProfileTaskCenterSnapshot, +) -> module_runtime::RuntimeProfileTaskCenterSnapshot { + module_runtime::RuntimeProfileTaskCenterSnapshot { + user_id: snapshot.user_id, + day_key: snapshot.day_key, + wallet_balance: snapshot.wallet_balance, + tasks: snapshot + .tasks + .into_iter() + .map(map_runtime_profile_task_item_snapshot) + .collect(), + updated_at_micros: snapshot.updated_at_micros, + } +} + +pub(crate) fn map_runtime_profile_task_claim_snapshot( + snapshot: RuntimeProfileTaskClaimSnapshot, +) -> module_runtime::RuntimeProfileTaskClaimSnapshot { + module_runtime::RuntimeProfileTaskClaimSnapshot { + user_id: snapshot.user_id, + task_id: snapshot.task_id, + day_key: snapshot.day_key, + reward_points: snapshot.reward_points, + wallet_balance: snapshot.wallet_balance, + ledger_entry: map_runtime_profile_wallet_ledger_entry_snapshot(snapshot.ledger_entry), + center: map_runtime_profile_task_center_snapshot(snapshot.center), + } +} + +pub(crate) fn map_runtime_profile_redeem_code_snapshot( + snapshot: RuntimeProfileRedeemCodeSnapshot, +) -> module_runtime::RuntimeProfileRedeemCodeSnapshot { + module_runtime::RuntimeProfileRedeemCodeSnapshot { + code: snapshot.code, + mode: map_runtime_profile_redeem_code_mode_back(snapshot.mode), + reward_points: snapshot.reward_points, + max_uses: snapshot.max_uses, + global_used_count: snapshot.global_used_count, + enabled: snapshot.enabled, + allowed_user_ids: snapshot.allowed_user_ids, + created_by: snapshot.created_by, + created_at_micros: snapshot.created_at_micros, + updated_at_micros: snapshot.updated_at_micros, + } +} + +pub(crate) fn map_runtime_profile_invite_code_snapshot( + snapshot: RuntimeProfileInviteCodeSnapshot, +) -> module_runtime::RuntimeProfileInviteCodeSnapshot { + module_runtime::RuntimeProfileInviteCodeSnapshot { + user_id: snapshot.user_id, + invite_code: snapshot.invite_code, + metadata_json: snapshot.metadata_json, + starts_at_micros: snapshot.starts_at_micros, + expires_at_micros: snapshot.expires_at_micros, + created_at_micros: snapshot.created_at_micros, + updated_at_micros: snapshot.updated_at_micros, + } +} + +pub(crate) fn map_runtime_profile_played_world_snapshot( + snapshot: RuntimeProfilePlayedWorldSnapshot, +) -> module_runtime::RuntimeProfilePlayedWorldSnapshot { + module_runtime::RuntimeProfilePlayedWorldSnapshot { + played_world_id: snapshot.played_world_id, + user_id: snapshot.user_id, + world_key: snapshot.world_key, + owner_user_id: snapshot.owner_user_id, + profile_id: snapshot.profile_id, + world_type: snapshot.world_type, + world_title: snapshot.world_title, + world_subtitle: snapshot.world_subtitle, + first_played_at_micros: snapshot.first_played_at_micros, + last_played_at_micros: snapshot.last_played_at_micros, + last_observed_play_time_ms: snapshot.last_observed_play_time_ms, + } +} + +pub(crate) fn map_runtime_profile_play_stats_snapshot( + snapshot: RuntimeProfilePlayStatsSnapshot, +) -> module_runtime::RuntimeProfilePlayStatsSnapshot { + module_runtime::RuntimeProfilePlayStatsSnapshot { + user_id: snapshot.user_id, + total_play_time_ms: snapshot.total_play_time_ms, + played_works: snapshot + .played_works + .into_iter() + .map(map_runtime_profile_played_world_snapshot) + .collect(), + updated_at_micros: snapshot.updated_at_micros, + } +} + +pub(crate) fn map_analytics_granularity( + granularity: module_runtime::AnalyticsGranularity, +) -> AnalyticsGranularity { + match granularity { + module_runtime::AnalyticsGranularity::Day => AnalyticsGranularity::Day, + module_runtime::AnalyticsGranularity::Week => AnalyticsGranularity::Week, + module_runtime::AnalyticsGranularity::Month => AnalyticsGranularity::Month, + module_runtime::AnalyticsGranularity::Quarter => AnalyticsGranularity::Quarter, + module_runtime::AnalyticsGranularity::Year => AnalyticsGranularity::Year, + } +} + +pub(crate) fn map_runtime_profile_task_cycle( + value: DomainRuntimeProfileTaskCycle, +) -> crate::module_bindings::RuntimeProfileTaskCycle { + match value { + DomainRuntimeProfileTaskCycle::Daily => { + crate::module_bindings::RuntimeProfileTaskCycle::Daily + } + } +} + +pub(crate) fn map_runtime_profile_task_cycle_back( + value: crate::module_bindings::RuntimeProfileTaskCycle, +) -> DomainRuntimeProfileTaskCycle { + match value { + crate::module_bindings::RuntimeProfileTaskCycle::Daily => { + DomainRuntimeProfileTaskCycle::Daily + } + } +} + +pub(crate) fn map_runtime_profile_task_status_back( + value: crate::module_bindings::RuntimeProfileTaskStatus, +) -> DomainRuntimeProfileTaskStatus { + match value { + crate::module_bindings::RuntimeProfileTaskStatus::Incomplete => { + DomainRuntimeProfileTaskStatus::Incomplete + } + crate::module_bindings::RuntimeProfileTaskStatus::Claimable => { + DomainRuntimeProfileTaskStatus::Claimable + } + crate::module_bindings::RuntimeProfileTaskStatus::Claimed => { + DomainRuntimeProfileTaskStatus::Claimed + } + crate::module_bindings::RuntimeProfileTaskStatus::Disabled => { + DomainRuntimeProfileTaskStatus::Disabled + } + } +} + +pub(crate) fn map_runtime_profile_redeem_code_mode( + value: module_runtime::RuntimeProfileRedeemCodeMode, +) -> crate::module_bindings::RuntimeProfileRedeemCodeMode { + match value { + module_runtime::RuntimeProfileRedeemCodeMode::Public => { + crate::module_bindings::RuntimeProfileRedeemCodeMode::Public + } + module_runtime::RuntimeProfileRedeemCodeMode::Unique => { + crate::module_bindings::RuntimeProfileRedeemCodeMode::Unique + } + module_runtime::RuntimeProfileRedeemCodeMode::Private => { + crate::module_bindings::RuntimeProfileRedeemCodeMode::Private + } + } +} + +pub(crate) fn map_runtime_profile_redeem_code_mode_back( + value: crate::module_bindings::RuntimeProfileRedeemCodeMode, +) -> module_runtime::RuntimeProfileRedeemCodeMode { + match value { + crate::module_bindings::RuntimeProfileRedeemCodeMode::Public => { + module_runtime::RuntimeProfileRedeemCodeMode::Public + } + crate::module_bindings::RuntimeProfileRedeemCodeMode::Unique => { + module_runtime::RuntimeProfileRedeemCodeMode::Unique + } + crate::module_bindings::RuntimeProfileRedeemCodeMode::Private => { + module_runtime::RuntimeProfileRedeemCodeMode::Private + } + } +} + +pub(crate) fn map_runtime_profile_recharge_product_kind( + value: module_runtime::RuntimeProfileRechargeProductKind, +) -> crate::module_bindings::RuntimeProfileRechargeProductKind { + match value { + module_runtime::RuntimeProfileRechargeProductKind::Points => { + crate::module_bindings::RuntimeProfileRechargeProductKind::Points + } + module_runtime::RuntimeProfileRechargeProductKind::Membership => { + crate::module_bindings::RuntimeProfileRechargeProductKind::Membership + } + } +} + +pub(crate) fn map_runtime_profile_recharge_product_kind_back( + value: crate::module_bindings::RuntimeProfileRechargeProductKind, +) -> module_runtime::RuntimeProfileRechargeProductKind { + match value { + crate::module_bindings::RuntimeProfileRechargeProductKind::Points => { + module_runtime::RuntimeProfileRechargeProductKind::Points + } + crate::module_bindings::RuntimeProfileRechargeProductKind::Membership => { + module_runtime::RuntimeProfileRechargeProductKind::Membership + } + } +} + +pub(crate) fn map_runtime_profile_membership_tier( + value: module_runtime::RuntimeProfileMembershipTier, +) -> crate::module_bindings::RuntimeProfileMembershipTier { + match value { + module_runtime::RuntimeProfileMembershipTier::Normal => { + crate::module_bindings::RuntimeProfileMembershipTier::Normal + } + module_runtime::RuntimeProfileMembershipTier::Month => { + crate::module_bindings::RuntimeProfileMembershipTier::Month + } + module_runtime::RuntimeProfileMembershipTier::Season => { + crate::module_bindings::RuntimeProfileMembershipTier::Season + } + module_runtime::RuntimeProfileMembershipTier::Year => { + crate::module_bindings::RuntimeProfileMembershipTier::Year + } + } +} + +pub(crate) fn map_runtime_profile_membership_status_back( + value: crate::module_bindings::RuntimeProfileMembershipStatus, +) -> module_runtime::RuntimeProfileMembershipStatus { + match value { + crate::module_bindings::RuntimeProfileMembershipStatus::Normal => { + module_runtime::RuntimeProfileMembershipStatus::Normal + } + crate::module_bindings::RuntimeProfileMembershipStatus::Active => { + module_runtime::RuntimeProfileMembershipStatus::Active + } + } +} + +pub(crate) fn map_runtime_profile_membership_tier_back( + value: crate::module_bindings::RuntimeProfileMembershipTier, +) -> module_runtime::RuntimeProfileMembershipTier { + match value { + crate::module_bindings::RuntimeProfileMembershipTier::Normal => { + module_runtime::RuntimeProfileMembershipTier::Normal + } + crate::module_bindings::RuntimeProfileMembershipTier::Month => { + module_runtime::RuntimeProfileMembershipTier::Month + } + crate::module_bindings::RuntimeProfileMembershipTier::Season => { + module_runtime::RuntimeProfileMembershipTier::Season + } + crate::module_bindings::RuntimeProfileMembershipTier::Year => { + module_runtime::RuntimeProfileMembershipTier::Year + } + } +} + +pub(crate) fn map_runtime_profile_recharge_order_status_back( + value: crate::module_bindings::RuntimeProfileRechargeOrderStatus, +) -> module_runtime::RuntimeProfileRechargeOrderStatus { + match value { + crate::module_bindings::RuntimeProfileRechargeOrderStatus::Pending => { + module_runtime::RuntimeProfileRechargeOrderStatus::Pending + } + crate::module_bindings::RuntimeProfileRechargeOrderStatus::Paid => { + module_runtime::RuntimeProfileRechargeOrderStatus::Paid + } + crate::module_bindings::RuntimeProfileRechargeOrderStatus::Failed => { + module_runtime::RuntimeProfileRechargeOrderStatus::Failed + } + crate::module_bindings::RuntimeProfileRechargeOrderStatus::Closed => { + module_runtime::RuntimeProfileRechargeOrderStatus::Closed + } + crate::module_bindings::RuntimeProfileRechargeOrderStatus::Refunded => { + module_runtime::RuntimeProfileRechargeOrderStatus::Refunded + } + } +} + +pub(crate) fn map_runtime_profile_feedback_status_back( + value: crate::module_bindings::RuntimeProfileFeedbackStatus, +) -> module_runtime::RuntimeProfileFeedbackStatus { + match value { + crate::module_bindings::RuntimeProfileFeedbackStatus::Open => { + module_runtime::RuntimeProfileFeedbackStatus::Open + } + } +} + +#[derive(Clone, Debug, PartialEq, Eq)] +pub struct SquareHoleDropFeedbackRecord { + pub accepted: bool, + pub reject_reason: Option, + pub message: String, +} + +#[derive(Clone, Debug, PartialEq)] +pub struct SquareHoleRunRecord { + pub run_id: String, + pub profile_id: String, + pub owner_user_id: String, + pub status: String, + pub snapshot_version: u64, + pub started_at_ms: u64, + pub duration_limit_ms: u64, + pub server_now_ms: Option, + pub remaining_ms: u64, + pub total_shape_count: u32, + pub completed_shape_count: u32, + pub combo: u32, + pub best_combo: u32, + pub score: u32, + pub rule_label: String, + pub background_image_src: Option, + pub current_shape: Option, + pub holes: Vec, + pub last_feedback: Option, + pub last_confirmed_action_id: Option, +} + +#[derive(Clone, Debug, PartialEq)] +pub struct SquareHoleDropConfirmationRecord { + pub status: String, + pub accepted: bool, + pub reject_reason: Option, + pub failure_reason: Option, + pub feedback: SquareHoleDropFeedbackRecord, + pub run: SquareHoleRunRecord, +} diff --git a/server-rs/crates/spacetime-client/src/mapper/square_hole.rs b/server-rs/crates/spacetime-client/src/mapper/square_hole.rs new file mode 100644 index 00000000..79be0303 --- /dev/null +++ b/server-rs/crates/spacetime-client/src/mapper/square_hole.rs @@ -0,0 +1,417 @@ +use super::*; + +pub(crate) fn map_square_hole_agent_session_procedure_result( + result: SquareHoleAgentSessionProcedureResult, +) -> Result { + if !result.ok { + return Err(SpacetimeClientError::procedure_failed(result.error_message)); + } + + let session = result + .session + .ok_or_else(|| SpacetimeClientError::missing_snapshot("square hole agent session 快照"))?; + + Ok(map_square_hole_agent_session_snapshot(session)) +} + +pub(crate) fn map_square_hole_work_procedure_result( + result: SquareHoleWorkProcedureResult, +) -> Result { + if !result.ok { + return Err(SpacetimeClientError::procedure_failed(result.error_message)); + } + + let work = result + .work + .ok_or_else(|| SpacetimeClientError::missing_snapshot("square hole work 快照"))?; + + Ok(map_square_hole_work_snapshot(work)) +} + +pub(crate) fn map_square_hole_works_procedure_result( + result: SquareHoleWorksProcedureResult, +) -> Result, SpacetimeClientError> { + if !result.ok { + return Err(SpacetimeClientError::procedure_failed(result.error_message)); + } + + Ok(result + .items + .into_iter() + .map(map_square_hole_work_snapshot) + .collect()) +} + +pub(crate) fn map_square_hole_run_procedure_result( + result: SquareHoleRunProcedureResult, +) -> Result { + if !result.ok { + return Err(SpacetimeClientError::procedure_failed(result.error_message)); + } + + let run = result + .run + .ok_or_else(|| SpacetimeClientError::missing_snapshot("square hole run 快照"))?; + Ok(map_square_hole_run_snapshot(run)) +} + +pub(crate) fn map_square_hole_drop_shape_procedure_result( + result: SquareHoleDropShapeProcedureResult, +) -> Result { + if !result.ok { + return Err(SpacetimeClientError::procedure_failed(result.error_message)); + } + + let run = result + .run + .ok_or_else(|| SpacetimeClientError::missing_snapshot("square hole drop run 快照"))?; + let feedback = result + .feedback + .ok_or_else(|| SpacetimeClientError::missing_snapshot("square hole drop feedback 快照"))?; + let run = map_square_hole_run_snapshot(run); + + Ok(SquareHoleDropConfirmationRecord { + status: result.status, + accepted: feedback.accepted, + reject_reason: feedback.reject_reason.clone(), + failure_reason: result.failure_reason, + feedback: map_square_hole_feedback_snapshot(feedback), + run, + }) +} + +fn map_square_hole_agent_session_snapshot( + snapshot: SquareHoleAgentSessionSnapshot, +) -> SquareHoleAgentSessionRecord { + let config = map_square_hole_creator_config(snapshot.config); + SquareHoleAgentSessionRecord { + session_id: snapshot.session_id, + current_turn: snapshot.current_turn, + progress_percent: snapshot.progress_percent, + stage: normalize_square_hole_stage(&snapshot.stage).to_string(), + anchor_pack: build_square_hole_anchor_pack(&config), + config, + draft: snapshot.draft.map(map_square_hole_result_draft), + messages: snapshot + .messages + .into_iter() + .map(map_square_hole_agent_message_snapshot) + .collect(), + last_assistant_reply: empty_string_to_none(snapshot.last_assistant_reply), + published_profile_id: snapshot.published_profile_id, + updated_at: format_timestamp_micros(snapshot.updated_at_micros), + } +} + +fn map_square_hole_creator_config( + snapshot: SquareHoleCreatorConfigSnapshot, +) -> SquareHoleCreatorConfigRecord { + SquareHoleCreatorConfigRecord { + theme_text: snapshot.theme_text, + twist_rule: snapshot.twist_rule, + shape_count: snapshot.shape_count, + difficulty: snapshot.difficulty, + shape_options: snapshot + .shape_options + .into_iter() + .map(map_square_hole_shape_option) + .collect(), + hole_options: snapshot + .hole_options + .into_iter() + .map(map_square_hole_hole_option) + .collect(), + background_prompt: snapshot.background_prompt, + cover_image_src: empty_string_to_none(snapshot.cover_image_src), + background_image_src: empty_string_to_none(snapshot.background_image_src), + } +} + +fn map_square_hole_result_draft(snapshot: SquareHoleDraftSnapshot) -> SquareHoleResultDraftRecord { + SquareHoleResultDraftRecord { + profile_id: snapshot.profile_id, + game_name: snapshot.game_name, + theme_text: snapshot.theme_text, + twist_rule: snapshot.twist_rule, + summary: snapshot.summary_text, + tags: snapshot.tags, + cover_image_src: empty_string_to_none(snapshot.cover_image_src), + background_prompt: snapshot.background_prompt, + background_image_src: empty_string_to_none(snapshot.background_image_src), + shape_options: snapshot + .shape_options + .into_iter() + .map(map_square_hole_shape_option) + .collect(), + hole_options: snapshot + .hole_options + .into_iter() + .map(map_square_hole_hole_option) + .collect(), + shape_count: snapshot.shape_count, + difficulty: snapshot.difficulty, + publish_ready: false, + blockers: Vec::new(), + } +} + +fn map_square_hole_agent_message_snapshot( + snapshot: SquareHoleAgentMessageSnapshot, +) -> SquareHoleAgentMessageRecord { + SquareHoleAgentMessageRecord { + id: snapshot.message_id, + role: snapshot.role, + kind: normalize_square_hole_message_kind(&snapshot.kind).to_string(), + text: snapshot.text, + created_at: format_timestamp_micros(snapshot.created_at_micros), + } +} + +fn map_square_hole_work_snapshot(snapshot: SquareHoleWorkSnapshot) -> SquareHoleWorkProfileRecord { + SquareHoleWorkProfileRecord { + work_id: snapshot.work_id, + profile_id: snapshot.profile_id, + owner_user_id: snapshot.owner_user_id, + source_session_id: empty_string_to_none(snapshot.source_session_id), + author_display_name: snapshot.author_display_name, + game_name: snapshot.game_name, + theme_text: snapshot.theme_text, + twist_rule: snapshot.twist_rule, + summary: snapshot.summary_text, + tags: snapshot.tags, + cover_image_src: empty_string_to_none(snapshot.cover_image_src), + background_prompt: snapshot.background_prompt, + background_image_src: empty_string_to_none(snapshot.background_image_src), + shape_options: snapshot + .shape_options + .into_iter() + .map(map_square_hole_shape_option) + .collect(), + hole_options: snapshot + .hole_options + .into_iter() + .map(map_square_hole_hole_option) + .collect(), + shape_count: snapshot.shape_count, + difficulty: snapshot.difficulty, + publication_status: normalize_square_hole_publication_status(&snapshot.publication_status) + .to_string(), + play_count: snapshot.play_count, + updated_at: format_timestamp_micros(snapshot.updated_at_micros), + published_at: snapshot.published_at_micros.map(format_timestamp_micros), + publish_ready: snapshot.publish_ready, + } +} + +pub(crate) fn map_square_hole_gallery_view_row( + row: SquareHoleGalleryViewRow, +) -> SquareHoleWorkProfileRecord { + SquareHoleWorkProfileRecord { + work_id: row.work_id, + profile_id: row.profile_id, + owner_user_id: row.owner_user_id, + source_session_id: empty_string_to_none(row.source_session_id), + author_display_name: row.author_display_name, + game_name: row.game_name, + theme_text: row.theme_text, + twist_rule: row.twist_rule, + summary: row.summary_text, + tags: row.tags, + cover_image_src: empty_string_to_none(row.cover_image_src), + background_prompt: row.background_prompt, + background_image_src: empty_string_to_none(row.background_image_src), + shape_options: row + .shape_options + .into_iter() + .map(map_square_hole_shape_option) + .collect(), + hole_options: row + .hole_options + .into_iter() + .map(map_square_hole_hole_option) + .collect(), + shape_count: row.shape_count, + difficulty: row.difficulty, + publication_status: normalize_square_hole_publication_status(&row.publication_status) + .to_string(), + play_count: row.play_count, + updated_at: format_timestamp_micros(row.updated_at_micros), + published_at: row.published_at_micros.map(format_timestamp_micros), + publish_ready: row.publish_ready, + } +} + +fn map_square_hole_run_snapshot(snapshot: SquareHoleRunSnapshot) -> SquareHoleRunRecord { + SquareHoleRunRecord { + run_id: snapshot.run_id, + profile_id: snapshot.profile_id, + owner_user_id: snapshot.owner_user_id, + status: normalize_square_hole_run_status(&snapshot.status).to_string(), + snapshot_version: snapshot.snapshot_version, + started_at_ms: i64_to_u64_ms(snapshot.started_at_ms), + duration_limit_ms: i64_to_u64_ms(snapshot.duration_limit_ms), + server_now_ms: Some(i64_to_u64_ms(snapshot.server_now_ms)), + remaining_ms: i64_to_u64_ms(snapshot.remaining_ms), + total_shape_count: snapshot.total_shape_count, + completed_shape_count: snapshot.completed_shape_count, + combo: snapshot.combo, + best_combo: snapshot.best_combo, + score: snapshot.score, + rule_label: snapshot.rule_label, + background_image_src: empty_string_to_none(snapshot.background_image_src), + current_shape: snapshot.current_shape.map(map_square_hole_shape_snapshot), + holes: snapshot + .holes + .into_iter() + .map(map_square_hole_hole_snapshot) + .collect(), + last_feedback: snapshot + .last_feedback + .map(map_square_hole_feedback_snapshot), + last_confirmed_action_id: None, + } +} + +fn map_square_hole_shape_snapshot( + snapshot: SquareHoleShapeSnapshot, +) -> SquareHoleShapeSnapshotRecord { + SquareHoleShapeSnapshotRecord { + shape_id: snapshot.shape_id, + shape_kind: snapshot.shape_kind, + label: snapshot.label, + target_hole_id: snapshot.target_hole_id, + color: snapshot.color, + image_src: empty_string_to_none(snapshot.image_src), + } +} + +fn map_square_hole_hole_snapshot(snapshot: SquareHoleHoleSnapshot) -> SquareHoleHoleSnapshotRecord { + SquareHoleHoleSnapshotRecord { + hole_id: snapshot.hole_id, + hole_kind: snapshot.hole_kind, + label: snapshot.label, + x: snapshot.x, + y: snapshot.y, + image_src: empty_string_to_none(snapshot.image_src), + } +} + +fn map_square_hole_shape_option( + snapshot: SquareHoleShapeOptionSnapshot, +) -> SquareHoleShapeOptionRecord { + SquareHoleShapeOptionRecord { + option_id: snapshot.option_id, + shape_kind: snapshot.shape_kind, + label: snapshot.label, + target_hole_id: snapshot.target_hole_id, + image_prompt: snapshot.image_prompt, + image_src: empty_string_to_none(snapshot.image_src), + } +} + +fn map_square_hole_hole_option( + snapshot: SquareHoleHoleOptionSnapshot, +) -> SquareHoleHoleOptionRecord { + SquareHoleHoleOptionRecord { + hole_id: snapshot.hole_id, + hole_kind: snapshot.hole_kind, + label: snapshot.label, + image_prompt: snapshot.image_prompt, + image_src: empty_string_to_none(snapshot.image_src), + } +} + +fn map_square_hole_feedback_snapshot( + snapshot: SquareHoleDropFeedbackSnapshot, +) -> SquareHoleDropFeedbackRecord { + SquareHoleDropFeedbackRecord { + accepted: snapshot.accepted, + reject_reason: snapshot + .reject_reason + .map(|value| normalize_square_hole_reject_reason(&value).to_string()), + message: snapshot.message, + } +} + +fn build_square_hole_anchor_pack( + config: &SquareHoleCreatorConfigRecord, +) -> SquareHoleAnchorPackRecord { + let shape_count = config.shape_count.to_string(); + let difficulty = config.difficulty.to_string(); + SquareHoleAnchorPackRecord { + theme: build_square_hole_anchor_item("theme", "题材主题", config.theme_text.as_str()), + twist_rule: build_square_hole_anchor_item( + "twistRule", + "反差规则", + config.twist_rule.as_str(), + ), + shape_count: build_square_hole_anchor_item("shapeCount", "形状数量", shape_count.as_str()), + difficulty: build_square_hole_anchor_item("difficulty", "难度", difficulty.as_str()), + } +} + +fn build_square_hole_anchor_item( + key: &str, + label: &str, + value: &str, +) -> SquareHoleAnchorItemRecord { + SquareHoleAnchorItemRecord { + key: key.to_string(), + label: label.to_string(), + value: value.to_string(), + status: if value.trim().is_empty() { + "missing" + } else { + "confirmed" + } + .to_string(), + } +} + +fn normalize_square_hole_stage(value: &str) -> &str { + match value { + "Collecting" | "CollectingConfig" | "collecting" | "collecting_config" => { + "collecting_config" + } + "ReadyToCompile" | "ready_to_compile" => "ready_to_compile", + "DraftCompiled" | "DraftReady" | "draft_compiled" | "draft_ready" => "draft_ready", + "Published" | "published" => "published", + _ => value, + } +} + +fn normalize_square_hole_publication_status(value: &str) -> &str { + match value { + "Draft" | "draft" => "draft", + "Published" | "published" => "published", + _ => value, + } +} + +fn normalize_square_hole_run_status(value: &str) -> &str { + match value { + "Running" | "running" => "running", + "Won" | "won" => "won", + "Failed" | "failed" => "failed", + "Stopped" | "stopped" => "stopped", + _ => value, + } +} + +fn normalize_square_hole_message_kind(value: &str) -> &str { + match value { + "text" => "chat", + _ => value, + } +} + +fn normalize_square_hole_reject_reason(value: &str) -> &str { + match value { + "RunNotActive" | "run_not_active" => "run_not_active", + "SnapshotVersionMismatch" | "snapshot_version_mismatch" => "snapshot_version_mismatch", + "HoleNotFound" | "hole_not_found" => "hole_not_found", + "Incompatible" | "incompatible" => "incompatible", + "TimeUp" | "time_up" => "time_up", + _ => value, + } +} diff --git a/server-rs/crates/spacetime-client/src/mapper/story.rs b/server-rs/crates/spacetime-client/src/mapper/story.rs new file mode 100644 index 00000000..33bfc5d9 --- /dev/null +++ b/server-rs/crates/spacetime-client/src/mapper/story.rs @@ -0,0 +1,291 @@ +use super::*; + +impl From for RuntimeSnapshotUpsertInput { + fn from(input: module_runtime::RuntimeSnapshotUpsertInput) -> Self { + Self { + user_id: input.user_id, + saved_at_micros: input.saved_at_micros, + bottom_tab: input.bottom_tab, + game_state_json: input.game_state_json, + current_story_json: input.current_story_json, + updated_at_micros: input.updated_at_micros, + } + } +} + +impl From for StorySessionInput { + fn from(input: DomainStorySessionInput) -> Self { + Self { + story_session_id: input.story_session_id, + runtime_session_id: input.runtime_session_id, + actor_user_id: input.actor_user_id, + world_profile_id: input.world_profile_id, + initial_prompt: input.initial_prompt, + opening_summary: input.opening_summary, + created_at_micros: input.created_at_micros, + } + } +} + +impl From for StoryContinueInput { + fn from(input: DomainStoryContinueInput) -> Self { + Self { + story_session_id: input.story_session_id, + event_id: input.event_id, + narrative_text: input.narrative_text, + choice_function_id: input.choice_function_id, + updated_at_micros: input.updated_at_micros, + } + } +} + +impl From for StorySessionStateInput { + fn from(input: DomainStorySessionStateInput) -> Self { + Self { + story_session_id: input.story_session_id, + } + } +} + +pub(crate) fn map_asset_history_list_result( + result: AssetHistoryListResult, +) -> Result, SpacetimeClientError> { + if !result.ok { + return Err(SpacetimeClientError::procedure_failed(result.error_message)); + } + + Ok(result + .entries + .into_iter() + .map(map_asset_history_entry_snapshot) + .map(build_asset_history_entry_record) + .collect()) +} + +pub(crate) fn map_runtime_browse_history_procedure_result( + result: RuntimeBrowseHistoryProcedureResult, +) -> Result, SpacetimeClientError> { + if !result.ok { + return Err(SpacetimeClientError::procedure_failed(result.error_message)); + } + + Ok(result + .entries + .into_iter() + .map(|snapshot| { + build_runtime_browse_history_record(map_runtime_browse_history_snapshot(snapshot)) + }) + .collect()) +} + +pub(crate) fn map_story_session_procedure_result( + result: StorySessionProcedureResult, +) -> Result { + if !result.ok { + return Err(SpacetimeClientError::procedure_failed(result.error_message)); + } + + let session = result + .session + .ok_or_else(|| SpacetimeClientError::missing_snapshot("story session 快照"))?; + let event = result + .event + .ok_or_else(|| SpacetimeClientError::missing_snapshot("story event 快照"))?; + + Ok(StorySessionResultRecord { + session: map_story_session_snapshot(session), + event: map_story_event_snapshot(event), + }) +} + +pub(crate) fn map_story_session_state_procedure_result( + result: StorySessionStateProcedureResult, +) -> Result { + if !result.ok { + return Err(SpacetimeClientError::procedure_failed(result.error_message)); + } + + let session = result + .session + .ok_or_else(|| SpacetimeClientError::missing_snapshot("story session state 快照"))?; + + Ok(StorySessionStateRecord { + session: map_story_session_snapshot(session), + events: result + .events + .into_iter() + .map(map_story_event_snapshot) + .collect(), + }) +} + +pub(crate) fn map_asset_history_entry_snapshot( + snapshot: AssetHistoryEntrySnapshot, +) -> module_assets::AssetHistoryEntrySnapshot { + module_assets::AssetHistoryEntrySnapshot { + asset_object_id: snapshot.asset_object_id, + asset_kind: snapshot.asset_kind, + image_src: snapshot.image_src, + owner_user_id: snapshot.owner_user_id, + profile_id: snapshot.profile_id, + entity_id: snapshot.entity_id, + created_at_micros: snapshot.created_at_micros, + updated_at_micros: snapshot.updated_at_micros, + } +} + +pub(crate) fn map_runtime_browse_history_snapshot( + snapshot: RuntimeBrowseHistorySnapshot, +) -> module_runtime::RuntimeBrowseHistorySnapshot { + module_runtime::RuntimeBrowseHistorySnapshot { + browse_history_id: snapshot.browse_history_id, + user_id: snapshot.user_id, + owner_user_id: snapshot.owner_user_id, + profile_id: snapshot.profile_id, + world_name: snapshot.world_name, + subtitle: snapshot.subtitle, + summary_text: snapshot.summary_text, + cover_image_src: snapshot.cover_image_src, + theme_mode: map_runtime_browse_history_theme_mode_back(snapshot.theme_mode), + author_display_name: snapshot.author_display_name, + visited_at_micros: snapshot.visited_at_micros, + created_at_micros: snapshot.created_at_micros, + updated_at_micros: snapshot.updated_at_micros, + } +} + +pub(crate) fn map_runtime_snapshot_snapshot( + snapshot: RuntimeSnapshot, +) -> module_runtime::RuntimeSnapshot { + module_runtime::RuntimeSnapshot { + user_id: snapshot.user_id, + version: snapshot.version, + saved_at_micros: snapshot.saved_at_micros, + bottom_tab: snapshot.bottom_tab, + game_state_json: snapshot.game_state_json, + current_story_json: snapshot.current_story_json, + created_at_micros: snapshot.created_at_micros, + updated_at_micros: snapshot.updated_at_micros, + } +} + +pub(crate) fn map_runtime_profile_save_archive_snapshot( + snapshot: RuntimeProfileSaveArchiveSnapshot, +) -> module_runtime::RuntimeProfileSaveArchiveSnapshot { + module_runtime::RuntimeProfileSaveArchiveSnapshot { + archive_id: snapshot.archive_id, + user_id: snapshot.user_id, + world_key: snapshot.world_key, + owner_user_id: snapshot.owner_user_id, + profile_id: snapshot.profile_id, + world_type: snapshot.world_type, + world_name: snapshot.world_name, + subtitle: snapshot.subtitle, + summary_text: snapshot.summary_text, + cover_image_src: snapshot.cover_image_src, + saved_at_micros: snapshot.saved_at_micros, + bottom_tab: snapshot.bottom_tab, + game_state_json: snapshot.game_state_json, + current_story_json: snapshot.current_story_json, + created_at_micros: snapshot.created_at_micros, + updated_at_micros: snapshot.updated_at_micros, + } +} + +pub(crate) fn map_story_session_snapshot(snapshot: StorySessionSnapshot) -> StorySessionRecord { + StorySessionRecord { + story_session_id: snapshot.story_session_id, + runtime_session_id: snapshot.runtime_session_id, + actor_user_id: snapshot.actor_user_id, + world_profile_id: snapshot.world_profile_id, + initial_prompt: snapshot.initial_prompt, + opening_summary: snapshot.opening_summary, + latest_narrative_text: snapshot.latest_narrative_text, + latest_choice_function_id: snapshot.latest_choice_function_id, + status: map_story_session_status(snapshot.status) + .as_str() + .to_string(), + version: snapshot.version, + created_at: format_timestamp_micros(snapshot.created_at_micros), + updated_at: format_timestamp_micros(snapshot.updated_at_micros), + } +} + +pub(crate) fn map_story_event_snapshot(snapshot: StoryEventSnapshot) -> StoryEventRecord { + StoryEventRecord { + event_id: snapshot.event_id, + story_session_id: snapshot.story_session_id, + event_kind: map_story_event_kind(snapshot.event_kind) + .as_str() + .to_string(), + narrative_text: snapshot.narrative_text, + choice_function_id: snapshot.choice_function_id, + created_at: format_timestamp_micros(snapshot.created_at_micros), + } +} + +pub(crate) fn map_runtime_browse_history_theme_mode_back( + value: crate::module_bindings::RuntimeBrowseHistoryThemeMode, +) -> module_runtime::RuntimeBrowseHistoryThemeMode { + match value { + crate::module_bindings::RuntimeBrowseHistoryThemeMode::Martial => { + module_runtime::RuntimeBrowseHistoryThemeMode::Martial + } + crate::module_bindings::RuntimeBrowseHistoryThemeMode::Arcane => { + module_runtime::RuntimeBrowseHistoryThemeMode::Arcane + } + crate::module_bindings::RuntimeBrowseHistoryThemeMode::Machina => { + module_runtime::RuntimeBrowseHistoryThemeMode::Machina + } + crate::module_bindings::RuntimeBrowseHistoryThemeMode::Tide => { + module_runtime::RuntimeBrowseHistoryThemeMode::Tide + } + crate::module_bindings::RuntimeBrowseHistoryThemeMode::Rift => { + module_runtime::RuntimeBrowseHistoryThemeMode::Rift + } + crate::module_bindings::RuntimeBrowseHistoryThemeMode::Mythic => { + module_runtime::RuntimeBrowseHistoryThemeMode::Mythic + } + } +} + +pub(crate) fn map_story_session_status(value: StorySessionStatus) -> DomainStorySessionStatus { + match value { + StorySessionStatus::Active => DomainStorySessionStatus::Active, + StorySessionStatus::Completed => DomainStorySessionStatus::Completed, + StorySessionStatus::Archived => DomainStorySessionStatus::Archived, + } +} + +pub(crate) fn map_story_event_kind(value: StoryEventKind) -> DomainStoryEventKind { + match value { + StoryEventKind::SessionStarted => DomainStoryEventKind::SessionStarted, + StoryEventKind::StoryContinued => DomainStoryEventKind::StoryContinued, + } +} + +#[derive(Clone, Debug, PartialEq, Eq)] +pub struct VisualNovelRuntimeEventRecordInput { + pub event_id: String, + pub run_id: String, + pub owner_user_id: String, + pub profile_id: Option, + pub event_kind: String, + pub client_event_id: Option, + pub history_entry_id: Option, + pub payload_json: String, + pub occurred_at_micros: i64, +} + +#[derive(Clone, Debug, PartialEq)] +pub struct VisualNovelRuntimeEventRecord { + pub event_id: String, + pub run_id: Option, + pub owner_user_id: String, + pub profile_id: Option, + pub event_kind: String, + pub client_event_id: Option, + pub history_entry_id: Option, + pub payload: serde_json::Value, + pub occurred_at: String, +} diff --git a/server-rs/crates/spacetime-client/src/mapper/visual_novel.rs b/server-rs/crates/spacetime-client/src/mapper/visual_novel.rs new file mode 100644 index 00000000..98a4a709 --- /dev/null +++ b/server-rs/crates/spacetime-client/src/mapper/visual_novel.rs @@ -0,0 +1,252 @@ +use super::*; + +pub(crate) fn map_visual_novel_agent_session_procedure_result( + result: VisualNovelAgentSessionProcedureResult, +) -> Result { + if !result.ok { + return Err(SpacetimeClientError::procedure_failed(result.error_message)); + } + + let session = result + .session + .ok_or_else(|| SpacetimeClientError::missing_snapshot("visual novel agent session 快照"))?; + + Ok(map_visual_novel_agent_session_snapshot(session)) +} + +pub(crate) fn map_visual_novel_work_procedure_result( + result: VisualNovelWorkProcedureResult, +) -> Result { + if !result.ok { + return Err(SpacetimeClientError::procedure_failed(result.error_message)); + } + + let work = result + .work + .ok_or_else(|| SpacetimeClientError::missing_snapshot("visual novel work 快照"))?; + + Ok(map_visual_novel_work_snapshot(work)) +} + +pub(crate) fn map_visual_novel_works_procedure_result( + result: VisualNovelWorksProcedureResult, +) -> Result, SpacetimeClientError> { + if !result.ok { + return Err(SpacetimeClientError::procedure_failed(result.error_message)); + } + + Ok(result + .items + .into_iter() + .map(map_visual_novel_work_snapshot) + .collect()) +} + +pub(crate) fn map_visual_novel_run_procedure_result( + result: VisualNovelRunProcedureResult, +) -> Result { + if !result.ok { + return Err(SpacetimeClientError::procedure_failed(result.error_message)); + } + + let run = result + .run + .ok_or_else(|| SpacetimeClientError::missing_snapshot("visual novel run 快照"))?; + + Ok(map_visual_novel_run_snapshot(run)) +} + +pub(crate) fn map_visual_novel_history_procedure_result( + result: VisualNovelHistoryProcedureResult, +) -> Result, SpacetimeClientError> { + if !result.ok { + return Err(SpacetimeClientError::procedure_failed(result.error_message)); + } + + Ok(result + .items + .into_iter() + .map(map_visual_novel_history_entry) + .collect()) +} + +pub(crate) fn map_visual_novel_runtime_event_procedure_result( + result: VisualNovelRuntimeEventProcedureResult, +) -> Result { + if !result.ok { + return Err(SpacetimeClientError::procedure_failed(result.error_message)); + } + + let event = result + .event + .ok_or_else(|| SpacetimeClientError::missing_snapshot("visual novel runtime event 快照"))?; + + Ok(map_visual_novel_runtime_event(event)) +} + +fn map_visual_novel_agent_session_snapshot( + snapshot: VisualNovelAgentSessionSnapshot, +) -> VisualNovelAgentSessionRecord { + VisualNovelAgentSessionRecord { + session_id: snapshot.session_id, + owner_user_id: snapshot.owner_user_id, + source_mode: snapshot.source_mode, + status: snapshot.status, + seed_text: snapshot.seed_text, + source_asset_ids: snapshot.source_asset_ids, + current_turn: snapshot.current_turn, + progress_percent: snapshot.progress_percent, + messages: snapshot + .messages + .into_iter() + .map(map_visual_novel_agent_message) + .collect(), + draft: snapshot.draft.map(visual_novel_json_to_value), + pending_action: snapshot.pending_action.map(visual_novel_json_to_value), + last_assistant_reply: snapshot.last_assistant_reply, + published_profile_id: snapshot.published_profile_id, + created_at: format_timestamp_micros(snapshot.created_at_micros), + updated_at: format_timestamp_micros(snapshot.updated_at_micros), + } +} + +fn map_visual_novel_agent_message( + snapshot: VisualNovelAgentMessageSnapshot, +) -> VisualNovelAgentMessageRecord { + VisualNovelAgentMessageRecord { + message_id: snapshot.message_id, + session_id: snapshot.session_id, + role: snapshot.role, + kind: snapshot.kind, + text: snapshot.text, + created_at: format_timestamp_micros(snapshot.created_at_micros), + } +} + +fn map_visual_novel_work_snapshot( + snapshot: VisualNovelWorkSnapshot, +) -> VisualNovelWorkProfileRecord { + VisualNovelWorkProfileRecord { + work_id: snapshot.work_id, + profile_id: snapshot.profile_id, + owner_user_id: snapshot.owner_user_id, + source_session_id: snapshot.source_session_id, + author_display_name: snapshot.author_display_name, + work_title: snapshot.work_title, + work_description: snapshot.work_description, + tags: snapshot.tags, + cover_image_src: snapshot.cover_image_src, + source_asset_ids: snapshot.source_asset_ids, + draft: visual_novel_json_to_value(snapshot.draft), + publication_status: snapshot.publication_status, + publish_ready: snapshot.publish_ready, + play_count: snapshot.play_count, + created_at: format_timestamp_micros(snapshot.created_at_micros), + updated_at: format_timestamp_micros(snapshot.updated_at_micros), + published_at: snapshot.published_at_micros.map(format_timestamp_micros), + } +} + +pub(crate) fn map_visual_novel_gallery_view_row( + row: VisualNovelGalleryViewRow, +) -> VisualNovelWorkProfileRecord { + VisualNovelWorkProfileRecord { + work_id: row.work_id, + profile_id: row.profile_id, + owner_user_id: row.owner_user_id, + source_session_id: row.source_session_id, + author_display_name: row.author_display_name, + work_title: row.work_title, + work_description: row.work_description, + tags: row.tags, + cover_image_src: row.cover_image_src, + source_asset_ids: row.source_asset_ids, + // 中文注释:公开列表 view 不暴露完整 draft,详情页仍通过 detail procedure 读取。 + draft: serde_json::Value::Null, + publication_status: row.publication_status, + publish_ready: row.publish_ready, + play_count: row.play_count, + created_at: format_timestamp_micros(row.created_at_micros), + updated_at: format_timestamp_micros(row.updated_at_micros), + published_at: row.published_at_micros.map(format_timestamp_micros), + } +} + +fn map_visual_novel_run_snapshot(snapshot: VisualNovelRunSnapshot) -> VisualNovelRunRecord { + VisualNovelRunRecord { + run_id: snapshot.run_id, + owner_user_id: snapshot.owner_user_id, + profile_id: snapshot.profile_id, + mode: snapshot.mode, + status: snapshot.status, + current_scene_id: snapshot.current_scene_id, + current_phase_id: snapshot.current_phase_id, + visible_character_ids: snapshot.visible_character_ids, + flags: visual_novel_json_to_value(snapshot.flags), + metrics: visual_novel_json_to_value(snapshot.metrics), + history: snapshot + .history + .into_iter() + .map(map_visual_novel_history_entry) + .collect(), + available_choices: visual_novel_json_to_value(snapshot.available_choices), + text_mode_enabled: snapshot.text_mode_enabled, + created_at: format_timestamp_micros(snapshot.created_at_micros), + updated_at: format_timestamp_micros(snapshot.updated_at_micros), + } +} + +fn map_visual_novel_history_entry( + snapshot: VisualNovelRuntimeHistoryEntrySnapshot, +) -> VisualNovelHistoryEntryRecord { + VisualNovelHistoryEntryRecord { + entry_id: snapshot.entry_id, + run_id: snapshot.run_id, + owner_user_id: snapshot.owner_user_id, + profile_id: snapshot.profile_id, + turn_index: snapshot.turn_index, + source: snapshot.source, + action_text: snapshot.action_text, + steps: visual_novel_json_to_value(snapshot.steps), + snapshot_before_hash: snapshot.snapshot_before_hash, + snapshot_after_hash: snapshot.snapshot_after_hash, + created_at: format_timestamp_micros(snapshot.created_at_micros), + } +} + +fn map_visual_novel_runtime_event( + snapshot: VisualNovelRuntimeEventSnapshot, +) -> VisualNovelRuntimeEventRecord { + VisualNovelRuntimeEventRecord { + event_id: snapshot.event_id, + run_id: snapshot.run_id, + owner_user_id: snapshot.owner_user_id, + profile_id: snapshot.profile_id, + event_kind: snapshot.event_kind, + client_event_id: snapshot.client_event_id, + history_entry_id: snapshot.history_entry_id, + payload: visual_novel_json_to_value(snapshot.payload), + occurred_at: format_timestamp_micros(snapshot.occurred_at_micros), + } +} + +fn visual_novel_json_to_value(value: VisualNovelJsonValue) -> serde_json::Value { + match value { + VisualNovelJsonValue::Null => serde_json::Value::Null, + VisualNovelJsonValue::Bool(value) => serde_json::Value::Bool(value), + VisualNovelJsonValue::Number(value) => serde_json::Number::from_f64(value) + .map(serde_json::Value::Number) + .unwrap_or(serde_json::Value::Null), + VisualNovelJsonValue::String(value) => serde_json::Value::String(value), + VisualNovelJsonValue::Array(items) => { + serde_json::Value::Array(items.into_iter().map(visual_novel_json_to_value).collect()) + } + VisualNovelJsonValue::Object(fields) => { + let object = fields + .into_iter() + .map(|field| (field.key, visual_novel_json_to_value(field.value))) + .collect(); + serde_json::Value::Object(object) + } + } +} diff --git a/server-rs/crates/spacetime-client/src/module_bindings/mod.rs b/server-rs/crates/spacetime-client/src/module_bindings.rs similarity index 100% rename from server-rs/crates/spacetime-client/src/module_bindings/mod.rs rename to server-rs/crates/spacetime-client/src/module_bindings.rs diff --git a/server-rs/crates/spacetime-module/src/ai/mod.rs b/server-rs/crates/spacetime-module/src/ai.rs similarity index 100% rename from server-rs/crates/spacetime-module/src/ai/mod.rs rename to server-rs/crates/spacetime-module/src/ai.rs diff --git a/server-rs/crates/spacetime-module/src/asset_metadata/mod.rs b/server-rs/crates/spacetime-module/src/asset_metadata.rs similarity index 100% rename from server-rs/crates/spacetime-module/src/asset_metadata/mod.rs rename to server-rs/crates/spacetime-module/src/asset_metadata.rs diff --git a/server-rs/crates/spacetime-module/src/auth/mod.rs b/server-rs/crates/spacetime-module/src/auth.rs similarity index 100% rename from server-rs/crates/spacetime-module/src/auth/mod.rs rename to server-rs/crates/spacetime-module/src/auth.rs diff --git a/server-rs/crates/spacetime-module/src/bark_battle/mod.rs b/server-rs/crates/spacetime-module/src/bark_battle.rs similarity index 100% rename from server-rs/crates/spacetime-module/src/bark_battle/mod.rs rename to server-rs/crates/spacetime-module/src/bark_battle.rs diff --git a/server-rs/crates/spacetime-module/src/big_fish/mod.rs b/server-rs/crates/spacetime-module/src/big_fish.rs similarity index 100% rename from server-rs/crates/spacetime-module/src/big_fish/mod.rs rename to server-rs/crates/spacetime-module/src/big_fish.rs diff --git a/server-rs/crates/spacetime-module/src/custom_world/mod.rs b/server-rs/crates/spacetime-module/src/custom_world.rs similarity index 100% rename from server-rs/crates/spacetime-module/src/custom_world/mod.rs rename to server-rs/crates/spacetime-module/src/custom_world.rs diff --git a/server-rs/crates/spacetime-module/src/gameplay/mod.rs b/server-rs/crates/spacetime-module/src/gameplay.rs similarity index 100% rename from server-rs/crates/spacetime-module/src/gameplay/mod.rs rename to server-rs/crates/spacetime-module/src/gameplay.rs diff --git a/server-rs/crates/spacetime-module/src/match3d/mod.rs b/server-rs/crates/spacetime-module/src/match3d.rs similarity index 100% rename from server-rs/crates/spacetime-module/src/match3d/mod.rs rename to server-rs/crates/spacetime-module/src/match3d.rs diff --git a/server-rs/crates/spacetime-module/src/runtime/mod.rs b/server-rs/crates/spacetime-module/src/runtime.rs similarity index 100% rename from server-rs/crates/spacetime-module/src/runtime/mod.rs rename to server-rs/crates/spacetime-module/src/runtime.rs diff --git a/server-rs/crates/spacetime-module/src/square_hole/mod.rs b/server-rs/crates/spacetime-module/src/square_hole.rs similarity index 100% rename from server-rs/crates/spacetime-module/src/square_hole/mod.rs rename to server-rs/crates/spacetime-module/src/square_hole.rs diff --git a/src/components/platform-entry/PlatformEntryFlowShellImpl.tsx b/src/components/platform-entry/PlatformEntryFlowShellImpl.tsx index cc3e7501..b97a525a 100644 --- a/src/components/platform-entry/PlatformEntryFlowShellImpl.tsx +++ b/src/components/platform-entry/PlatformEntryFlowShellImpl.tsx @@ -1,4 +1,4 @@ -import { ArrowRight, Loader2, Sparkles } from 'lucide-react'; +import { ArrowRight, Loader2 } from 'lucide-react'; import { AnimatePresence, motion } from 'motion/react'; import { type Dispatch, @@ -332,6 +332,11 @@ import { filterGeneralPublicWorks, } from './platformEdutainmentVisibility'; import { PlatformEntryCreationTypeModal } from './PlatformEntryCreationTypeModal'; +import { + PuzzleOnboardingLoginOverlay, + type PuzzleOnboardingPhase, + PuzzleOnboardingView, +} from './PlatformEntryFlowShellImpl/PuzzleOnboardingView'; import type { PlatformCreationTypeId } from './platformEntryCreationTypes'; import { derivePlatformCreationTypes, @@ -402,8 +407,6 @@ type PuzzleRuntimeReturnStage = | 'platform'; type PuzzleRuntimeAuthMode = 'default' | 'isolated'; -type PuzzleOnboardingPhase = 'input' | 'generating' | 'generated'; - type PuzzleOnboardingDraft = { promptText: string; item: PuzzleWorkSummary; @@ -1340,134 +1343,6 @@ function markPuzzleOnboardingSeen() { } } -function PuzzleOnboardingView({ - prompt, - phase, - error, - onPromptChange, - onSubmit, - onSkip, -}: { - prompt: string; - phase: PuzzleOnboardingPhase; - error: string | null; - onPromptChange: (value: string) => void; - onSubmit: () => void; - onSkip: () => void; -}) { - const isGenerating = phase === 'generating'; - const isGenerated = phase === 'generated'; - const canSubmit = Boolean(prompt.trim()) && !isGenerating && !isGenerated; - - return ( -
-
- -
-
- {isGenerating ? ( - - ) : ( - - )} -
-

- {PUZZLE_ONBOARDING_COPY} -

-
{ - event.preventDefault(); - onSubmit(); - }} - > -